compass-kb-validation 3.0.0__tar.gz → 3.2.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 (121) hide show
  1. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/PKG-INFO +1 -1
  2. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/__init__.py +1 -1
  3. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/cli.py +7 -0
  4. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/config/__init__.py +8 -1
  5. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/report/__init__.py +341 -65
  6. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass_kb_validation.egg-info/PKG-INFO +1 -1
  7. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/discover_and_validate.py +34 -3
  8. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/ingestion_validation/__init__.py +28 -5
  9. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/run_validation.py +1 -1
  10. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_compat_shim.py +10 -0
  11. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_config.py +16 -0
  12. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_discover_cli.py +17 -0
  13. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_reporting.py +129 -5
  14. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/LICENSE +0 -0
  15. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/README.md +0 -0
  16. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/__main__.py +0 -0
  17. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/ask.py +0 -0
  18. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/baseline.py +0 -0
  19. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/chunk_coherence.py +0 -0
  20. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/citation.py +0 -0
  21. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/config_lint.py +0 -0
  22. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/content_inspect.py +0 -0
  23. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/corpus.py +0 -0
  24. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/corpus_health.py +0 -0
  25. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/diagnostics.py +0 -0
  26. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/embedding_drift.py +0 -0
  27. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/eval_interop.py +0 -0
  28. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/evidence.py +0 -0
  29. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/grounding.py +0 -0
  30. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/init_wizard.py +0 -0
  31. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/live_dashboard.py +0 -0
  32. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/mcp_server.py +0 -0
  33. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/models/__init__.py +0 -0
  34. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/notifications.py +0 -0
  35. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/pipeline/__init__.py +0 -0
  36. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/profiler.py +0 -0
  37. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/provenance.py +0 -0
  38. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/providers/__init__.py +0 -0
  39. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/providers/embedding.py +0 -0
  40. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/providers/graph.py +0 -0
  41. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/providers/source.py +0 -0
  42. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/providers/storage.py +0 -0
  43. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/providers/vectorstore.py +0 -0
  44. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/remediation.py +0 -0
  45. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/report/_report_css.py +0 -0
  46. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/report/_report_js.py +0 -0
  47. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/report/analysis.py +0 -0
  48. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/report/audit.py +0 -0
  49. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/report/export.py +0 -0
  50. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/report/shell.py +0 -0
  51. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/retrieval_analysis.py +0 -0
  52. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/text_probes.py +0 -0
  53. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/trend_tracker.py +0 -0
  54. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/utils/__init__.py +0 -0
  55. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/validators/__init__.py +0 -0
  56. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/validators/api_upload.py +0 -0
  57. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/validators/base.py +0 -0
  58. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/validators/base_sparql.py +0 -0
  59. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/validators/cross_stage.py +0 -0
  60. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/validators/graphdb_metadata.py +0 -0
  61. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/validators/neptune_metadata.py +0 -0
  62. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/validators/redis_chunks.py +0 -0
  63. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/validators/registry.py +0 -0
  64. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/validators/retrieval_quality.py +0 -0
  65. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass/validators/s3_storage.py +0 -0
  66. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass_kb_validation.egg-info/SOURCES.txt +0 -0
  67. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
  68. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass_kb_validation.egg-info/entry_points.txt +0 -0
  69. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass_kb_validation.egg-info/requires.txt +0 -0
  70. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/compass_kb_validation.egg-info/top_level.txt +0 -0
  71. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/profile_kb.py +0 -0
  72. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/pyproject.toml +0 -0
  73. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/setup.cfg +0 -0
  74. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_alt_adapters.py +0 -0
  75. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_analysis_report.py +0 -0
  76. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_api_upload.py +0 -0
  77. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_ask.py +0 -0
  78. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_baseline.py +0 -0
  79. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_benchmark.py +0 -0
  80. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_check_catalog.py +0 -0
  81. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_citation.py +0 -0
  82. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_cli.py +0 -0
  83. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_config_lint.py +0 -0
  84. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_content_inspect.py +0 -0
  85. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_corpus.py +0 -0
  86. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_corpus_health.py +0 -0
  87. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_coverage.py +0 -0
  88. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_cross_stage.py +0 -0
  89. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_diagnostics.py +0 -0
  90. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_discovery_parallel.py +0 -0
  91. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_embedding_drift.py +0 -0
  92. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_eval_interop.py +0 -0
  93. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_evidence.py +0 -0
  94. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_gate.py +0 -0
  95. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_graph_transport.py +0 -0
  96. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_graph_validators.py +0 -0
  97. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_grounding.py +0 -0
  98. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_init_wizard.py +0 -0
  99. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_manifest.py +0 -0
  100. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_mcp.py +0 -0
  101. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_migration.py +0 -0
  102. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_models.py +0 -0
  103. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_neptune_bugfixes.py +0 -0
  104. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_neptune_bugfixes2.py +0 -0
  105. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_notifications.py +0 -0
  106. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_pipeline.py +0 -0
  107. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_profiler.py +0 -0
  108. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_providers.py +0 -0
  109. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_redis_chunks.py +0 -0
  110. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_registry.py +0 -0
  111. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_remediation.py +0 -0
  112. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_retrieval_assertions.py +0 -0
  113. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_retrieval_quality.py +0 -0
  114. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_review_hardening.py +0 -0
  115. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_run_cli.py +0 -0
  116. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_sources.py +0 -0
  117. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_storage_validator.py +0 -0
  118. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_trends.py +0 -0
  119. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_universal_concerns.py +0 -0
  120. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.0}/tests/test_utils.py +0 -0
  121. {compass_kb_validation-3.0.0 → compass_kb_validation-3.2.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: 3.0.0
3
+ Version: 3.2.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__ = "3.0.0"
8
+ __version__ = "3.2.0"
@@ -36,6 +36,13 @@ pipeline:
36
36
  critical_steps: []
37
37
  readiness_threshold: 1.0 # READY when document pass-rate >= this (e.g. 0.99)
38
38
 
39
+ # Advisory checks you have ACCEPTED as expected for this corpus (e.g. an insurance KB where
40
+ # near-duplicate boilerplate is normal). They drop out of the hygiene / Platform Health scores
41
+ # but are still counted and shown ("N suppressed"). Only advisory checks - a hard gating failure
42
+ # is never suppressed. Ad-hoc equivalent: `compass discover --suppress "Near-duplicate embeddings"`.
43
+ # suppressions:
44
+ # - "Near-duplicate embeddings"
45
+
39
46
  environments:
40
47
  dev:
41
48
  redis:
@@ -750,6 +750,12 @@ class Settings:
750
750
  display_name: str = ""
751
751
  environment: str = "dev"
752
752
  skip_steps: list[str] = field(default_factory=list)
753
+ # Advisory check labels the project has ACCEPTED as expected for this corpus (e.g. an
754
+ # insurance KB where near-duplicate boilerplate is normal). Suppressed advisories are
755
+ # excluded from the hygiene / Platform Health scores but still counted and shown ("N
756
+ # suppressed"). Only advisory (warn) checks can be suppressed - a hard gating failure
757
+ # always counts, so a known-issue can never mask a real failure.
758
+ suppressions: list[str] = field(default_factory=list)
753
759
  # Advisories raised while loading (e.g. an unrecognized top-level block that was
754
760
  # ignored). Surfaced by validate_config so a silently-dropped key is never invisible.
755
761
  config_warnings: list[str] = field(default_factory=list)
@@ -760,7 +766,7 @@ class Settings:
760
766
  # --------------------------------------------------------------------------- #
761
767
  # Non-service top-level keys that are legitimately not a service block.
762
768
  _META_KEYS = frozenset({"default_env", "display_name", "environments", "skip_steps",
763
- "pipeline_dependencies"})
769
+ "pipeline_dependencies", "suppressions"})
764
770
  # The service-config blocks that may appear at the top level or under an environment.
