From 56f2d486698f5621dc8fa430faec23ffd5bda487 Mon Sep 17 00:00:00 2001 From: Daniel Hinderink Date: Mon, 13 Apr 2026 14:58:21 +0200 Subject: [PATCH] feat: add HTTP serve subcommand to quasi-bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds axum-based HTTP server with three endpoints: POST /run — full SMILES → energy pipeline POST /analyze — RHF + partition analysis only GET /health — liveness with uptime + request count Heavy linear algebra runs on spawn_blocking to keep the event loop responsive. All 453 workspace tests pass. --- Cargo.lock | 79 +++++++++++++ quasi-bridge/Cargo.toml | 4 + quasi-bridge/src/lib.rs | 1 + quasi-bridge/src/main.rs | 20 ++++ quasi-bridge/src/server.rs | 235 +++++++++++++++++++++++++++++++++++++ 5 files changed, 339 insertions(+) create mode 100644 quasi-bridge/src/server.rs diff --git a/Cargo.lock b/Cargo.lock index 33d5395..6f6066a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,6 +155,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -1312,6 +1364,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "matrixmultiply" version = "0.3.10" @@ -1338,6 +1396,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.1.1" @@ -1755,6 +1819,7 @@ version = "0.1.0" dependencies = [ "afana", "anyhow", + "axum", "ciborium", "clap", "nalgebra", @@ -1765,6 +1830,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.18", + "tokio", ] [[package]] @@ -2315,6 +2381,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2816,6 +2893,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2854,6 +2932,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/quasi-bridge/Cargo.toml b/quasi-bridge/Cargo.toml index d7f942d..0586ddd 100644 --- a/quasi-bridge/Cargo.toml +++ b/quasi-bridge/Cargo.toml @@ -33,6 +33,10 @@ nalgebra = "0.33" # CLI clap = { version = "4", features = ["derive"] } +# HTTP server +axum = "0.8" +tokio = { version = "1", features = ["full"] } + # Error handling anyhow = "1" thiserror = "2" diff --git a/quasi-bridge/src/lib.rs b/quasi-bridge/src/lib.rs index e55542e..4946b22 100644 --- a/quasi-bridge/src/lib.rs +++ b/quasi-bridge/src/lib.rs @@ -21,3 +21,4 @@ pub mod partition; pub mod pipeline; pub mod postprocess; pub mod rhf; +pub mod server; diff --git a/quasi-bridge/src/main.rs b/quasi-bridge/src/main.rs index 37287b3..03b61c4 100644 --- a/quasi-bridge/src/main.rs +++ b/quasi-bridge/src/main.rs @@ -4,6 +4,7 @@ //! ```text //! quasi-bridge run --smiles "O" --basis sto-3g --accuracy chemical //! quasi-bridge analyze --smiles "[H][H]" +//! quasi-bridge serve --bind 0.0.0.0:9090 //! ``` use clap::{Parser, Subcommand}; @@ -44,6 +45,12 @@ enum Command { #[arg(long, default_value = "sto-3g")] basis: String, }, + /// Start HTTP server + Serve { + /// Bind address (e.g. 0.0.0.0:9090) + #[arg(long, default_value = "127.0.0.1:9090")] + bind: String, + }, } fn main() { @@ -104,6 +111,19 @@ fn main() { } } } + Command::Serve { bind } => { + let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime"); + rt.block_on(async { + let app = quasi_bridge::server::app(); + let listener = tokio::net::TcpListener::bind(&bind) + .await + .unwrap_or_else(|e| panic!("failed to bind {bind}: {e}")); + eprintln!("quasi-bridge serving on http://{bind}"); + axum::serve(listener, app) + .await + .expect("server error"); + }); + } Command::Analyze { smiles, basis } => { // Just RHF + partition analysis let atoms = match quasi_bridge::molecule::from_smiles(&smiles) { diff --git a/quasi-bridge/src/server.rs b/quasi-bridge/src/server.rs new file mode 100644 index 0000000..eda2d49 --- /dev/null +++ b/quasi-bridge/src/server.rs @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +//! HTTP server for quasi-bridge. +//! +//! Three endpoints: +//! POST /run — full pipeline (SMILES → energy) +//! POST /analyze — RHF + partition only +//! GET /health — liveness check + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; + +use crate::ehrenfest_mol::Accuracy; +use crate::pipeline::PipelineConfig; +use crate::{basis, integrals, molecule, partition, rhf}; + +// ── Shared state ──────────────────────────────────────────────── + +#[derive(Clone)] +pub struct AppState { + start_time: Instant, + request_count: &'static AtomicU64, +} + +// ── Request / response types ──────────────────────────────────── + +#[derive(Deserialize)] +pub struct RunRequest { + pub smiles: String, + #[serde(default = "default_basis")] + pub basis: String, + #[serde(default = "default_accuracy")] + pub accuracy: String, + pub n_active: Option, + pub garm_partition: Option, +} + +fn default_basis() -> String { + "sto-3g".into() +} +fn default_accuracy() -> String { + "chemical".into() +} + +#[derive(Serialize)] +pub struct RunResponse { + pub energy: f64, + pub rhf_energy: f64, + pub correlation: f64, + pub error_bound_mha: f64, + pub n_qubits: usize, + pub n_pauli_terms: usize, + pub backend: String, + pub trotter_steps: u32, + pub gate_count: usize, + pub smiles: String, + pub basis: String, +} + +#[derive(Deserialize)] +pub struct AnalyzeRequest { + pub smiles: String, + #[serde(default = "default_basis")] + pub basis: String, +} + +#[derive(Serialize)] +pub struct AnalyzeResponse { + pub smiles: String, + pub n_atoms: usize, + pub n_electrons: usize, + pub n_basis_functions: usize, + pub nuclear_repulsion: f64, + pub rhf_energy: f64, + pub rhf_iterations: usize, + pub active_orbitals: Vec, + pub n_electrons_active: usize, + pub n_qubits: usize, + pub partition_method: String, + pub orbital_energies: Vec, +} + +#[derive(Serialize)] +pub struct HealthResponse { + pub status: String, + pub uptime_seconds: u64, + pub requests_served: u64, +} + +// ── Router ────────────────────────────────────────────────────── + +pub fn app() -> Router { + static REQUEST_COUNT: AtomicU64 = AtomicU64::new(0); + + let state = AppState { + start_time: Instant::now(), + request_count: &REQUEST_COUNT, + }; + + Router::new() + .route("/run", post(handle_run)) + .route("/analyze", post(handle_analyze)) + .route("/health", get(handle_health)) + .with_state(state) +} + +// ── Handlers ──────────────────────────────────────────────────── + +async fn handle_run( + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + state.request_count.fetch_add(1, Ordering::Relaxed); + + let accuracy = match req.accuracy.as_str() { + "chemical" => Accuracy::Chemical, + "spectroscopic" => Accuracy::Spectroscopic, + "exact" => Accuracy::Exact, + other => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("unknown accuracy: {other}")})), + ); + } + }; + + let garm_json = req + .garm_partition + .map(|v| serde_json::to_string(&v).unwrap_or_default()); + + let config = PipelineConfig { + basis: req.basis, + accuracy, + n_active: req.n_active, + garm_json, + verbose: false, + }; + + // Run pipeline on blocking thread (heavy linear algebra) + let smiles = req.smiles.clone(); + let result = tokio::task::spawn_blocking(move || { + crate::pipeline::run_molecule(&smiles, &config) + }) + .await; + + match result { + Ok(Ok(r)) => ( + StatusCode::OK, + Json(serde_json::json!({ + "energy": r.energy, + "rhf_energy": r.rhf_energy, + "correlation": r.energy - r.rhf_energy, + "error_bound_mha": r.trotter_error_bound_mha, + "n_qubits": r.n_qubits, + "n_pauli_terms": r.n_pauli_terms, + "backend": r.backend, + "trotter_steps": r.trotter_steps, + "gate_count": r.gate_count, + "smiles": r.smiles, + "basis": r.basis, + })), + ), + Ok(Err(e)) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(serde_json::json!({"error": e.to_string()})), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("task panicked: {e}")})), + ), + } +} + +async fn handle_analyze( + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + state.request_count.fetch_add(1, Ordering::Relaxed); + + let smiles = req.smiles.clone(); + let basis_name = req.basis.clone(); + + let result = tokio::task::spawn_blocking(move || -> Result { + let atoms = molecule::from_smiles(&smiles).map_err(|e| e.to_string())?; + let n_electrons = molecule::count_electrons(&atoms); + let bf = basis::build_basis(&atoms, &basis_name).map_err(|e| e.to_string())?; + let n_basis_functions = bf.len(); + let ints = integrals::compute_integrals(&atoms, &bf); + let rhf_result = rhf::rhf(&ints, n_electrons).map_err(|e| e.to_string())?; + let part = partition::frontier_partition(n_basis_functions, n_electrons, None) + .map_err(|e| e.to_string())?; + + Ok(AnalyzeResponse { + smiles, + n_atoms: atoms.len(), + n_electrons, + n_basis_functions, + nuclear_repulsion: ints.nuclear_repulsion, + rhf_energy: rhf_result.energy, + rhf_iterations: rhf_result.iterations, + active_orbitals: part.active.clone(), + n_electrons_active: part.n_electrons_active, + n_qubits: 2 * part.active.len(), + partition_method: part.method.clone(), + orbital_energies: rhf_result.orbital_energies.iter().copied().collect(), + }) + }) + .await; + + match result { + Ok(Ok(resp)) => (StatusCode::OK, Json(serde_json::to_value(resp).unwrap())), + Ok(Err(e)) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(serde_json::json!({"error": e})), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("task panicked: {e}")})), + ), + } +} + +async fn handle_health(State(state): State) -> Json { + Json(HealthResponse { + status: "ok".into(), + uptime_seconds: state.start_time.elapsed().as_secs(), + requests_served: state.request_count.load(Ordering::Relaxed), + }) +}