compass-kb-validation 2.1.0__tar.gz → 2.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 (111) hide show
  1. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/PKG-INFO +1 -1
  2. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/compass_kb_validation.egg-info/PKG-INFO +1 -1
  3. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/discover_and_validate.py +108 -7
  4. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/__init__.py +1 -1
  5. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/config_lint.py +34 -0
  6. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/corpus_health.py +22 -3
  7. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/report/__init__.py +279 -14
  8. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/report/_report_css.py +12 -2
  9. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/base.py +4 -1
  10. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_config_lint.py +19 -0
  11. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_corpus_health.py +18 -0
  12. compass_kb_validation-2.2.0/tests/test_discover_cli.py +146 -0
  13. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_reporting.py +138 -8
  14. compass_kb_validation-2.1.0/tests/test_discover_cli.py +0 -74
  15. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/LICENSE +0 -0
  16. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/README.md +0 -0
  17. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/compass_kb_validation.egg-info/SOURCES.txt +0 -0
  18. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
  19. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/compass_kb_validation.egg-info/entry_points.txt +0 -0
  20. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/compass_kb_validation.egg-info/requires.txt +0 -0
  21. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/compass_kb_validation.egg-info/top_level.txt +0 -0
  22. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/__main__.py +0 -0
  23. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/ask.py +0 -0
  24. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/baseline.py +0 -0
  25. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/chunk_coherence.py +0 -0
  26. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/cli.py +0 -0
  27. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/config/__init__.py +0 -0
  28. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/content_inspect.py +0 -0
  29. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/corpus.py +0 -0
  30. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/diagnostics.py +0 -0
  31. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/embedding_drift.py +0 -0
  32. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/eval_interop.py +0 -0
  33. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/init_wizard.py +0 -0
  34. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/live_dashboard.py +0 -0
  35. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/mcp_server.py +0 -0
  36. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/models/__init__.py +0 -0
  37. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/notifications.py +0 -0
  38. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/pipeline/__init__.py +0 -0
  39. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/profiler.py +0 -0
  40. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/provenance.py +0 -0
  41. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/providers/__init__.py +0 -0
  42. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/providers/embedding.py +0 -0
  43. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/providers/graph.py +0 -0
  44. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/providers/source.py +0 -0
  45. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/providers/storage.py +0 -0
  46. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/providers/vectorstore.py +0 -0
  47. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/remediation.py +0 -0
  48. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/report/_report_js.py +0 -0
  49. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/report/analysis.py +0 -0
  50. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/report/export.py +0 -0
  51. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/report/shell.py +0 -0
  52. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/retrieval_analysis.py +0 -0
  53. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/trend_tracker.py +0 -0
  54. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/utils/__init__.py +0 -0
  55. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/__init__.py +0 -0
  56. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/api_upload.py +0 -0
  57. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/base_sparql.py +0 -0
  58. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/cross_stage.py +0 -0
  59. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/graphdb_metadata.py +0 -0
  60. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/neptune_metadata.py +0 -0
  61. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/redis_chunks.py +0 -0
  62. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/registry.py +0 -0
  63. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/retrieval_quality.py +0 -0
  64. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/ingestion_validation/validators/s3_storage.py +0 -0
  65. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/profile_kb.py +0 -0
  66. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/pyproject.toml +0 -0
  67. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/run_validation.py +0 -0
  68. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/setup.cfg +0 -0
  69. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_alt_adapters.py +0 -0
  70. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_analysis_report.py +0 -0
  71. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_api_upload.py +0 -0
  72. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_ask.py +0 -0
  73. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_baseline.py +0 -0
  74. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_benchmark.py +0 -0
  75. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_check_catalog.py +0 -0
  76. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_cli.py +0 -0
  77. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_config.py +0 -0
  78. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_content_inspect.py +0 -0
  79. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_corpus.py +0 -0
  80. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_coverage.py +0 -0
  81. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_cross_stage.py +0 -0
  82. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_diagnostics.py +0 -0
  83. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_discovery_parallel.py +0 -0
  84. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_embedding_drift.py +0 -0
  85. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_eval_interop.py +0 -0
  86. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_gate.py +0 -0
  87. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_graph_transport.py +0 -0
  88. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_graph_validators.py +0 -0
  89. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_init_wizard.py +0 -0
  90. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_manifest.py +0 -0
  91. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_mcp.py +0 -0
  92. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_migration.py +0 -0
  93. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_models.py +0 -0
  94. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_neptune_bugfixes.py +0 -0
  95. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_neptune_bugfixes2.py +0 -0
  96. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_notifications.py +0 -0
  97. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_pipeline.py +0 -0
  98. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_profiler.py +0 -0
  99. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_providers.py +0 -0
  100. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_redis_chunks.py +0 -0
  101. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_registry.py +0 -0
  102. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_remediation.py +0 -0
  103. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_retrieval_assertions.py +0 -0
  104. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_retrieval_quality.py +0 -0
  105. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_run_cli.py +0 -0
  106. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_sources.py +0 -0
  107. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_storage_validator.py +0 -0
  108. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_trends.py +0 -0
  109. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_universal_concerns.py +0 -0
  110. {compass_kb_validation-2.1.0 → compass_kb_validation-2.2.0}/tests/test_utils.py +0 -0
  111. {compass_kb_validation-2.1.0 → compass_kb_validation-2.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: 2.1.0
3
+ Version: 2.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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compass-kb-validation
3
- Version: 2.1.0
3
+ Version: 2.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
@@ -63,6 +63,10 @@ def parse_args(argv=None) -> argparse.Namespace:
63
63
  "Replaces the default 'fail on any failure' rule.")
