Skip to main content

sqlite_graphrag/commands/health/
mod.rs

1//! Handler for the `health` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::output;
5use crate::paths::AppPaths;
6use crate::storage::connection::open_ro;
7use serde::Serialize;
8use std::fs;
9use std::time::Instant;
10
11mod embed_stats;
12mod tables;
13
14use embed_stats::{
15    chunk_embedding_health, coverage_pct, entity_embedding_health, llm_slot_info,
16    memory_embedding_health,
17};
18use tables::table_exists;
19
20#[derive(clap::Args)]
21#[command(after_long_help = "EXAMPLES:\n  \
22    # Check database health (connectivity, integrity, vector index)\n  \
23    sqlite-graphrag health\n\n  \
24    # Check health of a database at a custom path\n  \
25    sqlite-graphrag health --db /path/to/graphrag.sqlite\n\n  \
26    # Explicit database path\n  \
27    sqlite-graphrag health --db /data/graphrag.sqlite")]
28/// Health args.
29pub struct HealthArgs {
30    /// Path to the SQLite database file.
31    #[arg(long)]
32    pub db: Option<String>,
33    /// Explicit JSON flag. Accepted as a no-op because output is already JSON by default.
34    #[arg(long, default_value_t = false)]
35    pub json: bool,
36    /// Output format: `json` or `text`. JSON is always emitted on stdout regardless of the value.
37    #[arg(long, value_parser = ["json", "text"], hide = true)]
38    pub format: Option<String>,
39    /// Filter health report counts to a specific namespace.
40    /// When omitted, counts are global (sum across all namespaces).
41    /// Global checks (integrity, schema_version, journal_mode) are always reported.
42    #[arg(long)]
43    pub namespace: Option<String>,
44}
45
46/// Health counts.
47#[derive(Serialize, schemars::JsonSchema)]
48pub struct HealthCounts {
49    memories: i64,
50    /// Alias of `memories` for the documented contract in SKILL.md.
51    memories_total: i64,
52    entities: i64,
53    relationships: i64,
54    vec_memories: i64,
55}
56
57/// Health check.
58#[derive(Serialize, schemars::JsonSchema)]
59pub struct HealthCheck {
60    name: String,
61    ok: bool,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    detail: Option<String>,
64}
65
66/// Health response.
67#[derive(Serialize, schemars::JsonSchema)]
68pub struct HealthResponse {
69    status: String,
70    /// Namespace filter applied to the counts. None means global (sum across all namespaces).
71    #[serde(skip_serializing_if = "Option::is_none")]
72    namespace: Option<String>,
73    integrity: String,
74    integrity_ok: bool,
75    schema_ok: bool,
76    vec_memories_ok: bool,
77    vec_memories_missing: i64,
78    vec_memories_orphaned: i64,
79    vec_entities_ok: bool,
80    /// v1.1.1 (P6a): entities without a row in entity_embeddings/vec_entities.
81    /// Completeness (coverage), distinct from the table-existence consistency
82    /// reported by `vec_entities_ok`.
83    vec_entities_missing: i64,
84    vec_chunks_ok: bool,
85    /// v1.1.1 (P6a): memory_chunks rows without a row in chunk_embeddings/vec_chunks.
86    vec_chunks_missing: i64,
87    /// v1.1.1 (P6a): vector coverage percentages in [0.0, 100.0] — fraction of
88    /// source rows (active memories / entities / chunks) that have a vector.
89    /// 100.0 when there is nothing to cover.
90    vec_memories_coverage_pct: f64,
91    vec_entities_coverage_pct: f64,
92    vec_chunks_coverage_pct: f64,
93    fts_ok: bool,
94    /// Whether a live FTS5 MATCH query against fts_memories succeeded.
95    fts_query_ok: bool,
96    model_ok: bool,
97    counts: HealthCounts,
98    db_path: String,
99    db_size_bytes: u64,
100    /// MAX(version) from refinery_schema_history — number of the last applied migration.
101    /// Distinct from PRAGMA schema_version (SQLite DDL counter) and PRAGMA user_version
102    /// (canonical SCHEMA_USER_VERSION from __debug_schema).
103    schema_version: u32,
104    /// List of entities referenced by memories but absent from the entities table.
105    /// Empty in a healthy DB. Per the contract documented in SKILL.md.
106    missing_entities: Vec<String>,
107    /// WAL file size in MB (0.0 if WAL does not exist or journal_mode != wal).
108    wal_size_mb: f64,
109    /// SQLite journaling mode (wal, delete, truncate, persist, memory, off).
110    journal_mode: String,
111    /// SQLite version string, e.g. `"3.46.0"`.
112    sqlite_version: String,
113    /// Fraction of relationships that use the `mentions` relation type (0.0–1.0).
114    /// Omitted when there are no relationships in the database.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    mentions_ratio: Option<f64>,
117    /// Human-readable warning when `mentions` relationships dominate the graph (ratio > 0.5).
118    /// Omitted when the ratio is within acceptable bounds or there are no relationships.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    mentions_warning: Option<String>,
121    /// The relation type with the highest edge count in the namespace.
122    /// Omitted when there are no relationships in the database.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    top_relation: Option<String>,
125    /// Fraction of all edges occupied by `top_relation` (0.0–1.0).
126    /// Omitted when there are no relationships in the database.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    top_relation_ratio: Option<f64>,
129    /// Fraction of relationships that use the `applies_to` relation type (0.0–1.0).
130    /// Omitted when there are no relationships or when `applies_to` is absent.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    applies_to_ratio: Option<f64>,
133    /// Human-readable warning when a single relation type occupies more than 40 % of edges.
134    /// Omitted when concentration is within acceptable bounds or there are no relationships.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    relation_concentration_warning: Option<String>,
137    /// Number of entities whose name differs from its normalized kebab-case form.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    non_normalized_count: Option<i64>,
140    /// Warning when non-normalized entities are detected.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    normalization_warning: Option<String>,
143    /// Number of entities with degree exceeding the super-hub threshold (default 50).
144    #[serde(skip_serializing_if = "Option::is_none")]
145    super_hub_count: Option<i64>,
146    /// Warning listing top super-hub entity names.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    super_hub_warning: Option<String>,
149    /// Name of the entity with the highest connection count in the namespace.
150    /// Omitted when there are no entities in the database.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    top_hub_entity: Option<String>,
153    /// Number of connections (degree) of `top_hub_entity`.
154    /// Omitted when there are no entities in the database.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    top_hub_degree: Option<i64>,
157    /// Human-readable warning when `top_hub_entity` exceeds 50 connections.
158    /// Omitted when degree is within acceptable bounds or there are no entities.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    hub_warning: Option<String>,
161    /// Total LLM embedding slots available on this host.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    llm_slots_total: Option<u32>,
164    /// LLM embedding slots currently occupied (slot file exists).
165    #[serde(skip_serializing_if = "Option::is_none")]
166    llm_slots_occupied: Option<u32>,
167    /// LLM embedding slots held by dead processes (stale).
168    #[serde(skip_serializing_if = "Option::is_none")]
169    llm_slots_stale: Option<u32>,
170    checks: Vec<HealthCheck>,
171    elapsed_ms: u64,
172}
173
174/// Run.
175pub fn run(args: HealthArgs) -> Result<(), AppError> {
176    let start = Instant::now();
177    let _ = args.json; // --json is a no-op because output is already JSON by default
178    let _ = args.format; // --format is a no-op; JSON is always emitted on stdout
179    let paths = AppPaths::resolve(args.db.as_deref())?;
180    // GAP-E2E-002: resolve --namespace for counts filtering.
181    // Global checks (integrity, schema_version, journal_mode) remain namespace-agnostic.
182    let namespace_filter = match args.namespace.as_deref() {
183        Some(ns) => Some(crate::namespace::resolve_namespace(Some(ns))?),
184        None => None,
185    };
186
187    // BUG-AUDIT-1 (v1.0.88): refuse to silently bootstrap an empty database
188    // when the operator passes a typo'd or non-existent path. `health` must
189    // observe the database as-is, never mutate it.
190    if !paths.db.exists() {
191        let msg = format!(
192            "database not found at {}; `health` does not auto-create the database — \
193             run `sqlite-graphrag init --db {}` first or pass an existing path",
194            paths.db.display(),
195            paths.db.display(),
196        );
197        tracing::warn!(target: "health", db_path = %paths.db.display(), "database path does not exist; refusing to bootstrap");
198        output::emit_json(&serde_json::json!({
199            "error": true,
200            "code": 4,
201            "message": msg,
202            "db_path": paths.db.display().to_string(),
203        }))?;
204        return Err(AppError::NotFound(msg));
205    }
206
207    let conn = open_ro(&paths.db)?;
208
209    let integrity: String = conn.query_row("PRAGMA integrity_check;", [], |r| r.get(0))?;
210    let integrity_ok = integrity == "ok";
211    tracing::info!(target: "health", integrity_ok = %integrity_ok, "PRAGMA integrity_check complete");
212
213    if !integrity_ok {
214        let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
215        output::emit_json(&HealthResponse {
216            status: "degraded".to_string(),
217            namespace: None,
218            integrity: integrity.clone(),
219            integrity_ok: false,
220            schema_ok: false,
221            vec_memories_ok: false,
222            vec_memories_missing: 0,
223            vec_memories_orphaned: 0,
224            vec_entities_ok: false,
225            vec_entities_missing: 0,
226            vec_chunks_ok: false,
227            vec_chunks_missing: 0,
228            vec_memories_coverage_pct: 0.0,
229            vec_entities_coverage_pct: 0.0,
230            vec_chunks_coverage_pct: 0.0,
231            fts_ok: false,
232            fts_query_ok: false,
233            model_ok: false,
234            counts: HealthCounts {
235                memories: 0,
236                memories_total: 0,
237                entities: 0,
238                relationships: 0,
239                vec_memories: 0,
240            },
241            db_path: paths.db.display().to_string(),
242            db_size_bytes,
243            schema_version: 0,
244            sqlite_version: "unknown".to_string(),
245            missing_entities: vec![],
246            wal_size_mb: 0.0,
247            journal_mode: "unknown".to_string(),
248            mentions_ratio: None,
249            mentions_warning: None,
250            top_relation: None,
251            top_relation_ratio: None,
252            applies_to_ratio: None,
253            relation_concentration_warning: None,
254            non_normalized_count: None,
255            normalization_warning: None,
256            super_hub_count: None,
257            super_hub_warning: None,
258            top_hub_entity: None,
259            top_hub_degree: None,
260            hub_warning: None,
261            llm_slots_total: None,
262            llm_slots_occupied: None,
263            llm_slots_stale: None,
264            checks: vec![HealthCheck {
265                name: "integrity".to_string(),
266                ok: false,
267                detail: Some(integrity),
268            }],
269            elapsed_ms: start.elapsed().as_millis() as u64,
270        })?;
271        return Err(AppError::Database(rusqlite::Error::SqliteFailure(
272            rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CORRUPT),
273            Some("integrity check failed".to_string()),
274        )));
275    }
276
277    // GAP-E2E-002: filter memory count by namespace when --namespace is set.
278    let memories_count: i64 = match &namespace_filter {
279        Some(ns) => conn.query_row(
280            "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL AND namespace = ?1",
281            rusqlite::params![ns],
282            |r| r.get(0),
283        )?,
284        None => conn.query_row(
285            "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
286            [],
287            |r| r.get(0),
288        )?,
289    };
290    let entities_count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
291    let relationships_count: i64 =
292        conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?;
293    let (vec_memories_ok, vec_memories_count, vec_memories_missing, vec_memories_orphaned) =
294        memory_embedding_health(&conn);
295
296    let mentions_count: i64 = conn.query_row(
297        "SELECT COUNT(*) FROM relationships WHERE relation = 'mentions'",
298        [],
299        |r| r.get(0),
300    )?;
301    let (mentions_ratio, mentions_warning) = if relationships_count > 0 {
302        let ratio = mentions_count as f64 / relationships_count as f64;
303        let warning = if ratio > 0.5 {
304            Some(format!(
305                "mentions relationships dominate graph at {:.1}% ({}/{} total); consider running prune-relations --relation mentions --dry-run",
306                ratio * 100.0,
307                mentions_count,
308                relationships_count
309            ))
310        } else {
311            None
312        };
313        (Some(ratio), warning)
314    } else {
315        (None, None)
316    };
317
318    // Relation concentration: find the most frequent relation type and check threshold.
319    let (top_relation, top_relation_ratio, applies_to_ratio, relation_concentration_warning) =
320        if relationships_count > 0 {
321            // Identify the relation with the highest edge count.
322            let (top_rel, top_count): (String, i64) = conn
323                .query_row(
324                    "SELECT relation, COUNT(*) AS cnt
325                     FROM relationships
326                     GROUP BY relation
327                     ORDER BY cnt DESC
328                     LIMIT 1",
329                    [],
330                    |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
331                )
332                .unwrap_or_else(|_| ("unknown".to_string(), 0));
333
334            let top_ratio = top_count as f64 / relationships_count as f64;
335
336            // Compute applies_to ratio separately (may be 0 if absent).
337            let applies_count: i64 = conn
338                .query_row(
339                    "SELECT COUNT(*) FROM relationships WHERE relation = 'applies_to'",
340                    [],
341                    |r| r.get(0),
342                )
343                .unwrap_or(0);
344            let at_ratio = if applies_count > 0 {
345                Some(applies_count as f64 / relationships_count as f64)
346            } else {
347                None
348            };
349
350            let concentration_warning = if top_ratio > 0.40 {
351                Some(format!(
352                    "relation '{}' dominates graph at {:.1}% ({}/{} total); consider running prune-relations --relation {} --dry-run",
353                    top_rel,
354                    top_ratio * 100.0,
355                    top_count,
356                    relationships_count,
357                    top_rel,
358                ))
359            } else {
360                None
361            };
362
363            (
364                Some(top_rel),
365                Some(top_ratio),
366                at_ratio,
367                concentration_warning,
368            )
369        } else {
370            (None, None, None, None)
371        };
372
373    let status = "ok";
374
375    let schema_version: u32 = conn
376        .query_row(
377            "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
378            [],
379            |r| r.get::<_, i64>(0),
380        )
381        .unwrap_or(0) as u32;
382
383    let schema_ok = schema_version > 0;
384
385    // Checks vector tables via sqlite_master (consistency: table exists)
386    // and counts source rows without a vector (completeness: coverage).
387    let (vec_entities_ok, vec_entities_missing) = entity_embedding_health(&conn);
388    let (vec_chunks_ok, vec_chunks_missing) = chunk_embedding_health(&conn);
389
390    // v1.1.1 (P6a): coverage percentages. The memory total is global (the
391    // vec_memories_missing count above is namespace-agnostic too).
392    let memories_total_global: i64 = conn.query_row(
393        "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
394        [],
395        |r| r.get(0),
396    )?;
397    let chunks_total: i64 = conn
398        .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
399        .unwrap_or(0);
400    let vec_memories_coverage_pct =
401        coverage_pct(vec_memories_ok, memories_total_global, vec_memories_missing);
402    let vec_entities_coverage_pct =
403        coverage_pct(vec_entities_ok, entities_count, vec_entities_missing);
404    let vec_chunks_coverage_pct = coverage_pct(vec_chunks_ok, chunks_total, vec_chunks_missing);
405
406    tracing::info!(target: "health", vec_memories_ok = %vec_memories_ok, vec_entities_ok = %vec_entities_ok, vec_missing = vec_memories_missing, vec_orphaned = vec_memories_orphaned, "vector table checks complete");
407    let fts_ok = table_exists(&conn, "fts_memories");
408
409    // Verifies that FTS5 can execute a MATCH query (catches index corruption distinct from table absence).
410    let fts_query_ok = if fts_ok {
411        conn.query_row(
412            "SELECT COUNT(*) FROM fts_memories WHERE fts_memories MATCH 'a' LIMIT 1",
413            [],
414            |r| r.get::<_, i64>(0),
415        )
416        .is_ok()
417    } else {
418        false
419    };
420
421    tracing::info!(target: "health", fts_ok = %fts_ok, fts_query_ok = %fts_query_ok, "FTS5 checks complete");
422
423    // Captures the SQLite runtime version for observability.
424    let sqlite_version: String = conn
425        .query_row("SELECT sqlite_version()", [], |r| r.get(0))
426        .unwrap_or_else(|_| "unknown".to_string());
427
428    // Detects orphan entities referenced by memories but absent from the entities table.
429    let mut missing_entities: Vec<String> = Vec::with_capacity(4);
430    let mut stmt = conn.prepare_cached(
431        "SELECT DISTINCT me.entity_id
432         FROM memory_entities me
433         LEFT JOIN entities e ON e.id = me.entity_id
434         WHERE e.id IS NULL",
435    )?;
436    let orphans: Vec<i64> = stmt
437        .query_map([], |r| r.get(0))?
438        .collect::<Result<Vec<_>, _>>()?;
439    for id in orphans {
440        missing_entities.push(format!("entity_id={id}"));
441    }
442
443    let journal_mode: String = conn
444        .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
445        .unwrap_or_else(|_| "unknown".to_string());
446
447    let wal_size_mb = fs::metadata(format!("{}-wal", paths.db.display()))
448        .map(|m| m.len() as f64 / 1024.0 / 1024.0)
449        .unwrap_or(0.0);
450
451    // Database file size in bytes
452    let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
453
454    // G46: the ONNX model cache no longer exists in the LLM-only build
455    // (v1.0.76+). model_ok now reports whether an LLM CLI (claude or codex)
456    // is reachable on PATH — the real prerequisite for embedding generation.
457    let model_ok = crate::commands::ingest_claude::find_claude_binary(None).is_ok()
458        || crate::commands::ingest_codex::find_codex_binary(None).is_ok();
459    tracing::info!(target: "health", model_ok = %model_ok, "LLM CLI availability check complete");
460
461    // Builds the checks array for detailed diagnostics
462    let mut checks: Vec<HealthCheck> = Vec::with_capacity(8);
463
464    // At this point integrity_ok is always true (corrupt DB returned early above).
465    checks.push(HealthCheck {
466        name: "integrity".to_string(),
467        ok: true,
468        detail: None,
469    });
470
471    checks.push(HealthCheck {
472        name: "schema_version".to_string(),
473        ok: schema_ok,
474        detail: if schema_ok {
475            None
476        } else {
477            Some(format!("schema_version={schema_version} (expected >0)"))
478        },
479    });
480
481    checks.push(HealthCheck {
482        name: "vec_memories".to_string(),
483        ok: vec_memories_ok,
484        detail: if vec_memories_ok {
485            None
486        } else {
487            Some("memory_embeddings/vec_memories table missing from sqlite_master".to_string())
488        },
489    });
490
491    checks.push(HealthCheck {
492        name: "vec_entities".to_string(),
493        ok: vec_entities_ok,
494        detail: if vec_entities_ok {
495            None
496        } else {
497            Some("entity_embeddings/vec_entities table missing from sqlite_master".to_string())
498        },
499    });
500
501    checks.push(HealthCheck {
502        name: "vec_chunks".to_string(),
503        ok: vec_chunks_ok,
504        detail: if vec_chunks_ok {
505            None
506        } else {
507            Some("chunk_embeddings/vec_chunks table missing from sqlite_master".to_string())
508        },
509    });
510
511    checks.push(HealthCheck {
512        name: "fts_memories".to_string(),
513        ok: fts_ok,
514        detail: if fts_ok {
515            None
516        } else {
517            Some("fts_memories table missing from sqlite_master".to_string())
518        },
519    });
520
521    checks.push(HealthCheck {
522        name: "fts_query".to_string(),
523        ok: fts_query_ok,
524        detail: if fts_query_ok {
525            None
526        } else {
527            Some("FTS5 MATCH query failed — run 'sqlite-graphrag fts rebuild'".to_string())
528        },
529    });
530
531    checks.push(HealthCheck {
532        name: "llm_cli".to_string(),
533        ok: model_ok,
534        detail: if model_ok {
535            None
536        } else {
537            Some(
538                "no LLM CLI found on PATH; install 'claude' (Claude Code) or 'codex' \
539                 (Codex CLI) — required for embedding generation since v1.0.76"
540                    .to_string(),
541            )
542        },
543    });
544
545    // G24: detect non-normalized entity names
546    let (non_normalized_count, normalization_warning) = {
547        let mut stmt = conn.prepare_cached("SELECT name FROM entities")?;
548        let names: Vec<String> = stmt
549            .query_map([], |r| r.get(0))?
550            .filter_map(|r| r.ok())
551            .collect();
552        let count = names
553            .iter()
554            .filter(|n| crate::parsers::normalize_entity_name(n) != **n)
555            .count() as i64;
556        let warning = if count > 0 {
557            Some(format!(
558                "run 'normalize-entities --yes' to fix {count} non-normalized entities"
559            ))
560        } else {
561            None
562        };
563        (Some(count), warning)
564    };
565
566    // G25: detect super-hub entities (degree > 50)
567    let (super_hub_count, super_hub_warning) = {
568        let mut stmt = conn.prepare_cached(
569            "SELECT e.name, COUNT(r.id) as deg FROM entities e \
570             LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id \
571             GROUP BY e.id HAVING deg > 50 ORDER BY deg DESC LIMIT 5",
572        )?;
573        let hubs: Vec<(String, i64)> = stmt
574            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
575            .filter_map(|r| r.ok())
576            .collect();
577        let count = hubs.len() as i64;
578        let warning = if count > 0 {
579            let names: Vec<String> = hubs
580                .iter()
581                .map(|(n, d)| format!("{n} (degree {d})"))
582                .collect();
583            Some(format!("super-hubs detected: {}", names.join(", ")))
584        } else {
585            None
586        };
587        (Some(count), warning)
588    };
589
590    // G25 (extended): identify the single highest-degree entity for programmatic use.
591    let (top_hub_entity, top_hub_degree, hub_warning) = {
592        let result: Option<(String, i64)> = conn
593            .query_row(
594                "SELECT e.name, COUNT(r.id) AS degree
595                 FROM entities e
596                 LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id
597                 GROUP BY e.id
598                 ORDER BY degree DESC
599                 LIMIT 1",
600                [],
601                |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
602            )
603            .ok();
604        match result {
605            Some((name, degree)) => {
606                let warning = if degree > 50 {
607                    Some(format!(
608                        "entity '{name}' has {degree} connections; consider splitting or using --max-neighbors-per-hop"
609                    ))
610                } else {
611                    None
612                };
613                (Some(name), Some(degree), warning)
614            }
615            None => (None, None, None),
616        }
617    };
618
619    let llm_slots = llm_slot_info();
620    let response = HealthResponse {
621        status: status.to_string(),
622        namespace: namespace_filter.clone(),
623        integrity,
624        integrity_ok,
625        schema_ok,
626        vec_memories_ok,
627        vec_memories_missing,
628        vec_memories_orphaned,
629        vec_entities_ok,
630        vec_entities_missing,
631        vec_chunks_ok,
632        vec_chunks_missing,
633        vec_memories_coverage_pct,
634        vec_entities_coverage_pct,
635        vec_chunks_coverage_pct,
636        fts_ok,
637        fts_query_ok,
638        model_ok,
639        counts: HealthCounts {
640            memories: memories_count,
641            memories_total: memories_count,
642            entities: entities_count,
643            relationships: relationships_count,
644            vec_memories: vec_memories_count,
645        },
646        db_path: paths.db.display().to_string(),
647        db_size_bytes,
648        schema_version,
649        sqlite_version,
650        missing_entities,
651        wal_size_mb,
652        journal_mode,
653        mentions_ratio,
654        mentions_warning,
655        top_relation,
656        top_relation_ratio,
657        applies_to_ratio,
658        relation_concentration_warning,
659        non_normalized_count,
660        normalization_warning,
661        super_hub_count,
662        super_hub_warning,
663        top_hub_entity,
664        top_hub_degree,
665        hub_warning,
666        llm_slots_total: Some(llm_slots.0),
667        llm_slots_occupied: Some(llm_slots.1),
668        llm_slots_stale: Some(llm_slots.2),
669        checks,
670        elapsed_ms: start.elapsed().as_millis() as u64,
671    };
672    output::emit_json(&response)?;
673    Ok(())
674}
675#[cfg(test)]
676#[path = "../health_tests.rs"]
677mod tests;