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