1use 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
11#[derive(clap::Args)]
12#[command(after_long_help = "EXAMPLES:\n \
13 # Check database health (connectivity, integrity, vector index)\n \
14 sqlite-graphrag health\n\n \
15 # Check health of a database at a custom path\n \
16 sqlite-graphrag health --db /path/to/graphrag.sqlite\n\n \
17 # Use SQLITE_GRAPHRAG_DB_PATH env var\n \
18 SQLITE_GRAPHRAG_DB_PATH=/data/graphrag.sqlite sqlite-graphrag health")]
19pub struct HealthArgs {
20 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
21 pub db: Option<String>,
22 #[arg(long, default_value_t = false)]
24 pub json: bool,
25 #[arg(long, value_parser = ["json", "text"], hide = true)]
27 pub format: Option<String>,
28}
29
30#[derive(Serialize)]
31struct HealthCounts {
32 memories: i64,
33 memories_total: i64,
35 entities: i64,
36 relationships: i64,
37 vec_memories: i64,
38}
39
40#[derive(Serialize)]
41struct HealthCheck {
42 name: String,
43 ok: bool,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 detail: Option<String>,
46}
47
48#[derive(Serialize)]
49struct HealthResponse {
50 status: String,
51 integrity: String,
52 integrity_ok: bool,
53 schema_ok: bool,
54 vec_memories_ok: bool,
55 vec_memories_missing: i64,
56 vec_memories_orphaned: i64,
57 vec_entities_ok: bool,
58 vec_chunks_ok: bool,
59 fts_ok: bool,
60 fts_query_ok: bool,
62 model_ok: bool,
63 counts: HealthCounts,
64 db_path: String,
65 db_size_bytes: u64,
66 schema_version: u32,
70 missing_entities: Vec<String>,
73 wal_size_mb: f64,
75 journal_mode: String,
77 sqlite_version: String,
79 #[serde(skip_serializing_if = "Option::is_none")]
82 mentions_ratio: Option<f64>,
83 #[serde(skip_serializing_if = "Option::is_none")]
86 mentions_warning: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
90 top_relation: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
94 top_relation_ratio: Option<f64>,
95 #[serde(skip_serializing_if = "Option::is_none")]
98 applies_to_ratio: Option<f64>,
99 #[serde(skip_serializing_if = "Option::is_none")]
102 relation_concentration_warning: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 non_normalized_count: Option<i64>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 normalization_warning: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 super_hub_count: Option<i64>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 super_hub_warning: Option<String>,
115 #[serde(skip_serializing_if = "Option::is_none")]
118 top_hub_entity: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
122 top_hub_degree: Option<i64>,
123 #[serde(skip_serializing_if = "Option::is_none")]
126 hub_warning: Option<String>,
127 checks: Vec<HealthCheck>,
128 elapsed_ms: u64,
129}
130
131fn table_exists(conn: &rusqlite::Connection, table_name: &str) -> bool {
133 conn.query_row(
134 "SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table', 'shadow') AND name = ?1",
135 rusqlite::params![table_name],
136 |r| r.get::<_, i64>(0),
137 )
138 .unwrap_or(0)
139 > 0
140}
141
142pub fn run(args: HealthArgs) -> Result<(), AppError> {
143 let start = Instant::now();
144 let _ = args.json; let _ = args.format; let paths = AppPaths::resolve(args.db.as_deref())?;
147
148 crate::storage::connection::ensure_db_ready(&paths)?;
149
150 let conn = open_ro(&paths.db)?;
151
152 let integrity: String = conn.query_row("PRAGMA integrity_check;", [], |r| r.get(0))?;
153 let integrity_ok = integrity == "ok";
154 tracing::info!(target: "health", integrity_ok = %integrity_ok, "PRAGMA integrity_check complete");
155
156 if !integrity_ok {
157 let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
158 output::emit_json(&HealthResponse {
159 status: "degraded".to_string(),
160 integrity: integrity.clone(),
161 integrity_ok: false,
162 schema_ok: false,
163 vec_memories_ok: false,
164 vec_memories_missing: 0,
165 vec_memories_orphaned: 0,
166 vec_entities_ok: false,
167 vec_chunks_ok: false,
168 fts_ok: false,
169 fts_query_ok: false,
170 model_ok: false,
171 counts: HealthCounts {
172 memories: 0,
173 memories_total: 0,
174 entities: 0,
175 relationships: 0,
176 vec_memories: 0,
177 },
178 db_path: paths.db.display().to_string(),
179 db_size_bytes,
180 schema_version: 0,
181 sqlite_version: "unknown".to_string(),
182 missing_entities: vec![],
183 wal_size_mb: 0.0,
184 journal_mode: "unknown".to_string(),
185 mentions_ratio: None,
186 mentions_warning: None,
187 top_relation: None,
188 top_relation_ratio: None,
189 applies_to_ratio: None,
190 relation_concentration_warning: None,
191 non_normalized_count: None,
192 normalization_warning: None,
193 super_hub_count: None,
194 super_hub_warning: None,
195 top_hub_entity: None,
196 top_hub_degree: None,
197 hub_warning: None,
198 checks: vec![HealthCheck {
199 name: "integrity".to_string(),
200 ok: false,
201 detail: Some(integrity),
202 }],
203 elapsed_ms: start.elapsed().as_millis() as u64,
204 })?;
205 return Err(AppError::Database(rusqlite::Error::SqliteFailure(
206 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CORRUPT),
207 Some("integrity check failed".to_string()),
208 )));
209 }
210
211 let memories_count: i64 = conn.query_row(
212 "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
213 [],
214 |r| r.get(0),
215 )?;
216 let entities_count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
217 let relationships_count: i64 =
218 conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?;
219 let vec_memories_count: i64 =
220 conn.query_row("SELECT COUNT(*) FROM vec_memories", [], |r| r.get(0))?;
221
222 let mentions_count: i64 = conn.query_row(
223 "SELECT COUNT(*) FROM relationships WHERE relation = 'mentions'",
224 [],
225 |r| r.get(0),
226 )?;
227 let (mentions_ratio, mentions_warning) = if relationships_count > 0 {
228 let ratio = mentions_count as f64 / relationships_count as f64;
229 let warning = if ratio > 0.5 {
230 Some(format!(
231 "mentions relationships dominate graph at {:.1}% ({}/{} total); consider running prune-relations --relation mentions --dry-run",
232 ratio * 100.0,
233 mentions_count,
234 relationships_count
235 ))
236 } else {
237 None
238 };
239 (Some(ratio), warning)
240 } else {
241 (None, None)
242 };
243
244 let (top_relation, top_relation_ratio, applies_to_ratio, relation_concentration_warning) =
246 if relationships_count > 0 {
247 let (top_rel, top_count): (String, i64) = conn
249 .query_row(
250 "SELECT relation, COUNT(*) AS cnt
251 FROM relationships
252 GROUP BY relation
253 ORDER BY cnt DESC
254 LIMIT 1",
255 [],
256 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
257 )
258 .unwrap_or_else(|_| ("unknown".to_string(), 0));
259
260 let top_ratio = top_count as f64 / relationships_count as f64;
261
262 let applies_count: i64 = conn
264 .query_row(
265 "SELECT COUNT(*) FROM relationships WHERE relation = 'applies_to'",
266 [],
267 |r| r.get(0),
268 )
269 .unwrap_or(0);
270 let at_ratio = if applies_count > 0 {
271 Some(applies_count as f64 / relationships_count as f64)
272 } else {
273 None
274 };
275
276 let concentration_warning = if top_ratio > 0.40 {
277 Some(format!(
278 "relation '{}' dominates graph at {:.1}% ({}/{} total); consider running prune-relations --relation {} --dry-run",
279 top_rel,
280 top_ratio * 100.0,
281 top_count,
282 relationships_count,
283 top_rel,
284 ))
285 } else {
286 None
287 };
288
289 (
290 Some(top_rel),
291 Some(top_ratio),
292 at_ratio,
293 concentration_warning,
294 )
295 } else {
296 (None, None, None, None)
297 };
298
299 let status = "ok";
300
301 let schema_version: u32 = conn
302 .query_row(
303 "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
304 [],
305 |r| r.get::<_, i64>(0),
306 )
307 .unwrap_or(0) as u32;
308
309 let schema_ok = schema_version > 0;
310
311 let vec_memories_ok = table_exists(&conn, "vec_memories");
313 let vec_entities_ok = table_exists(&conn, "vec_entities");
314 let vec_chunks_ok = table_exists(&conn, "vec_chunks");
315
316 let vec_memories_missing: i64 = if vec_memories_ok {
317 conn.query_row(
318 "SELECT COUNT(*) FROM memories m LEFT JOIN vec_memories v ON v.memory_id = m.id WHERE v.memory_id IS NULL AND m.deleted_at IS NULL",
319 [], |r| r.get(0),
320 ).unwrap_or(0)
321 } else {
322 0
323 };
324
325 let vec_memories_orphaned: i64 = if vec_memories_ok {
326 conn.query_row(
327 "SELECT COUNT(*) FROM vec_memories v LEFT JOIN memories m ON m.id = v.memory_id WHERE m.id IS NULL",
328 [], |r| r.get(0),
329 ).unwrap_or(0)
330 } else {
331 0
332 };
333
334 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");
335 let fts_ok = table_exists(&conn, "fts_memories");
336
337 let fts_query_ok = if fts_ok {
339 conn.query_row(
340 "SELECT COUNT(*) FROM fts_memories WHERE fts_memories MATCH 'a' LIMIT 1",
341 [],
342 |r| r.get::<_, i64>(0),
343 )
344 .is_ok()
345 } else {
346 false
347 };
348
349 tracing::info!(target: "health", fts_ok = %fts_ok, fts_query_ok = %fts_query_ok, "FTS5 checks complete");
350
351 let sqlite_version: String = conn
353 .query_row("SELECT sqlite_version()", [], |r| r.get(0))
354 .unwrap_or_else(|_| "unknown".to_string());
355
356 let mut missing_entities: Vec<String> = Vec::with_capacity(4);
358 let mut stmt = conn.prepare_cached(
359 "SELECT DISTINCT me.entity_id
360 FROM memory_entities me
361 LEFT JOIN entities e ON e.id = me.entity_id
362 WHERE e.id IS NULL",
363 )?;
364 let orphans: Vec<i64> = stmt
365 .query_map([], |r| r.get(0))?
366 .collect::<Result<Vec<_>, _>>()?;
367 for id in orphans {
368 missing_entities.push(format!("entity_id={id}"));
369 }
370
371 let journal_mode: String = conn
372 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
373 .unwrap_or_else(|_| "unknown".to_string());
374
375 let wal_size_mb = fs::metadata(format!("{}-wal", paths.db.display()))
376 .map(|m| m.len() as f64 / 1024.0 / 1024.0)
377 .unwrap_or(0.0);
378
379 let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
381
382 let model_dir = paths.models.join("models--intfloat--multilingual-e5-small");
384 let model_ok = model_dir.exists();
385 tracing::info!(target: "health", model_ok = %model_ok, "embedding model check complete");
386
387 let mut checks: Vec<HealthCheck> = Vec::with_capacity(8);
389
390 checks.push(HealthCheck {
392 name: "integrity".to_string(),
393 ok: true,
394 detail: None,
395 });
396
397 checks.push(HealthCheck {
398 name: "schema_version".to_string(),
399 ok: schema_ok,
400 detail: if schema_ok {
401 None
402 } else {
403 Some(format!("schema_version={schema_version} (expected >0)"))
404 },
405 });
406
407 checks.push(HealthCheck {
408 name: "vec_memories".to_string(),
409 ok: vec_memories_ok,
410 detail: if vec_memories_ok {
411 None
412 } else {
413 Some("vec_memories table missing from sqlite_master".to_string())
414 },
415 });
416
417 checks.push(HealthCheck {
418 name: "vec_entities".to_string(),
419 ok: vec_entities_ok,
420 detail: if vec_entities_ok {
421 None
422 } else {
423 Some("vec_entities table missing from sqlite_master".to_string())
424 },
425 });
426
427 checks.push(HealthCheck {
428 name: "vec_chunks".to_string(),
429 ok: vec_chunks_ok,
430 detail: if vec_chunks_ok {
431 None
432 } else {
433 Some("vec_chunks table missing from sqlite_master".to_string())
434 },
435 });
436
437 checks.push(HealthCheck {
438 name: "fts_memories".to_string(),
439 ok: fts_ok,
440 detail: if fts_ok {
441 None
442 } else {
443 Some("fts_memories table missing from sqlite_master".to_string())
444 },
445 });
446
447 checks.push(HealthCheck {
448 name: "fts_query".to_string(),
449 ok: fts_query_ok,
450 detail: if fts_query_ok {
451 None
452 } else {
453 Some("FTS5 MATCH query failed — run 'sqlite-graphrag fts rebuild'".to_string())
454 },
455 });
456
457 checks.push(HealthCheck {
458 name: "model_onnx".to_string(),
459 ok: model_ok,
460 detail: if model_ok {
461 None
462 } else {
463 Some(format!(
464 "model missing at {}; run 'sqlite-graphrag models download'",
465 model_dir.display()
466 ))
467 },
468 });
469
470 let (non_normalized_count, normalization_warning) = {
472 let mut stmt = conn.prepare_cached("SELECT name FROM entities")?;
473 let names: Vec<String> = stmt
474 .query_map([], |r| r.get(0))?
475 .filter_map(|r| r.ok())
476 .collect();
477 let count = names
478 .iter()
479 .filter(|n| crate::parsers::normalize_entity_name(n) != **n)
480 .count() as i64;
481 let warning = if count > 0 {
482 Some(format!(
483 "run 'normalize-entities --yes' to fix {count} non-normalized entities"
484 ))
485 } else {
486 None
487 };
488 (Some(count), warning)
489 };
490
491 let (super_hub_count, super_hub_warning) = {
493 let mut stmt = conn.prepare_cached(
494 "SELECT e.name, COUNT(r.id) as deg FROM entities e \
495 LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id \
496 GROUP BY e.id HAVING deg > 50 ORDER BY deg DESC LIMIT 5",
497 )?;
498 let hubs: Vec<(String, i64)> = stmt
499 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
500 .filter_map(|r| r.ok())
501 .collect();
502 let count = hubs.len() as i64;
503 let warning = if count > 0 {
504 let names: Vec<String> = hubs
505 .iter()
506 .map(|(n, d)| format!("{n} (degree {d})"))
507 .collect();
508 Some(format!("super-hubs detected: {}", names.join(", ")))
509 } else {
510 None
511 };
512 (Some(count), warning)
513 };
514
515 let (top_hub_entity, top_hub_degree, hub_warning) = {
517 let result: Option<(String, i64)> = conn
518 .query_row(
519 "SELECT e.name, COUNT(r.id) AS degree
520 FROM entities e
521 LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id
522 GROUP BY e.id
523 ORDER BY degree DESC
524 LIMIT 1",
525 [],
526 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
527 )
528 .ok();
529 match result {
530 Some((name, degree)) => {
531 let warning = if degree > 50 {
532 Some(format!(
533 "entity '{name}' has {degree} connections; consider splitting or using --max-neighbors-per-hop"
534 ))
535 } else {
536 None
537 };
538 (Some(name), Some(degree), warning)
539 }
540 None => (None, None, None),
541 }
542 };
543
544 let response = HealthResponse {
545 status: status.to_string(),
546 integrity,
547 integrity_ok,
548 schema_ok,
549 vec_memories_ok,
550 vec_memories_missing,
551 vec_memories_orphaned,
552 vec_entities_ok,
553 vec_chunks_ok,
554 fts_ok,
555 fts_query_ok,
556 model_ok,
557 counts: HealthCounts {
558 memories: memories_count,
559 memories_total: memories_count,
560 entities: entities_count,
561 relationships: relationships_count,
562 vec_memories: vec_memories_count,
563 },
564 db_path: paths.db.display().to_string(),
565 db_size_bytes,
566 schema_version,
567 sqlite_version,
568 missing_entities,
569 wal_size_mb,
570 journal_mode,
571 mentions_ratio,
572 mentions_warning,
573 top_relation,
574 top_relation_ratio,
575 applies_to_ratio,
576 relation_concentration_warning,
577 non_normalized_count,
578 normalization_warning,
579 super_hub_count,
580 super_hub_warning,
581 top_hub_entity,
582 top_hub_degree,
583 hub_warning,
584 checks,
585 elapsed_ms: start.elapsed().as_millis() as u64,
586 };
587
588 output::emit_json(&response)?;
589
590 Ok(())
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596
597 #[test]
598 fn health_check_serializes_all_new_fields() {
599 let response = HealthResponse {
600 status: "ok".to_string(),
601 integrity: "ok".to_string(),
602 integrity_ok: true,
603 schema_ok: true,
604 vec_memories_ok: true,
605 vec_memories_missing: 0,
606 vec_memories_orphaned: 0,
607 vec_entities_ok: true,
608 vec_chunks_ok: true,
609 fts_ok: true,
610 fts_query_ok: true,
611 model_ok: false,
612 counts: HealthCounts {
613 memories: 5,
614 memories_total: 5,
615 entities: 3,
616 relationships: 2,
617 vec_memories: 5,
618 },
619 db_path: "/tmp/test.sqlite".to_string(),
620 db_size_bytes: 4096,
621 schema_version: 6,
622 sqlite_version: "3.46.0".to_string(),
623 elapsed_ms: 0,
624 missing_entities: vec![],
625 wal_size_mb: 0.0,
626 journal_mode: "wal".to_string(),
627 mentions_ratio: None,
628 mentions_warning: None,
629 top_relation: None,
630 top_relation_ratio: None,
631 applies_to_ratio: None,
632 relation_concentration_warning: None,
633 non_normalized_count: None,
634 normalization_warning: None,
635 super_hub_count: None,
636 super_hub_warning: None,
637 top_hub_entity: None,
638 top_hub_degree: None,
639 hub_warning: None,
640 checks: vec![
641 HealthCheck {
642 name: "integrity".to_string(),
643 ok: true,
644 detail: None,
645 },
646 HealthCheck {
647 name: "model_onnx".to_string(),
648 ok: false,
649 detail: Some("model missing".to_string()),
650 },
651 ],
652 };
653
654 let json = serde_json::to_value(&response).unwrap();
655 assert_eq!(json["status"], "ok");
656 assert_eq!(json["integrity_ok"], true);
657 assert_eq!(json["schema_ok"], true);
658 assert_eq!(json["vec_memories_ok"], true);
659 assert_eq!(json["vec_entities_ok"], true);
660 assert_eq!(json["vec_chunks_ok"], true);
661 assert_eq!(json["fts_ok"], true);
662 assert_eq!(json["model_ok"], false);
663 assert_eq!(json["db_size_bytes"], 4096u64);
664 assert!(json["checks"].is_array());
665 assert_eq!(json["checks"].as_array().unwrap().len(), 2);
666
667 let integrity_check = &json["checks"][0];
669 assert_eq!(integrity_check["name"], "integrity");
670 assert_eq!(integrity_check["ok"], true);
671 assert!(integrity_check.get("detail").is_none());
672
673 let model_check = &json["checks"][1];
675 assert_eq!(model_check["name"], "model_onnx");
676 assert_eq!(model_check["ok"], false);
677 assert_eq!(model_check["detail"], "model missing");
678 }
679
680 #[test]
681 fn health_check_without_detail_omits_field() {
682 let check = HealthCheck {
683 name: "vec_memories".to_string(),
684 ok: true,
685 detail: None,
686 };
687 let json = serde_json::to_value(&check).unwrap();
688 assert!(
689 json.get("detail").is_none(),
690 "detail field must be omitted when None"
691 );
692 }
693
694 #[test]
695 fn health_check_with_detail_serializes_field() {
696 let check = HealthCheck {
697 name: "fts_memories".to_string(),
698 ok: false,
699 detail: Some("fts_memories table missing from sqlite_master".to_string()),
700 };
701 let json = serde_json::to_value(&check).unwrap();
702 assert_eq!(
703 json["detail"],
704 "fts_memories table missing from sqlite_master"
705 );
706 }
707
708 #[test]
709 fn health_response_fts_query_ok_and_sqlite_version_serialize() {
710 let response = HealthResponse {
713 status: "ok".to_string(),
714 integrity: "ok".to_string(),
715 integrity_ok: true,
716 schema_ok: true,
717 vec_memories_ok: true,
718 vec_memories_missing: 0,
719 vec_memories_orphaned: 0,
720 vec_entities_ok: true,
721 vec_chunks_ok: true,
722 fts_ok: true,
723 fts_query_ok: true,
724 model_ok: true,
725 counts: HealthCounts {
726 memories: 0,
727 memories_total: 0,
728 entities: 0,
729 relationships: 0,
730 vec_memories: 0,
731 },
732 db_path: "/tmp/test.sqlite".to_string(),
733 db_size_bytes: 0,
734 schema_version: 1,
735 sqlite_version: "3.45.1".to_string(),
736 elapsed_ms: 0,
737 missing_entities: vec![],
738 wal_size_mb: 0.0,
739 journal_mode: "wal".to_string(),
740 mentions_ratio: None,
741 mentions_warning: None,
742 top_relation: None,
743 top_relation_ratio: None,
744 applies_to_ratio: None,
745 relation_concentration_warning: None,
746 non_normalized_count: None,
747 normalization_warning: None,
748 super_hub_count: None,
749 super_hub_warning: None,
750 top_hub_entity: None,
751 top_hub_degree: None,
752 hub_warning: None,
753 checks: vec![],
754 };
755
756 let json = serde_json::to_value(&response).unwrap();
757
758 assert_eq!(
760 json["fts_query_ok"], true,
761 "fts_query_ok must be present and true in serialized JSON"
762 );
763
764 assert_eq!(
766 json["sqlite_version"], "3.45.1",
767 "sqlite_version must be present and match the provided string"
768 );
769
770 let check_fail = HealthCheck {
772 name: "fts_query".to_string(),
773 ok: false,
774 detail: Some("FTS5 MATCH query failed — run 'sqlite-graphrag fts rebuild'".to_string()),
775 };
776 let check_json = serde_json::to_value(&check_fail).unwrap();
777 assert_eq!(check_json["name"], "fts_query");
778 assert_eq!(check_json["ok"], false);
779 assert_eq!(
780 check_json["detail"],
781 "FTS5 MATCH query failed — run 'sqlite-graphrag fts rebuild'"
782 );
783 }
784
785 fn make_full_response(
786 top_relation: Option<String>,
787 top_relation_ratio: Option<f64>,
788 applies_to_ratio: Option<f64>,
789 relation_concentration_warning: Option<String>,
790 ) -> HealthResponse {
791 HealthResponse {
792 status: "ok".to_string(),
793 integrity: "ok".to_string(),
794 integrity_ok: true,
795 schema_ok: true,
796 vec_memories_ok: true,
797 vec_memories_missing: 0,
798 vec_memories_orphaned: 0,
799 vec_entities_ok: true,
800 vec_chunks_ok: true,
801 fts_ok: true,
802 fts_query_ok: true,
803 model_ok: true,
804 counts: HealthCounts {
805 memories: 10,
806 memories_total: 10,
807 entities: 5,
808 relationships: 20,
809 vec_memories: 10,
810 },
811 db_path: "/tmp/test.sqlite".to_string(),
812 db_size_bytes: 8192,
813 schema_version: 3,
814 sqlite_version: "3.46.0".to_string(),
815 elapsed_ms: 1,
816 missing_entities: vec![],
817 wal_size_mb: 0.0,
818 journal_mode: "wal".to_string(),
819 mentions_ratio: None,
820 mentions_warning: None,
821 top_relation,
822 top_relation_ratio,
823 applies_to_ratio,
824 relation_concentration_warning,
825 non_normalized_count: None,
826 normalization_warning: None,
827 super_hub_count: None,
828 super_hub_warning: None,
829 top_hub_entity: None,
830 top_hub_degree: None,
831 hub_warning: None,
832 checks: vec![],
833 }
834 }
835
836 #[test]
837 fn health_concentration_fields_omitted_when_no_relationships() {
838 let resp = make_full_response(None, None, None, None);
840 let json = serde_json::to_value(&resp).unwrap();
841 assert!(
842 json.get("top_relation").is_none(),
843 "top_relation must be omitted when None"
844 );
845 assert!(
846 json.get("top_relation_ratio").is_none(),
847 "top_relation_ratio must be omitted when None"
848 );
849 assert!(
850 json.get("applies_to_ratio").is_none(),
851 "applies_to_ratio must be omitted when None"
852 );
853 assert!(
854 json.get("relation_concentration_warning").is_none(),
855 "relation_concentration_warning must be omitted when None"
856 );
857 }
858
859 #[test]
860 fn health_concentration_fields_present_with_data() {
861 let resp = make_full_response(
862 Some("mentions".to_string()),
863 Some(0.60),
864 Some(0.10),
865 Some("relation 'mentions' dominates graph at 60.0%".to_string()),
866 );
867 let json = serde_json::to_value(&resp).unwrap();
868 assert_eq!(json["top_relation"], "mentions");
869 assert!((json["top_relation_ratio"].as_f64().unwrap() - 0.60).abs() < 1e-9);
870 assert!((json["applies_to_ratio"].as_f64().unwrap() - 0.10).abs() < 1e-9);
871 assert!(json["relation_concentration_warning"]
872 .as_str()
873 .unwrap()
874 .contains("60.0%"));
875 }
876
877 #[test]
878 fn health_concentration_warning_absent_when_ratio_below_threshold() {
879 let resp = make_full_response(Some("uses".to_string()), Some(0.39), None, None);
881 let json = serde_json::to_value(&resp).unwrap();
882 assert_eq!(json["top_relation"], "uses");
883 assert!(
884 json.get("relation_concentration_warning").is_none(),
885 "warning must be absent when ratio <= 0.40"
886 );
887 }
888
889 #[test]
890 fn health_concentration_warning_present_at_threshold() {
891 let resp = make_full_response(
893 Some("depends_on".to_string()),
894 Some(0.41),
895 None,
896 Some("relation 'depends_on' dominates graph at 41.0%".to_string()),
897 );
898 let json = serde_json::to_value(&resp).unwrap();
899 assert!(
900 json["relation_concentration_warning"].is_string(),
901 "warning must be present when top_relation_ratio > 0.40"
902 );
903 }
904
905 #[test]
906 fn health_applies_to_ratio_omitted_when_none() {
907 let resp = make_full_response(Some("related".to_string()), Some(0.30), None, None);
909 let json = serde_json::to_value(&resp).unwrap();
910 assert!(
911 json.get("applies_to_ratio").is_none(),
912 "applies_to_ratio must be omitted when None"
913 );
914 }
915}