compass-kb-validation 2.1.0__tar.gz → 2.3.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.3.0}/PKG-INFO +1 -1
  2. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/compass_kb_validation.egg-info/PKG-INFO +1 -1
  3. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/discover_and_validate.py +152 -7
  4. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/__init__.py +1 -1
  5. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/config_lint.py +34 -0
  6. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/corpus_health.py +22 -3
  7. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/profiler.py +73 -1
  8. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/report/__init__.py +286 -16
  9. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/report/_report_css.py +12 -2
  10. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/base.py +4 -1
  11. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_config_lint.py +19 -0
  12. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_corpus_health.py +18 -0
  13. compass_kb_validation-2.3.0/tests/test_discover_cli.py +192 -0
  14. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_reporting.py +153 -8
  15. compass_kb_validation-2.1.0/tests/test_discover_cli.py +0 -74
  16. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/LICENSE +0 -0
  17. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/README.md +0 -0
  18. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/compass_kb_validation.egg-info/SOURCES.txt +0 -0
  19. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
  20. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/compass_kb_validation.egg-info/entry_points.txt +0 -0
  21. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/compass_kb_validation.egg-info/requires.txt +0 -0
  22. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/compass_kb_validation.egg-info/top_level.txt +0 -0
  23. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/__main__.py +0 -0
  24. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/ask.py +0 -0
  25. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/baseline.py +0 -0
  26. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/chunk_coherence.py +0 -0
  27. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/cli.py +0 -0
  28. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/config/__init__.py +0 -0
  29. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/content_inspect.py +0 -0
  30. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/corpus.py +0 -0
  31. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/diagnostics.py +0 -0
  32. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/embedding_drift.py +0 -0
  33. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/eval_interop.py +0 -0
  34. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/init_wizard.py +0 -0
  35. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/live_dashboard.py +0 -0
  36. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/mcp_server.py +0 -0
  37. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/models/__init__.py +0 -0
  38. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/notifications.py +0 -0
  39. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/pipeline/__init__.py +0 -0
  40. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/provenance.py +0 -0
  41. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/providers/__init__.py +0 -0
  42. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/providers/embedding.py +0 -0
  43. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/providers/graph.py +0 -0
  44. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/providers/source.py +0 -0
  45. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/providers/storage.py +0 -0
  46. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/providers/vectorstore.py +0 -0
  47. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/remediation.py +0 -0
  48. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/report/_report_js.py +0 -0
  49. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/report/analysis.py +0 -0
  50. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/report/export.py +0 -0
  51. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/report/shell.py +0 -0
  52. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/retrieval_analysis.py +0 -0
  53. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/trend_tracker.py +0 -0
  54. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/utils/__init__.py +0 -0
  55. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/__init__.py +0 -0
  56. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/api_upload.py +0 -0
  57. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/base_sparql.py +0 -0
  58. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/cross_stage.py +0 -0
  59. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/graphdb_metadata.py +0 -0
  60. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/neptune_metadata.py +0 -0
  61. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/redis_chunks.py +0 -0
  62. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/registry.py +0 -0
  63. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/retrieval_quality.py +0 -0
  64. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/ingestion_validation/validators/s3_storage.py +0 -0
  65. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/profile_kb.py +0 -0
  66. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/pyproject.toml +0 -0
  67. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/run_validation.py +0 -0
  68. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/setup.cfg +0 -0
  69. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_alt_adapters.py +0 -0
  70. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_analysis_report.py +0 -0
  71. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_api_upload.py +0 -0
  72. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_ask.py +0 -0
  73. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_baseline.py +0 -0
  74. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_benchmark.py +0 -0
  75. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_check_catalog.py +0 -0
  76. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_cli.py +0 -0
  77. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_config.py +0 -0
  78. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_content_inspect.py +0 -0
  79. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_corpus.py +0 -0
  80. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_coverage.py +0 -0
  81. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_cross_stage.py +0 -0
  82. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_diagnostics.py +0 -0
  83. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_discovery_parallel.py +0 -0
  84. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_embedding_drift.py +0 -0
  85. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_eval_interop.py +0 -0
  86. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_gate.py +0 -0
  87. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_graph_transport.py +0 -0
  88. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_graph_validators.py +0 -0
  89. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_init_wizard.py +0 -0
  90. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_manifest.py +0 -0
  91. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_mcp.py +0 -0
  92. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_migration.py +0 -0
  93. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_models.py +0 -0
  94. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_neptune_bugfixes.py +0 -0
  95. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_neptune_bugfixes2.py +0 -0
  96. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_notifications.py +0 -0
  97. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_pipeline.py +0 -0
  98. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_profiler.py +0 -0
  99. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_providers.py +0 -0
  100. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_redis_chunks.py +0 -0
  101. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_registry.py +0 -0
  102. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_remediation.py +0 -0
  103. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_retrieval_assertions.py +0 -0
  104. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_retrieval_quality.py +0 -0
  105. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_run_cli.py +0 -0
  106. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_sources.py +0 -0
  107. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_storage_validator.py +0 -0
  108. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_trends.py +0 -0
  109. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_universal_concerns.py +0 -0
  110. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.0}/tests/test_utils.py +0 -0
  111. {compass_kb_validation-2.1.0 → compass_kb_validation-2.3.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.3.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.3.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,16 @@ 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.")
70
+ p.add_argument("--suggest-config", nargs="?", const="-", metavar="PATH",
71
+ help="After the run, emit config refinements from what it observed "
72
+ "(document count, chunk-size distribution, checks that never applied). "
73
+ "Prints to stdout, or writes to PATH. Review before merging.")
74
+ p.add_argument("--no-tips", action="store_true",
75
+ help="Suppress the end-of-run tips about relevant unused flags.")
66
76
  # --- config overrides (run scenarios without editing YAML; CLI wins over config) ---
67
77
  p.add_argument("--readiness-threshold", type=float, metavar="FRAC",
68
78
  help="Override corpus readiness cutoff (0-1). READY when pass-rate >= this.")
@@ -163,15 +173,18 @@ def _normalize_skips(tokens: list[str]) -> list[str]:
163
173
 
164
174
 
165
175
  def gate_failed(passed: int, total: int, verdict: str, fail_under: float | None,
166
- require_ready: bool) -> tuple[bool, str]:
176
+ require_ready: bool, quality_threshold: float | None = None,
177
+ hygiene_score: float | None = None) -> tuple[bool, str]:
167
178
  """Decide whether the readiness gate fails, and why.
168
179
 
169
180
  Returns (failed, reason). The gate is the product's contract with a CI
170
181
  pipeline: a non-zero exit blocks promotion of a knowledge base that is not
171
182
  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
183
+ * default -- fail if any document failed validation
184
+ * --fail-under PCT -- fail if pass-rate < PCT (tolerates a few failures)
185
+ * --require-ready -- additionally fail if the corpus verdict != READY
186
+ * --quality-threshold N -- additionally fail if KB Hygiene < N (data-plane
187
+ cleanliness, separate from the integrity checks above)
175
188
  """
