compass-kb-validation 2.4.0__tar.gz → 2.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/PKG-INFO +1 -1
  2. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/compass_kb_validation.egg-info/PKG-INFO +1 -1
  3. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/__init__.py +1 -1
  4. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/report/__init__.py +154 -19
  5. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/report/_report_css.py +5 -3
  6. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/trend_tracker.py +16 -4
  7. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_reporting.py +104 -1
  8. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/LICENSE +0 -0
  9. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/README.md +0 -0
  10. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/compass_kb_validation.egg-info/SOURCES.txt +0 -0
  11. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
  12. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/compass_kb_validation.egg-info/entry_points.txt +0 -0
  13. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/compass_kb_validation.egg-info/requires.txt +0 -0
  14. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/compass_kb_validation.egg-info/top_level.txt +0 -0
  15. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/discover_and_validate.py +0 -0
  16. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/__main__.py +0 -0
  17. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/ask.py +0 -0
  18. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/baseline.py +0 -0
  19. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/chunk_coherence.py +0 -0
  20. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/cli.py +0 -0
  21. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/config/__init__.py +0 -0
  22. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/config_lint.py +0 -0
  23. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/content_inspect.py +0 -0
  24. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/corpus.py +0 -0
  25. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/corpus_health.py +0 -0
  26. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/diagnostics.py +0 -0
  27. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/embedding_drift.py +0 -0
  28. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/eval_interop.py +0 -0
  29. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/grounding.py +0 -0
  30. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/init_wizard.py +0 -0
  31. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/live_dashboard.py +0 -0
  32. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/mcp_server.py +0 -0
  33. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/models/__init__.py +0 -0
  34. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/notifications.py +0 -0
  35. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/pipeline/__init__.py +0 -0
  36. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/profiler.py +0 -0
  37. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/provenance.py +0 -0
  38. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/providers/__init__.py +0 -0
  39. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/providers/embedding.py +0 -0
  40. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/providers/graph.py +0 -0
  41. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/providers/source.py +0 -0
  42. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/providers/storage.py +0 -0
  43. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/providers/vectorstore.py +0 -0
  44. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/remediation.py +0 -0
  45. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/report/_report_js.py +0 -0
  46. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/report/analysis.py +0 -0
  47. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/report/export.py +0 -0
  48. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/report/shell.py +0 -0
  49. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/retrieval_analysis.py +0 -0
  50. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/utils/__init__.py +0 -0
  51. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/__init__.py +0 -0
  52. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/api_upload.py +0 -0
  53. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/base.py +0 -0
  54. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/base_sparql.py +0 -0
  55. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/cross_stage.py +0 -0
  56. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/graphdb_metadata.py +0 -0
  57. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/neptune_metadata.py +0 -0
  58. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/redis_chunks.py +0 -0
  59. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/registry.py +0 -0
  60. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/retrieval_quality.py +0 -0
  61. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/s3_storage.py +0 -0
  62. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/profile_kb.py +0 -0
  63. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/pyproject.toml +0 -0
  64. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/run_validation.py +0 -0
  65. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/setup.cfg +0 -0
  66. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_alt_adapters.py +0 -0
  67. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_analysis_report.py +0 -0
  68. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_api_upload.py +0 -0
  69. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_ask.py +0 -0
  70. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_baseline.py +0 -0
  71. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_benchmark.py +0 -0
  72. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_check_catalog.py +0 -0
  73. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_cli.py +0 -0
  74. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_config.py +0 -0
  75. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_config_lint.py +0 -0
  76. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_content_inspect.py +0 -0
  77. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_corpus.py +0 -0
  78. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_corpus_health.py +0 -0
  79. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_coverage.py +0 -0
  80. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_cross_stage.py +0 -0
  81. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_diagnostics.py +0 -0
  82. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_discover_cli.py +0 -0
  83. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_discovery_parallel.py +0 -0
  84. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_embedding_drift.py +0 -0
  85. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_eval_interop.py +0 -0
  86. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_gate.py +0 -0
  87. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_graph_transport.py +0 -0
  88. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_graph_validators.py +0 -0
  89. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_grounding.py +0 -0
  90. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_init_wizard.py +0 -0
  91. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_manifest.py +0 -0
  92. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_mcp.py +0 -0
  93. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_migration.py +0 -0
  94. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_models.py +0 -0
  95. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_neptune_bugfixes.py +0 -0
  96. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_neptune_bugfixes2.py +0 -0
  97. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_notifications.py +0 -0
  98. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_pipeline.py +0 -0
  99. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_profiler.py +0 -0
  100. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_providers.py +0 -0
  101. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_redis_chunks.py +0 -0
  102. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_registry.py +0 -0
  103. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_remediation.py +0 -0
  104. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_retrieval_assertions.py +0 -0
  105. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_retrieval_quality.py +0 -0
  106. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_run_cli.py +0 -0
  107. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_sources.py +0 -0
  108. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_storage_validator.py +0 -0
  109. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_trends.py +0 -0
  110. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_universal_concerns.py +0 -0
  111. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_utils.py +0 -0
  112. {compass_kb_validation-2.4.0 → compass_kb_validation-2.5.0}/tests/test_vectorstore_filters.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compass-kb-validation
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Knowledge-base readiness validation for RAG & AI agents — config-driven, deterministic pipeline integrity checks.
5
5
  Author: COMPASS
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compass-kb-validation
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Knowledge-base readiness validation for RAG & AI agents — config-driven, deterministic pipeline integrity checks.
5
5
  Author: COMPASS