765
771
  # Single source of truth: the flatten loop AND the unknown-key check both read this, so a
766
772
  # new block can never be added to one and forgotten in the other (which is exactly how
@@ -903,6 +909,7 @@ def load_settings(
903
909
  display_name=merged.get("display_name", ""),
904
910
  environment=resolved_env,
905
911
  skip_steps=list(merged.get("skip_steps", [])),
912
+ suppressions=list(merged.get("suppressions", [])),
906
913
  )
907
914
  # The embedding block's declared dimension IS the index's expected dimension. Cross-fill
908
915
  # it into the vector-store check when that check has no explicit value, so a project
@@ -80,8 +80,11 @@ _STEP_CATEGORY = {
80
80
  "API Upload": "Ingestion API",
81
81
  }
82
82
  _CAT_PATTERNS = [
83
- ("Metadata & Consistency", re.compile(r"e_tag|required field|metadata consist|positional|section|page metadata|directory|predicate|date format|email|field rule", re.I)),
84
- ("Chunk Structure", re.compile(r"boundaries|chunk count|size within|ordering|overlap", re.I)),
83
+ ("Metadata & Consistency", re.compile(r"e_tag|required field|metadata consist|page metadata|directory|predicate|date format|email|field rule", re.I)),
84
+ # 'positional correctness', 'chunk structure coherence' and 'all chunks validated' are
85
+ # STRUCTURAL (chunk ordering / integrity), not metadata - keep them out of the metadata
86
+ # bucket and out of an opaque "Other".
87
+ ("Chunk Structure", re.compile(r"boundaries|chunk count|chunk structure|all chunks|coheren|positional|section split|size within|ordering|overlap", re.I)),
85
88
  ("Embedding Quality", re.compile(r"embedding|normaliz|near-duplicate|dimension|distance metric", re.I)),
86
89
  ("Content Preservation", re.compile(r"fidelity|coverage|contaminat|duplicate|header|footer|table/figure", re.I)),
87
90
  ("Index & Schema", re.compile(r"index|schema", re.I)),
@@ -115,6 +118,35 @@ li .check-hint{margin:4px 0 0 0;display:block;}
115
118
  .rec-list{padding:14px 16px 16px;display:flex;flex-direction:column;gap:10px;}
116
119
  .rec-fix{font-size:12.5px;line-height:1.55;color:var(--ink);}
117
120
  .rec-more{font-size:12px;color:var(--muted);padding:4px 2px;}
121
+ /* Action Plan confidence tiers: Observed (fact) / Likely (primary) / Possible (alternates). */
122
+ .rca-line{display:flex;gap:8px;align-items:baseline;font-size:12.5px;line-height:1.55;color:var(--ink);margin-top:3px;}
123
+ .rca-line:first-child{margin-top:0;}
124
+ .conf-tag{flex:none;font-size:9.5px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:1px 7px;border-radius:999px;line-height:1.5;}
125
+ .conf-tag.observed{background:rgba(37,99,235,.13);color:var(--accent);}
126
+ .conf-tag.likely{background:rgba(217,119,6,.14);color:var(--warn);}
127
+ .conf-tag.possible{background:var(--chip-bg);color:var(--muted);}
128
+ /* Platform Health: a compact per-subsystem pass-rate rollup (dimensions are dynamic). */
129
+ .phz{margin-top:12px;padding-top:11px;border-top:1px solid var(--line);}
130
+ .phz-head{display:flex;align-items:baseline;gap:6px;margin-bottom:8px;}
131
+ .phz-cap{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:700;}
132
+ .phz-overall{font-size:17px;font-weight:800;margin-left:auto;font-variant-numeric:tabular-nums;}
133
+ .phz-overall.ok{color:var(--ok);}.phz-overall.warn{color:var(--warn);}.phz-overall.bad{color:var(--bad);}
134
+ .phz-max{font-size:11px;color:var(--muted);font-weight:600;}
135
+ .phz-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:8px 16px;}
136
+ .phz-top{display:flex;justify-content:space-between;align-items:baseline;font-size:11px;margin-bottom:2px;gap:8px;}
137
+ .phz-name{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
138
+ .phz-score{flex:none;}
139
+ .phz-score{font-weight:800;font-variant-numeric:tabular-nums;}
140
+ .phz-score.ok{color:var(--ok);}.phz-score.warn{color:var(--warn);}.phz-score.bad{color:var(--bad);}
141
+ .phz-bar{height:4px;border-radius:999px;background:var(--chip-bg);overflow:hidden;}
142
+ .phz-bar i{display:block;height:100%;border-radius:999px;}
143
+ .phz-bar i.ok{background:var(--ok);}.phz-bar i.warn{background:var(--warn);}.phz-bar i.bad{background:var(--bad);}
144
+ /* Engineering Wins: the healthy counterpart to the Action Plan. */
145
+ .wins-block{margin-top:16px;padding:12px 14px;background:rgba(22,163,74,.06);border:1px solid rgba(22,163,74,.2);border-radius:10px;}
146
+ .wins-cap{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--ok);font-weight:800;margin-bottom:8px;}
147
+ .wins-grid{display:flex;flex-wrap:wrap;gap:7px 16px;}
148
+ .win-item{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--ink);}
149
+ .win-tick{color:var(--ok);font-weight:800;}
118
150
  @media(max-width:600px){.subsys-row{grid-template-columns:1fr;gap:3px;}}