176
189
  rate = (passed / total * 100.0) if total else 100.0
177
190
  if fail_under is not None:
@@ -181,6 +194,8 @@ def gate_failed(passed: int, total: int, verdict: str, fail_under: float | None,
181
194
  return True, f"{total - passed} document(s) failed validation"
182
195
  if require_ready and verdict != "READY":
183
196
  return True, f"corpus readiness verdict is {verdict} (READY required)"
197
+ if quality_threshold is not None and hygiene_score is not None and hygiene_score < quality_threshold:
198
+ return True, f"KB hygiene {hygiene_score:g}% < required {quality_threshold:g}%"
184
199
  return False, "all gate conditions met"
185
200
 
186
201
 
@@ -227,6 +242,99 @@ def _validate_one_document(pipeline: IngestionPipeline, doc: dict) -> dict:
227
242
  return {"name": name, "result": result}
228
243
 
229
244
 
245
+ _AUTH_HINTS = ("403", "forbidden", "expired", "credential", "token", "signaturedoesnotmatch",
246
+ "invalidclienttokenid", "accessdenied", "unauthor")
247
+
248
+
249
+ def _print_tips(args, hygiene: dict | None, saved_baseline: bool) -> None:
250
+ """End-of-run hints about the most relevant unused flags.
251
+
252
+ Discoverability, not noise: at most three, only when the flag was NOT already used and
253
+ the run's own outcome makes it relevant. Suppress with --no-tips. This is why a partner
254
+ can run for weeks without finding --save-baseline / --unreachable-as / --suggest-config.
255
+ """
256
+ tips: list[str] = []
257
+ if not saved_baseline:
258
+ tips.append("Save this run as a baseline to catch regressions next time: "
259
+ "--save-baseline baselines/{}.json".format(args.project or "kb"))
260
+ if hygiene and hygiene.get("score", 100) < 90 and args.quality_threshold is None:
261
+ tips.append("Gate CI on data-plane hygiene (not just integrity): "
262
+ "--quality-threshold {}".format(int(hygiene["score"])))
263
+ if not getattr(args, "include_retrieval", False):
264
+ tips.append("Add retrieval-quality checks (can a stored vector be found via KNN?): "
265
+ "--include-retrieval")
266
+ if not args.suggest_config:
267
+ tips.append("Refine your config from what this run observed: --suggest-config")
268
+ if not tips:
269
+ return
270
+ print("\n Tips (--no-tips to silence):")
271
+ for t in tips[:3]:
272
+ print(f" * {t}")
273
+
274
+
275
+ def _resolve_expected_documents(raw, project: str):
276
+ """Resolve the configured expected_documents, supporting ``auto``.
277
+
278
+ An int is used as-is. ``"auto"`` uses the previous run's document count for this
279
+ project as the baseline, so completeness alerts on drift (documents appearing or
280
+ disappearing) without a hand-maintained number. The FIRST run has no prior count, so
281
+ it returns None - the baseline is simply established this run, no false "missing".
282
+ """
283
+ if isinstance(raw, bool): # guard: bool is an int subclass
284
+ return None
285
+ if isinstance(raw, int):
286
+ return raw
287
+ if isinstance(raw, str) and raw.strip().isdigit():
288
+ return int(raw.strip())
289
+ if isinstance(raw, str) and raw.strip().lower() == "auto":
290
+ try:
291
+ from ingestion_validation.trend_tracker import TrendTracker
292
+ recent = TrendTracker().summary(last_n=1, project=project)
293
+ if recent and recent[0].get("total_cases"):
294
+ return int(recent[0]["total_cases"])
295
+ except Exception: # noqa: BLE001 - no history / unreadable db: establish baseline this run
296
+ return None
297
+ return None
298
+
299
+
300
+ def _warn_on_unreachable_backends(results: list[dict]) -> None:
301
+ """After a run, warn loudly if any step was SKIPPED because its backend was unreachable.
302
+
303
+ An unreachable backend does not gate the run (infrastructure is not a data defect), so
304
+ without this the affected documents are validated on a partial pipeline and the run
305
+ still reports green - the exact silent degradation that let an expired STS token skip
306
+ 315 documents unnoticed. Auth-flavoured errors get a credential-refresh hint.
307
+ """
308
+ by_step: dict[str, dict] = {}
309
+ for item in results:
310
+ for st in getattr(item.get("result"), "steps", []) or []:
311
+ d = getattr(st, "details", {}) or {}
312
+ if d.get("unreachable"):
313
+ rec = by_step.setdefault(st.step_name, {"count": 0, "auth": False, "error": ""})
314
+ rec["count"] += 1
315
+ err = str(d.get("last_error", "") or "")
316
+ if err and not rec["error"]:
317
+ rec["error"] = err
318
+ if any(h in err.lower().replace(" ", "") for h in _AUTH_HINTS):
319
+ rec["auth"] = True
320
+ if not by_step:
321
+ return
322
+ print("\n " + "!" * 68)
323
+ for step, rec in by_step.items():
324
+ print(f" WARNING: {step} was unreachable for {rec['count']} document(s) - they were "
325
+ f"validated WITHOUT it.")
326
+ if rec["auth"]:
327
+ print(" The endpoint rejected the request (auth). Credentials may have "
328
+ "expired mid-run;")
329
+ print(" re-run with fresh credentials (e.g. new STS tokens) to validate "
330
+ "these documents.")
331
+ elif rec["error"]:
332
+ print(f" Last error: {rec['error'][:140]}")
333
+ print(" These steps did not gate the run (set pipeline.unreachable_as: "
334
+ "failed to change that).")
335
+ print(" " + "!" * 68)
336
+
337
+
230
338
  def validate_documents(settings, documents: list[dict], skip_steps: list[str],
231
339
  parallel: bool = False, workers: int = 4) -> list[dict]:
232
340
  total = len(documents)
@@ -390,21 +498,28 @@ def main(argv=None) -> int:
390
498
  print(" Note: discovery source is not object storage; skipping the S3 Storage "
391
499
  "step (use --keep s3_storage to force it).")
392
500
 
501
+ import time as _t
502
+ _run_started = _t.perf_counter()
393
503
  results = validate_documents(settings, documents, skip_steps=skip_steps,
394
504
  parallel=args.parallel, workers=args.workers)
505
+ wall_seconds = _t.perf_counter() - _run_started
506
+ concurrency = args.workers if args.parallel and len(documents) > 1 else 1
395
507
 
396
508
  # Corpus-level knowledge-base readiness (completeness, dedup, coverage).
397
509
  from ingestion_validation.corpus import analyze_corpus
398
510
 
511
+ expected_documents = _resolve_expected_documents(
512
+ settings.discovery.extra.get("expected_documents"), args.project)
399
513
  corpus = analyze_corpus(
400
514
  documents, results,
401
- expected_documents=settings.discovery.extra.get("expected_documents"),
515
+ expected_documents=expected_documents,
402
516
  expected_folders=settings.discovery.extra.get("expected_folders"),
403
517
  expected_document_names=settings.discovery.extra.get("expected_document_names"),
404
518
  readiness_threshold=settings.pipeline.readiness_threshold,
405
519
  )
406
520
  print(f" KB readiness: {corpus['verdict']} ({corpus['passed']}/{corpus['total']} passed, "
407
521
  f"{corpus['total'] - corpus['passed']} failed)")
522
+ _warn_on_unreachable_backends(results)
408
523
 
409
524
  # Maintain historical trends (stdlib sqlite; on by default) and embed them in the report.
410
525
  trend_rows = _record_trends(results, args.project, args.env, run_id, "Discovery", disabled=args.no_trends)
@@ -413,14 +528,42 @@ def main(argv=None) -> int:
413
528
  failed = len(results) - passed
414
529
  base = f"reports/{args.project}-discovery-report"
415
530
  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)}")