6
6
  License: MIT
@@ -5,4 +5,4 @@ end-to-end: API upload -> object storage -> knowledge graph -> vector store ->
5
5
  retrieval quality, plus cross-store consistency.
6
6
  """
7
7
 
8
- __version__ = "2.4.0"
8
+ __version__ = "2.5.0"
@@ -58,8 +58,23 @@ _CAT_ORDER = [
58
58
  ("Content Preservation", "Fidelity, coverage, contamination"),
59
59
  ("Index & Schema", "Index fields, types, vector config"),
60
60
  ("Retrieval & Query", "Semantic search, filters, resilience"),
61
- ("Other", "Existence and core integrity"),
61
+ # Stage-derived buckets: catch a stage's checks that don't match a semantic pattern
62
+ # above, so they get a named home instead of swelling an opaque "Other".
63
+ ("Knowledge Graph", "Graph existence, typing, links, namespaces"),
64
+ ("Object Storage", "Source object existence, type, size, integrity"),
65
+ ("Cross-Stage Consistency", "Do the stores agree about a document?"),
66
+ ("Ingestion API", "Upload response, document id, status"),
67
+ ("Other", "Uncategorised checks"),
62
68
  ]
69
+ # When a check's label matches no semantic pattern, fall back to the category of the STEP
70
+ # it came from (so 'Other' stays small and honest instead of holding 54% of the checks).
71
+ _STEP_CATEGORY = {
72
+ "Neptune Metadata": "Knowledge Graph",
73
+ "GraphDB Metadata": "Knowledge Graph",
74
+ "S3 Storage": "Object Storage",
75
+ "Cross-Stage Consistency": "Cross-Stage Consistency",
76
+ "API Upload": "Ingestion API",
77
+ }
63
78
  _CAT_PATTERNS = [
64
79
  ("Metadata & Consistency", re.compile(r"e_tag|required field|metadata consist|positional|section|page metadata|directory|predicate|date format|email|field rule", re.I)),
65
80
  ("Chunk Structure", re.compile(r"boundaries|chunk count|size within|ordering|overlap", re.I)),
@@ -92,6 +107,15 @@ li .check-hint{margin:4px 0 0 0;display:block;}
92
107
  .metric-chip{background:var(--chip-bg);border:1px solid var(--line);border-radius:8px;padding:6px 11px;font-size:12px;color:var(--muted);}
93
108
  .metric-chip b{color:var(--ink);font-variant-numeric:tabular-nums;}
94
109
  .exec-interp-title{font-size:12px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);font-weight:700;margin-bottom:6px;}
110
+ .exec-narrative{font-size:13.5px;line-height:1.6;color:var(--ink);margin:0 0 10px;padding:9px 12px;background:var(--chip-bg);border-radius:8px;border-left:3px solid var(--accent);}
111
+ .subsys-rollup{padding:14px 16px 18px;display:flex;flex-direction:column;gap:8px;border-bottom:1px solid var(--line);}
112
+ .subsys-head{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:700;margin-bottom:4px;}
113
+ .subsys-row{display:grid;grid-template-columns:160px 1fr auto;align-items:center;gap:12px;font-size:12px;}
114
+ .subsys-name{font-weight:700;color:var(--ink);}
115
+ .subsys-bar{height:10px;background:var(--line);border-radius:5px;overflow:hidden;}
116
+ .subsys-bar i{display:block;height:100%;border-radius:5px;}
117
+ .subsys-meta{color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums;}
118
+ @media(max-width:600px){.subsys-row{grid-template-columns:1fr;gap:3px;}}
95
119
  .exec-bullets{margin:0 0 4px;padding-left:18px;font-size:13.5px;line-height:1.75;color:var(--ink);}
96
120
  .exec-bullets li{margin:0;}
97
121
  .exec-bullets li::marker{color:var(--muted);}
@@ -103,6 +127,8 @@ li .check-hint{margin:4px 0 0 0;display:block;}
103
127
  .hygiene-score{font-size:15px;font-weight:800;margin-top:2px;}
104
128
  .hygiene-score.ok{color:var(--ok);}.hygiene-score.warn{color:var(--warn);}.hygiene-score.bad{color:var(--bad);}
105
129
  .hygiene-worst{font-size:11px;color:var(--muted);margin-top:2px;}
130
+ .hygiene-delta{font-size:11px;font-weight:700;margin-left:6px;}
131
+ .hygiene-delta.ok{color:var(--ok);}.hygiene-delta.bad{color:var(--bad);}
106
132
  .hero-chips{display:flex;flex-wrap:wrap;gap:5px;}
107
133
  .hero-chip{font-size:10.5px;font-weight:700;padding:2px 7px;border-radius:9px;border:1px solid var(--line);color:var(--muted);white-space:nowrap;}
108
134
  .hero-chip.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);}
@@ -198,11 +224,12 @@ def _band(status: str) -> str:
198
224
  return _BAND.get(status, "na")
199
225
 
200
226
 
201
- def _categorise(label: str) -> str:
227
+ def _categorise(label: str, step: str = "") -> str:
202
228
  for name, pat in _CAT_PATTERNS:
203
229
  if pat.search(label or ""):
204
230
  return name
205
- return "Other"
231
+ # No semantic match: give it the step's own category so it isn't dumped into "Other".
232
+ return _STEP_CATEGORY.get(step, "Other")
206
233
 
207
234
 
208
235
  def _esc(s) -> str:
@@ -293,7 +320,7 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
293
320
  rec["ran"] += 1
294
321
  docs_incomplete += incomplete
295
322
  for c in cases:
296
- for st in c["steps"].values():
323
+ for step_name, st in c["steps"].items():
297
324
  # A step that did not run has nothing to say about the data. Its checks are
298
325
  # neutral, exactly like an unevaluable one - never hard failures. Without
299
326
  # this, a SKIPPED step (e.g. an unreachable graph, or a document absent from
@@ -310,7 +337,7 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
310
337
  if ch.get("supported", True) is False:
311
338
  checks_neutral += 1 # neutral — backend can't evaluate this check
312
339
  continue
313
- k = _categorise(ch.get("label", ""))
340
+ k = _categorise(ch.get("label", ""), step_name)
314
341
  cat[k]["total"] += 1
315
342
  ch_passed = bool(ch.get("passed"))
316
343
  sev = "warn" if str(ch.get("severity", "hard")).lower() == "warn" else "hard"
@@ -329,7 +356,7 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
329
356
  else:
330
357
  checks_hard += 1
331
358
  label = ch.get("label", "")
332
- rec = offenders.setdefault(label, {"label": label, "docs": 0, "severity": sev})
359
+ rec = offenders.setdefault(label, {"label": label, "docs": 0, "severity": sev, "step": step_name})
333
360
  rec["docs"] += 1
334
361
  # a label is HARD overall if it ever gates
335
362
  if sev == "hard":
@@ -444,6 +471,53 @@ _SKIP_REASON_TEXT = {
444
471
  }
445
472
 
446
473
 
474
+ def _narrative(agg: dict) -> str:
475
+ """A 2-3 sentence deterministic reading of the run - the story the bullets support.
476
+
477
+ Purely template-selected from the aggregate (no LLM, no inference beyond arithmetic):
478
+ integrity verdict, then the dominant hygiene issue if any, then a coverage caveat if
479
+ the run did not look at everything. Turns "Chunk Structure 47%" (data) into "chunking
480
+ is the dominant issue, typically configuration not corruption" (reading).
481
+ """
482
+ total = max(agg.get("total", 0), 1)
483
+ gated = agg.get("failed", 0) + agg.get("errors", 0)
484
+ sents: list[str] = []
485
+
486
+ # 1. Integrity verdict.
487
+ if gated == 0:
488
+ sents.append(f"All {agg['total']} document(s) passed every integrity gate")
489
+ else:
490
+ sents.append(f"{gated} of {agg['total']} document(s) failed an integrity gate")
491
+
492
+ # 2. Dominant hygiene issue (advisory quality beyond the gate).
493
+ hy = agg.get("hygiene")
494
+ worst = hy.get("worst") if hy else None
495
+ if hy and worst and worst["rate"] < 100:
496
+ tier = hy["tier"].lower()
497
+ if gated == 0:
498
+ sents[-1] += f", but data-plane hygiene is {tier} at {hy['score']:g}%"
499
+ else:
500
+ sents[-1] += f"; data-plane hygiene is {tier} at {hy['score']:g}%"
501
+ if worst["rate"] < 50:
502
+ sents.append(f"The {worst['name']} checks are the dominant issue "
503
+ f"({worst['rate']:g}% pass) - a pattern that usually points to "
504
+ f"ingestion configuration rather than data corruption")
505
+ else:
506
+ sents.append(f"The weakest area is {worst['name']} ({worst['rate']:g}% pass)")
507
+ elif hy:
508
+ sents[-1] += f", and data-plane hygiene is {hy['tier'].lower()} at {hy['score']:g}%"
509
+
510
+ # 3. Coverage caveat - never let the reading imply full coverage it did not have.
511
+ cov = agg.get("coverage") or []
512
+ if cov:
513
+ worst_cov = cov[0]
514
+ pct = round(100 * worst_cov["skipped"] / total)
515
+ sents.append(f"Note: {worst_cov['name']} did not run on {pct}% of documents, so this "
516
+ "reading covers less than the whole pipeline")
517
+
518
+ return ". ".join(s.rstrip(".") for s in sents) + "."
519
+
520
+
447
521
  def _exec_bullets(agg: dict) -> str:
448
522
  """The executive summary: verdict, what was NOT validated, and the top offender.
