compass-kb-validation 3.1.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.1.0 → compass_kb_validation-3.2.0}/PKG-INFO +1 -1
  2. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/__init__.py +1 -1
  3. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/cli.py +7 -0
  4. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/config/__init__.py +8 -1
  5. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/report/__init__.py +65 -11
  6. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass_kb_validation.egg-info/PKG-INFO +1 -1
  7. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/discover_and_validate.py +12 -2
  8. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/run_validation.py +1 -1
  9. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_config.py +16 -0
  10. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_reporting.py +43 -0
  11. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/LICENSE +0 -0
  12. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/README.md +0 -0
  13. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/__main__.py +0 -0
  14. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/ask.py +0 -0
  15. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/baseline.py +0 -0
  16. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/chunk_coherence.py +0 -0
  17. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/citation.py +0 -0
  18. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/config_lint.py +0 -0
  19. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/content_inspect.py +0 -0
  20. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/corpus.py +0 -0
  21. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/corpus_health.py +0 -0
  22. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/diagnostics.py +0 -0
  23. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/embedding_drift.py +0 -0
  24. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/eval_interop.py +0 -0
  25. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/evidence.py +0 -0
  26. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/grounding.py +0 -0
  27. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/init_wizard.py +0 -0
  28. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/live_dashboard.py +0 -0
  29. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/mcp_server.py +0 -0
  30. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/models/__init__.py +0 -0
  31. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/notifications.py +0 -0
  32. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/pipeline/__init__.py +0 -0
  33. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/profiler.py +0 -0
  34. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/provenance.py +0 -0
  35. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/providers/__init__.py +0 -0
  36. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/providers/embedding.py +0 -0
  37. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/providers/graph.py +0 -0
  38. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/providers/source.py +0 -0
  39. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/providers/storage.py +0 -0
  40. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/providers/vectorstore.py +0 -0
  41. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/remediation.py +0 -0
  42. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/report/_report_css.py +0 -0
  43. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/report/_report_js.py +0 -0
  44. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/report/analysis.py +0 -0
  45. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/report/audit.py +0 -0
  46. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/report/export.py +0 -0
  47. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/report/shell.py +0 -0
  48. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/retrieval_analysis.py +0 -0
  49. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/text_probes.py +0 -0
  50. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/trend_tracker.py +0 -0
  51. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/utils/__init__.py +0 -0
  52. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/validators/__init__.py +0 -0
  53. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/validators/api_upload.py +0 -0
  54. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/validators/base.py +0 -0
  55. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/validators/base_sparql.py +0 -0
  56. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/validators/cross_stage.py +0 -0
  57. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/validators/graphdb_metadata.py +0 -0
  58. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/validators/neptune_metadata.py +0 -0
  59. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/validators/redis_chunks.py +0 -0
  60. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/validators/registry.py +0 -0
  61. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/validators/retrieval_quality.py +0 -0
  62. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass/validators/s3_storage.py +0 -0
  63. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass_kb_validation.egg-info/SOURCES.txt +0 -0
  64. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
  65. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass_kb_validation.egg-info/entry_points.txt +0 -0
  66. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass_kb_validation.egg-info/requires.txt +0 -0
  67. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/compass_kb_validation.egg-info/top_level.txt +0 -0
  68. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/ingestion_validation/__init__.py +0 -0
  69. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/profile_kb.py +0 -0
  70. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/pyproject.toml +0 -0
  71. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/setup.cfg +0 -0
  72. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_alt_adapters.py +0 -0
  73. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_analysis_report.py +0 -0
  74. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_api_upload.py +0 -0
  75. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_ask.py +0 -0
  76. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_baseline.py +0 -0
  77. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_benchmark.py +0 -0
  78. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_check_catalog.py +0 -0
  79. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_citation.py +0 -0
  80. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_cli.py +0 -0
  81. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_compat_shim.py +0 -0
  82. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_config_lint.py +0 -0
  83. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_content_inspect.py +0 -0
  84. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_corpus.py +0 -0
  85. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_corpus_health.py +0 -0
  86. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_coverage.py +0 -0
  87. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_cross_stage.py +0 -0
  88. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_diagnostics.py +0 -0
  89. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_discover_cli.py +0 -0
  90. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_discovery_parallel.py +0 -0
  91. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_embedding_drift.py +0 -0
  92. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_eval_interop.py +0 -0
  93. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_evidence.py +0 -0
  94. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_gate.py +0 -0
  95. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_graph_transport.py +0 -0
  96. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_graph_validators.py +0 -0
  97. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_grounding.py +0 -0
  98. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_init_wizard.py +0 -0
  99. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_manifest.py +0 -0
  100. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_mcp.py +0 -0
  101. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_migration.py +0 -0
  102. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_models.py +0 -0
  103. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_neptune_bugfixes.py +0 -0
  104. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_neptune_bugfixes2.py +0 -0
  105. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_notifications.py +0 -0
  106. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_pipeline.py +0 -0
  107. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_profiler.py +0 -0
  108. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_providers.py +0 -0
  109. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_redis_chunks.py +0 -0
  110. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_registry.py +0 -0
  111. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_remediation.py +0 -0
  112. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_retrieval_assertions.py +0 -0
  113. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_retrieval_quality.py +0 -0
  114. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_review_hardening.py +0 -0
  115. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_run_cli.py +0 -0
  116. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_sources.py +0 -0
  117. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_storage_validator.py +0 -0
  118. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_trends.py +0 -0
  119. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_universal_concerns.py +0 -0
  120. {compass_kb_validation-3.1.0 → compass_kb_validation-3.2.0}/tests/test_utils.py +0 -0
  121. {compass_kb_validation-3.1.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.1.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.1.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
@@ -317,7 +317,8 @@ def _result_payload(name: str, r: PipelineResult) -> dict:
317
317
  }
