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            //
338            // v1.2.8: the literal comes from `parsers::GENERIC_RELATION`. This
339            // query is the clearest evidence of the spelling split, because the
340            // metric two lines above it was always right: `top_relation` groups
341            // by the stored value and is agnostic to spelling, while this one
342            // compares against a literal and was reporting 0.0085% where the
343            // real share is 17.8%. Same command, same table, one instrument
344            // reading the wrong scale by a factor of 2098.
345            let applies_count: i64 = conn
346                .query_row(
347                    "SELECT COUNT(*) FROM relationships WHERE relation = ?1",
348                    [crate::parsers::GENERIC_RELATION],
349                    |r| r.get(0),
350                )
351                .unwrap_or(0);
352            let at_ratio = if applies_count > 0 {
353                Some(applies_count as f64 / relationships_count as f64)
354            } else {
355                None
356            };
357
358            let concentration_warning = if top_ratio > 0.40 {
359                Some(format!(
360                    "relation '{}' dominates graph at {:.1}% ({}/{} total); consider running prune-relations --relation {} --dry-run",
361                    top_rel,
362                    top_ratio * 100.0,
363                    top_count,
364                    relationships_count,
365                    top_rel,
366                ))
367            } else {
368                None
369            };
370
371            (
372                Some(top_rel),
373                Some(top_ratio),
374                at_ratio,
375                concentration_warning,
376            )
377        } else {
378            (None, None, None, None)
379        };
380
381    let status = "ok";
382
383    let schema_version: u32 = conn
384        .query_row(
385            "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
386            [],
387            |r| r.get::<_, i64>(0),
388        )
389        .unwrap_or(0) as u32;
390
391    let schema_ok = schema_version > 0;
392
393    // Checks vector tables via sqlite_master (consistency: table exists)
394    // and counts source rows without a vector (completeness: coverage).
395    let (vec_entities_ok, vec_entities_missing) = entity_embedding_health(&conn);
396    let (vec_chunks_ok, vec_chunks_missing) = chunk_embedding_health(&conn);
397
398    // v1.1.1 (P6a): coverage percentages. The memory total is global (the
399    // vec_memories_missing count above is namespace-agnostic too).
400    let memories_total_global: i64 = conn.query_row(
401        "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
402        [],
403        |r| r.get(0),
404    )?;
405    let chunks_total: i64 = conn
406        .query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| r.get(0))
407        .unwrap_or(0);
408    let vec_memories_coverage_pct =
409        coverage_pct(vec_memories_ok, memories_total_global, vec_memories_missing);
410    let vec_entities_coverage_pct =
411        coverage_pct(vec_entities_ok, entities_count, vec_entities_missing);
412    let vec_chunks_coverage_pct = coverage_pct(vec_chunks_ok, chunks_total, vec_chunks_missing);
413
414    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");
415    let fts_ok = table_exists(&conn, "fts_memories");
416
417    // Verifies that FTS5 can execute a MATCH query (catches index corruption distinct from table absence).
418    let fts_query_ok = if fts_ok {
419        conn.query_row(
420            "SELECT COUNT(*) FROM fts_memories WHERE fts_memories MATCH 'a' LIMIT 1",
421            [],
422            |r| r.get::<_, i64>(0),
423        )
424        .is_ok()
425    } else {
426        false
427    };
428
429    tracing::info!(target: "health", fts_ok = %fts_ok, fts_query_ok = %fts_query_ok, "FTS5 checks complete");
430
431    // Captures the SQLite runtime version for observability.
432    let sqlite_version: String = conn
433        .query_row("SELECT sqlite_version()", [], |r| r.get(0))
434        .unwrap_or_else(|_| "unknown".to_string());
435
436    // Detects orphan entities referenced by memories but absent from the entities table.
437    let mut missing_entities: Vec<String> = Vec::with_capacity(4);
438    let mut stmt = conn.prepare_cached(
439        "SELECT DISTINCT me.entity_id
440         FROM memory_entities me
441         LEFT JOIN entities e ON e.id = me.entity_id
442         WHERE e.id IS NULL",
443    )?;
444    let orphans: Vec<i64> = stmt
445        .query_map([], |r| r.get(0))?
446        .collect::<Result<Vec<_>, _>>()?;
447    for id in orphans {
448        missing_entities.push(format!("entity_id={id}"));
449    }
450
451    let journal_mode: String = conn
452        .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
453        .unwrap_or_else(|_| "unknown".to_string());
454
455    let wal_size_mb = fs::metadata(format!("{}-wal", paths.db.display()))
456        .map(|m| m.len() as f64 / 1024.0 / 1024.0)
457        .unwrap_or(0.0);
458
459    // Database file size in bytes
460    let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
461
462    // G46: the ONNX model cache no longer exists in the LLM-only build
463    // (v1.0.76+). OpenRouter REST is the only embedding backend, so model_ok
464    // reports whether an API key resolves — the real prerequisite for
465    // embedding generation.
466    let model_ok = crate::config::resolve_api_key("openrouter", None).is_some();
467    tracing::info!(target: "health", model_ok = %model_ok, "OpenRouter key availability check complete");
468
469    // Builds the checks array for detailed diagnostics
470    let mut checks: Vec<HealthCheck> = Vec::with_capacity(8);
471
472    // At this point integrity_ok is always true (corrupt DB returned early above).
473    checks.push(HealthCheck {
474        name: "integrity".to_string(),
475        ok: true,
476        detail: None,
477    });
478
479    checks.push(HealthCheck {
480        name: "schema_version".to_string(),
481        ok: schema_ok,
482        detail: if schema_ok {
483            None
484        } else {
485            Some(format!("schema_version={schema_version} (expected >0)"))
486        },
487    });
488
489    checks.push(HealthCheck {
490        name: "vec_memories".to_string(),
491        ok: vec_memories_ok,
492        detail: if vec_memories_ok {
493            None
494        } else {
495            Some("memory_embeddings/vec_memories table missing from sqlite_master".to_string())
496        },
497    });
498
499    checks.push(HealthCheck {
500        name: "vec_entities".to_string(),
501        ok: vec_entities_ok,
502        detail: if vec_entities_ok {
503            None
504        } else {
505            Some("entity_embeddings/vec_entities table missing from sqlite_master".to_string())
506        },
507    });
508
509    checks.push(HealthCheck {
510        name: "vec_chunks".to_string(),
511        ok: vec_chunks_ok,
512        detail: if vec_chunks_ok {
513            None
514        } else {
515            Some("chunk_embeddings/vec_chunks table missing from sqlite_master".to_string())
516        },
517    });
518
519    checks.push(HealthCheck {
520        name: "fts_memories".to_string(),
521        ok: fts_ok,
522        detail: if fts_ok {
523            None
524        } else {
525            Some("fts_memories table missing from sqlite_master".to_string())
526        },
527    });
528
529    checks.push(HealthCheck {
530        name: "fts_query".to_string(),
531        ok: fts_query_ok,
532        detail: if fts_query_ok {
533            None
534        } else {
535            Some("FTS5 MATCH query failed — run 'sqlite-graphrag fts rebuild'".to_string())
536        },
537    });
538
539    checks.push(HealthCheck {
540        // Renamed from `llm_cli` in v1.2.5: since v1.0.76 this probes whether an
541        // OpenRouter key is reachable, not whether a CLI sits on PATH, and the
542        // product has had no LLM subprocess since v1.2.0. `health.schema.json`
543        // types `name` as a free string and no test or document pinned the old
544        // value, so the rename costs nothing and stops the envelope naming a
545        // component that does not exist.
546        name: "embedding_key".to_string(),
547        ok: model_ok,
548        detail: if model_ok {
549            None
550        } else {
551            // The check itself moved to the OpenRouter key in v1.0.76 (see the
552            // `resolve_api_key` call above), but this message kept telling the
553            // operator to install two CLIs that v1.2.0 removed from the product.
554            // Following it fixed nothing: the check would still fail.
555            Some(
556                "no OpenRouter API key reachable; store one with \
557                 `sqlite-graphrag config add-key --provider openrouter --from-stdin` \
558                 or pass --openrouter-api-key — embedding generation is REST-only"
559                    .to_string(),
560            )
561        },
562    });
563
564    // G24: detect non-normalized entity names
565    let (non_normalized_count, normalization_warning) = {
566        let mut stmt = conn.prepare_cached("SELECT name FROM entities")?;
567        let names: Vec<String> = stmt
568            .query_map([], |r| r.get(0))?
569            .filter_map(|r| r.ok())
570            .collect();
571        let count = names
572            .iter()
573            .filter(|n| crate::parsers::normalize_entity_name(n) != **n)
574            .count() as i64;
575        let warning = if count > 0 {
576            Some(format!(
577                "run 'normalize-entities --yes' to fix {count} non-normalized entities"
578            ))
579        } else {
580            None
581        };
582        (Some(count), warning)
583    };
584
585    // G25: detect super-hub entities.
586    let (super_hub_count, super_hub_warning) = {
587        let (count, warning) = super_hub_stats(&conn)?;
588        (Some(count), warning)
589    };
590
591    // G25 (extended): identify the single highest-degree entity for programmatic use.
592    let (top_hub_entity, top_hub_degree, hub_warning) = {
593        let result: Option<(String, i64)> = conn
594            .query_row(
595                "SELECT e.name, COUNT(r.id) AS degree
596                 FROM entities e
597                 LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id
598                 GROUP BY e.id
599                 ORDER BY degree DESC
600                 LIMIT 1",
601                [],
602                |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
603            )
604            .ok();
605        match result {
606            Some((name, degree)) => {
607                let warning = if degree > 50 {
608                    Some(format!(
609                        "entity '{name}' has {degree} connections; consider splitting or using --max-neighbors-per-hop"
610                    ))
611                } else {
612                    None
613                };
614                (Some(name), Some(degree), warning)
615            }
616            None => (None, None, None),
617        }
618    };
619
620    let llm_slots = llm_slot_info();
621    let response = HealthResponse {
622        status: status.to_string(),
623        namespace: namespace_filter.clone(),
624        integrity,
625        integrity_ok,
626        schema_ok,
627        vec_memories_ok,
628        vec_memories_missing,
629        vec_memories_orphaned,
630        vec_entities_ok,
631        vec_entities_missing,
632        vec_chunks_ok,
633        vec_chunks_missing,
634        vec_memories_coverage_pct,
635        vec_entities_coverage_pct,
636        vec_chunks_coverage_pct,
637        fts_ok,
638        fts_query_ok,
639        model_ok,
640        counts: HealthCounts {
641            memories: memories_count,
642            memories_total: memories_count,
643            entities: entities_count,
644            relationships: relationships_count,
645            vec_memories: vec_memories_count,
646        },
647        db_path: paths.db.display().to_string(),
648        db_size_bytes,
649        schema_version,
650        sqlite_version,
651        missing_entities,
652        wal_size_mb,
653        journal_mode,
654        mentions_ratio,
655        mentions_warning,
656        top_relation,
657        top_relation_ratio,
658        applies_to_ratio,
659        relation_concentration_warning,
660        non_normalized_count,
661        normalization_warning,
662        super_hub_count,
663        super_hub_warning,
664        top_hub_entity,
665        top_hub_degree,
666        hub_warning,
667        llm_slots_total: Some(llm_slots.0),
668        llm_slots_occupied: Some(llm_slots.1),
669        llm_slots_stale: Some(llm_slots.2),
670        checks,
671        elapsed_ms: start.elapsed().as_millis() as u64,
672    };
673    output::emit_json(&response)?;
674    Ok(())
675}
676/// Counts super-hub entities and names a sample of the widest ones.
677///
678/// The count spans the whole graph; the warning names only
679/// [`crate::constants::HEALTH_SUPER_HUB_SAMPLE_LIMIT`] entities. They need
680/// separate queries because deriving the count from the sample capped it at the
681/// sample size: a graph with hundreds of hubs reported exactly five and never
682/// moved, contradicting the documented meaning of the field.
683fn super_hub_stats(conn: &rusqlite::Connection) -> Result<(i64, Option<String>), AppError> {
684    let threshold = crate::constants::HEALTH_SUPER_HUB_DEGREE_THRESHOLD;
685    let count: i64 = conn.query_row(
686        "SELECT COUNT(*) FROM ( \
687           SELECT e.id FROM entities e \
688           LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id \
689           GROUP BY e.id HAVING COUNT(r.id) > ?1 \
690         )",
691        rusqlite::params![threshold],
692        |r| r.get(0),
693    )?;
694    if count == 0 {
695        return Ok((0, None));
696    }
697
698    let sample_limit =
699        i64::try_from(crate::constants::HEALTH_SUPER_HUB_SAMPLE_LIMIT).unwrap_or(i64::MAX);
700    let mut stmt = conn.prepare_cached(
701        "SELECT e.name, COUNT(r.id) as deg FROM entities e \
702         LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id \
703         GROUP BY e.id HAVING deg > ?1 ORDER BY deg DESC LIMIT ?2",
704    )?;
705    let names: Vec<String> = stmt
706        .query_map(rusqlite::params![threshold, sample_limit], |r| {
707            Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
708        })?
709        .filter_map(|r| r.ok())
710        .map(|(n, d)| format!("{n} (degree {d})"))
711        .collect();
712
713    Ok((
714        count,
715        Some(format!(
716            "super-hubs detected ({count} total, showing {}): {}",
717            names.len(),
718            names.join(", ")
719        )),
720    ))
721}
722
723#[cfg(test)]
724#[path = "../health_tests.rs"]
725mod tests;