449
523
 
@@ -508,11 +582,18 @@ def _hygiene_gauge(h: dict | None) -> str:
508
582
  worst_html = (f'<div class="hygiene-worst" data-tip="Lowest-scoring dimension - fix this in '
509
583
  f'ingestion first">worst: {_esc(worst["name"])} {worst["rate"]:g}%</div>'
510
584
  if worst and worst["rate"] < 100 else "")
585
+ # Direction vs the previous run, so the trend is legible without opening the panel.
586
+ delta = h.get("delta")
587
+ delta_html = ""
588
+ if isinstance(delta, (int, float)) and delta != 0:
589
+ arrow, cls = ("&uarr;", "ok") if delta > 0 else ("&darr;", "bad")
590
+ delta_html = (f'<span class="hygiene-delta {cls}" data-tip="vs previous run">'
591
+ f'{arrow} {abs(delta):g}</span>')
511
592
  return (
512
593
  f'<div class="hygiene-gauge" data-tip="Share of advisory (WARN) data-quality checks that '
513
594
  f'passed - data-plane hygiene, not answer quality. Separate from the READY gate.">'
514
595
  f'<div class="hygiene-cap">KB Hygiene</div>'
515
- f'<div class="hygiene-score {h["band"]}">{h["score"]:g}% &middot; {h["tier"]}</div>'
596
+ f'<div class="hygiene-score {h["band"]}">{h["score"]:g}% &middot; {h["tier"]}{delta_html}</div>'
516
597
  f'{worst_html}</div>'
517
598
  )
