Skip to main content

semantic_memory_mcp/
http_server.rs

1//! HTTP search server for semantic-memory-mcp.
2//!
3//! A minimal HTTP server that exposes the most-used semantic-memory
4//! operations over a local TCP port. Runs alongside the stdio MCP
5//! transport so the same warm process serves both MCP clients and
6//! HTTP clients (hooks, benchmarks, scripts).
7//!
8//! Endpoints:
9//!   POST /search   {"query": "...", "top_k": 10} -> search results
10//!   POST /stats    {} -> DB stats
11//!   POST /add      {"content": "...", "namespace": "...", "source": "..."} -> authority receipt
12//!   GET  /health   -> {"ok": true}
13
14use std::io::{BufRead, BufReader, Read, Write};
15use std::net::TcpListener;
16use tokio::runtime::Handle;
17use tokio::task::block_in_place;
18
19use crate::bridge::MemoryBridge;
20
21/// Call Ollama to rate each result's relevance to the query (1-5) and sort descending.
22/// Returns a new vec with a `rerank_score` field added to each result object.
23fn rerank_results(
24    query: &str,
25    results: &[serde_json::Value],
26    model: &str,
27) -> Vec<serde_json::Value> {
28    let client = reqwest::blocking::Client::new();
29    let mut scored: Vec<(f64, serde_json::Value)> = results
30        .iter()
31        .map(|r| {
32            let content = r.get("content").and_then(|v| v.as_str()).unwrap_or("");
33            let truncated: String = content.chars().take(500).collect();
34            let prompt = format!(
35                "Rate the relevance of this document to the query on a scale of 1-5. Reply with ONLY the number.\nQuery: {query}\nDocument: {truncated}\nRating:"
36            );
37            let body = serde_json::json!({
38                "model": model,
39                "prompt": prompt,
40                "stream": false,
41                "options": {"temperature": 0, "num_predict": 1}
42            });
43            let rating = client
44                .post("http://127.0.0.1:11434/api/generate")
45                .json(&body)
46                .send()
47                .ok()
48                .and_then(|resp| resp.json::<serde_json::Value>().ok())
49                .and_then(|v| {
50                    v.get("response")
51                        .and_then(|r| r.as_str())
52                        .and_then(|s| s.trim().chars().next())
53                        .and_then(|c| c.to_digit(10))
54                        .map(|d| d as f64)
55                })
56                .unwrap_or(1.0);
57            (rating, r.clone())
58        })
59        .collect();
60    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
61    scored
62        .into_iter()
63        .map(|(score, mut r)| {
64            if let Some(obj) = r.as_object_mut() {
65                obj.insert("rerank_score".to_string(), serde_json::json!(score));
66            }
67            r
68        })
69        .collect()
70}
71
72pub fn start_http_server(port: u16, auth_token: &str, bridge: MemoryBridge, handle: Handle) {
73    let auth_token = auth_token.to_string();
74    std::thread::spawn(move || {
75        let listener = match TcpListener::bind(("127.0.0.1", port)) {
76            Ok(l) => {
77                eprintln!("HTTP search server listening on 127.0.0.1:{}", port);
78                l
79            }
80            Err(e) => {
81                eprintln!("Failed to bind HTTP port {}: {}", port, e);
82                return;
83            }
84        };
85
86        for stream in listener.incoming() {
87            let stream = match stream {
88                Ok(s) => s,
89                Err(_) => continue,
90            };
91
92            let bridge = bridge.clone();
93            let h = handle.clone();
94            let token = auth_token.clone();
95            std::thread::spawn(move || {
96                handle_connection(stream, &token, bridge, h);
97            });
98        }
99    });
100}
101
102fn handle_connection(
103    mut stream: std::net::TcpStream,
104    auth_token: &str,
105    bridge: MemoryBridge,
106    handle: Handle,
107) {
108    let mut reader = BufReader::new(stream.try_clone().expect("clone"));
109    let mut request_line = String::new();
110    if reader.read_line(&mut request_line).is_err() {
111        return;
112    }
113
114    let parts: Vec<&str> = request_line.split_whitespace().collect();
115    if parts.len() < 2 {
116        return;
117    }
118    let method = parts[0];
119    let path = parts[1];
120
121    let mut content_length = 0;
122    let mut auth_header: Option<String> = None;
123    let mut host_header: Option<String> = None;
124    loop {
125        let mut header = String::new();
126        if reader.read_line(&mut header).is_err() {
127            return;
128        }
129        if header.trim().is_empty() {
130            break;
131        }
132        if let Some(len_str) = header
133            .strip_prefix("Content-Length:")
134            .or_else(|| header.strip_prefix("content-length:"))
135        {
136            content_length = len_str.trim().parse().unwrap_or(0);
137        }
138        if let Some(val) = header
139            .strip_prefix("Authorization:")
140            .or_else(|| header.strip_prefix("authorization:"))
141        {
142            auth_header = Some(val.trim().to_string());
143        }
144        if let Some(val) = header
145            .strip_prefix("Host:")
146            .or_else(|| header.strip_prefix("host:"))
147        {
148            host_header = Some(val.trim().to_string());
149        }
150    }
151
152    // Auth check
153    let authorized = auth_header
154        .as_deref()
155        .map(|h| h == format!("Bearer {}", auth_token))
156        .unwrap_or(false);
157    if !authorized {
158        let response =
159            "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
160        let _ = stream.write_all(response.as_bytes());
161        return;
162    }
163
164    // Host check
165    let host_ok = host_header
166        .as_deref()
167        .map(|h| h.starts_with("127.0.0.1") || h.starts_with("localhost"))
168        .unwrap_or(false);
169    if !host_ok {
170        let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
171        let _ = stream.write_all(response.as_bytes());
172        return;
173    }
174
175    // Content-Length cap: 10MB
176    const MAX_BODY_SIZE: usize = 10 * 1024 * 1024;
177    if content_length > MAX_BODY_SIZE {
178        let response =
179            "HTTP/1.1 413 Payload Too Large\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
180        let _ = stream.write_all(response.as_bytes());
181        return;
182    }
183
184    let mut body = vec![0u8; content_length];
185    if content_length > 0 && reader.read_exact(&mut body).is_err() {
186        return;
187    }
188    let body_str = String::from_utf8_lossy(&body);
189
190    let (status, response) = match (method, path) {
191        ("GET", "/health") => (
192            "200 OK",
193            serde_json::json!({"ok": true, "service": "semantic-memory-mcp"}),
194        ),
195        ("POST", "/search") => handle_search(&body_str, &bridge, &handle),
196        ("POST", "/search-routed") => handle_search_routed(&body_str, &bridge, &handle),
197        ("POST", "/rerank") => handle_rerank(&body_str),
198        ("POST", "/stats") => handle_stats(&bridge, &handle),
199        ("POST", "/add") => handle_add_fact(&body_str, &bridge, &handle),
200        ("POST", "/record-outcome") => handle_record_outcome(&body_str, &bridge, &handle),
201        ("GET", "/verify-integrity") => handle_verify_integrity(&bridge, &handle),
202        ("POST", "/discord") => handle_discord(&body_str, &bridge, &handle),
203        ("POST", "/maintenance/check") => handle_maintenance_check(&bridge, &handle),
204        ("POST", "/maintenance/vacuum") => handle_maintenance_vacuum(&bridge, &handle),
205        ("POST", "/maintenance/reembed") => handle_maintenance_reembed(&bridge, &handle),
206        ("POST", "/maintenance/reconcile") => {
207            handle_maintenance_reconcile(&body_str, &bridge, &handle)
208        }
209        ("POST", "/maintenance/rebuild-hnsw") => handle_maintenance_rebuild_hnsw(&bridge, &handle),
210        ("POST", "/maintenance/compact-hnsw") => handle_maintenance_compact_hnsw(&bridge, &handle),
211        _ => (
212            "404 Not Found",
213            serde_json::json!({"error": "not found", "path": path}),
214        ),
215    };
216
217    let response_str = serde_json::to_string(&response).unwrap_or_default();
218    let response_bytes = response_str.as_bytes();
219    let http_response = format!(
220        "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
221        status,
222        response_bytes.len()
223    );
224
225    let _ = stream.write_all(http_response.as_bytes());
226    let _ = stream.write_all(response_bytes);
227    let _ = stream.flush();
228}
229
230fn handle_search(
231    body: &str,
232    bridge: &MemoryBridge,
233    handle: &Handle,
234) -> (&'static str, serde_json::Value) {
235    let params: serde_json::Value = match serde_json::from_str(body) {
236        Ok(v) => v,
237        Err(e) => {
238            return (
239                "400 Bad Request",
240                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
241            )
242        }
243    };
244
245    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
246    let top_k = params.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
247    let namespaces: Option<Vec<String>> = params
248        .get("namespaces")
249        .and_then(|v| serde_json::from_value(v.clone()).ok());
250    let do_rerank = params
251        .get("rerank")
252        .and_then(|v| v.as_bool())
253        .unwrap_or(false);
254
255    if query.is_empty() {
256        return (
257            "400 Bad Request",
258            serde_json::json!({"ok": false, "error": "missing 'query' field"}),
259        );
260    }
261
262    let store = &bridge.store;
263    let ns_slice: Option<Vec<&str>> = namespaces
264        .as_ref()
265        .map(|v| v.iter().map(|s| s.as_str()).collect());
266    // Fetch top_k * 2 candidates when reranking so the LLM has a richer pool to sort.
267    let fetch_k = if do_rerank { top_k * 2 } else { top_k };
268    let result = block_in_place(|| {
269        handle.block_on(store.search(query, Some(fetch_k), ns_slice.as_deref(), None))
270    });
271
272    match result {
273        Ok(results) => {
274            let json_results: Vec<serde_json::Value> = results
275                .iter()
276                .map(|r| {
277                    let namespace = match &r.source {
278                        semantic_memory::SearchSource::Fact { namespace, .. } => namespace.clone(),
279                        semantic_memory::SearchSource::Chunk { document_title, .. } => {
280                            document_title.clone()
281                        }
282                        _ => String::new(),
283                    };
284                    serde_json::json!({
285                        "result_id": r.source.result_id(),
286                        "content": r.content,
287                        "score": r.score,
288                        "cosine_similarity": r.cosine_similarity,
289                        "namespace": namespace,
290                    })
291                })
292                .collect();
293
294            let final_results: Vec<serde_json::Value> = if do_rerank && !json_results.is_empty() {
295                rerank_results(query, &json_results, "granite4.1:3b")
296                    .into_iter()
297                    .take(top_k)
298                    .collect()
299            } else {
300                json_results
301            };
302
303            let count = final_results.len();
304            let provenance = serde_json::json!({
305                "stages_fired": {
306                    "bm25": true,
307                    "vector": true,
308                    "late_interaction": false,
309                    "rerank": do_rerank,
310                },
311                "result_count": count,
312                "view": "semantic",
313                "widening_occurred": null, // TODO: derive from execution receipt
314                "widening_reason": null,
315                "verification_status": "verified",
316            });
317            (
318                "200 OK",
319                serde_json::json!({
320                    "ok": true,
321                    "query": query,
322                    "top_k": top_k,
323                    "results": final_results,
324                    "count": count,
325                    "reranked": do_rerank,
326                    "provenance": provenance,
327                }),
328            )
329        }
330        Err(e) => (
331            "500 Internal Server Error",
332            serde_json::json!({"ok": false, "error": format!("search error: {e}")}),
333        ),
334    }
335}
336
337/// Handle /search-routed: routing-aware search with full pipeline.
338///
339/// Uses the library's routing system to profile the query and decide which
340/// retrieval stages to activate. For class C/D queries with contradictions,
341/// runs factor graph belief propagation and decoder syndrome detection.
342/// When discord is enabled, runs second-order retrieval via graph neighborhood.
343/// Optionally groups results by community.
344fn handle_search_routed(
345    body: &str,
346    bridge: &MemoryBridge,
347    handle: &Handle,
348) -> (&'static str, serde_json::Value) {
349    use semantic_memory::integration::plan_execution;
350    use semantic_memory::rl_routing::{is_trained, route_with_policy};
351    use semantic_memory::routing::{QueryProfile, RetrievalRouter};
352
353    let params: serde_json::Value = match serde_json::from_str(body) {
354        Ok(v) => v,
355        Err(e) => {
356            return (
357                "400 Bad Request",
358                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
359            )
360        }
361    };
362
363    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
364    let base_top_k = params.get("top_k").and_then(|v| v.as_u64()).unwrap_or(12) as usize;
365    let query_class = params
366        .get("query_class")
367        .and_then(|v| v.as_str())
368        .unwrap_or("A");
369    let namespaces: Option<Vec<String>> = params
370        .get("namespaces")
371        .and_then(|v| serde_json::from_value(v.clone()).ok());
372    let contradictions: Vec<(String, String)> = params
373        .get("contradictions")
374        .and_then(|v| serde_json::from_value(v.clone()).ok())
375        .unwrap_or_default();
376    let group_by_community = params
377        .get("group_by_community")
378        .and_then(|v| v.as_bool())
379        .unwrap_or(false);
380
381    if query.is_empty() {
382        return (
383            "400 Bad Request",
384            serde_json::json!({"ok": false, "error": "missing 'query' field"}),
385        );
386    }
387
388    // Use the routing system to profile the query
389    let router = RetrievalRouter {
390        decoder_enabled: true,
391        discord_enabled: true,
392        corpus_density: 0.5,
393        ..Default::default()
394    };
395    let store = &bridge.store;
396    let policy = match block_in_place(|| handle.block_on(store.load_routing_policy())) {
397        Ok(policy) => policy,
398        Err(e) => {
399            return (
400                "500 Internal Server Error",
401                serde_json::json!({"ok": false, "error": format!("load routing policy error: {e}")}),
402            )
403        }
404    };
405    let profile = QueryProfile::from_query(query);
406    let (decision, routing_source) = match policy.as_ref().filter(|p| is_trained(p)) {
407        Some(policy) => (route_with_policy(policy, &profile), "trained_policy"),
408        None => (router.route(&profile), "heuristic"),
409    };
410    let contras = contradictions.clone();
411    let plan = plan_execution(&decision, contras.clone());
412
413    // Class D (SYNTHESIS): retrieve more candidates to support comprehensive answers
414    let top_k = if query_class == "D" {
415        (base_top_k * 2).min(20)
416    } else {
417        base_top_k
418    };
419
420    let ns_slice: Option<Vec<&str>> = namespaces
421        .as_ref()
422        .map(|v| v.iter().map(|s| s.as_str()).collect());
423
424    // Class C (CONTRADICTION): use ExactSearch context for higher-fidelity results
425    let result = if query_class == "C" {
426        use semantic_memory::{ExactnessProfile, SearchContext};
427        let mut ctx = SearchContext::default_now();
428        ctx.exactness_profile = ExactnessProfile::PreferExact;
429        block_in_place(|| {
430            handle.block_on(store.search_with_context(
431                query,
432                Some(top_k),
433                ns_slice.as_deref(),
434                None,
435                ctx,
436            ))
437        })
438        .map(|r| r.results)
439    } else {
440        block_in_place(|| {
441            handle.block_on(store.search(query, Some(top_k), ns_slice.as_deref(), None))
442        })
443    };
444
445    match result {
446        Ok(results) => {
447            let json_results: Vec<serde_json::Value> = results
448                .iter()
449                .map(|r| {
450                    let namespace = match &r.source {
451                        semantic_memory::SearchSource::Fact { namespace, .. } => namespace.clone(),
452                        semantic_memory::SearchSource::Chunk { document_title, .. } => {
453                            document_title.clone()
454                        }
455                        _ => String::new(),
456                    };
457                    serde_json::json!({
458                        "result_id": r.source.result_id(),
459                        "content": r.content,
460                        "score": r.score,
461                        "cosine_similarity": r.cosine_similarity,
462                        "namespace": namespace,
463                        "source_type": match &r.source {
464                            semantic_memory::SearchSource::Fact { .. } => "fact",
465                            semantic_memory::SearchSource::Chunk { .. } => "chunk",
466                            semantic_memory::SearchSource::Message { .. } => "message",
467                            _ => "unknown",
468                        },
469                    })
470                })
471                .collect();
472
473            let mut factor_graph_payload = serde_json::json!({"enabled": false});
474            let mut decoder_executed = false;
475            let mut discord_executed = false;
476            let mut discord_results_payload: Vec<serde_json::Value> = Vec::new();
477
478            // Factor graph belief propagation for class C/D with contradictions
479            if decision.decoder {
480                #[cfg(feature = "full")]
481                {
482                    use semantic_memory::factor_graph::{
483                        factors_from_edges, FactorGraph, FactorGraphConfig,
484                    };
485
486                    let graph_edges =
487                        block_in_place(|| handle.block_on(store.list_all_graph_edges()));
488
489                    if let Ok(edges) = graph_edges {
490                        let raw_edges: Vec<(
491                            String,
492                            String,
493                            semantic_memory::GraphEdgeType,
494                            f64,
495                            Option<String>,
496                        )> = edges
497                            .iter()
498                            .map(|edge| {
499                                let parsed_type = edge
500                                    .edge_type_parsed
501                                    .clone()
502                                    .or_else(|| serde_json::from_str(&edge.edge_type).ok())
503                                    .unwrap_or(semantic_memory::GraphEdgeType::Entity {
504                                        relation: "unknown".to_string(),
505                                    });
506                                (
507                                    edge.source.clone(),
508                                    edge.target.clone(),
509                                    parsed_type,
510                                    edge.weight,
511                                    edge.metadata.clone(),
512                                )
513                            })
514                            .collect();
515
516                        let nodes: Vec<(String, f64)> = results
517                            .iter()
518                            .map(|r| (r.source.result_id(), r.score))
519                            .collect();
520                        let factors = factors_from_edges(&raw_edges);
521                        let graph = FactorGraph::new(&nodes, factors, FactorGraphConfig::default());
522                        let propagated = graph.propagate();
523                        let top_beliefs = propagated.top_k(top_k);
524
525                        factor_graph_payload = serde_json::json!({
526                            "enabled": true,
527                            "top_k_beliefs": top_beliefs
528                                .into_iter()
529                                .map(|(item_id, belief)| serde_json::json!({
530                                    "item_id": item_id,
531                                    "belief": belief,
532                                }))
533                                .collect::<Vec<_>>(),
534                            "iterations": propagated.iterations,
535                            "converged": propagated.converged,
536                            "elapsed_ms": propagated.elapsed_ms,
537                            "factor_counts": {
538                                "semantic": propagated.factor_counts.semantic,
539                                "temporal": propagated.factor_counts.temporal,
540                                "causal": propagated.factor_counts.causal,
541                                "entity": propagated.factor_counts.entity,
542                                "total": propagated.factor_counts.total(),
543                            },
544                        });
545                        decoder_executed = true;
546                    }
547                }
548
549                // Decoder syndrome detection for contradictions
550                if !plan.contradictions.is_empty() {
551                    use semantic_memory::decoder::{compute_correction, detect_syndromes};
552                    let result_scores: Vec<(String, f64)> = results
553                        .iter()
554                        .map(|r| (r.source.result_id(), r.score))
555                        .collect();
556                    let syndromes = detect_syndromes(&result_scores, &plan.contradictions);
557                    let _ = compute_correction(&syndromes, 10.0);
558                    decoder_executed = true;
559                }
560            }
561
562            // Discord second-order retrieval
563            if plan.use_discord {
564                use semantic_memory::discord::DiscordScorer;
565                let direct_ids: Vec<String> =
566                    results.iter().map(|r| r.source.result_id()).collect();
567                let existing_ids: std::collections::HashSet<String> =
568                    direct_ids.iter().cloned().collect();
569                let edges_result = block_in_place(|| {
570                    handle.block_on(store.list_graph_edges_for_neighborhood(
571                        direct_ids.clone(),
572                        2,
573                        200,
574                    ))
575                });
576                if let Ok(raw_edges) = edges_result {
577                    let edge_refs: Vec<semantic_memory::discord::GraphEdgeRef> = raw_edges
578                        .iter()
579                        .map(|edge| {
580                            let parsed_type = edge
581                                .edge_type_parsed
582                                .clone()
583                                .or_else(|| serde_json::from_str(&edge.edge_type).ok())
584                                .unwrap_or(semantic_memory::GraphEdgeType::Entity {
585                                    relation: "unknown".to_string(),
586                                });
587                            let type_str = match parsed_type {
588                                semantic_memory::GraphEdgeType::Semantic { .. } => "semantic",
589                                semantic_memory::GraphEdgeType::Temporal { .. } => "temporal",
590                                semantic_memory::GraphEdgeType::Causal { .. } => "causal",
591                                semantic_memory::GraphEdgeType::Entity { .. } => "entity",
592                            };
593                            semantic_memory::discord::GraphEdgeRef {
594                                source: edge.source.clone(),
595                                target: edge.target.clone(),
596                                edge_type: type_str.to_string(),
597                                weight: edge.weight,
598                            }
599                        })
600                        .collect();
601                    let scorer = DiscordScorer::with_defaults();
602                    let discord_hits = scorer.score(&direct_ids, &edge_refs);
603                    for hit in &discord_hits {
604                        if !existing_ids.contains(&hit.item_id) {
605                            discord_results_payload.push(serde_json::json!({
606                                "result_id": hit.item_id,
607                                "discord_score": hit.discord_score,
608                                "anchor_ids": hit.anchor_ids,
609                                "relationship_types": hit.relationship_types,
610                            }));
611                        }
612                    }
613                    discord_executed = true;
614                }
615            }
616
617            // Community grouping (opt-in)
618            let grouped_results_payload: serde_json::Value = if group_by_community {
619                let seed_ids: Vec<String> = results.iter().map(|r| r.source.result_id()).collect();
620                let edges_result = block_in_place(|| {
621                    handle.block_on(store.list_graph_edges_for_neighborhood(
622                        seed_ids.clone(),
623                        2,
624                        200,
625                    ))
626                });
627                let edges: Vec<(String, String)> = match edges_result {
628                    Ok(raw_edges) => raw_edges
629                        .iter()
630                        .map(|edge| (edge.source.clone(), edge.target.clone()))
631                        .collect(),
632                    Err(_) => Vec::new(),
633                };
634                if !edges.is_empty() {
635                    use semantic_memory::community::detect_communities;
636                    let communities = detect_communities(&edges, 1.0, 42);
637                    let mut member_to_comm: std::collections::HashMap<String, String> =
638                        std::collections::HashMap::new();
639                    for c in &communities {
640                        for m in &c.members {
641                            member_to_comm.insert(m.clone(), c.id.clone());
642                        }
643                    }
644                    let mut groups: std::collections::HashMap<String, Vec<serde_json::Value>> =
645                        std::collections::HashMap::new();
646                    let mut ungrouped: Vec<serde_json::Value> = Vec::new();
647                    for r in &json_results {
648                        if let Some(rid) = r.get("result_id").and_then(|v| v.as_str()) {
649                            match member_to_comm.get(rid).cloned() {
650                                Some(cid) => groups.entry(cid).or_default().push(r.clone()),
651                                None => ungrouped.push(r.clone()),
652                            }
653                        }
654                    }
655                    let mut map = serde_json::Map::new();
656                    for (cid, items) in groups {
657                        map.insert(format!("community_{cid}"), serde_json::json!(items));
658                    }
659                    if !ungrouped.is_empty() {
660                        map.insert("ungrouped".to_string(), serde_json::json!(ungrouped));
661                    }
662                    serde_json::Value::Object(map)
663                } else {
664                    serde_json::Value::Null
665                }
666            } else {
667                serde_json::Value::Null
668            };
669
670            // Query provenance: declare which retrieval stages contributed
671            let provenance = serde_json::json!({
672                "stages_fired": {
673                    "bm25": results.iter().any(|r| r.bm25_rank.is_some()),
674                    "vector": results.iter().any(|r| r.vector_rank.is_some()),
675                    "late_interaction": true,
676                    "discord": discord_executed,
677                    "decoder": decoder_executed,
678                },
679                "result_count": results.len(),
680                "view": "routed",
681                "query_class": query_class,
682                "widening_occurred": null, // TODO: derive from execution receipt
683                "widening_reason": null,
684                "verification_status": "verified",
685            });
686
687            (
688                "200 OK",
689                serde_json::json!({
690                    "ok": true,
691                    "query": query,
692                    "top_k": base_top_k,
693                    "results": json_results,
694                    "provenance": provenance,
695                    "query_class": query_class,
696                    "routed": true,
697                    "routing_decision": {
698                        "source": routing_source,
699                        "bm25_coarse": decision.bm25_coarse,
700                        "vector_medium": decision.vector_medium,
701                        "rerank_fine": decision.rerank_fine,
702                        "graph_expansion": decision.graph_expansion,
703                        "decoder": decision.decoder,
704                        "discord": decision.discord,
705                        "no_retrieval": decision.no_retrieval,
706                        "reasoning": decision.reasoning,
707                    },
708                    "decoder_planned": plan.use_decoder,
709                    "decoder_executed": decoder_executed,
710                    "discord_planned": plan.use_discord,
711                    "discord_executed": discord_executed,
712                    "discord_results": discord_results_payload,
713                    "factor_graph": factor_graph_payload,
714                    "grouped_results": grouped_results_payload,
715                }),
716            )
717        }
718        Err(e) => (
719            "500 Internal Server Error",
720            serde_json::json!({"ok": false, "error": format!("search error: {e}")}),
721        ),
722    }
723}
724
725fn handle_stats(bridge: &MemoryBridge, handle: &Handle) -> (&'static str, serde_json::Value) {
726    let store = &bridge.store;
727    let core = block_in_place(|| handle.block_on(store.stats()));
728    let graph = block_in_place(|| handle.block_on(store.list_all_graph_edges()));
729    let core_health = match &core {
730        Ok(_) => serde_json::json!({"health":"healthy","error":null}),
731        Err(e) => serde_json::json!({"health":"error","error":e.to_string()}),
732    };
733    let graph_health = match &graph {
734        Ok(_) => serde_json::json!({"health":"healthy","error":null}),
735        Err(e) => serde_json::json!({"health":"error","error":e.to_string()}),
736    };
737    let stats = core.ok();
738    let graph_edges = graph.ok().map(|edges| edges.len());
739    let ok = stats.is_some() && graph_edges.is_some();
740    (
741        if ok {
742            "200 OK"
743        } else {
744            "503 Service Unavailable"
745        },
746        serde_json::json!({
747            "ok": ok,
748            "components": {"core": core_health, "graph": graph_health},
749            "facts": stats.as_ref().map(|s| s.total_facts),
750            "documents": stats.as_ref().map(|s| s.total_documents),
751            "chunks": stats.as_ref().map(|s| s.total_chunks),
752            "graph_edges": graph_edges,
753            "db_size_mb": stats.as_ref().map(|s| (s.database_size_bytes as f64) / (1024.0 * 1024.0)),
754        }),
755    )
756}
757
758fn handle_rerank(body: &str) -> (&'static str, serde_json::Value) {
759    let params: serde_json::Value = match serde_json::from_str(body) {
760        Ok(v) => v,
761        Err(e) => {
762            return (
763                "400 Bad Request",
764                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
765            )
766        }
767    };
768
769    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
770    let model = params
771        .get("model")
772        .and_then(|v| v.as_str())
773        .unwrap_or("granite4.1:3b");
774    let results = match params.get("results").and_then(|v| v.as_array()) {
775        Some(r) => r.clone(),
776        None => {
777            return (
778                "400 Bad Request",
779                serde_json::json!({"ok": false, "error": "missing 'results' array"}),
780            )
781        }
782    };
783
784    if query.is_empty() {
785        return (
786            "400 Bad Request",
787            serde_json::json!({"ok": false, "error": "missing 'query' field"}),
788        );
789    }
790
791    let reranked = rerank_results(query, &results, model);
792    let count = reranked.len();
793    (
794        "200 OK",
795        serde_json::json!({
796            "ok": true,
797            "results": reranked,
798            "count": count,
799        }),
800    )
801}
802
803fn handle_add_fact(
804    body: &str,
805    bridge: &MemoryBridge,
806    handle: &Handle,
807) -> (&'static str, serde_json::Value) {
808    let params: serde_json::Value = match serde_json::from_str(body) {
809        Ok(v) => v,
810        Err(e) => {
811            return (
812                "400 Bad Request",
813                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
814            )
815        }
816    };
817
818    let content = params.get("content").and_then(|v| v.as_str()).unwrap_or("");
819    let namespace = params
820        .get("namespace")
821        .and_then(|v| v.as_str())
822        .unwrap_or("general");
823    let source = params.get("source").and_then(|v| v.as_str());
824    let idempotency_key = params
825        .get("idempotency_key")
826        .and_then(|v| v.as_str())
827        .filter(|v| !v.trim().is_empty())
828        .map(str::to_owned)
829        .unwrap_or_else(|| format!("http-add:{}", uuid::Uuid::new_v4()));
830
831    if content.is_empty() {
832        return (
833            "400 Bad Request",
834            serde_json::json!({"ok": false, "error": "missing 'content' field"}),
835        );
836    }
837
838    let store = &bridge.store;
839    let source_ref = source.filter(|value| !value.trim().is_empty());
840    let origin = match source_ref {
841        Some(value) => semantic_memory::OriginAuthorityLabelV1::new(
842            semantic_memory::OriginClassV1::ExternalEvidence,
843            "principal:semantic-memory-http",
844            "caller:http-add",
845            format!("blake3:{}", blake3::hash(value.as_bytes()).to_hex()),
846            semantic_memory::OriginRiskV1::Medium,
847            semantic_memory::AuthorityScopesV1 {
848                recall: semantic_memory::AuthorityScopeV1::Universal,
849                assertion: semantic_memory::AuthorityScopeV1::Denied,
850                action: semantic_memory::AuthorityScopeV1::Denied,
851            },
852            semantic_memory::ElevationRequirementV1::ExplicitOperatorApproval,
853            None,
854            semantic_memory::RevocationStatusV1::Active,
855            vec!["principal:semantic-memory-http".into()],
856        )
857        .expect("HTTP external-evidence origin constants are valid"),
858        None => semantic_memory::OriginAuthorityLabelV1::operator_system(
859            "principal:semantic-memory-http",
860            "caller:http-add",
861        ),
862    };
863    let permit = match source_ref {
864        Some(source_ref) => semantic_memory::AuthorityPermit::with_evidence(
865            "principal:semantic-memory-http",
866            "caller:http-add",
867            semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
868            vec![source_ref.to_owned()],
869        ),
870        None => semantic_memory::AuthorityPermit::operator_system(
871            "principal:semantic-memory-http",
872            "caller:http-add",
873            semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
874        ),
875    }
876    .with_origin(origin);
877    let result = block_in_place(|| {
878        handle.block_on(store.authority().append(
879            permit,
880            idempotency_key,
881            namespace.to_owned(),
882            content.to_owned(),
883            source.map(str::to_owned),
884        ))
885    });
886
887    match result {
888        Ok(receipt) => {
889            let fact_id = receipt.affected_ids.first().cloned();
890            (
891                "200 OK",
892                serde_json::json!({"ok": true, "fact_id": fact_id, "authority_receipt": receipt}),
893            )
894        }
895        Err(e) => (
896            "500 Internal Server Error",
897            serde_json::json!({"ok": false, "error": format!("{e}")}),
898        ),
899    }
900}
901
902/// Handle /record-outcome: record a search outcome for RL routing feedback.
903fn handle_record_outcome(
904    body: &str,
905    bridge: &MemoryBridge,
906    handle: &Handle,
907) -> (&'static str, serde_json::Value) {
908    use semantic_memory::rl_routing::{record_routing_outcome, RoutingOutcome};
909    use semantic_memory::routing::{QueryProfile, RetrievalRouter};
910
911    let params: serde_json::Value = match serde_json::from_str(body) {
912        Ok(v) => v,
913        Err(e) => {
914            return (
915                "400 Bad Request",
916                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
917            )
918        }
919    };
920
921    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
922    let outcome = params
923        .get("outcome")
924        .and_then(|v| v.as_str())
925        .unwrap_or("neutral");
926    let _query_class = params
927        .get("query_class")
928        .and_then(|v| v.as_str())
929        .unwrap_or("A");
930
931    if query.is_empty() {
932        return (
933            "400 Bad Request",
934            serde_json::json!({"ok": false, "error": "missing 'query' field"}),
935        );
936    }
937
938    let outcome_enum = match outcome.to_lowercase().as_str() {
939        "good" => RoutingOutcome::Good,
940        "bad" => RoutingOutcome::Bad,
941        "neutral" => RoutingOutcome::Neutral,
942        _ => {
943            return (
944                "400 Bad Request",
945                serde_json::json!({"ok": false, "error": "outcome must be 'good', 'bad', or 'neutral'"}),
946            )
947        }
948    };
949
950    let profile = QueryProfile::from_query(query);
951    let router = RetrievalRouter::default();
952    let decision = router.route(&profile);
953
954    let store = &bridge.store;
955    // Load persisted policy (or default if none saved yet)
956    let mut policy = match block_in_place(|| handle.block_on(store.load_routing_policy())) {
957        Ok(policy) => policy.unwrap_or_default(),
958        Err(e) => {
959            return (
960                "500 Internal Server Error",
961                serde_json::json!({"ok": false, "error": format!("load routing policy error: {e}")}),
962            )
963        }
964    };
965    record_routing_outcome(&mut policy, &profile, &decision, outcome_enum);
966    // Save updated policy
967    if let Err(e) = block_in_place(|| handle.block_on(store.save_routing_policy(&policy))) {
968        return (
969            "500 Internal Server Error",
970            serde_json::json!({"ok": false, "error": format!("persist routing policy error: {e}")}),
971        );
972    }
973
974    (
975        "200 OK",
976        serde_json::json!({
977            "ok": true,
978            "mutating": true,
979            "recorded": true,
980            "feedback": {"kind": "ProxyLabel", "label": outcome},
981            "routing_decision": {
982                "bm25_coarse": decision.bm25_coarse,
983                "vector_medium": decision.vector_medium,
984                "rerank_fine": decision.rerank_fine,
985                "graph_expansion": decision.graph_expansion,
986                "decoder": decision.decoder,
987                "discord": decision.discord,
988                "no_retrieval": decision.no_retrieval,
989                "reasoning": decision.reasoning,
990            },
991            "policy_state": {
992                "trained_examples": policy.trained_examples,
993                "baseline": policy.baseline,
994            },
995        }),
996    )
997}
998
999/// Handle GET /verify-integrity: check DB integrity using real library checks.
1000fn handle_verify_integrity(
1001    bridge: &MemoryBridge,
1002    handle: &Handle,
1003) -> (&'static str, serde_json::Value) {
1004    let store = &bridge.store;
1005    let result = block_in_place(|| {
1006        handle.block_on(store.verify_integrity(semantic_memory::VerifyMode::Quick))
1007    });
1008
1009    match result {
1010        Ok(report) => (
1011            "200 OK",
1012            serde_json::json!({
1013                "ok": report.ok,
1014                "integrity": report.ok,
1015                "schema_version": report.schema_version,
1016                "fact_count": report.fact_count,
1017                "chunk_count": report.chunk_count,
1018                "message_count": report.message_count,
1019                "facts_missing_embeddings": report.facts_missing_embeddings,
1020                "chunks_missing_embeddings": report.chunks_missing_embeddings,
1021                "issues": report.issues,
1022                "issue_count": report.issues.len(),
1023                "message": if report.ok { "All integrity checks passed".to_string() } else { format!("{} integrity issues found", report.issues.len()) },
1024            }),
1025        ),
1026        Err(e) => (
1027            "500 Internal Server Error",
1028            serde_json::json!({"ok": false, "integrity": false, "error": format!("verify_integrity error: {e}")}),
1029        ),
1030    }
1031}
1032
1033/// Handle POST /discord: second-order retrieval via graph neighborhood.
1034///
1035/// Accepts {"query": "...", "top_k": 5, "direct_ids": ["fact:uuid1", ...]}.
1036/// If direct_ids not provided, runs a search first to get top_k results.
1037fn handle_discord(
1038    body: &str,
1039    bridge: &MemoryBridge,
1040    handle: &Handle,
1041) -> (&'static str, serde_json::Value) {
1042    use semantic_memory::discord::DiscordScorer;
1043
1044    let params: serde_json::Value = match serde_json::from_str(body) {
1045        Ok(v) => v,
1046        Err(e) => {
1047            return (
1048                "400 Bad Request",
1049                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
1050            )
1051        }
1052    };
1053
1054    let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
1055    let top_k = params.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
1056
1057    // Get direct_ids from params, or run a search to get them
1058    let direct_ids: Vec<String> = match params.get("direct_ids").and_then(|v| v.as_array()) {
1059        Some(arr) => arr
1060            .iter()
1061            .filter_map(|v| v.as_str().map(|s| s.to_string()))
1062            .collect(),
1063        None => {
1064            // Need a query to search
1065            if query.is_empty() {
1066                return (
1067                    "400 Bad Request",
1068                    serde_json::json!({"ok": false, "error": "either 'direct_ids' or 'query' must be provided"}),
1069                );
1070            }
1071            let store = &bridge.store;
1072            let search_result =
1073                block_in_place(|| handle.block_on(store.search(query, Some(top_k), None, None)));
1074            match search_result {
1075                Ok(results) => results.iter().map(|r| r.source.result_id()).collect(),
1076                Err(e) => {
1077                    return (
1078                        "500 Internal Server Error",
1079                        serde_json::json!({"ok": false, "error": format!("search error: {e}")}),
1080                    )
1081                }
1082            }
1083        }
1084    };
1085
1086    if direct_ids.is_empty() {
1087        return (
1088            "200 OK",
1089            serde_json::json!({"ok": true, "discord_results": [], "count": 0, "edges_loaded": 0}),
1090        );
1091    }
1092
1093    let store = &bridge.store;
1094    // Load graph edges for the neighborhood
1095    let edges_result = block_in_place(|| {
1096        handle.block_on(store.list_graph_edges_for_neighborhood(direct_ids.clone(), 2, 200))
1097    });
1098
1099    let edges: Vec<semantic_memory::discord::GraphEdgeRef> = match edges_result {
1100        Ok(raw_edges) => raw_edges
1101            .iter()
1102            .map(|edge| {
1103                let parsed_type = edge
1104                    .edge_type_parsed
1105                    .clone()
1106                    .or_else(|| serde_json::from_str(&edge.edge_type).ok())
1107                    .unwrap_or(semantic_memory::GraphEdgeType::Entity {
1108                        relation: "unknown".to_string(),
1109                    });
1110                let type_str = match parsed_type {
1111                    semantic_memory::GraphEdgeType::Semantic { .. } => "semantic",
1112                    semantic_memory::GraphEdgeType::Temporal { .. } => "temporal",
1113                    semantic_memory::GraphEdgeType::Causal { .. } => "causal",
1114                    semantic_memory::GraphEdgeType::Entity { .. } => "entity",
1115                };
1116                semantic_memory::discord::GraphEdgeRef {
1117                    source: edge.source.clone(),
1118                    target: edge.target.clone(),
1119                    edge_type: type_str.to_string(),
1120                    weight: edge.weight,
1121                }
1122            })
1123            .collect(),
1124        Err(e) => {
1125            return (
1126                "500 Internal Server Error",
1127                serde_json::json!({"ok": false, "error": format!("failed to load graph edges: {e}")}),
1128            )
1129        }
1130    };
1131
1132    let edges_loaded = edges.len();
1133    let scorer = DiscordScorer::with_defaults();
1134    let discord_hits = scorer.score(&direct_ids, &edges);
1135
1136    // Filter out items already in direct_ids
1137    let existing: std::collections::HashSet<String> = direct_ids.iter().cloned().collect();
1138    let filtered_hits: Vec<serde_json::Value> = discord_hits
1139        .iter()
1140        .filter(|hit| !existing.contains(&hit.item_id))
1141        .map(|hit| {
1142            serde_json::json!({
1143                "result_id": hit.item_id,
1144                "discord_score": hit.discord_score,
1145                "anchor_ids": hit.anchor_ids,
1146                "relationship_types": hit.relationship_types,
1147            })
1148        })
1149        .collect();
1150
1151    (
1152        "200 OK",
1153        serde_json::json!({
1154            "ok": true,
1155            "discord_results": filtered_hits,
1156            "count": filtered_hits.len(),
1157            "edges_loaded": edges_loaded,
1158            "direct_ids": direct_ids,
1159        }),
1160    )
1161}
1162
1163// ---------------------------------------------------------------------------
1164// Maintenance endpoints — for hooks and cron jobs to trigger auto-management
1165// without needing MCP tools to be visible (they're hidden in lean profile).
1166// ---------------------------------------------------------------------------
1167
1168/// Handle POST /maintenance/check: returns embeddings_are_dirty + verify_integrity(Quick)
1169/// in one call. This is the "health check" for auto-management.
1170fn handle_maintenance_check(
1171    bridge: &MemoryBridge,
1172    handle: &Handle,
1173) -> (&'static str, serde_json::Value) {
1174    let store = &bridge.store;
1175    let embeddings_dirty = block_in_place(|| handle.block_on(store.embeddings_are_dirty()));
1176    let embeddings_dirty = match embeddings_dirty {
1177        Ok(v) => v,
1178        Err(e) => {
1179            return (
1180                "500 Internal Server Error",
1181                serde_json::json!({"ok": false, "error": format!("embeddings_are_dirty error: {e}")}),
1182            )
1183        }
1184    };
1185    let integrity_result = block_in_place(|| {
1186        handle.block_on(store.verify_integrity(semantic_memory::VerifyMode::Quick))
1187    });
1188
1189    match integrity_result {
1190        Ok(report) => (
1191            "200 OK",
1192            serde_json::json!({
1193                "ok": report.ok,
1194                "embeddings_are_dirty": embeddings_dirty,
1195                "integrity": {
1196                    "ok": report.ok,
1197                    "schema_version": report.schema_version,
1198                    "fact_count": report.fact_count,
1199                    "chunk_count": report.chunk_count,
1200                    "message_count": report.message_count,
1201                    "facts_missing_embeddings": report.facts_missing_embeddings,
1202                    "chunks_missing_embeddings": report.chunks_missing_embeddings,
1203                    "issues": report.issues,
1204                    "issue_count": report.issues.len(),
1205                },
1206                "message": if report.ok && !embeddings_dirty {
1207                    "All checks passed".to_string()
1208                } else if report.ok && embeddings_dirty {
1209                    "Integrity OK but embeddings need re-embedding".to_string()
1210                } else {
1211                    format!("{} integrity issues found", report.issues.len())
1212                },
1213            }),
1214        ),
1215        Err(e) => (
1216            "500 Internal Server Error",
1217            serde_json::json!({
1218                "ok": false,
1219                "embeddings_are_dirty": embeddings_dirty,
1220                "error": format!("verify_integrity error: {e}"),
1221            }),
1222        ),
1223    }
1224}
1225
1226/// Handle POST /maintenance/vacuum: calls store.vacuum(). Returns ok.
1227fn handle_maintenance_vacuum(
1228    bridge: &MemoryBridge,
1229    handle: &Handle,
1230) -> (&'static str, serde_json::Value) {
1231    let store = &bridge.store;
1232    let result = block_in_place(|| handle.block_on(store.vacuum()));
1233
1234    match result {
1235        Ok(()) => (
1236            "200 OK",
1237            serde_json::json!({"ok": true, "action": "vacuum", "message": "Database vacuumed successfully"}),
1238        ),
1239        Err(e) => (
1240            "500 Internal Server Error",
1241            serde_json::json!({"ok": false, "error": format!("vacuum error: {e}")}),
1242        ),
1243    }
1244}
1245
1246/// Handle POST /maintenance/reembed: calls store.reembed_all(). Returns count.
1247/// This is expensive so the handler just calls it and returns the count.
1248fn handle_maintenance_reembed(
1249    bridge: &MemoryBridge,
1250    handle: &Handle,
1251) -> (&'static str, serde_json::Value) {
1252    let store = &bridge.store;
1253    let result = block_in_place(|| handle.block_on(store.reembed_all()));
1254
1255    match result {
1256        Ok(count) => (
1257            "200 OK",
1258            serde_json::json!({"ok": true, "action": "reembed", "reembedded_count": count, "message": format!("Re-embedded {count} items")}),
1259        ),
1260        Err(e) => (
1261            "500 Internal Server Error",
1262            serde_json::json!({"ok": false, "error": format!("reembed_all error: {e}")}),
1263        ),
1264    }
1265}
1266
1267/// Handle POST /maintenance/reconcile: accepts {"action": "ReportOnly"|"RebuildFts"|"ReEmbed"}.
1268/// Calls store.reconcile(action). Returns the IntegrityReport.
1269fn handle_maintenance_reconcile(
1270    body: &str,
1271    bridge: &MemoryBridge,
1272    handle: &Handle,
1273) -> (&'static str, serde_json::Value) {
1274    let params: serde_json::Value = match serde_json::from_str(body) {
1275        Ok(v) => v,
1276        Err(e) => {
1277            return (
1278                "400 Bad Request",
1279                serde_json::json!({"ok": false, "error": format!("invalid JSON: {e}")}),
1280            )
1281        }
1282    };
1283
1284    let action_str = params
1285        .get("action")
1286        .and_then(|v| v.as_str())
1287        .unwrap_or("ReportOnly");
1288
1289    let action = match action_str {
1290        "RebuildFts" => semantic_memory::ReconcileAction::RebuildFts,
1291        "ReEmbed" => semantic_memory::ReconcileAction::ReEmbed,
1292        _ => semantic_memory::ReconcileAction::ReportOnly,
1293    };
1294
1295    let store = &bridge.store;
1296    let result = block_in_place(|| handle.block_on(store.reconcile(action)));
1297
1298    match result {
1299        Ok(report) => (
1300            "200 OK",
1301            serde_json::json!({
1302                "ok": report.ok,
1303                "action": "reconcile",
1304                "reconcile_action": action_str,
1305                "integrity": {
1306                    "ok": report.ok,
1307                    "schema_version": report.schema_version,
1308                    "fact_count": report.fact_count,
1309                    "chunk_count": report.chunk_count,
1310                    "message_count": report.message_count,
1311                    "facts_missing_embeddings": report.facts_missing_embeddings,
1312                    "chunks_missing_embeddings": report.chunks_missing_embeddings,
1313                    "issues": report.issues,
1314                    "issue_count": report.issues.len(),
1315                },
1316                "message": if report.ok {
1317                    "Reconciliation completed, no issues found".to_string()
1318                } else {
1319                    format!("Reconciliation completed with {} issues", report.issues.len())
1320                },
1321            }),
1322        ),
1323        Err(e) => (
1324            "500 Internal Server Error",
1325            serde_json::json!({"ok": false, "error": format!("reconcile error: {e}")}),
1326        ),
1327    }
1328}
1329
1330/// Handle POST /maintenance/rebuild-hnsw: calls store.rebuild_hnsw_index().
1331///
1332/// Rebuilds the HNSW sidecar from current SQLite embeddings. Use this when
1333/// the index is stale (e.g. after bulk imports, model changes, or long periods
1334/// without automatic sync).
1335fn handle_maintenance_rebuild_hnsw(
1336    bridge: &MemoryBridge,
1337    handle: &Handle,
1338) -> (&'static str, serde_json::Value) {
1339    #[cfg(feature = "hnsw")]
1340    {
1341        let store = &bridge.store;
1342        let result = block_in_place(|| handle.block_on(store.rebuild_hnsw_index()));
1343
1344        match result {
1345            Ok(receipt) => (
1346                "200 OK",
1347                serde_json::json!({
1348                    "ok": true,
1349                    "action": "rebuild-hnsw",
1350                    "message": "HNSW index rebuilt successfully",
1351                    "generation_id": receipt.generation_id,
1352                    "vector_count": receipt.source_row_count,
1353                }),
1354            ),
1355            Err(e) => (
1356                "500 Internal Server Error",
1357                serde_json::json!({"ok": false, "error": format!("rebuild_hnsw error: {e}")}),
1358            ),
1359        }
1360    }
1361
1362    #[cfg(not(feature = "hnsw"))]
1363    {
1364        let _ = bridge;
1365        let _ = handle;
1366        (
1367            "200 OK",
1368            serde_json::json!({
1369                "ok": true,
1370                "action": "rebuild-hnsw",
1371                "message": "HNSW rebuild not applicable — usearch backend does not require rebuild",
1372                "skipped": true,
1373            }),
1374        )
1375    }
1376}
1377
1378/// Handle POST /maintenance/compact-hnsw: calls store.compact_hnsw(). Returns ok.
1379///
1380/// Only available when the `hnsw` feature is enabled. The default backend is
1381/// usearch, so this endpoint returns a not-applicable response without the feature.
1382fn handle_maintenance_compact_hnsw(
1383    bridge: &MemoryBridge,
1384    handle: &Handle,
1385) -> (&'static str, serde_json::Value) {
1386    #[cfg(feature = "hnsw")]
1387    {
1388        let store = &bridge.store;
1389        let result = block_in_place(|| handle.block_on(store.compact_hnsw()));
1390
1391        match result {
1392            Ok(()) => (
1393                "200 OK",
1394                serde_json::json!({"ok": true, "action": "compact-hnsw", "message": "HNSW index compacted successfully"}),
1395            ),
1396            Err(e) => (
1397                "500 Internal Server Error",
1398                serde_json::json!({"ok": false, "error": format!("compact_hnsw error: {e}")}),
1399            ),
1400        }
1401    }
1402
1403    #[cfg(not(feature = "hnsw"))]
1404    {
1405        let _ = bridge;
1406        let _ = handle;
1407        (
1408            "200 OK",
1409            serde_json::json!({
1410                "ok": true,
1411                "action": "compact-hnsw",
1412                "message": "HNSW compaction not applicable — usearch backend does not require compaction",
1413                "skipped": true,
1414            }),
1415        )
1416    }
1417}