64
64
  p.add_argument("--require-ready", action="store_true",
65
65
  help="Also fail unless the corpus readiness verdict is READY.")
66
+ p.add_argument("--quality-threshold", type=float, metavar="PCT",
67
+ help="Also fail unless the KB Hygiene score is >= PCT (0-100). Hygiene is "
68
+ "the share of advisory data-quality checks that passed - data-plane "
69
+ "cleanliness, separate from the READY integrity gate.")
66
70
  # --- config overrides (run scenarios without editing YAML; CLI wins over config) ---
67
71
  p.add_argument("--readiness-threshold", type=float, metavar="FRAC",
68
72
  help="Override corpus readiness cutoff (0-1). READY when pass-rate >= this.")
@@ -163,15 +167,18 @@ def _normalize_skips(tokens: list[str]) -> list[str]:
163
167
 
164
168
 
165
169
  def gate_failed(passed: int, total: int, verdict: str, fail_under: float | None,
166
- require_ready: bool) -> tuple[bool, str]:
170
+ require_ready: bool, quality_threshold: float | None = None,
171
+ hygiene_score: float | None = None) -> tuple[bool, str]:
167
172
  """Decide whether the readiness gate fails, and why.
168
173
 
169
174
  Returns (failed, reason). The gate is the product's contract with a CI
170
175
  pipeline: a non-zero exit blocks promotion of a knowledge base that is not
171
176
  retrieval-ready. Strictness is caller-controlled:
172
- * default -- fail if any document failed validation
173
- * --fail-under PCT -- fail if pass-rate < PCT (tolerates a few failures)
174
- * --require-ready -- additionally fail if the corpus verdict != READY
177
+ * default -- fail if any document failed validation
178
+ * --fail-under PCT -- fail if pass-rate < PCT (tolerates a few failures)
179
+ * --require-ready -- additionally fail if the corpus verdict != READY
180
+ * --quality-threshold N -- additionally fail if KB Hygiene < N (data-plane
181
+ cleanliness, separate from the integrity checks above)
175
182
  """
176
183
  rate = (passed / total * 100.0) if total else 100.0
177
184
  if fail_under is not None:
@@ -181,6 +188,8 @@ def gate_failed(passed: int, total: int, verdict: str, fail_under: float | None,
181
188
  return True, f"{total - passed} document(s) failed validation"
182
189
  if require_ready and verdict != "READY":
183
190
  return True, f"corpus readiness verdict is {verdict} (READY required)"
191
+ if quality_threshold is not None and hygiene_score is not None and hygiene_score < quality_threshold:
192
+ return True, f"KB hygiene {hygiene_score:g}% < required {quality_threshold:g}%"
184
193
  return False, "all gate conditions met"
185
194
 
186
195
 
@@ -227,6 +236,73 @@ def _validate_one_document(pipeline: IngestionPipeline, doc: dict) -> dict:
227
236
  return {"name": name, "result": result}
228
237
 
229
238
 
239
+ _AUTH_HINTS = ("403", "forbidden", "expired", "credential", "token", "signaturedoesnotmatch",
240
+ "invalidclienttokenid", "accessdenied", "unauthor")
241
+
242
+
243
+ def _resolve_expected_documents(raw, project: str):
244
+ """Resolve the configured expected_documents, supporting ``auto``.
245
+
246
+ An int is used as-is. ``"auto"`` uses the previous run's document count for this
247
+ project as the baseline, so completeness alerts on drift (documents appearing or
248
+ disappearing) without a hand-maintained number. The FIRST run has no prior count, so
249
+ it returns None - the baseline is simply established this run, no false "missing".
250
+ """
251
+ if isinstance(raw, bool): # guard: bool is an int subclass
252
+ return None
253
+ if isinstance(raw, int):
254
+ return raw
255
+ if isinstance(raw, str) and raw.strip().isdigit():
256
+ return int(raw.strip())
257
+ if isinstance(raw, str) and raw.strip().lower() == "auto":
258
+ try:
259
+ from ingestion_validation.trend_tracker import TrendTracker
260
+ recent = TrendTracker().summary(last_n=1, project=project)
261
+ if recent and recent[0].get("total_cases"):
262
+ return int(recent[0]["total_cases"])
263
+ except Exception: # noqa: BLE001 - no history / unreadable db: establish baseline this run
264
+ return None
265
+ return None
266
+
267
+
268
+ def _warn_on_unreachable_backends(results: list[dict]) -> None:
269
+ """After a run, warn loudly if any step was SKIPPED because its backend was unreachable.
270
+
271
+ An unreachable backend does not gate the run (infrastructure is not a data defect), so
272
+ without this the affected documents are validated on a partial pipeline and the run
273
+ still reports green - the exact silent degradation that let an expired STS token skip
274
+ 315 documents unnoticed. Auth-flavoured errors get a credential-refresh hint.
275
+ """
276
+ by_step: dict[str, dict] = {}
277
+ for item in results:
278
+ for st in getattr(item.get("result"), "steps", []) or []:
279
+ d = getattr(st, "details", {}) or {}
280
+ if d.get("unreachable"):
281
+ rec = by_step.setdefault(st.step_name, {"count": 0, "auth": False, "error": ""})
282
+ rec["count"] += 1
283
+ err = str(d.get("last_error", "") or "")
284
+ if err and not rec["error"]:
285
+ rec["error"] = err
286
+ if any(h in err.lower().replace(" ", "") for h in _AUTH_HINTS):
287
+ rec["auth"] = True
288
+ if not by_step:
289
+ return
290
+ print("\n " + "!" * 68)
291
+ for step, rec in by_step.items():
292
+ print(f" WARNING: {step} was unreachable for {rec['count']} document(s) - they were "
293
+ f"validated WITHOUT it.")
294
+ if rec["auth"]:
295
+ print(" The endpoint rejected the request (auth). Credentials may have "
296
+ "expired mid-run;")
297
+ print(" re-run with fresh credentials (e.g. new STS tokens) to validate "
298
+ "these documents.")
299
+ elif rec["error"]:
300
+ print(f" Last error: {rec['error'][:140]}")
301
+ print(" These steps did not gate the run (set pipeline.unreachable_as: "
302
+ "failed to change that).")
303
+ print(" " + "!" * 68)
304
+
305
+
230
306
  def validate_documents(settings, documents: list[dict], skip_steps: list[str],
231
307
  parallel: bool = False, workers: int = 4) -> list[dict]:
232
308
  total = len(documents)
@@ -390,21 +466,28 @@ def main(argv=None) -> int:
390
466
  print(" Note: discovery source is not object storage; skipping the S3 Storage "
391
467
  "step (use --keep s3_storage to force it).")
392
468
 
469
+ import time as _t
470
+ _run_started = _t.perf_counter()
393
471
  results = validate_documents(settings, documents, skip_steps=skip_steps,
394
472
  parallel=args.parallel, workers=args.workers)
473
+ wall_seconds = _t.perf_counter() - _run_started
474
+ concurrency = args.workers if args.parallel and len(documents) > 1 else 1
395
475
 
396
476
  # Corpus-level knowledge-base readiness (completeness, dedup, coverage).
397
477
  from ingestion_validation.corpus import analyze_corpus
398
478
 
479
+ expected_documents = _resolve_expected_documents(
480
+ settings.discovery.extra.get("expected_documents"), args.project)
399
481
  corpus = analyze_corpus(
400
482
  documents, results,
401
- expected_documents=settings.discovery.extra.get("expected_documents"),
483
+ expected_documents=expected_documents,
402
484
  expected_folders=settings.discovery.extra.get("expected_folders"),
403
485
  expected_document_names=settings.discovery.extra.get("expected_document_names"),
404
486
  readiness_threshold=settings.pipeline.readiness_threshold,
405
487
  )
406
488
  print(f" KB readiness: {corpus['verdict']} ({corpus['passed']}/{corpus['total']} passed, "
407
489
  f"{corpus['total'] - corpus['passed']} failed)")
490
+ _warn_on_unreachable_backends(results)
408
491
 
409
492
  # Maintain historical trends (stdlib sqlite; on by default) and embed them in the report.
410
493
  trend_rows = _record_trends(results, args.project, args.env, run_id, "Discovery", disabled=args.no_trends)
@@ -413,14 +496,32 @@ def main(argv=None) -> int:
413
496
  failed = len(results) - passed
414
497
  base = f"reports/{args.project}-discovery-report"
415
498
  if args.output_format in ("html", "all"):
416
- 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)}")
499
+ 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)}")
417
500
  if args.output_format in ("json", "all"):