518
599
 
@@ -559,6 +640,7 @@ def _exec_summary(agg: dict, corpus: dict | None = None, cases: list[dict] | Non
559
640
  "not-applicable checks could not be evaluated." if (adv or neu) else ".")
560
641
  )
561
642
  bullets = _exec_bullets(agg)
643
+ narrative = _narrative(agg)
562
644
  # Lead with the verdict, not the pass rate: the segment bar below already carries the
563
645
  # pass/partial/fail split, so a donut of the same number spends the most prominent
564
646
  # slot restating it. The corpus verdict is the real headline when discovery supplies
@@ -602,6 +684,7 @@ def _exec_summary(agg: dict, corpus: dict | None = None, cases: list[dict] | Non
602
684
  </div>
603
685
  <div class="exec-right" style="flex:1">
604
686
  <div class="exec-interp-title">Executive Summary</div>
687
+ <div class="exec-narrative">{_esc(narrative)}</div>
605
688
  <ul class="exec-bullets" data-tip="{_esc(interp)}">{bullets}</ul>
606
689
  <div class="seg-bar">{seg}</div>
607
690
  <div class="seg-legend">
@@ -755,7 +838,7 @@ def _top_issues_table(agg: dict) -> str:
755
838
  pct = round(100 * o["docs"] / total)
756
839
  sev = o.get("severity", "hard")
757
840
  sev_cls, sev_txt = ("bad", "Gating") if sev == "hard" else ("warn", "Advisory")
758
- cat = _categorise(o["label"])
841
+ cat = _categorise(o["label"], o.get("step", ""))
759
842
  bar_cls = "bad" if sev == "hard" else "warn"
760
843
  rows += (
761
844
  f'<tr><td><b>{_esc(o["label"])}</b></td><td class="check-detail">{_esc(cat)}</td>'
@@ -770,13 +853,52 @@ def _top_issues_table(agg: dict) -> str:
770
853
  return (
771
854
  '<div class="table-card" style="margin-top:18px"><div class="table-cap">Most Frequent Issues '
772
855
  '<span class="cat-badge">ranked by documents affected</span></div>'
773
- '<div class="table-wrap"><table>'
856
+ + _subsystem_rollup(agg)
857
+ + '<div class="subsys-head" style="padding:18px 16px 0">By check</div>'
858
+ + '<div class="table-wrap"><table>'
774
859
  '<thead><tr><th>Check</th><th>Category</th><th class="num">Documents</th>'
775
860
  '<th class="num">Share</th><th>Spread</th><th>Severity</th></tr></thead>'
776
861
  f'<tbody>{rows}</tbody></table></div></div>'
777
862
  )
778
863
 
779
864
 
865
+ def _subsystem_rollup(agg: dict) -> str:
866
+ """Group offending checks by subsystem so one root cause reads as one problem.
867
+
868
+ Five failing chunk checks are not five problems - they are one chunking issue seen
869
+ five ways. This rolls the offenders up by category: how many distinct checks, and the
870
+ widest document spread within it, so the story ("chunking is the problem") is visible
871
+ above the per-check detail.
872
+ """
873
+ offenders = agg.get("offenders", [])
874
+ if not offenders:
875
+ return ""
876
+ total = max(agg.get("total", 0), 1)
877
+ groups: dict[str, dict] = {}
878
+ for o in offenders:
879
+ cat = _categorise(o["label"], o.get("step", ""))
880
+ g = groups.setdefault(cat, {"checks": 0, "max_docs": 0, "severity": "warn"})
881
+ g["checks"] += 1
882
+ g["max_docs"] = max(g["max_docs"], o["docs"])
883
+ if o.get("severity") == "hard":
884
+ g["severity"] = "hard"
885
+ ranked = sorted(groups.items(), key=lambda kv: (-kv[1]["max_docs"], kv[0]))
886
+ bars = ""
887
+ for cat, g in ranked:
888
+ pct = round(100 * g["max_docs"] / total)
889
+ cls = "bad" if g["severity"] == "hard" else "warn"
890
+ bars += (
891
+ f'<div class="subsys-row"><div class="subsys-name">{_esc(cat)}</div>'
892
+ f'<div class="subsys-bar"><i style="width:{pct}%;background:var(--{cls})"></i></div>'
893
+ f'<div class="subsys-meta">{g["checks"]} check(s) &middot; up to {g["max_docs"]}/{total} docs</div></div>'
894
+ )
895
+ return (
896
+ '<div class="subsys-rollup">'
897
+ '<div class="subsys-head">By subsystem <span class="cat-badge">one root cause, grouped</span></div>'
898
+ f'{bars}</div>'
899
+ )
900
+
901
+
780
902
  def _findings_section(cases: list[dict], agg: dict | None = None) -> str:
781
903
  failed_items, skipped_items = [], []
782
904
  advisory: Counter = Counter() # warn-severity check label -> count, across the corpus
@@ -1166,7 +1288,9 @@ def _trends_section(trends: list[dict]) -> str:
1166
1288
  charts_block = ""
1167
1289
  if runs >= 2:
1168
1290
  series = sorted(trends, key=lambda t: str(t.get("timestamp", "")))
1169
- labels = [str(t.get("run_id") or str(t.get("timestamp", ""))[:16] or f"run {i+1}")
1291
+ # Prefer the run DATE on the x-axis (a timeline reads temporally); fall back to the
1292
+ # run id only when no timestamp was recorded. The run-id table below keeps the ids.
1293
+ labels = [str(str(t.get("timestamp", ""))[:16].replace("T", " ") or t.get("run_id") or f"run {i+1}")
1170
1294
  for i, t in enumerate(series)]
1171
1295
  def _rate(num, den):
1172
1296
  return [round(100 * t.get(num, 0) / t[den], 1) if t.get(den) else 0 for t in series]
@@ -1359,7 +1483,8 @@ function _insights(d){
1359
1483
  }
1360
1484
  return out;
1361
1485
  }
1362
- function _categorise(label){
1486
+ var _STEP_CATEGORY={"Neptune Metadata":"Knowledge Graph","GraphDB Metadata":"Knowledge Graph","S3 Storage":"Object Storage","Cross-Stage Consistency":"Cross-Stage Consistency","API Upload":"Ingestion API"};
1487
+ function _categorise(label, step){
1363
1488
  var l=(label||'').toLowerCase();
1364
1489
  if(/e_tag|required field|metadata consist|positional|section|page metadata|directory|predicate|date format|email|field rule/.test(l)) return 'Metadata & Consistency';
1365
1490
  if(/boundaries|chunk count|size within|ordering|overlap/.test(l)) return 'Chunk Structure';
@@ -1367,9 +1492,9 @@ function _categorise(label){
1367
1492
  if(/fidelity|coverage|contaminat|duplicate|header|footer|table\/figure/.test(l)) return 'Content Preservation';
1368
1493
  if(/index|schema/.test(l)) return 'Index & Schema';
1369
1494
  if(/retriev|semantic|filter|hybrid|wildcard|empty query|recall|mrr|paraphrase|boundary-span|exact-term/.test(l)) return 'Retrieval & Query';
1370
- return 'Other';
1495
+ return _STEP_CATEGORY[step]||'Other';
1371
1496
  }
1372
- function _buildCategorisedChecks(checks){
1497
+ function _buildCategorisedChecks(checks, step){
1373
1498
  function _sup(c){ return c.supported !== false; } // unsupported = neutral
1374
1499
  var evaluable=checks.filter(_sup);
1375
1500
  var total=evaluable.length, passed=evaluable.filter(function(c){return c.passed;}).length;
@@ -1379,7 +1504,7 @@ function _buildCategorisedChecks(checks){
1379
1504
  var hard=evaluable.filter(function(c){return !c.passed && (c.severity||'hard')!=='warn';}).length;
1380
1505
  var advisory=evaluable.filter(function(c){return !c.passed && c.severity==='warn';}).length;
1381
1506
  var groups={};
1382
- checks.forEach(function(c){ var cat=_categorise(c.label); (groups[cat]=groups[cat]||[]).push(c); });
1507
+ checks.forEach(function(c){ var cat=_categorise(c.label, step); (groups[cat]=groups[cat]||[]).push(c); });
1383
1508
  var cats=_CAT_ORDER.filter(function(co){return groups[co[0]];});
1384
1509
  var note=(advisory?(' &middot; '+advisory+' advisory'):'')+(unsupported?(' &middot; '+unsupported+' not applicable'):'');
1385
1510
  var html='<div class="check-detail" style="margin-bottom:6px"><b style="color:var(--ink)">'+passed+'/'+total+'</b> checks passed across '+cats.length+' categor'+(cats.length===1?'y':'ies')+note+(hard?'':'. Only hard failures gate; advisory is informational.')+'</div>';
@@ -1412,14 +1537,17 @@ function showStepDetail(ci, name){
1412
1537
  var band=_bandOf(d.status);
1413
1538
  document.getElementById('fbModalTitle').textContent=name+' — '+c.name;
1414
1539
  var st=document.getElementById('fbModalStatus'); st.textContent=d.status; st.className='status-pill '+band;
1415
- document.getElementById('fbModalDur').textContent=(d.duration!=null?d.duration+'s':'');
1540
+ // A step that reuses earlier steps' artifacts (e.g. Cross-Stage Consistency) does almost
1541
+ // no I/O and rounds to 0.0s, which reads as "not measured". Show <0.1s so near-instant is
1542
+ // legible as fast, not broken.
1543
+ document.getElementById('fbModalDur').textContent=(d.duration!=null?(d.duration<0.05?'<0.1s':d.duration+'s'):'');
1416
1544
  document.getElementById('fbModalMsg').textContent=d.message||'—';
1417
1545
  var keys=d.metrics?Object.keys(d.metrics):[];
1418
1546
  var mRow=document.getElementById('fbModalMetricsRow');
1419
1547
  if(keys.length){ document.getElementById('fbModalMetrics').innerHTML=keys.map(function(k){return '<span class="metric-chip">'+_escHtml(k)+': <b>'+_escHtml(d.metrics[k])+'</b></span>';}).join(''); mRow.style.display=''; }
1420
1548
  else { mRow.style.display='none'; }
1421
1549
  var box=document.getElementById('fbModalChecks');
1422
- var inner=(!d.checks||!d.checks.length)?'<div class="check-detail">No individual checks for this step.</div>':_buildCategorisedChecks(d.checks);
1550
+ var inner=(!d.checks||!d.checks.length)?'<div class="check-detail">No individual checks for this step.</div>':_buildCategorisedChecks(d.checks, name);
1423
1551
  // Chunks belong to the vector step — show them here (collapsed) rather than at document level.
1424
1552
  if(name===c.chunksStep && c.chunks && c.chunks.items && c.chunks.items.length){
1425
1553
  inner+=_buildChunksDisclosure(c.chunks);
@@ -1547,7 +1675,7 @@ function renderDoc(idx){
1547
1675
  }
1548
1676
  // Per-document Checks by Category — actionable at the document level.
1549
1677
  var cc={}; _CAT_ORDER.forEach(function(co){cc[co[0]]={t:0,p:0};});
1550
- d.order.forEach(function(name){ var s=d.steps[name]; if(!s) return; (s.checks||[]).forEach(function(ch){ if(ch.supported===false) return; var k=_categorise(ch.label); cc[k].t++; if(ch.passed) cc[k].p++; }); });
1678
+ d.order.forEach(function(name){ var s=d.steps[name]; if(!s) return; (s.checks||[]).forEach(function(ch){ if(ch.supported===false) return; var k=_categorise(ch.label, name); cc[k].t++; if(ch.passed) cc[k].p++; }); });
1551
1679
  var catRows=_CAT_ORDER.filter(function(co){return cc[co[0]].t;}).map(function(co){
1552
1680
  var x=cc[co[0]], rate=x.t?Math.round(100*x.p/x.t):0, rb=rate===100?'ok':(rate>=50?'warn':'bad');
1553
1681
  var color=rb==='ok'?'#16a34a':rb==='warn'?'#d97706':'#dc2626';
@@ -1575,14 +1703,14 @@ function renderDoc(idx){
1575
1703
  datasets:[{label:'Passed',data:s.map(function(x){return x.passed;}),backgroundColor:OK,stack:'a'},
1576
1704
  {label:'Failed',data:s.map(function(x){return x.failed+x.error;}),backgroundColor:BAD,stack:'a'},
1577
1705
  {label:'Skipped',data:s.map(function(x){return x.skipped;}),backgroundColor:NA,stack:'a'}]},
1578
- options:{plugins:{legend:{labels:{color:c.text,font:{size:11}}}},
1706
+ options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:c.text,font:{size:11}}}},
1579
1707
  scales:{x:{stacked:true,ticks:{color:c.text,font:{size:10}},grid:{color:c.grid}},y:{stacked:true,ticks:{color:c.text,precision:0},grid:{color:c.grid}}}}});
1580
1708
  var cr=REPORT.agg.cat_rows||[];
1581
1709
  var catRate=cr.map(function(x){return x.total?Math.round(100*x.passed/x.total):0;});
1582
1710
  var cat=new Chart(document.getElementById('catChart'),{type:'bar',
1583
1711
  data:{labels:cr.map(function(x){return x.name.replace(' & ','/').replace(' Quality','').replace(' Preservation','').replace(' Consistency','').replace(' Structure','').replace(' Query','');}),
1584
1712
  datasets:[{label:'Pass rate %',data:catRate,backgroundColor:catRate.map(function(r){return r===100?OK:(r>=50?WARN:BAD);}),borderWidth:0,borderRadius:4}]},
1585
- options:{indexAxis:'y',plugins:{legend:{display:false}},
1713
+ options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},
1586
1714
  scales:{x:{max:100,ticks:{color:c.text,callback:function(v){return v+'%';}},grid:{color:c.grid}},y:{ticks:{color:c.text,font:{size:10}},grid:{display:false}}}}});
1587
1715
  if(typeof allCharts!=='undefined'){allCharts.push(donut,step,cat);}
1588
1716
  })();
@@ -1646,6 +1774,13 @@ def generate_batch_dashboard(
1646
1774
  agg["wall_seconds"] = round(wall_seconds, 1)
1647
1775
  if concurrency is not None:
1648
1776
  agg["concurrency"] = concurrency
1777
+ # Hygiene delta vs the previous run: trends are newest-first and already include THIS
1778
+ # run (recorded before the report), so the prior run is trends[1]. Lets the gauge show
1779
+ # direction ("62% POOR +3 vs previous") without opening the trends panel.
1780
+ if agg.get("hygiene") and trends and len(trends) > 1:
1781
+ prev = trends[1].get("hygiene_score")
1782
+ if isinstance(prev, (int, float)):
1783
+ agg["hygiene"]["delta"] = round(agg["hygiene"]["score"] - prev, 1)
1649
1784
  # Shape + coverage are computed on FULL check data, before the scale-trim below
1650
1785
  # drops per-check detail for passed documents.
1651
1786
  shape_inner = _profile_section(profile) + _coverage_section(cases)
@@ -215,20 +215,22 @@ main { max-width:1800px; margin:0 auto; padding:24px 32px; }
215
215
 
216
216
  /* Charts */
217
217
  .charts-row {
218
- display:grid; grid-template-columns:1.4fr 1fr 1fr; gap:16px; margin-top:24px;
218
+ display:grid; grid-template-columns:repeat(3, 1fr); gap:16px; margin-top:24px;
219
219
  }
220
220
  @media(max-width:1100px) { .charts-row { grid-template-columns:repeat(2,1fr); } }
221
221
  @media(max-width:700px) { .charts-row { grid-template-columns:1fr; } }
222
222
  .chart-box {
223
223
  background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
224
224
  padding:16px; box-shadow:var(--box-shadow); position:relative;
225
+ /* flex column so the canvas fills the box height (grid stretches all boxes equal),
226
+ removing the empty real-estate the aspect-ratio bar charts used to leave. */
227
+ display:flex; flex-direction:column;
225
228
  }
226
229
  .chart-box h3 {
227
230
  font-size:13px; font-weight:600; color:var(--muted);
228
231
  text-transform:uppercase; letter-spacing:.06em; margin:0 0 12px 0;
229
232
  }
230
- .chart-box canvas { width:100%!important; max-height:260px; }
231
- #donutChart { max-height:320px!important; }
233
+ .chart-box canvas { width:100%!important; flex:1 1 auto; min-height:230px; max-height:320px; }
232
234
 
233
235
  /* Tables */
234
236
  .table-card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--box-shadow); overflow:hidden; }
@@ -56,7 +56,8 @@ class TrendTracker:
56
56
 
57
57
  # Columns added after the original schema; absent in older DBs.
58
58
  _ADDED_COLUMNS = {"project": "TEXT", "steps_total": "INTEGER",
59
- "steps_passed": "INTEGER", "steps_failed": "INTEGER"}
59
+ "steps_passed": "INTEGER", "steps_failed": "INTEGER",
60
+ "hygiene_score": "REAL"}
60
61
 
61
62
  def _ensure_schema(self) -> None:
62
63
  with self._connect() as conn:
@@ -98,12 +99,23 @@ class TrendTracker:
98
99
  )