531
+ 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
532
  if args.output_format in ("json", "all"):
418
533
  print(f" JSON report: {export_json(results, base + '.json')}")
419
534
  if args.output_format in ("junit", "all"):
420
535
  print(f" JUnit report: {export_junit(results, base + '.xml')}")
421
536
 
537
+ if args.suggest_config:
538
+ from ingestion_validation.profiler import suggest_config_from_run
539
+ snippet = suggest_config_from_run(results, documents)
540
+ if args.suggest_config == "-":
541
+ print("\n" + snippet)
542
+ else:
543
+ from pathlib import Path as _P
544
+ _P(args.suggest_config).write_text(snippet, encoding="utf-8")
545
+ print(f" Config suggestions written: {args.suggest_config} (review before merging)")
546
+
547
+ # KB Hygiene: data-plane cleanliness (advisory checks), separate from the integrity
548
+ # gate. Printed for every run; gates the build only when --quality-threshold is set.
549
+ # Best-effort context - a failure to score it must not crash the gate decision.
550
+ from ingestion_validation.report import hygiene_from_results
551
+ try:
552
+ hygiene = hygiene_from_results(results)
553
+ except Exception: # noqa: BLE001 - hygiene is advisory; never break the run over it
554
+ hygiene = None
555
+ if hygiene:
556
+ worst = hygiene.get("worst")
557
+ worst_note = (f" | worst: {worst['name']} {worst['rate']:g}%"
558
+ if worst and worst["rate"] < 100 else "")
559
+ print(f" KB hygiene: {hygiene['score']:g}% {hygiene['tier']} "
560
+ f"({hygiene['advisory_passed']}/{hygiene['advisory_total']} advisory checks){worst_note}")
561
+ hygiene_score = hygiene["score"] if hygiene else None
562
+
422
563
  failed_gate, reason = gate_failed(passed, len(results), corpus["verdict"],
423
- args.fail_under, args.require_ready)
564
+ args.fail_under, args.require_ready,
565
+ quality_threshold=args.quality_threshold,
566
+ hygiene_score=hygiene_score)
424
567
 