119
151
  .exec-bullets{margin:0 0 4px;padding-left:18px;font-size:13.5px;line-height:1.75;color:var(--ink);}
120
152
  .exec-bullets li{margin:0;}
@@ -285,7 +317,8 @@ def _result_payload(name: str, r: PipelineResult) -> dict:
285
317
  }
286
318
 
287
319
 
288
- def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
320
+ def _aggregate(cases: list[dict], hide_skipped: bool = True,
321
+ suppress: set[str] | None = None) -> dict:
289
322
  total = len(cases)
290
323
  passed = sum(1 for c in cases if c["overall"] == "PASSED")
291
324
  failed = sum(1 for c in cases if c["overall"] == "FAILED")
@@ -298,6 +331,12 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
298
331
  # check is either a HARD failure (fails the step) or an ADVISORY warning (does not).
299
332
  # Neutral checks (the backend/config can't evaluate them) are counted separately.
300
333
  checks_hard = checks_advisory = checks_neutral = 0
334
+ # Advisory checks a project has explicitly accepted (config `suppressions`). They are
335
+ # excluded from scoring - like neutral checks - but tallied and shown, never hidden. HARD
336
+ # (gating) checks can NEVER be suppressed, so an accepted known-issue can't mask a failure.
337
+ checks_suppressed = 0
338
+ suppressed: Counter = Counter()
339
+ suppress = suppress or set()
301
340
  cat = {name: {"total": 0, "passed": 0, "adv_total": 0, "adv_passed": 0} for name, _ in _CAT_ORDER}
302
341
  # Which checks fail across the MOST documents. One systemic cause usually shows up as
303
342
  # one label repeated over many documents, so ranking by document count points at the
@@ -337,10 +376,19 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
337
376
  if ch.get("supported", True) is False:
338
377
  checks_neutral += 1 # neutral — backend can't evaluate this check
339
378
  continue
340
- k = _categorise(ch.get("label", ""), step_name)
379
+ _label = ch.get("label", "")
380
+ _sev = "warn" if str(ch.get("severity", "hard")).lower() == "warn" else "hard"
381
+ # Suppressed = an ADVISORY check the project accepted as expected (e.g. known
382
+ # boilerplate near-duplicates). Excluded from scoring but counted + shown.
383
+ # Only advisory can be suppressed - a hard/gating check ignores the list.
384
+ if _sev == "warn" and _label in suppress:
385
+ checks_suppressed += 1
386
+ suppressed[_label] += 1
387
+ continue
388
+ k = _categorise(_label, step_name)
341
389
  cat[k]["total"] += 1
342
390
  ch_passed = bool(ch.get("passed"))
343
- sev = "warn" if str(ch.get("severity", "hard")).lower() == "warn" else "hard"
391
+ sev = _sev
344
392
  # Hygiene tracks ADVISORY (WARN) checks only - the quality BEYOND the
345
393
  # integrity gate. HARD checks passing is what READY already reports, so
346
394
  # counting them would just pull hygiene toward 100 and hide the signal.
@@ -355,9 +403,17 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
355
403
  checks_advisory += 1
356
404
  else:
357
405
  checks_hard += 1
358
- label = ch.get("label", "")
359
- rec = offenders.setdefault(label, {"label": label, "docs": 0, "severity": sev, "step": step_name})
406
+ label = _label
407
+ rec = offenders.setdefault(
408
+ label, {"label": label, "docs": 0, "severity": sev, "step": step_name, "detail": ""})
360
409
  rec["docs"] += 1
410
+ # Keep one concrete, measured example (the check's own detail, e.g.
411
+ # "4200 (~3000 expected)") so the Action Plan can show a specific number
412
+ # instead of a generic cause. First non-empty wins - deterministic.
413
+ if not rec["detail"]:
414
+ _dv = str(ch.get("detail") or "").strip()
415
+ if _dv:
416
+ rec["detail"] = _dv
361
417
  # a label is HARD overall if it ever gates
362
418
  if sev == "hard":
363
419
  rec["severity"] = "hard"
@@ -401,6 +457,11 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
401
457
  # rests on less than the full pipeline.
402
458
  "docs_incomplete": docs_incomplete,
403
459
  "hygiene": hygiene,
460
+ # Accepted advisory issues (config `suppressions`): count + per-label tally, shown
461
+ # so a suppressed warning is disclosed, never silently dropped.
462
+ "checks_suppressed": checks_suppressed,
463
+ "suppressed": [{"label": lbl, "docs": n} for lbl, n in
464
+ sorted(suppressed.items(), key=lambda kv: (-kv[1], kv[0]))],
404
465
  }
405
466
 
406
467
 
@@ -412,15 +473,16 @@ _HYGIENE_TIERS = (
412
473
  )
413
474
 
414
475
 
415
- def hygiene_from_results(results: list[dict]) -> dict | None:
476
+ def hygiene_from_results(results: list[dict], suppress: list[str] | None = None) -> dict | None:
416
477
  """Public: compute the KB Hygiene score from a list of ``{name, result}`` items.
417
478
 
418
479
  Same deterministic scoring the report uses, exposed so the CLI can gate on it
419
- (``--quality-threshold``) without rendering HTML. Returns None if there are no
420
- advisory checks to score.
480
+ (``--quality-threshold``) without rendering HTML. ``suppress`` accepts the same accepted
481
+ advisory labels as the report, so the gate and the report agree. Returns None if there
482
+ are no advisory checks to score.
421
483
  """
422
484
  cases = [_result_payload(item.get("name", item["result"].file_name), item["result"]) for item in results]
423
- return _aggregate(cases).get("hygiene")
485
+ return _aggregate(cases, suppress=set(suppress or [])).get("hygiene")
424
486
 
425
487
 
426
488
  def _hygiene_score(cat: dict) -> dict | None:
@@ -661,6 +723,12 @@ def _exec_summary(agg: dict, corpus: dict | None = None, cases: list[dict] | Non
661
723
  f'these documents - see Coverage">{agg["docs_incomplete"]} not fully validated</span>')
662
724
  if agg.get("checks_advisory"):
663
725
  chips.append(f'<span class="hero-chip">{agg["checks_advisory"]} advisory</span>')
726
+ if agg.get("checks_suppressed"):
727
+ # Accepted known-issues: disclosed, never hidden. Tooltip names what was suppressed.
728
+ acc = ", ".join(f'{s["label"]} (x{s["docs"]})' for s in agg.get("suppressed", [])[:8])
729
+ chips.append(f'<span class="hero-chip" data-tip="Accepted advisory issues (config '
730
+ f'suppressions) - excluded from hygiene &amp; health, never from the gate: '
731
+ f'{_esc(acc)}">{agg["checks_suppressed"]} suppressed</span>')
664
732
  chips_html = f'<div class="hero-chips">{"".join(chips)}</div>' if chips else ""
665
733
  t = max(agg["total"], 1)
666
734
  seg = ""
@@ -692,8 +760,10 @@ def _exec_summary(agg: dict, corpus: dict | None = None, cases: list[dict] | Non
692
760
  <span><i class="warn"></i> {agg['partial']} partial</span>
693
761
  <span><i class="bad"></i> {agg['failed']} failed</span>
694
762
  </div>
763
+ {_health_strip(_platform_health(agg.get('cat_rows', [])))}
695
764
  </div>
696
765
  </div>
766
+ {_wins_block(agg)}
697
767
  <div class="exec-stats summary-stats-foot">
698
768
  <div class="exec-stat"><div class="v">{agg['total']}</div><div class="l">Documents</div></div>
699
769
  <div class="exec-stat"><div class="v">{agg['steps_passed']}/{agg['steps_total']}</div><div class="l">Steps Passed</div></div>
@@ -848,6 +918,135 @@ _EFFORT = {
848
918
  _EFFORT_RANK = {"LOW": 0, "MEDIUM": 1, "HIGH": 2}
849
919
  _ACTION_CAP = 6 # max cards in the always-open Action Plan; the rest are in the collapsible list
850
920
 
921
+ # A category-specific, ACTIONABLE fix (an instruction), distinct from _ROOT_CAUSE (a cause).
922
+ # Keeping one per subsystem stops two different cards printing the same remediation verbatim.
923
+ _FIX_GUIDANCE = {
924
+ "Chunk Structure": "Re-chunk with a size/overlap that fits these documents and keep splits on sentence or paragraph boundaries.",
925
+ "Embedding Quality": "Re-embed after de-duplicating and stripping boilerplate; confirm the model and dimension match the index.",
926
+ "Content Preservation": "Strip headers/footers before chunking and verify ingestion drops or duplicates no source text.",
927
+ "Metadata & Consistency": "Backfill the missing metadata field and reconcile its value across the stores that disagree.",
928
+ "Index & Schema": "Align the index field types and vector config with what the pipeline actually writes.",
929
+ "Retrieval & Query": "Verify the query-embedding path and metadata filters, then re-tune the similarity threshold.",
930
+ "Knowledge Graph": "Re-ingest the graph so every document has its expected nodes, links, and typing.",
931
+ "Object Storage": "Restore the missing or altered source objects and re-run the storage stage.",
932
+ "Cross-Stage Consistency": "Re-run ingestion so every store agrees on id, hash, and content for each document.",
933
+ "Ingestion API": "Inspect the upload response and retry ingestion for documents without a success id.",
934
+ "Other": "Review the grouped checks below and address the shared cause.",
935
+ }
936
+ # Secondary hypotheses. Rendered under "Possible" so the report separates what it MEASURED
937
+ # (Observed) from its primary guess (Likely) and the alternates it cannot rule out (Possible).
938
+ _POSSIBLE_CAUSES = {
939
+ "Chunk Structure": ["Source documents vary widely in length", "Overlap set too low for cross-boundary context"],
940
+ "Embedding Quality": ["Repetitive/boilerplate text inflating near-duplicates", "A model or dimension mismatch at write time"],
941
+ "Content Preservation": ["Tables or figures dropped by the extractor", "Headers/footers folded into chunk bodies"],
942
+ "Metadata & Consistency": ["A field renamed between stores", "Ingestion wrote before metadata was attached"],
943
+ "Index & Schema": ["Schema drifted after an upgrade", "A field indexed with the wrong type"],
944
+ "Retrieval & Query": ["A metadata filter excluding valid chunks", "Similarity threshold set too high", "Chunk overlap starving boundary recall"],
945
+ "Knowledge Graph": ["A namespace or prefix mismatch", "Links written under an unexpected predicate"],
946
+ "Object Storage": ["An object expired or was moved", "Wrong content-type set on upload"],
947
+ "Cross-Stage Consistency": ["A store lagged during ingestion", "A partial re-ingest left stores out of sync"],
948
+ "Ingestion API": ["A transient upload error went unretried", "The ingestion response schema changed"],
949
+ "Other": [],
950
+ }
951
+
952
+
953
+ def _platform_health(cat_rows: list[dict]) -> dict | None:
954
+ """A compact KB-health rollup: one dimension PER CATEGORY actually present in this run.
955
+
956
+ Dynamic by design - the dimensions are whatever the pipeline's steps produced (via
957
+ ``cat_rows``), not a hardcoded list. A project with no graph step simply has no graph
958
+ dimension. Each dimension scores the pass rate of its evaluable checks (hard + advisory),
959
+ so it reads as overall subsystem health, and the composite is check-weighted across them.
960
+ Worst-first, so the weakest subsystem is the signal that leads. None when nothing ran.
961
+ """
962
+ dims, tp, tt = [], 0, 0
963
+ for r in cat_rows:
964
+ if not r.get("total"):
965
+ continue
966
+ dims.append({"name": r["name"], "score": round(100 * r["passed"] / r["total"]),
967
+ "passed": r["passed"], "total": r["total"]})
968
+ tp += r["passed"]
969
+ tt += r["total"]
970
+ if not dims:
971
+ return None
972
+ dims.sort(key=lambda d: (d["score"], d["name"]))
973
+ return {"score": round(100 * tp / tt), "dimensions": dims}
974
+
975
+
976
+ def _health_band(score: int) -> str:
977
+ return "ok" if score >= 90 else "warn" if score >= 70 else "bad"
978
+
979
+
980
+ def _health_strip(h: dict | None) -> str:
981
+ """A subtle five-second read of subsystem health: a composite plus a mini-bar per
982
+ dimension. Deliberately compact - a signal, not another table."""
983
+ if not h:
984
+ return ""
985
+ bars = ""
986
+ for d in h["dimensions"]:
987
+ cls = _health_band(d["score"])
988
+ bars += (
989
+ f'<div class="phz-dim" data-tip="{d["passed"]}/{d["total"]} checks passed">'
990
+ f'<div class="phz-top"><span class="phz-name">{_esc(d["name"])}</span>'
991
+ f'<span class="phz-score {cls}">{d["score"]}</span></div>'
992
+ f'<div class="phz-bar"><i class="{cls}" style="width:{d["score"]}%"></i></div></div>'
993
+ )
994
+ ocls = _health_band(h["score"])
995
+ return (
996
+ '<div class="phz" data-tip="Check pass-rate grouped by KB subsystem present in this run. '
997
+ 'Deterministic; overall integrity/quality still live in the verdict and hygiene above.">'
998
+ '<div class="phz-head"><span class="phz-cap">Platform Health</span>'
999
+ f'<span class="phz-overall {ocls}">{h["score"]}</span><span class="phz-max">/100</span></div>'
1000
+ f'<div class="phz-grid">{bars}</div></div>'
1001
+ )
1002
+
1003
+
1004
+ # A per-category "win" phrased as an outcome, shown only when that category is 100% clean.
1005
+ _WIN_LABEL = {
1006
+ "Object Storage": "No storage failures",
1007
+ "Knowledge Graph": "No graph inconsistencies",
1008
+ "Cross-Stage Consistency": "Stores agree on every document",
1009
+ "Ingestion API": "Every document ingested cleanly",
1010
+ "Embedding Quality": "Embeddings clean across the corpus",
1011
+ "Chunk Structure": "Chunk structure clean across the corpus",
1012
+ "Content Preservation": "Source content fully preserved",
1013
+ "Metadata & Consistency": "Metadata complete and consistent",
1014
+ "Index & Schema": "Index schema matches expectations",
1015
+ "Retrieval & Query": "Retrieval clean across the corpus",
1016
+ }
1017
+
1018
+
1019
+ def _engineering_wins(agg: dict) -> list[str]:
1020
+ """What is demonstrably HEALTHY - so a good KB is not represented only by its problems.
1021
+
1022
+ Deterministic and honest: every item is a check-backed fact (a category that passed
1023
+ 100%, zero errors, a full-pass gate), never a platitude. Empty list -> no section.
1024
+ """
1025
+ wins: list[str] = []
1026
+ total = agg.get("total", 0)
1027
+ if total and agg.get("failed", 0) == 0 and agg.get("errors", 0) == 0:
1028
+ wins.append(f"All {total} document(s) validated - no integrity failures")
1029
+ elif agg.get("errors", 0) == 0 and agg.get("checks_total", 0):
1030
+ wins.append("No runtime errors")
1031
+ for row in agg.get("cat_rows", []):
1032
+ if row.get("total") and row["passed"] == row["total"]:
1033
+ lbl = _WIN_LABEL.get(row["name"])
1034
+ if lbl and lbl not in wins:
1035
+ wins.append(lbl)
1036
+ return wins[:6]
1037
+
1038
+
1039
+ def _wins_block(agg: dict) -> str:
1040
+ wins = _engineering_wins(agg)
1041
+ if not wins:
1042
+ return ""
1043
+ items = "".join(f'<span class="win-item"><span class="win-tick">&#10003;</span>{_esc(w)}</span>'
1044
+ for w in wins)
1045
+ return ('<div class="wins-block" data-tip="Check-backed strengths in this run - '
1046
+ 'the healthy counterpart to the Action Plan.">'
1047
+ '<div class="wins-cap">Engineering Wins</div>'
1048
+ f'<div class="wins-grid">{items}</div></div>')
1049
+
851
1050
 
852
1051
  def _impact_cls(pct: int) -> str:
853
1052
  return "bad" if pct >= 50 else "warn" if pct >= 20 else "ok"
@@ -901,9 +1100,22 @@ def _action_plan_section(agg: dict) -> str:
901
1100
  # cards below; a pointer here makes the remainder explicit rather than silently dropped.
902
1101
  cards = ""
903
1102
  for i, g in enumerate(ranked[:_ACTION_CAP], 1):
1103
+ ranked_checks = sorted(g["checks"], key=lambda o: -o["docs"])
904
1104
  evidence = ", ".join(f'{_esc(o["label"])} {round(100 * o["docs"] / g["total"])}%'
905
- for o in sorted(g["checks"], key=lambda o: -o["docs"])[:3])
906
- fix = g["fix"] or _ROOT_CAUSE.get(g["cat"], "")
1105
+ for o in ranked_checks[:3])
1106
+ # A concrete measured example from the worst check (e.g. "4200 (~3000 expected)"),
1107
+ # so Observed carries a specific number - the "why" - not just which check failed.
1108
+ example = next((o.get("detail") for o in ranked_checks if o.get("detail")), "")
1109
+ if example:
1110
+ evidence += f' <span class="check-detail">e.g. {_esc(example)}</span>'
1111
+ # A concise, category-specific INSTRUCTION here - the diagnosis now lives in the
1112
+ # Observed/Likely/Possible tiers, so the fix line is just "what to do" and never
1113
+ # repeats a cause. The verbose per-check hint stays in the detailed cards below.
1114
+ fix = _FIX_GUIDANCE.get(g["cat"]) or g["fix"] or _ROOT_CAUSE.get(g["cat"], "")
1115
+ possible = _POSSIBLE_CAUSES.get(g["cat"], [])
1116
+ possible_html = (
1117
+ '<div class="rca-line"><span class="conf-tag possible">Possible</span>'
1118
+ f'<span>{_esc("; ".join(possible))}</span></div>' if possible else "")
907
1119
  cards += (
908
1120
  # Border color driven by PRIORITY (inline overrides the shared :has(.tag.*) rule,
909
1121
  # which would otherwise pick a colour from the impact/effort chips too).
@@ -914,8 +1126,13 @@ def _action_plan_section(agg: dict) -> str:
914
1126
  f'<span class="tag {_impact_cls(g["impact"])}" title="documents affected">impact {g["impact"]}%</span>'
915
1127
  f'<span class="tag {g["effort_cls"]}" title="estimated engineering effort">est. effort {g["effort"]}</span>'
916
1128
  '</div>'
917
- f'<div class="rca-cause"><b>Likely cause:</b> {_esc(_ROOT_CAUSE.get(g["cat"], ""))} '
918
- f'<span class="check-detail">Evidence: {evidence}</span></div>'
1129
+ # Separate what we MEASURED (Observed) from the primary guess (Likely) and the
1130
+ # alternates we can't rule out (Possible), so a hypothesis never reads as a fact.
1131
+ '<div class="rca-cause">'
1132
+ f'<div class="rca-line"><span class="conf-tag observed">Observed</span><span>{evidence}</span></div>'
1133
+ f'<div class="rca-line"><span class="conf-tag likely">Likely</span><span>{_esc(_ROOT_CAUSE.get(g["cat"], ""))}</span></div>'
1134
+ f'{possible_html}'
1135
+ '</div>'
919
1136
  + (f'<div class="rec-fix">Fix: {_esc(fix)}</div>' if fix else "")
920
1137
  + '</div>'
921
1138
  )
@@ -953,7 +1170,8 @@ def _issues_by_subsystem(agg: dict) -> str:
953
1170
  f'{o["docs"]}/{g["total"]} docs ({round(100 * o["docs"] / g["total"])}%)</span></li>'
954
1171
  for o in sorted(g["checks"], key=lambda o: -o["docs"])
955
1172
  )