418
501
  print(f" JSON report: {export_json(results, base + '.json')}")
419
502
  if args.output_format in ("junit", "all"):
420
503
  print(f" JUnit report: {export_junit(results, base + '.xml')}")
421
504
 
505
+ # KB Hygiene: data-plane cleanliness (advisory checks), separate from the integrity
506
+ # gate. Printed for every run; gates the build only when --quality-threshold is set.
507
+ # Best-effort context - a failure to score it must not crash the gate decision.
508
+ from ingestion_validation.report import hygiene_from_results
509
+ try:
510
+ hygiene = hygiene_from_results(results)
511
+ except Exception: # noqa: BLE001 - hygiene is advisory; never break the run over it
512
+ hygiene = None
513
+ if hygiene:
514
+ worst = hygiene.get("worst")
515
+ worst_note = (f" | worst: {worst['name']} {worst['rate']:g}%"
516
+ if worst and worst["rate"] < 100 else "")
517
+ print(f" KB hygiene: {hygiene['score']:g}% {hygiene['tier']} "
518
+ f"({hygiene['advisory_passed']}/{hygiene['advisory_total']} advisory checks){worst_note}")
519
+ hygiene_score = hygiene["score"] if hygiene else None
520
+
422
521
  failed_gate, reason = gate_failed(passed, len(results), corpus["verdict"],
423
- args.fail_under, args.require_ready)
522
+ args.fail_under, args.require_ready,
523
+ quality_threshold=args.quality_threshold,
524
+ hygiene_score=hygiene_score)
424
525
 
425
526
  # Baselines + drift: snapshot a known-good run and/or diff against one.
426
527
  regression = False
@@ -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.1.0"
8
+ __version__ = "2.2.0"
@@ -125,6 +125,39 @@ def _lint_consistency(settings: Any) -> list[Finding]:
125
125
  return findings
126
126
 
127
127
 
128
+ def _lint_skip_steps(settings: Any) -> list[Finding]:
129
+ """Flag skip_steps entries that match no registered step.
130
+
131
+ A skip_steps entry naming a step that does not exist is silently ignored at runtime -
132
+ so a typo ("Redis Chunk"), or a step removed in an upgrade (the v2.0 UI Visibility
133
+ step), quietly does nothing and the run is not scoped the way the config says. The
134
+ runtime stays quiet (an unknown skip is harmless there and a per-run warning is just
135
+ noise); lint is where config drift belongs, so it is surfaced here once, with the fix.
136
+ """
137
+ skip = list(getattr(settings, "skip_steps", []) or [])
138
+ if not skip:
139
+ return []
140
+ try:
141
+ from ingestion_validation.validators.registry import registered_steps
142
+ known = list(registered_steps())
143
+ except Exception: # noqa: BLE001 - registry unavailable; skip this check rather than crash lint
144
+ return []
145
+
146
+ def _norm(s: str) -> str:
147
+ return "".join(str(s).lower().split()).replace("_", "").replace("-", "")
148
+
149
+ valid = {_norm(s.name) for s in known} | {_norm(s.key) for s in known}
150
+ findings: list[Finding] = []
151
+ for entry in skip:
152
+ if _norm(entry) not in valid:
153
+ names = ", ".join(sorted(s.name for s in known))
154
+ removed = " (the UI Visibility step was removed in v2.0)" if _norm(entry) == _norm("UI Visibility") else ""
155
+ findings.append(("info",
156
+ f"skip_steps entry '{entry}' matches no registered step{removed}; it is "
157
+ f"silently ignored. Remove it, or use one of: {names}."))
158
+ return findings
159
+
160
+
128
161
  def lint_settings(settings: Any) -> list[Finding]:
129
162
  """Run every static config check against a loaded Settings. Never connects anywhere."""
130
163
  findings: list[Finding] = []
@@ -137,6 +170,7 @@ def lint_settings(settings: Any) -> list[Finding]:
137
170
  findings += _lint_helper_queries(label, cfg)
138
171
  findings += _lint_redis(settings)