425
568
  # Baselines + drift: snapshot a known-good run and/or diff against one.
426
569
  regression = False
@@ -438,6 +581,8 @@ def main(argv=None) -> int:
438
581
  reason = f"{len(documents)} documents < s3.min_documents ({min_docs})"
439
582
  failed_gate = True
440
583
  code = EXIT_GATE_FAILED if (failed_gate or (regression and args.fail_on_regression)) else EXIT_OK
584
+ if not args.no_tips:
585
+ _print_tips(args, hygiene, saved_baseline=args.save_baseline is not None or bool(args.against))
441
586
  print("=" * 60)
442
587
  print(f" Results: {passed} passed, {failed} failed ({len(results)} total)")
443
588
  print(f" Gate: {'FAILED' if failed_gate else 'PASSED'} - {reason}")
@@ -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.3.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"]:
@@ -537,7 +537,79 @@ def apply_profile_to_settings(settings: Any, profiles: dict) -> list[str]:
537
537
  return applied
538
538
 
539
539
 
540
+ def suggest_config_from_run(results: list, documents: list) -> str:
541
+ """Suggest config refinements from what a validation RUN actually observed.
542
+
543
+ Complements ``emit_project_config`` (pre-run backend introspection) with the things
544
+ only a run reveals: the real document count, the observed chunk-size distribution, and
545
+ the checks that were NEUTRAL on every document - checks that never apply to this KB and
546
+ are pure noise (e.g. section_metadata when nothing carries sections). Returns a review-
547
+ and-merge snippet, never a file to apply blind - a wrong config key would be exactly the
548
+ silent-failure trap COMPASS exists to catch, so these are suggestions with the evidence.
549
+ """
550
+ import statistics
551
+
552
+ n_docs = len(documents)
553
+ avg_chars: list[int] = []
554
+ # label -> [evaluated_count, neutral_count]; a check neutral everywhere never applies.
555
+ support: dict[str, list[int]] = {}
556
+ for item in results:
557
+ for st in getattr(item.get("result"), "steps", []) or []:
558
+ details = getattr(st, "details", {}) or {}
559
+ cv = details.get("chunk_view")
560
+ if isinstance(cv, dict) and cv.get("avg_chars"):
561
+ avg_chars.append(int(cv["avg_chars"]))
562
+ status = getattr(getattr(st, "status", None), "value", "")
563
+ if status == "SKIPPED":
564
+ continue # a step that did not run says nothing about applicability
565
+ for ch in getattr(st, "checks", []) or []:
566
+ lbl = ch.get("label", "")
567
+ if not lbl:
568
+ continue
569
+ rec = support.setdefault(lbl, [0, 0])
570
+ if ch.get("supported", True) is False:
571
+ rec[1] += 1
572
+ else:
573
+ rec[0] += 1
574
+
575
+ never_applies = sorted(lbl for lbl, (ev, neu) in support.items() if ev == 0 and neu > 0)
576
+
577
+ lines = [
578
+ "# --- compass discover --suggest-config -------------------------------------",
579
+ "# Refinements from THIS run's observations. Review each, then merge into your",
580
+ "# project YAML - nothing here is applied automatically.",
581
+ "",
582
+ "# Documents discovered this run. Pin it to alert on drift (or use 'auto' to",
583
+ "# baseline against the previous run automatically):",
584
+ "discovery:",
585
+ " extra:",
586
+ f" expected_documents: {n_docs}",
587
+ ]
588
+ if avg_chars:
589
+ median_size = int(statistics.median(avg_chars))
590
+ lines += [
591
+ "",
592
+ f"# Observed chunk size: median {median_size} chars across {len(avg_chars)} document(s)",
593
+ f"# (range {min(avg_chars)}-{max(avg_chars)}). Set expected bounds if you gate on size:",
594
+ "# redis:",
595
+ f"# chunk_size: {median_size}",
596
+ ]
597
+ if never_applies:
598
+ lines += [
599
+ "",
600
+ "# These checks were NOT APPLICABLE to any document this run (neutral every time) -",
601
+ "# the backend/data cannot answer them. Disabling removes them from the tally and",
602
+ "# the report noise. Verify each is genuinely irrelevant before disabling:",
603
+ ]
604
+ for lbl in never_applies:
605
+ lines.append(f"# - {lbl}")
606
+ else:
607
+ lines += ["", "# Every check applied to at least one document - nothing to disable."]
608
+ return "\n".join(lines) + "\n"
609
+
610
+
540
611
  __all__ = [
541
612
  "profile_vectorstore", "profile_storage", "profile_graph", "profile_kb",
542
- "apply_profile_to_settings", "suggest_config",
613
+ "apply_profile_to_settings", "suggest_config", "emit_project_config",
614
+ "suggest_config_from_run",
543
615
  ]