956
- fix_html = f'<div class="rec-fix">Fix: {_esc(g["fix"])}</div>' if g["fix"] else ""
1173
+ fix_txt = g["fix"] or _FIX_GUIDANCE.get(g["cat"])
1174
+ fix_html = f'<div class="rec-fix">Fix: {_esc(fix_txt)}</div>' if fix_txt else ""
957
1175
  cards += (
958
1176
  f'<details class="cov-row"{" open" if g["severity"] == "hard" else ""}>'
959
1177
  '<summary class="cov-sum">'
@@ -1422,35 +1640,113 @@ def _trends_section(trends: list[dict]) -> str:
1422
1640
  </div>"""
1423
1641
 
1424
1642
 
1425
- def _glossary_section(present_steps: list[str] | None = None) -> str:
1426
- # Each entry carries the step name(s) it documents so the glossary can show only the
1427
- # steps that actually appear in THIS run (not the whole catalogue). present_steps=None
1428
- # (e.g. legacy/trends-only) falls back to listing all of them.
1429
- steps = [
1643
+ def _glossary_section(agg: dict | None = None, corpus: dict | None = None, mode: str = "") -> str:
1644
+ """A run-driven glossary: it explains only the vocabulary THIS run actually used.
1645
+
1646
+ Sections are included conditionally - a step that did not run, a category with no checks,
1647
+ a Platform Health rollup that could not be computed, the Action Plan tiers when there is
1648
+ no Action Plan, a Readiness Verdict without a corpus, or the mode that was not used, are
1649
+ all omitted. So the glossary tracks the report above it instead of listing the full
1650
+ catalogue. ``agg=None`` (e.g. a trends-only render) falls back to the complete set.
1651
+ """
1652
+ present_steps = agg.get("present_steps") if agg else None
1653
+ present_cats = {row["name"] for row in agg.get("cat_rows", [])} if agg else None
1654
+
1655
+ sections: list[str] = []
1656
+
1657
+ def section(title: str, body: str) -> None:
1658
+ sections.append(f'<div class="glossary-section"><h4>{_esc(title)}</h4>{body}</div>')
1659
+
1660
+ def dl(pairs) -> str:
1661
+ return "<dl>" + "".join(f"<dt>{_esc(n)}</dt><dd>{_esc(d)}</dd>" for n, d in pairs) + "</dl>"
1662
+
1663
+ # Counting levels are the base vocabulary for every run - always shown.
1664
+ section("Counting Levels", dl([
1665
+ ("Document", "One knowledge-base item (a file / object). Reports may also call it a 'case'. A run validates one or more documents."),
1666
+ ("Pipeline step", "One stage of the pipeline applied to a document (e.g. S3 Storage, Redis Chunks). Each document runs several steps."),
1667
+ ("Check", "One individual assertion inside a step (e.g. 'embedding dimension matches'). A step bundles many checks."),
1668
+ ]))
1669
+
1670
+ # Pipeline steps: only those that appear in THIS run.
1671
+ step_defs = [
1430
1672
  ("API Upload", "Uploads the document through the ingestion API (GraphQL multipart) and confirms a document id is returned.", ("API Upload",)),
1431
1673
  ("S3 Storage", "Confirms the object landed in storage with the expected content type, size, recency and ETag/MD5 integrity.", ("S3 Storage",)),
1432
1674
  ("GraphDB / Neptune Metadata", "Validates document metadata in the knowledge graph: required predicates, identity uniqueness, cross-store agreement with object storage (eTag + storage key), RDF typing, namespace consistency, dangling links, orphan nodes, formats and declarative structure checks.", ("GraphDB Metadata", "Neptune Metadata")),
1433
- ("Redis Chunks", "28 toggleable checks over the vector store: chunk existence, ids, embeddings, dimensions, ordering, duplicates and metadata.", ("Redis Chunks",)),
1675
+ ("Redis Chunks", "Toggleable checks over the vector store: chunk existence, ids, embeddings, dimensions, ordering, duplicates and metadata.", ("Redis Chunks",)),
1434
1676
  ("Retrieval Quality", "Informational diagnostics: recall@K, MRR, exact-term and paraphrase robustness. Never blocks the pipeline.", ("Retrieval Quality",)),
1435
1677
  ("Cross-Stage Consistency", "Do object storage, the graph and the vector index agree about each document: presence, eTag, shared metadata, chunk-count sanity, ACL propagation.", ("Cross-Stage Consistency",)),
1436
1678
  ]
1437
1679
  if present_steps is not None:
1438
- present = set(present_steps)
1439
- steps = [t for t in steps if any(name in present for name in t[2])]
1440
- defs = "".join(f"<dt>{_esc(n)}</dt><dd>{_esc(d)}</dd>" for n, d, _ in steps)
1441
- cats = "".join(f"<dt>{_esc(n)}</dt><dd>{_esc(d)}</dd>" for n, d in _CAT_ORDER)
1442
- # The three counting levels - the source of most "what does this number mean?" confusion.
1443
- levels = [
1444
- ("Document", "One knowledge-base item (a file / object). Reports may also call it a 'case'. A run validates one or more documents."),
1445
- ("Pipeline step", "One stage of the pipeline applied to a document (e.g. S3 Storage, Redis Chunks). Each document runs several steps."),
1446
- ("Check", "One individual assertion inside a step (e.g. 'embedding dimension matches'). A step bundles many checks."),
1447
- ]
1448
- levels_html = "".join(f"<dt>{_esc(n)}</dt><dd>{_esc(d)}</dd>" for n, d in levels)
1449
- modes = [
1450
- ("Live Ingestion", "Push documents through the ingestion API and validate each stage as it lands (run_validation)."),
1451
- ("Discovery", "Validate a knowledge base that is already populated, without re-ingesting (discover_and_validate)."),
1452
- ]
1453
- modes_html = "".join(f"<dt>{_esc(n)}</dt><dd>{_esc(d)}</dd>" for n, d in modes)
1680
+ pset = set(present_steps)
1681
+ step_defs = [t for t in step_defs if any(n in pset for n in t[2])]
1682
+ if step_defs:
1683
+ section("Pipeline Steps", dl([(n, d) for n, d, _ in step_defs]))
1684
+
1685
+ # Check categories: only those actually exercised this run.
1686
+ cat_pairs = [(n, d) for n, d in _CAT_ORDER if present_cats is None or n in present_cats]
1687
+ if cat_pairs:
1688
+ section("Check Categories", dl(cat_pairs))
1689
+
1690
+ # Platform Health: only when there were evaluable checks to roll up.
1691
+ if agg is not None and _platform_health(agg.get("cat_rows", [])) is not None:
1692
+ section("Platform Health",
1693
+ '<div class="score-scale"><span class="s-ok">90-100 healthy</span>'
1694
+ '<span class="s-warn">70-89 attention</span><span class="s-bad">below 70 weak</span></div>'
1695
+ + dl([
1696
+ ("Platform Health (0-100)", "The share of evaluable checks (hard + advisory) that passed, grouped by the subsystems present in this run and check-weighted into one composite. It is data-plane health, deterministic - not answer quality, which needs an LLM judge and lives in the eval layer."),
1697
+ ("Dimension score", "The same pass rate for a single subsystem - e.g. 'Retrieval & Query 52' means 52% of its checks passed. Dimensions are dynamic (only subsystems that actually ran appear) and are shown worst-first so the weakest leads."),
1698
+ ]).replace("<dl>", '<dl style="margin-top:10px">')
1699
+ + '<dd style="margin-top:8px">A high overall can still hide one weak dimension - read the per-subsystem bars, not only the composite.</dd>')
1700
+
1701
+ # Signal Confidence: only when the run produced an Action Plan (i.e. there are findings).
1702
+ if agg is not None and _subsystem_groups(agg):
1703
+ section("Signal Confidence (Action Plan)",
1704
+ dl([
1705
+ ("Observed", "What the run measured directly: the failing checks and how many documents they affect. Fact, not inference."),
1706
+ ("Likely", "The single best hypothesis for that observed pattern. A strong guess, not a proven cause."),
1707
+ ("Possible", "Other explanations the evidence cannot rule out - worth checking if the Likely cause does not hold."),
1708
+ ])
1709
+ + '<dd style="margin-top:8px">Each Action Plan card separates these three tiers, so a hypothesis is never presented as a measured fact.</dd>')
1710
+
1711
+ # Run mode: only the one that ran (both if unknown, e.g. a legacy render).
1712
+ mode_defs = {
1713
+ "Live Ingestion": "Push documents through the ingestion API and validate each stage as it lands (run_validation).",
1714
+ "Discovery": "Validate a knowledge base that is already populated, without re-ingesting (discover_and_validate).",
1715
+ }
1716
+ active_modes = [(mode, mode_defs[mode])] if mode in mode_defs else list(mode_defs.items())
1717
+ section("Run Mode" if mode in mode_defs else "Run Modes", dl(active_modes))
1718
+
1719
+ # Status scale is fundamental to reading any status pill - always shown.
1720
+ section("Status Scale",
1721
+ '<div class="score-scale"><span class="s-ok">PASSED</span><span class="s-warn">PARTIAL</span>'
1722
+ '<span class="s-bad">FAILED / ERROR</span><span class="s-na">SKIPPED</span></div>'
1723
+ '<dl style="margin-top:10px">'
1724
+ '<dt>PASSED</dt><dd>Every evaluable check passed.</dd>'
1725
+ '<dt>PARTIAL</dt><dd>Some steps passed and some failed within a document.</dd>'
1726
+ '<dt>FAILED / ERROR</dt><dd>A check failed (FAILED) or the step raised unexpectedly (ERROR).</dd>'
1727
+ '<dt>SKIPPED</dt><dd>Step not part of this project\'s pipeline / configured out, or BLOCKED because an upstream step it depends on did not produce the artifact it needs (e.g. the object never reached storage, so it cannot be chunked). Blocked steps are still listed with the upstream culprit - they are neutral, never counted as a failure. Independent branches (e.g. graph vs vector) are unaffected by each other.</dd>'
1728
+ '<dt>UNSUPPORTED</dt><dd>A check the backend cannot answer (e.g. a vector store that does not expose raw vectors). Counted as neutral - excluded from pass/fail totals, never a failure.</dd>'
1729
+ '</dl>'
1730
+ '<dd style="margin-top:8px">A document is PASSED only when every non-skipped step passes. Critical-step failures halt remaining steps.</dd>')
1731
+
1732
+ # Suppressed: only when this run actually accepted advisory issues.
1733
+ if agg is not None and agg.get("checks_suppressed"):
1734
+ section("Suppressed (accepted)", dl([
1735
+ ("Suppressed", "An advisory check the project accepted as expected for this corpus "
1736
+ "(config `suppressions` / `--suppress`) - e.g. near-duplicate boilerplate in policy "
1737
+ "documents. Excluded from the hygiene and Platform Health scores but still counted "
1738
+ "and shown as 'N suppressed'. Only advisory checks can be suppressed; a hard gating "
1739
+ "failure is never suppressed, so an accepted known-issue can never mask a real one."),
1740
+ ]))
1741
+
1742
+ # Readiness Verdict: only when a corpus supplied one.
1743
+ if corpus is not None:
1744
+ section("Readiness Verdict",
1745
+ '<div class="score-scale"><span class="s-ok">READY</span><span class="s-warn">AT RISK</span>'
1746
+ '<span class="s-bad">NOT READY</span></div>'
1747
+ '<dd style="margin-top:10px">Corpus-level verdict over the whole document set: completeness '
1748
+ '(expected vs actual), duplicates, and coverage - distinct from any single document\'s pass/fail.</dd>')
1749
+
1454
1750
  return f"""
1455
1751
  <div id="section-definitions">
1456
1752
  <section class="section">
@@ -1460,29 +1756,7 @@ def _glossary_section(present_steps: list[str] | None = None) -> str:
1460
1756
  <svg class="glossary-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
1461
1757
  </div>
1462
1758
  <div class="glossary-body" id="glossaryBody">
1463
- <div class="glossary-grid">
1464
- <div class="glossary-section"><h4>Counting Levels</h4><dl>{levels_html}</dl></div>
1465
- <div class="glossary-section"><h4>Pipeline Steps</h4><dl>{defs}</dl></div>
1466
- <div class="glossary-section"><h4>Check Categories</h4><dl>{cats}</dl></div>
1467
- <div class="glossary-section"><h4>Run Modes</h4><dl>{modes_html}</dl></div>
1468
- <div class="glossary-section">
1469
- <h4>Status Scale</h4>
1470
- <div class="score-scale"><span class="s-ok">PASSED</span><span class="s-warn">PARTIAL</span><span class="s-bad">FAILED / ERROR</span><span class="s-na">SKIPPED</span></div>
1471
- <dl style="margin-top:10px">
1472
- <dt>PASSED</dt><dd>Every evaluable check passed.</dd>
1473
- <dt>PARTIAL</dt><dd>Some steps passed and some failed within a document.</dd>
1474
- <dt>FAILED / ERROR</dt><dd>A check failed (FAILED) or the step raised unexpectedly (ERROR).</dd>
1475
- <dt>SKIPPED</dt><dd>Step not part of this project's pipeline / configured out, or BLOCKED because an upstream step it depends on did not produce the artifact it needs (e.g. the object never reached storage, so it cannot be chunked). Blocked steps are still listed with the upstream culprit - they are neutral, never counted as a failure. Independent branches (e.g. graph vs vector) are unaffected by each other.</dd>
1476
- <dt>UNSUPPORTED</dt><dd>A check the backend cannot answer (e.g. a vector store that does not expose raw vectors). Counted as neutral - excluded from pass/fail totals, never a failure.</dd>
1477
- </dl>
1478
- <dd style="margin-top:8px">A document is PASSED only when every non-skipped step passes. Critical-step failures halt remaining steps.</dd>
1479
- </div>
1480
- <div class="glossary-section">
1481
- <h4>Readiness Verdict</h4>
1482
- <div class="score-scale"><span class="s-ok">READY</span><span class="s-warn">AT RISK</span><span class="s-bad">NOT READY</span></div>
1483
- <dd style="margin-top:10px">Corpus-level verdict over the whole document set: completeness (expected vs actual), duplicates, and coverage - distinct from any single document's pass/fail.</dd>
1484
- </div>
1485
- </div>
1759
+ <div class="glossary-grid">{"".join(sections)}</div>
1486
1760
  </div>
1487
1761
  </div>
1488
1762
  </section>
@@ -1561,8 +1835,8 @@ function _insights(d){
1561
1835
  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"};
1562
1836
  function _categorise(label, step){
1563
1837
  var l=(label||'').toLowerCase();
1564
- if(/e_tag|required field|metadata consist|positional|section|page metadata|directory|predicate|date format|email|field rule/.test(l)) return 'Metadata & Consistency';
1565
- if(/boundaries|chunk count|size within|ordering|overlap/.test(l)) return 'Chunk Structure';
1838
+ if(/e_tag|required field|metadata consist|page metadata|directory|predicate|date format|email|field rule/.test(l)) return 'Metadata & Consistency';
1839
+ if(/boundaries|chunk count|chunk structure|all chunks|coheren|positional|section split|size within|ordering|overlap/.test(l)) return 'Chunk Structure';
1566
1840
  if(/embedding|normaliz|near-duplicate|dimension|distance metric/.test(l)) return 'Embedding Quality';
1567
1841
  if(/fidelity|coverage|contaminat|duplicate|header|footer|table\/figure/.test(l)) return 'Content Preservation';
1568
1842
  if(/index|schema/.test(l)) return 'Index & Schema';
@@ -1835,6 +2109,7 @@ def generate_batch_dashboard(
1835
2109
  wall_seconds: float | None = None,
1836
2110
  concurrency: int | None = None,
1837
2111
  evidence: dict | None = None,
2112
+ suppress: list[str] | None = None,
1838
2113
  ) -> str:
1839
2114
  """Generate a self-contained, interactive HTML dashboard. Returns abs path.
1840
2115
 
@@ -1854,7 +2129,8 @@ def generate_batch_dashboard(
1854
2129
  _ICON = {s.name: s.icon for s in registered_steps()}
1855
2130
 
1856
2131
  cases = [_result_payload(item.get("name", item["result"].file_name), item["result"]) for item in results]
1857
- agg = _aggregate(cases, hide_skipped=hide_skipped) # computed on FULL data, before any trim
2132
+ # computed on FULL data, before any trim; suppressions accepted from config
2133
+ agg = _aggregate(cases, hide_skipped=hide_skipped, suppress=set(suppress or []))
1858
2134
  # Wall clock vs compute time: agg["duration"] is the SUM of per-document durations, so
1859
2135
  # under --parallel it far exceeds the elapsed time (a 502-doc run summed 1319s but
1860
2136
  # finished in 329s). Carry the real wall clock and concurrency so the summary can show
@@ -1984,7 +2260,7 @@ def generate_batch_dashboard(
1984
2260
  {_document_section(cases)}
1985
2261
  {_details_section(cases, agg['present_steps'])}
1986
2262
  {trends_html}
1987
- {_glossary_section(agg.get('present_steps'))}
2263
+ {_glossary_section(agg, corpus, mode)}
1988
2264
  </main>
1989
2265
  {_MODAL}
1990
2266
  <div id="compass-tip"></div>