139
172
  findings += _lint_consistency(settings)
173
+ findings += _lint_skip_steps(settings)
140
174
  return findings
141
175
 
142
176
 
@@ -129,8 +129,11 @@ def freshness_report(settings: Settings, *, sample: int = 500, stale_after_days:
129
129
  _INGEST_FIELDS = ("ingested_at", "indexed_at", "embedded_at", "updated_at", "created_at")
130
130
  doc_key: dict[str, str] = {}
131
131
  doc_ingest: dict[str, datetime] = {}
132
+ seen_docs: set[str] = set() # every distinct document in the sample, source key or not
132
133
  for c in vs.sample_chunks(sample):
133
134
  doc = str(c.get(id_field) or c.get("document_id") or c.get("key") or "")
135
+ if doc:
136
+ seen_docs.add(doc)
134
137
  s3_key = c.get("s3_key") or ""
135
138
  if doc and s3_key and doc not in doc_key:
136
139
  doc_key[doc] = s3_key
@@ -182,6 +185,11 @@ def freshness_report(settings: Settings, *, sample: int = 500, stale_after_days:
182
185
  stale.sort(key=lambda x: -x["age_days"])
183
186
  drift.sort(key=lambda x: -x["behind_days"])
184
187
  checked = len(doc_key)
188
+ # Documents sampled that carry NO source key were never checked against storage - the
189
+ # gap between "documents in the index" and "documents freshness could verify". Left
190
+ # implicit, it reads as "freshness found only 176 of 502 documents"; it usually means
191
+ # those chunks lack an s3_key (a prefix/field mismatch) or point at a different store.
192
+ without_key = len(seen_docs) - checked
185
193
  if not stale and not orphans and not drift:
186
194
  verdict = "FRESH - all sampled sources within the window and in sync"
187
195
  else:
@@ -193,14 +201,21 @@ def freshness_report(settings: Settings, *, sample: int = 500, stale_after_days:
193
201
  if orphans:
194
202
  parts.append(f"{len(orphans)} orphan(s) (source object gone)")
195
203
  verdict = "STALE - " + ", ".join(parts)
196
- return {
197
- "documents": checked, "stale_after_days": stale_after_days,
204
+ result = {
205
+ "documents": checked, "sampled_documents": len(seen_docs),
206
+ "without_source_key": without_key, "stale_after_days": stale_after_days,
198
207
  "stale_count": len(stale), "orphan_count": len(orphans), "drift_count": len(drift),
199
208
  "max_age_days": max(ages) if ages else 0, "median_age_days": sorted(ages)[len(ages) // 2] if ages else 0,
200
209
  "stale": stale[: min(20, len(stale))], "orphans": orphans[: min(20, len(orphans))],
201
210
  "drift": drift[: min(20, len(drift))],
202
211
  "verdict": verdict,
203
212
  }
213
+ if without_key:
214
+ result["note"] = (
215
+ f"{without_key} of {len(seen_docs)} sampled document(s) carry no source key, so "
216
+ f"their source could not be checked - likely a missing s3_key field (prefix/field "
217
+ f"mismatch) or sources in a different store, not necessarily orphans.")
218
+ return result
204
219
 
205
220
 
206
221
  def _parse_timestamp(value: Any) -> Optional[datetime]:
@@ -222,13 +237,17 @@ def _parse_timestamp(value: Any) -> Optional[datetime]:
222
237
  def format_freshness(payload: dict) -> str:
223
238
  if payload.get("error"):
224
239
  return f"Freshness: {payload['error']}"
240
+ sampled = payload.get("sampled_documents")
241
+ checked_note = f" of {sampled} sampled" if sampled and sampled != payload["documents"] else ""
225
242
  lines = [
226
- f"Freshness: {payload['documents']} document(s) checked (stale after {payload['stale_after_days']}d)",
243
+ f"Freshness: {payload['documents']} document(s) checked{checked_note} (stale after {payload['stale_after_days']}d)",
227
244
  f" stale: {payload['stale_count']} orphans (source missing): {payload['orphan_count']} "
228
245
  f"drift (source newer than KB): {payload.get('drift_count', 0)} "
229
246
  f"max age: {payload['max_age_days']}d median age: {payload['median_age_days']}d",
230
247
  f" VERDICT: {payload['verdict']}",
231
248
  ]
249
+ if payload.get("note"):
250
+ lines.append(f" NOTE: {payload['note']}")
232
251
  if payload.get("drift"):
233
252
  lines.append(" content drift (source modified after the KB ingested it):")
234
253
  for d in payload["drift"]: