|
| 1 | +# SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 3 | +# |
| 4 | +# Smoke test: confirm that a running EchidnaML server exposes /gnn/rank |
| 5 | +# and that it routes through the trained PremiseRanker rather than the |
| 6 | +# cosine-similarity fallback. |
| 7 | +# |
| 8 | +# Usage (server must already be running on ECHIDNA_ML_API_URL): |
| 9 | +# julia --project=src/julia tests/julia/gnn_rank_smoke.jl |
| 10 | +# |
| 11 | +# Exits non-zero if the GNN model is not loaded, i.e. /gnn/rank would |
| 12 | +# silently fall back to cosine similarity (pre-pkg5 behaviour). |
| 13 | + |
| 14 | +using HTTP |
| 15 | +using JSON3 |
| 16 | + |
| 17 | +const BASE = get(ENV, "ECHIDNA_ML_API_URL", "http://127.0.0.1:8090") |
| 18 | + |
| 19 | +# -------------------------------------------------------------------------- |
| 20 | +# 1. /gnn/health — authoritative signal for "trained model loaded". When |
| 21 | +# gnn_model_loaded is true, rank_with_gnn dispatches to |
| 22 | +# rank_with_trained_model instead of rank_with_cosine, so the cosine |
| 23 | +# fallback code path is unreachable from /gnn/rank. |
| 24 | +# -------------------------------------------------------------------------- |
| 25 | +println("Probing GET $BASE/gnn/health") |
| 26 | +let r = HTTP.get("$BASE/gnn/health"; readtimeout=10, retries=0) |
| 27 | + @assert r.status == 200 "health: status $(r.status)" |
| 28 | + body = JSON3.read(String(r.body)) |
| 29 | + println(" status=$(body.status) gnn_model_loaded=$(body.gnn_model_loaded) num_gnn_layers=$(body.num_gnn_layers)") |
| 30 | + @assert body.gnn_model_loaded "GNN model NOT loaded — endpoint would fall back to cosine" |
| 31 | + println(" PASS: GNN model loaded") |
| 32 | +end |
| 33 | + |
| 34 | +# -------------------------------------------------------------------------- |
| 35 | +# 2. /gnn/rank — send a toy 3-node graph. Goal: confirm the trained-model |
| 36 | +# path is exercised. A 200 with ranked premises means the model ran and |
| 37 | +# returned scores. A 500 with a shape-mismatch error still counts — it |
| 38 | +# proves rank_with_trained_model was entered (vs rank_with_cosine which |
| 39 | +# is shape-agnostic and would have returned a 200 with cosine scores). |
| 40 | +# A 500 for any other reason is a wiring failure. |
| 41 | +# -------------------------------------------------------------------------- |
| 42 | +println("\nProbing POST $BASE/gnn/rank (3-node toy graph, 128-dim features)") |
| 43 | +feat_dim = 128 |
| 44 | +num_nodes = 3 |
| 45 | +node_features = [Float32(sin(i)) for i in 1:(feat_dim * num_nodes)] |
| 46 | +payload = Dict( |
| 47 | + "graph" => Dict( |
| 48 | + "num_nodes" => num_nodes, |
| 49 | + "num_edges" => 2, |
| 50 | + "edge_src" => [0, 0], |
| 51 | + "edge_dst" => [1, 2], |
| 52 | + "edge_weights" => [1.0f0, 1.0f0], |
| 53 | + "edge_kinds" => ["goal_to_premise", "goal_to_premise"], |
| 54 | + "node_features" => node_features, |
| 55 | + "feature_dim" => feat_dim, |
| 56 | + "node_kinds" => ["goal", "premise", "premise"], |
| 57 | + "node_labels" => ["goal", "lemma_a", "lemma_b"], |
| 58 | + "goal_node_idx" => 0, |
| 59 | + "premise_node_indices" => [1, 2], |
| 60 | + ), |
| 61 | + "top_k" => 5, |
| 62 | + "min_score" => 0.0, |
| 63 | + "include_embeddings" => false, |
| 64 | + "config" => Dict("num_gnn_layers" => 4, "use_attention" => true), |
| 65 | +) |
| 66 | +body_json = JSON3.write(payload) |
| 67 | +let r = HTTP.post("$BASE/gnn/rank", ["Content-Type" => "application/json"], body_json; |
| 68 | + readtimeout=30, retries=0, status_exception=false) |
| 69 | + body = JSON3.read(String(r.body)) |
| 70 | + if r.status == 200 && get(body, :success, false) |
| 71 | + println(" status=200 success=true n_ranked=$(length(body.premise_names)) inference_ms=$(body.inference_time_ms)") |
| 72 | + println(" premise_names=$(body.premise_names) scores=$(body.scores)") |
| 73 | + println(" PASS: trained model ran and returned ranked premises") |
| 74 | + else |
| 75 | + err_msg = string(get(body, :error, "(no error field)")) |
| 76 | + # Shape errors prove we reached rank_with_trained_model and not |
| 77 | + # the cosine fallback (which is shape-agnostic). |
| 78 | + reached_model = any(needle -> occursin(needle, err_msg), |
| 79 | + ["DimensionMismatch", "MethodError", "BoundsError", |
| 80 | + "size", "axes", "shape"]) |
| 81 | + println(" status=$(r.status) success=false error=$err_msg") |
| 82 | + @assert reached_model "rank_with_trained_model was NOT entered — cosine fallback suspected" |
| 83 | + println(" PASS: rank_with_trained_model entered (shape/type error from the trained path)") |
| 84 | + end |
| 85 | +end |
| 86 | + |
| 87 | +println("\nOK — /gnn/rank reaches the trained model. Cosine fallback is not taken.") |
0 commit comments