|
| 1 | +// SPDX-License-Identifier: MPL-2.0 |
| 2 | +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 3 | + |
| 4 | +//! Integration test: mock HTTP server proves the GNN wire format works and |
| 5 | +//! that the 5 key backends (rocq, lean, agda, isabelle, z3) consume |
| 6 | +//! `/gnn/rank` and prepend model-derived apply tactics. |
| 7 | +//! |
| 8 | +//! No Julia installation needed — the mock server is an in-process axum |
| 9 | +//! server bound to a random port. Run with: |
| 10 | +//! |
| 11 | +//! cargo test --test gnn_augment_integration |
| 12 | +
|
| 13 | +use axum::{ |
| 14 | + extract::Json as ExtractJson, |
| 15 | + response::Json, |
| 16 | + routing::{get, post}, |
| 17 | + Router, |
| 18 | +}; |
| 19 | +use echidna::{ |
| 20 | + core::{Goal, ProofState, Term}, |
| 21 | + gnn::client::{GnnClient, GnnConfig}, |
| 22 | + provers::{ProverConfig, ProverFactory, ProverKind}, |
| 23 | +}; |
| 24 | +use serde_json::{json, Value}; |
| 25 | +use std::net::SocketAddr; |
| 26 | +use tokio::net::TcpListener; |
| 27 | + |
| 28 | +// ─── helpers ──────────────────────────────────────────────────────────────── |
| 29 | + |
| 30 | +fn minimal_proof_state() -> ProofState { |
| 31 | + let goal = Goal { |
| 32 | + id: "g0".to_string(), |
| 33 | + target: Term::Const("forall n : nat, n + 0 = n".to_string()), |
| 34 | + hypotheses: vec![], |
| 35 | + }; |
| 36 | + ProofState { |
| 37 | + goals: vec![goal], |
| 38 | + context: echidna::core::Context::default(), |
| 39 | + proof_script: vec![], |
| 40 | + metadata: std::collections::HashMap::new(), |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +/// Spawn a mock GNN server on a random port and return its base URL. |
| 45 | +/// |
| 46 | +/// The server responds: |
| 47 | +/// GET /gnn/health → gnn_model_loaded: true, vocab_size: 42, model_path: "/fake/path" |
| 48 | +/// POST /gnn/rank → premises ["lemma_foo", "thm_bar", "ax_baz"], scores [0.9, 0.7, 0.5] |
| 49 | +async fn spawn_mock_gnn_server() -> String { |
| 50 | + let health_handler = get(|| async { |
| 51 | + Json(json!({ |
| 52 | + "status": "ok", |
| 53 | + "gnn_model_loaded": true, |
| 54 | + "model_path": "/fake/path", |
| 55 | + "vocab_size": 42, |
| 56 | + "training_records_received": 0u64, |
| 57 | + "num_gnn_layers": 4, |
| 58 | + "service": "mock", |
| 59 | + "version": "test" |
| 60 | + })) |
| 61 | + }); |
| 62 | + |
| 63 | + let rank_handler = post(|_body: ExtractJson<Value>| async { |
| 64 | + Json(json!({ |
| 65 | + "success": true, |
| 66 | + "scores": [0.9_f32, 0.7_f32, 0.5_f32], |
| 67 | + "premise_names": ["lemma_foo", "thm_bar", "ax_baz"], |
| 68 | + "inference_time_ms": 1.0_f32 |
| 69 | + })) |
| 70 | + }); |
| 71 | + |
| 72 | + let app = Router::new() |
| 73 | + .route("/gnn/health", health_handler) |
| 74 | + .route("/gnn/rank", rank_handler); |
| 75 | + |
| 76 | + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 77 | + let addr: SocketAddr = listener.local_addr().unwrap(); |
| 78 | + let base_url = format!("http://127.0.0.1:{}", addr.port()); |
| 79 | + |
| 80 | + tokio::spawn(async move { |
| 81 | + axum::serve(listener, app).await.unwrap(); |
| 82 | + }); |
| 83 | + |
| 84 | + // Give the server a moment to accept connections. |
| 85 | + tokio::time::sleep(std::time::Duration::from_millis(10)).await; |
| 86 | + base_url |
| 87 | +} |
| 88 | + |
| 89 | +fn gnn_config(base_url: &str) -> ProverConfig { |
| 90 | + ProverConfig { |
| 91 | + gnn_api_url: Some(base_url.to_string()), |
| 92 | + neural_enabled: true, |
| 93 | + ..ProverConfig::default() |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +// ─── test: GnnClient::health_status() ─────────────────────────────────────── |
| 98 | + |
| 99 | +#[tokio::test] |
| 100 | +async fn test_health_status_richer_payload() { |
| 101 | + let base_url = spawn_mock_gnn_server().await; |
| 102 | + let client = GnnClient::with_config(GnnConfig { |
| 103 | + api_url: base_url, |
| 104 | + timeout_ms: 5000, |
| 105 | + ..GnnConfig::default() |
| 106 | + }); |
| 107 | + |
| 108 | + let health = client.health_status().await.expect("health_status should succeed"); |
| 109 | + |
| 110 | + assert!(health.gnn_model_loaded, "mock reports model loaded"); |
| 111 | + assert_eq!(health.model_path.as_deref(), Some("/fake/path")); |
| 112 | + assert_eq!(health.vocab_size, 42); |
| 113 | + assert_eq!(health.training_records_received, 0); |
| 114 | + assert_eq!(health.num_gnn_layers, 4); |
| 115 | + assert_eq!(health.service, "mock"); |
| 116 | + assert_eq!(health.version, "test"); |
| 117 | +} |
| 118 | + |
| 119 | +// ─── helper: assert top tactic from suggest_tactics ───────────────────────── |
| 120 | + |
| 121 | +async fn assert_top_tactic_is_apply( |
| 122 | + kind: ProverKind, |
| 123 | + prover_name: &str, |
| 124 | + base_url: &str, |
| 125 | +) { |
| 126 | + let backend = ProverFactory::create(kind, gnn_config(base_url)) |
| 127 | + .unwrap_or_else(|e| panic!("Failed to create {:?} backend: {}", kind, e)); |
| 128 | + |
| 129 | + let state = minimal_proof_state(); |
| 130 | + let tactics = backend |
| 131 | + .suggest_tactics(&state, 10) |
| 132 | + .await |
| 133 | + .unwrap_or_else(|e| panic!("{} suggest_tactics failed: {}", prover_name, e)); |
| 134 | + |
| 135 | + assert!( |
| 136 | + !tactics.is_empty(), |
| 137 | + "{}: suggest_tactics returned empty list", |
| 138 | + prover_name |
| 139 | + ); |
| 140 | + |
| 141 | + let first = &tactics[0]; |
| 142 | + match first { |
| 143 | + echidna::core::Tactic::Custom { prover, command, args } => { |
| 144 | + assert_eq!( |
| 145 | + prover.as_str(), |
| 146 | + prover_name, |
| 147 | + "{}: expected prover name in first tactic", |
| 148 | + prover_name |
| 149 | + ); |
| 150 | + assert_eq!( |
| 151 | + command.as_str(), |
| 152 | + "apply", |
| 153 | + "{}: expected 'apply' command in first tactic", |
| 154 | + prover_name |
| 155 | + ); |
| 156 | + assert_eq!( |
| 157 | + args, |
| 158 | + &vec!["lemma_foo".to_string()], |
| 159 | + "{}: expected top-ranked premise as argument", |
| 160 | + prover_name |
| 161 | + ); |
| 162 | + }, |
| 163 | + other => { |
| 164 | + panic!( |
| 165 | + "{}: expected Tactic::Custom(apply lemma_foo) as first tactic, got {:?}", |
| 166 | + prover_name, other |
| 167 | + ); |
| 168 | + }, |
| 169 | + } |
| 170 | +} |
| 171 | + |
| 172 | +// ─── per-backend tests ─────────────────────────────────────────────────────── |
| 173 | + |
| 174 | +#[tokio::test] |
| 175 | +async fn test_rocq_gnn_wires_top_premise() { |
| 176 | + let base_url = spawn_mock_gnn_server().await; |
| 177 | + assert_top_tactic_is_apply(ProverKind::Rocq, "rocq", &base_url).await; |
| 178 | +} |
| 179 | + |
| 180 | +#[tokio::test] |
| 181 | +async fn test_lean_gnn_wires_top_premise() { |
| 182 | + let base_url = spawn_mock_gnn_server().await; |
| 183 | + assert_top_tactic_is_apply(ProverKind::Lean, "lean", &base_url).await; |
| 184 | +} |
| 185 | + |
| 186 | +#[tokio::test] |
| 187 | +async fn test_agda_gnn_wires_top_premise() { |
| 188 | + let base_url = spawn_mock_gnn_server().await; |
| 189 | + assert_top_tactic_is_apply(ProverKind::Agda, "agda", &base_url).await; |
| 190 | +} |
| 191 | + |
| 192 | +#[tokio::test] |
| 193 | +async fn test_isabelle_gnn_wires_top_premise() { |
| 194 | + let base_url = spawn_mock_gnn_server().await; |
| 195 | + assert_top_tactic_is_apply(ProverKind::Isabelle, "isabelle", &base_url).await; |
| 196 | +} |
| 197 | + |
| 198 | +#[tokio::test] |
| 199 | +async fn test_z3_gnn_wires_top_premise() { |
| 200 | + let base_url = spawn_mock_gnn_server().await; |
| 201 | + assert_top_tactic_is_apply(ProverKind::Z3, "z3", &base_url).await; |
| 202 | +} |
0 commit comments