318
318
 
319
319
 
320
- 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:
321
322
  total = len(cases)
322
323
  passed = sum(1 for c in cases if c["overall"] == "PASSED")
323
324
  failed = sum(1 for c in cases if c["overall"] == "FAILED")
@@ -330,6 +331,12 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
330
331
  # check is either a HARD failure (fails the step) or an ADVISORY warning (does not).
331
332
  # Neutral checks (the backend/config can't evaluate them) are counted separately.
332
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()
333
340
  cat = {name: {"total": 0, "passed": 0, "adv_total": 0, "adv_passed": 0} for name, _ in _CAT_ORDER}
334
341
  # Which checks fail across the MOST documents. One systemic cause usually shows up as
335
342
  # one label repeated over many documents, so ranking by document count points at the
@@ -369,10 +376,19 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
369
376
  if ch.get("supported", True) is False:
370
377
  checks_neutral += 1 # neutral — backend can't evaluate this check
371
378
  continue
372
- 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)
373
389
  cat[k]["total"] += 1
374
390
  ch_passed = bool(ch.get("passed"))
375
- sev = "warn" if str(ch.get("severity", "hard")).lower() == "warn" else "hard"
391
+ sev = _sev
376
392
  # Hygiene tracks ADVISORY (WARN) checks only - the quality BEYOND the
377
393
  # integrity gate. HARD checks passing is what READY already reports, so
378
394
  # counting them would just pull hygiene toward 100 and hide the signal.
@@ -387,9 +403,17 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
387
403
  checks_advisory += 1
388
404
  else:
389
405
  checks_hard += 1
390
- label = ch.get("label", "")
391
- 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": ""})
392
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
393
417
  # a label is HARD overall if it ever gates
394
418
  if sev == "hard":
395
419
  rec["severity"] = "hard"
@@ -433,6 +457,11 @@ def _aggregate(cases: list[dict], hide_skipped: bool = True) -> dict:
433
457
  # rests on less than the full pipeline.
434
458
  "docs_incomplete": docs_incomplete,
435
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]))],
436
465
  }
437
466
 
438
467
 
@@ -444,15 +473,16 @@ _HYGIENE_TIERS = (
444
473
  )
445
474
 
446
475
 