99
100
  )
100
101
 
102
+ # KB Hygiene score for this run, so trends can show its direction over time. Computed
103
+ # from the same deterministic aggregation the report uses; None when there are no
104
+ # advisory checks to score.
105
+ hygiene_score = None
106
+ try:
107
+ from ingestion_validation.report import hygiene_from_results
108
+ hy = hygiene_from_results(results)
109
+ hygiene_score = hy["score"] if hy else None
110
+ except Exception: # noqa: BLE001 - hygiene is advisory context; never break recording
111
+ hygiene_score = None
112
+
101
113
  with self._connect() as conn:
102
114
  conn.execute(
103
- "INSERT INTO runs (run_id,project,timestamp,environment,total_cases,passed,failed,errors,skipped,total_duration_seconds,mode,steps_total,steps_passed,steps_failed)"
104
- " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
115
+ "INSERT INTO runs (run_id,project,timestamp,environment,total_cases,passed,failed,errors,skipped,total_duration_seconds,mode,steps_total,steps_passed,steps_failed,hygiene_score)"
116
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
105
117
  (run_id, project, ts, environment, len(results), agg["passed"], agg["failed"], agg["errors"], agg["skipped"], round(agg["duration"], 3), mode,
106
- agg["steps_total"], agg["steps_passed"], agg["steps_failed"]),
118
+ agg["steps_total"], agg["steps_passed"], agg["steps_failed"], hygiene_score),
107
119
  )