447
- def hygiene_from_results(results: list[dict]) -> dict | None:
476
+ def hygiene_from_results(results: list[dict], suppress: list[str] | None = None) -> dict | None:
448
477
  """Public: compute the KB Hygiene score from a list of ``{name, result}`` items.
449
478
 
450
479
  Same deterministic scoring the report uses, exposed so the CLI can gate on it
451
- (``--quality-threshold``) without rendering HTML. Returns None if there are no
452
- 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.
453
483
  """
454
484
  cases = [_result_payload(item.get("name", item["result"].file_name), item["result"]) for item in results]
455
- return _aggregate(cases).get("hygiene")
485
+ return _aggregate(cases, suppress=set(suppress or [])).get("hygiene")
456
486
 
457
487
 
458
488
  def _hygiene_score(cat: dict) -> dict | None:
@@ -693,6 +723,12 @@ def _exec_summary(agg: dict, corpus: dict | None = None, cases: list[dict] | Non
693
723
  f'these documents - see Coverage">{agg["docs_incomplete"]} not fully validated</span>')
694
724
  if agg.get("checks_advisory"):
695
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>')
696
732
  chips_html = f'<div class="hero-chips">{"".join(chips)}</div>' if chips else ""
697
733
  t = max(agg["total"], 1)
698
734
  seg = ""
@@ -1064,8 +1100,14 @@ def _action_plan_section(agg: dict) -> str:
1064
1100
  # cards below; a pointer here makes the remainder explicit rather than silently dropped.
1065
1101
  cards = ""
1066
1102
  for i, g in enumerate(ranked[:_ACTION_CAP], 1):
1103
+ ranked_checks = sorted(g["checks"], key=lambda o: -o["docs"])
1067
1104
  evidence = ", ".join(f'{_esc(o["label"])} {round(100 * o["docs"] / g["total"])}%'
1068
- for o in sorted(g["checks"], key=lambda o: -o["docs"])[:3])
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>'
1069
1111
  # A concise, category-specific INSTRUCTION here - the diagnosis now lives in the
1070
1112
  # Observed/Likely/Possible tiers, so the fix line is just "what to do" and never
1071
1113
  # repeats a cause. The verbose per-check hint stays in the detailed cards below.
@@ -1687,6 +1729,16 @@ def _glossary_section(agg: dict | None = None, corpus: dict | None = None, mode:
1687
1729
  '</dl>'
1688
1730
  '<dd style="margin-top:8px">A document is PASSED only when every non-skipped step passes. Critical-step failures halt remaining steps.</dd>')
1689
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
+
1690
1742
  # Readiness Verdict: only when a corpus supplied one.
1691
1743
  if corpus is not None:
1692
1744
  section("Readiness Verdict",
@@ -2057,6 +2109,7 @@ def generate_batch_dashboard(
2057
2109
  wall_seconds: float | None = None,
2058
2110
  concurrency: int | None = None,
2059
2111
  evidence: dict | None = None,
2112
+ suppress: list[str] | None = None,
2060
2113
  ) -> str:
2061
2114
  """Generate a self-contained, interactive HTML dashboard. Returns abs path.
2062
2115
 
@@ -2076,7 +2129,8 @@ def generate_batch_dashboard(
2076
2129
  _ICON = {s.name: s.icon for s in registered_steps()}
2077
2130
 
2078
2131
  cases = [_result_payload(item.get("name", item["result"].file_name), item["result"]) for item in results]
2079
- 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 []))
2080
2134
  # Wall clock vs compute time: agg["duration"] is the SUM of per-document durations, so
2081
2135
  # under --parallel it far exceeds the elapsed time (a 502-doc run summed 1319s but
2082
2136
  # finished in 329s). Carry the real wall clock and concurrency so the summary can show
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compass-kb-validation
3
- Version: 3.1.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
@@ -90,6 +90,11 @@ def parse_args(argv=None) -> argparse.Namespace:
90
90
  "--disable-check chunk_count near_duplicate_embeddings.")
91
91
  p.add_argument("--severity", nargs="*", default=[], metavar="NAME=TIER",
92
92
  help="Override a check's severity, e.g. --severity duplicate_detection=hard.")
93
+ p.add_argument("--suppress", nargs="*", default=[], metavar="CHECK",
94
+ help="Accept an ADVISORY check as expected for this corpus, e.g. --suppress "
95
+ "'Near-duplicate embeddings'. Excluded from the hygiene / Platform Health "
96
+ "scores but still counted and shown ('N suppressed'). Adds to config "
97
+ "suppressions. Only advisory checks - a hard gating failure is never suppressed.")
93
98
  # --- baselines + drift (continuous gate) ---
94
99
  p.add_argument("--save-baseline", nargs="?", const="", default=None, metavar="PATH",
95
100
  help="Snapshot this run as a baseline (default: baselines/<project>.json).")
@@ -469,6 +474,11 @@ def main(argv=None) -> int:
469
474
  print(f" Config error: {exc}")
470
475
  return EXIT_CONFIG
471
476
 
477
+ # Accepted advisory issues = config suppressions + any ad-hoc --suppress (deduped, order-kept).
478
+ # Threaded into BOTH the hygiene gate and the report so the score and the HTML agree.
479
+ suppress = list(dict.fromkeys(
480
+ list(getattr(settings, "suppressions", []) or []) + list(args.suppress or [])))
481
+
472
482
  # Discovery validates the END STATE of an already-ingested corpus. Ingestion-stage
473
483
  # steps (upload, storage) already happened, so they must never HALT the run before
474
484
  # the chunk/retrieval checks that are the point. Strip them from critical_steps.
@@ -596,7 +606,7 @@ def main(argv=None) -> int:
596
606
  print(f" evidence skipped: {exc}")
597
607
 
598
608
  if args.output_format in ("html", "all"):
599
- print(f"\n HTML report: {generate_batch_dashboard(results, base + '.html', project=args.project, environment=args.env, run_id=run_id, trends=trend_rows, corpus=corpus, mode='Discovery', profile=profile_data, wall_seconds=wall_seconds, concurrency=concurrency, evidence=evidence_by_doc)}")
609
+ print(f"\n HTML report: {generate_batch_dashboard(results, base + '.html', project=args.project, environment=args.env, run_id=run_id, trends=trend_rows, corpus=corpus, mode='Discovery', profile=profile_data, wall_seconds=wall_seconds, concurrency=concurrency, evidence=evidence_by_doc, suppress=suppress)}")
600
610
  if args.output_format in ("json", "all"):
601
611
  print(f" JSON report: {export_json(results, base + '.json')}")
602
612
  if args.output_format in ("junit", "all"):
@@ -617,7 +627,7 @@ def main(argv=None) -> int:
617
627
  # Best-effort context - a failure to score it must not crash the gate decision.
618
628
  from compass.report import hygiene_from_results
619
629
  try:
620
- hygiene = hygiene_from_results(results)
630
+ hygiene = hygiene_from_results(results, suppress=suppress)
621
631
  except Exception: # noqa: BLE001 - hygiene is advisory; never break the run over it
622
632
  hygiene = None
623
633
  if hygiene:
@@ -228,7 +228,7 @@ def main(argv=None) -> int:
228
228
  report_base = f"reports/{args.project}-report"
229
229
  fmt = args.output_format
230
230
  if fmt in ("html", "all"):
231
- print(f" HTML report: {generate_batch_dashboard(results, report_base + '.html', project=args.project, environment=settings.environment, run_id=run_id, trends=trend_rows, corpus=corpus, mode='Live Ingestion')}")
231
+ print(f" HTML report: {generate_batch_dashboard(results, report_base + '.html', project=args.project, environment=settings.environment, run_id=run_id, trends=trend_rows, corpus=corpus, mode='Live Ingestion', suppress=list(getattr(settings, 'suppressions', []) or []))}")
232
232
  if fmt in ("json", "all"):
233
233
  print(f" JSON report: {export_json(results, report_base + '.json')}")
234
234
  if fmt in ("junit", "all"):
@@ -110,6 +110,22 @@ class TestLoadSettings:
110
110
  assert s.redis.search_index == "demo_index" # overridden
111
111
  assert s.redis.expected_embedding_dim == 1536 # inherited from base
112
112
 
113
+ def test_suppressions_load_from_project_yaml(self, tmp_path):
114
+ cfg_dir = tmp_path / "config"
115
+ (cfg_dir / "projects").mkdir(parents=True)
116
+ (cfg_dir / "base.yaml").write_text("default_env: dev\n")
117
+ (cfg_dir / "projects" / "demo.yaml").write_text(
118
+ textwrap.dedent(
119
+ """
120
+ suppressions:
121
+ - "Near-duplicate embeddings"
122
+ - "Duplicate text chunks"
123
+ """
124
+ )
125
+ )
126
+ s = load_settings(project="demo", env="dev", config_dir=cfg_dir)
127
+ assert s.suppressions == ["Near-duplicate embeddings", "Duplicate text chunks"]
128
+
113
129
  def test_environment_selection(self, tmp_path):
114
130
  cfg_dir = tmp_path / "config"
115
131
  cfg_dir.mkdir()
@@ -632,6 +632,49 @@ def test_engineering_wins_are_check_backed_and_specific():
632
632
  for w in report_mod._engineering_wins({**agg, "failed": 3}))
633
633
 
634
634
 
635
+ def test_advisory_suppression_excludes_from_scoring_but_discloses():
636
+ # An accepted advisory (config `suppressions`) drops out of hygiene / offenders but is
637
+ # still counted and listed - never silently hidden.
638
+ r = PipelineResult(document_id="d", file_name="d.pdf")
639
+ r.add_step(StepResult("Redis Chunks", StepStatus.PASSED, "ok", checks=[
640
+ {"label": "Near-duplicate embeddings", "passed": False, "severity": "warn"},
641
+ {"label": "Chunk boundaries clean", "passed": True, "severity": "warn"},
642
+ ]))
643
+ cases = [report_mod._result_payload("d.pdf", r)]
644
+ base = report_mod._aggregate(cases)
645
+ supp = report_mod._aggregate(cases, suppress={"Near-duplicate embeddings"})
646
+ assert any(o["label"] == "Near-duplicate embeddings" for o in base["offenders"])
647
+ assert not any(o["label"] == "Near-duplicate embeddings" for o in supp["offenders"])
648
+ assert supp["checks_suppressed"] == 1
649
+ assert supp["suppressed"] == [{"label": "Near-duplicate embeddings", "docs": 1}]
650
+ # suppressing the failing advisory lifts hygiene (only the clean advisory remains scored)
651
+ assert supp["hygiene"]["score"] > base["hygiene"]["score"]
652
+
653
+
654
+ def test_hard_check_is_never_suppressed():
655
+ # A gating failure must count even if its label is in the suppress list - a known-issue
656
+ # can never mask a real failure (the integrity mandate).
657
+ r = PipelineResult(document_id="d", file_name="d.pdf")
658
+ r.add_step(StepResult("Redis Chunks", StepStatus.FAILED, "bad",
659
+ checks=[{"label": "Chunks exist", "passed": False, "severity": "hard"}]))
660
+ agg = report_mod._aggregate([report_mod._result_payload("d.pdf", r)], suppress={"Chunks exist"})
661
+ assert agg["checks_suppressed"] == 0
662
+ assert any(o["label"] == "Chunks exist" for o in agg["offenders"])
663
+ assert agg["checks_hard"] == 1
664
+
665
+
666
+ def test_action_plan_surfaces_a_measured_detail():
667
+ # Specific RCA: the Action Plan's Observed line carries a concrete measured example from
668
+ # the check's own detail, not just which check failed.
669
+ r = PipelineResult(document_id="d", file_name="d.pdf")
670
+ r.add_step(StepResult("Redis Chunks", StepStatus.PASSED, "ok", checks=[
671
+ {"label": "Chunk count in range", "passed": False, "severity": "warn",
672
+ "detail": "4200 (~3000 expected)"}]))
673
+ agg = report_mod._aggregate([report_mod._result_payload("d.pdf", r)])
674
+ html = report_mod._action_plan_section(agg)
675
+ assert "4200 (~3000 expected)" in html # the measured number, surfaced
676
+
677
+
635
678
  def test_glossary_is_run_driven_not_a_fixed_catalogue():
636
679
  # A run with retrieval + chunk findings: the glossary explains exactly what appeared -
637
680
  # its categories, Platform Health, the Action Plan tiers, and only the mode that ran -