108
120
  conn.executemany(
109
121
  "INSERT INTO run_cases (run_id,case_name,file_name,document_id,overall,duration_seconds,step_details)"
@@ -479,7 +479,8 @@ def test_trends_render_tabbed_time_series_with_multiple_runs():
479
479
  m = re.search(r"window\.TREND_SERIES=(\{.*?\});", html, re.S)
480
480
  assert m is not None
481
481
  s = json.loads(m.group(1))
482
- assert s["labels"] == ["r1", "r2", "r3"] # oldest -> newest
482
+ # x-axis labels are the run DATE (a timeline reads temporally), oldest -> newest
483
+ assert s["labels"] == ["2026-07-12 09:00", "2026-07-14 09:00", "2026-07-17 09:00"]
483
484
  # Pass Rate carries two lines - documents and steps; the gap is the failures.
484
485
  doc_line = next(sr for sr in s["metrics"]["passRate"]["series"] if sr["name"] == "Documents")
485
486
  assert doc_line["data"] == [84.0, 59.8, 100.0]
@@ -560,3 +561,105 @@ def test_embedded_triples_keep_only_rendered_fields():
560
561
  triples = report_mod._result_payload("d.pdf", r)["steps"]["Neptune Metadata"]["triples"]
561
562
  assert triples == [{"predicate": "documentId", "value": "DOC-1"}]
562
563
  assert "subject" not in triples[0] and "predicate_uri" not in triples[0]
564
+
565
+
566
+ def test_stage_checks_get_named_categories_not_other():
567
+ # Graph/storage/cross-stage checks with no semantic-pattern match were all dumped into
568
+ # "Other" (54% of checks on a multi-stage run). They now fall back to their stage's
569
+ # category so the breakdown is transparent.
570
+ r = PipelineResult(document_id="d", file_name="d.pdf")
571
+ r.add_step(StepResult("S3 Storage", StepStatus.PASSED, "ok",
572
+ checks=[{"label": "File exists in storage", "passed": True}]))
573
+ r.add_step(StepResult("Neptune Metadata", StepStatus.PASSED, "ok",
574
+ checks=[{"label": "Document found in graph", "passed": True},
575
+ {"label": "Orphan nodes absent", "passed": True}]))
576
+ r.add_step(StepResult("Cross-Stage Consistency", StepStatus.PASSED, "ok",
577
+ checks=[{"label": "Stores agree on document", "passed": True}]))
578
+ agg = report_mod._aggregate([report_mod._result_payload("d.pdf", r)])
579
+ cats = {row["name"]: row["total"] for row in agg["cat_rows"]}
580
+ assert cats.get("Knowledge Graph") == 2 # graph checks, not "Other"
581
+ assert cats.get("Object Storage") == 1
582
+ assert cats.get("Cross-Stage Consistency") == 1
583
+ assert "Other" not in cats # nothing uncategorised here
584
+
585
+
586
+ def test_categorise_semantic_pattern_still_wins_over_step():
587
+ # a chunk-structure check on the vector step keeps its semantic category
588
+ assert report_mod._categorise("Overlap correctness", "Redis Chunks") == "Chunk Structure"
589
+ # a graph check with no semantic match takes the stage category
590
+ assert report_mod._categorise("Document found in graph", "Neptune Metadata") == "Knowledge Graph"
591
+
592
+
593
+ def test_narrative_reads_the_run_deterministically():
594
+ def case(i, overlap_ok):
595
+ r = PipelineResult(document_id=f"d{i}", file_name=f"d{i}.pdf")
596
+ r.add_step(StepResult("Redis Chunks", StepStatus.PASSED, "ok", checks=[
597
+ {"label": "Chunks exist", "passed": True},
598
+ {"label": "Overlap correctness", "passed": overlap_ok, "severity": "warn"}]))
599
+ r.finalize()
600
+ return report_mod._result_payload(f"d{i}.pdf", r)
601
+ # chunking poor -> names it as the dominant issue with the config-not-corruption reading
602
+ agg = report_mod._aggregate([case(i, overlap_ok=(i >= 8)) for i in range(10)])
603
+ text = report_mod._narrative(agg)
604
+ assert "passed every integrity gate" in text
605
+ assert "Chunk Structure" in text and "dominant issue" in text
606
+ assert "configuration rather than data corruption" in text
607
+
608
+
609
+ def test_narrative_flags_incomplete_coverage():
610
+ def case(i):
611
+ r = PipelineResult(document_id=f"d{i}", file_name=f"d{i}.pdf")
612
+ r.add_step(StepResult("Redis Chunks", StepStatus.PASSED, "ok",
613
+ checks=[{"label": "Chunks exist", "passed": True}]))
614
+ r.add_step(StepResult("Neptune Metadata", StepStatus.SKIPPED, "unreachable",
615
+ details={"skip_reason": "unreachable", "unreachable": True},
616
+ checks=[{"label": "reachable", "passed": False}]))
617
+ r.finalize()
618
+ return report_mod._result_payload(f"d{i}.pdf", r)
619
+ text = report_mod._narrative(report_mod._aggregate([case(i) for i in range(4)]))
620
+ assert "did not run on" in text and "less than the whole pipeline" in text
621
+
622
+
623
+ def test_subsystem_rollup_groups_offenders_by_category():
624
+ def case(i):
625
+ r = PipelineResult(document_id=f"d{i}", file_name=f"d{i}.pdf")
626
+ r.add_step(StepResult("Redis Chunks", StepStatus.PASSED, "ok", checks=[
627
+ {"label": "Overlap correctness", "passed": i >= 8, "severity": "warn"},
628
+ {"label": "Chunk boundaries clean", "passed": i >= 7, "severity": "warn"},
629
+ {"label": "No near-duplicate embeddings", "passed": i >= 4, "severity": "warn"}]))
630
+ r.finalize()
631
+ return report_mod._result_payload(f"d{i}.pdf", r)
632
+ agg = report_mod._aggregate([case(i) for i in range(10)])
633
+ html = report_mod._subsystem_rollup(agg)
634
+ # two chunk checks roll up into one Chunk Structure row (one root cause, not two)
635
+ assert "Chunk Structure" in html and "2 check(s)" in html
636
+ assert "Embedding Quality" in html and "1 check(s)" in html
637
+ # Chunk Structure (worst spread 8/10) ranks above Embedding Quality
638
+ assert html.index("Chunk Structure") < html.index("Embedding Quality")
639
+
640
+
641
+ def test_hygiene_gauge_shows_delta_direction():
642
+ up = report_mod._hygiene_gauge({"score": 62, "tier": "POOR", "band": "bad", "worst": None, "delta": 3.0})
643
+ assert "&uarr;" in up and "3" in up and "hygiene-delta ok" in up
644
+ down = report_mod._hygiene_gauge({"score": 58, "tier": "POOR", "band": "bad", "worst": None, "delta": -4.0})
645
+ assert "&darr;" in down and "hygiene-delta bad" in down
646
+ flat = report_mod._hygiene_gauge({"score": 62, "tier": "POOR", "band": "bad", "worst": None, "delta": 0})
647
+ assert "hygiene-delta" not in flat # no change -> no arrow
648
+
649
+
650
+ def test_trend_tracker_persists_hygiene_score(tmp_path):
651
+ from ingestion_validation.trend_tracker import TrendTracker
652
+
653
+ def results(overlap_ok):
654
+ r = PipelineResult(document_id="d", file_name="d.pdf")
655
+ r.add_step(StepResult("Redis Chunks", StepStatus.PASSED, "ok", checks=[
656
+ {"label": "Chunks exist", "passed": True},
657
+ {"label": "Overlap correctness", "passed": overlap_ok, "severity": "warn"}]))
658
+ r.finalize()
659
+ return [{"name": "d.pdf", "result": r}]
660
+
661
+ tt = TrendTracker(str(tmp_path / "t.db"))
662
+ tt.record(results(False), run_id="r1", project="p") # hygiene 0%
663
+ tt.record(results(True), run_id="r2", project="p") # hygiene 100%
664
+ scores = [r.get("hygiene_score") for r in tt.summary(project="p")]
665
+ assert scores == [100.0, 0.0] # newest-first, both persisted