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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/PKG-INFO +5 -1
  2. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/README.md +4 -0
  3. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/compass_kb_validation.egg-info/PKG-INFO +5 -1
  4. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/compass_kb_validation.egg-info/SOURCES.txt +2 -0
  5. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/discover_and_validate.py +19 -8
  6. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/__init__.py +1 -1
  7. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/cli.py +69 -0
  8. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/config/__init__.py +34 -1
  9. compass_kb_validation-2.5.0/ingestion_validation/grounding.py +341 -0
  10. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/profiler.py +2 -3
  11. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/providers/vectorstore.py +56 -0
  12. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/report/__init__.py +154 -19
  13. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/report/_report_css.py +5 -3
  14. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/trend_tracker.py +16 -4
  15. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_config.py +25 -0
  16. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_discover_cli.py +11 -0
  17. compass_kb_validation-2.5.0/tests/test_grounding.py +150 -0
  18. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_reporting.py +104 -1
  19. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_vectorstore_filters.py +35 -0
  20. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/LICENSE +0 -0
  21. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
  22. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/compass_kb_validation.egg-info/entry_points.txt +0 -0
  23. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/compass_kb_validation.egg-info/requires.txt +0 -0
  24. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/compass_kb_validation.egg-info/top_level.txt +0 -0
  25. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/__main__.py +0 -0
  26. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/ask.py +0 -0
  27. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/baseline.py +0 -0
  28. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/chunk_coherence.py +0 -0
  29. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/config_lint.py +0 -0
  30. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/content_inspect.py +0 -0
  31. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/corpus.py +0 -0
  32. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/corpus_health.py +0 -0
  33. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/diagnostics.py +0 -0
  34. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/embedding_drift.py +0 -0
  35. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/eval_interop.py +0 -0
  36. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/init_wizard.py +0 -0
  37. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/live_dashboard.py +0 -0
  38. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/mcp_server.py +0 -0
  39. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/models/__init__.py +0 -0
  40. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/notifications.py +0 -0
  41. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/pipeline/__init__.py +0 -0
  42. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/provenance.py +0 -0
  43. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/providers/__init__.py +0 -0
  44. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/providers/embedding.py +0 -0
  45. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/providers/graph.py +0 -0
  46. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/providers/source.py +0 -0
  47. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/providers/storage.py +0 -0
  48. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/remediation.py +0 -0
  49. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/report/_report_js.py +0 -0
  50. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/report/analysis.py +0 -0
  51. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/report/export.py +0 -0
  52. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/report/shell.py +0 -0
  53. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/retrieval_analysis.py +0 -0
  54. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/utils/__init__.py +0 -0
  55. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/__init__.py +0 -0
  56. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/api_upload.py +0 -0
  57. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/base.py +0 -0
  58. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/base_sparql.py +0 -0
  59. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/cross_stage.py +0 -0
  60. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/graphdb_metadata.py +0 -0
  61. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/neptune_metadata.py +0 -0
  62. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/redis_chunks.py +0 -0
  63. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/registry.py +0 -0
  64. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/retrieval_quality.py +0 -0
  65. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/ingestion_validation/validators/s3_storage.py +0 -0
  66. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/profile_kb.py +0 -0
  67. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/pyproject.toml +0 -0
  68. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/run_validation.py +0 -0
  69. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/setup.cfg +0 -0
  70. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_alt_adapters.py +0 -0
  71. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_analysis_report.py +0 -0
  72. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_api_upload.py +0 -0
  73. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_ask.py +0 -0
  74. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_baseline.py +0 -0
  75. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_benchmark.py +0 -0
  76. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_check_catalog.py +0 -0
  77. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_cli.py +0 -0
  78. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_config_lint.py +0 -0
  79. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_content_inspect.py +0 -0
  80. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_corpus.py +0 -0
  81. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_corpus_health.py +0 -0
  82. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_coverage.py +0 -0
  83. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_cross_stage.py +0 -0
  84. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_diagnostics.py +0 -0
  85. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_discovery_parallel.py +0 -0
  86. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_embedding_drift.py +0 -0
  87. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_eval_interop.py +0 -0
  88. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_gate.py +0 -0
  89. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_graph_transport.py +0 -0
  90. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_graph_validators.py +0 -0
  91. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_init_wizard.py +0 -0
  92. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_manifest.py +0 -0
  93. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_mcp.py +0 -0
  94. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_migration.py +0 -0
  95. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_models.py +0 -0
  96. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_neptune_bugfixes.py +0 -0
  97. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_neptune_bugfixes2.py +0 -0
  98. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_notifications.py +0 -0
  99. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_pipeline.py +0 -0
  100. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_profiler.py +0 -0
  101. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_providers.py +0 -0
  102. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_redis_chunks.py +0 -0
  103. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_registry.py +0 -0
  104. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_remediation.py +0 -0
  105. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_retrieval_assertions.py +0 -0
  106. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_retrieval_quality.py +0 -0
  107. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_run_cli.py +0 -0
  108. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_sources.py +0 -0
  109. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_storage_validator.py +0 -0
  110. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_trends.py +0 -0
  111. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_universal_concerns.py +0 -0
  112. {compass_kb_validation-2.3.0 → compass_kb_validation-2.5.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compass-kb-validation
3
- Version: 2.3.0
3
+ Version: 2.5.0
4
4
  Summary: Knowledge-base readiness validation for RAG & AI agents — config-driven, deterministic pipeline integrity checks.
5
5
  Author: COMPASS
6
6
  License: MIT
@@ -228,6 +228,10 @@ compass ask --project my-kb --batch questions.jsonl --format eval --output eval_
228
228
  # Corpus health (deterministic, no LLM): score chunk quality / find stale + orphan sources
229
229
  compass quality --project my-kb
230
230
  compass freshness --project my-kb --stale-after 30
231
+
232
+ # Adjudicate an eval's grounding finding against the KB: did the bot's counts match reality?
233
+ # Deterministic - attributes a failure to a layer (retrieval vs the model), never scores answer quality.
234
+ compass grounding-audit --project my-kb --eval-result case.json --fail-on-fabrication
231
235
  ```
232
236
 
233
237
  `migrate` and `coverage` also render a **self-contained HTML report** (same look as the
@@ -161,6 +161,10 @@ compass ask --project my-kb --batch questions.jsonl --format eval --output eval_
161
161
  # Corpus health (deterministic, no LLM): score chunk quality / find stale + orphan sources
162
162
  compass quality --project my-kb
163
163
  compass freshness --project my-kb --stale-after 30
164
+
165
+ # Adjudicate an eval's grounding finding against the KB: did the bot's counts match reality?
166
+ # Deterministic - attributes a failure to a layer (retrieval vs the model), never scores answer quality.
167
+ compass grounding-audit --project my-kb --eval-result case.json --fail-on-fabrication
164
168
  ```
165
169
 
166
170
  `migrate` and `coverage` also render a **self-contained HTML report** (same look as the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compass-kb-validation
3
- Version: 2.3.0
3
+ Version: 2.5.0
4
4
  Summary: Knowledge-base readiness validation for RAG & AI agents — config-driven, deterministic pipeline integrity checks.
5
5
  Author: COMPASS
6
6
  License: MIT
@@ -228,6 +228,10 @@ compass ask --project my-kb --batch questions.jsonl --format eval --output eval_
228
228
  # Corpus health (deterministic, no LLM): score chunk quality / find stale + orphan sources
229
229
  compass quality --project my-kb
230
230
  compass freshness --project my-kb --stale-after 30
231
+
232
+ # Adjudicate an eval's grounding finding against the KB: did the bot's counts match reality?
233
+ # Deterministic - attributes a failure to a layer (retrieval vs the model), never scores answer quality.
234
+ compass grounding-audit --project my-kb --eval-result case.json --fail-on-fabrication
231
235
  ```
232
236
 
233
237
  `migrate` and `coverage` also render a **self-contained HTML report** (same look as the
@@ -23,6 +23,7 @@ ingestion_validation/corpus_health.py
23
23
  ingestion_validation/diagnostics.py
24
24
  ingestion_validation/embedding_drift.py
25
25
  ingestion_validation/eval_interop.py
26
+ ingestion_validation/grounding.py
26
27
  ingestion_validation/init_wizard.py
27
28
  ingestion_validation/live_dashboard.py
28
29
  ingestion_validation/mcp_server.py
@@ -82,6 +83,7 @@ tests/test_eval_interop.py
82
83
  tests/test_gate.py
83
84
  tests/test_graph_transport.py
84
85
  tests/test_graph_validators.py
86
+ tests/test_grounding.py
85
87
  tests/test_init_wizard.py
86
88
  tests/test_manifest.py
87
89
  tests/test_mcp.py
@@ -142,8 +142,8 @@ def _resolve_skip_steps(settings, *, include_graphdb: bool, include_retrieval: b
142
142
  skip += ["GraphDB Metadata", "Neptune Metadata"]
143
143
  if not include_retrieval:
144
144
  skip.append("Retrieval Quality")
145
- skip += _normalize_skips(list(getattr(settings, "skip_steps", None) or []))
146
- skip += _normalize_skips(cli_skip)
145
+ skip += _normalize_skips(list(getattr(settings, "skip_steps", None) or []), source="config")
146
+ skip += _normalize_skips(cli_skip, source="cli")
147
147
 
148
148
  s3_auto_skipped = False
149
149
  if settings.discovery.provider in ("vectorstore", "index", "manifest"):
@@ -155,8 +155,14 @@ def _resolve_skip_steps(settings, *, include_graphdb: bool, include_retrieval: b
155
155
  return list(dict.fromkeys(skip)), s3_auto_skipped
156
156
 
157
157
 
158
- def _normalize_skips(tokens: list[str]) -> list[str]:
159
- """Map --skip tokens (step keys or names) to the step names the pipeline skips by."""
158
+ def _normalize_skips(tokens: list[str], source: str = "cli") -> list[str]:
159
+ """Map --skip tokens (step keys or names) to the step names the pipeline skips by.
160
+
161
+ An unknown entry from an interactive ``--skip`` is worth flagging (the user just typed
162
+ it and expects it to work). An unknown entry from the project's ``skip_steps`` is
163
+ config drift (e.g. a step removed in an upgrade) - silent at runtime because it is
164
+ harmless there and firing every run is noise; ``compass lint`` surfaces it once.
165
+ """
160
166
  from ingestion_validation.validators.registry import registered_steps
161
167
  specs = registered_steps()
162
168
  by_key = {s.key: s.name for s in specs}
@@ -167,7 +173,7 @@ def _normalize_skips(tokens: list[str]) -> list[str]:
167
173
  out.append(by_key[t])
168
174
  elif t in names:
169
175
  out.append(t)
170
- else:
176
+ elif source == "cli":
171
177
  print(f" (skip: unknown step '{t}'; valid keys: {', '.join(sorted(by_key))})")
172
178
  return out
173
179
 
@@ -508,13 +514,18 @@ def main(argv=None) -> int:
508
514
  # Corpus-level knowledge-base readiness (completeness, dedup, coverage).
509
515
  from ingestion_validation.corpus import analyze_corpus
510
516
 
517
+ # Read corpus expectations from the canonical `corpus:` block, falling back to the
518
+ # legacy `discovery.extra` location so older configs keep working.
519
+ cc = settings.corpus
520
+ dx = settings.discovery.extra
511
521
  expected_documents = _resolve_expected_documents(
512
- settings.discovery.extra.get("expected_documents"), args.project)
522
+ cc.expected_documents if cc.expected_documents is not None else dx.get("expected_documents"),
523
+ args.project)
513
524
  corpus = analyze_corpus(
514
525
  documents, results,
515
526
  expected_documents=expected_documents,
516
- expected_folders=settings.discovery.extra.get("expected_folders"),
517
- expected_document_names=settings.discovery.extra.get("expected_document_names"),
527
+ expected_folders=cc.expected_folders or dx.get("expected_folders"),
528
+ expected_document_names=cc.expected_document_names or dx.get("expected_document_names"),
518
529
  readiness_threshold=settings.pipeline.readiness_threshold,
519
530
  )
520
531
  print(f" KB readiness: {corpus['verdict']} ({corpus['passed']}/{corpus['total']} passed, "
@@ -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.3.0"
8
+ __version__ = "2.5.0"
@@ -1302,6 +1302,72 @@ def cmd_freshness(argv: list[str]) -> int:
1302
1302
  return 0
1303
1303
 
1304
1304
 
1305
+ def cmd_grounding_audit(argv: list[str]) -> int:
1306
+ p = argparse.ArgumentParser(
1307
+ prog="compass grounding-audit",
1308
+ description="Adjudicate an eval's grounding finding against the live KB. Deterministic: "
1309
+ "verifies count/aggregation claims exactly against the KB and attributes the "
1310
+ "failure to a layer (retrieval vs the model). Does not judge answer quality.")
1311
+ p.add_argument("--project")
1312
+ p.add_argument("--env", default="")
1313
+ g = p.add_mutually_exclusive_group(required=True)
1314
+ g.add_argument("--eval-result", metavar="FILE", help="One eval record as JSON.")
1315
+ g.add_argument("--eval-results", metavar="FILE", help="JSONL of eval records (one per line).")
1316
+ p.add_argument("--format", choices=["markdown", "json"], default="markdown")
1317
+ p.add_argument("--output", help="Write the report to a file (default: stdout).")
1318
+ p.add_argument("--fail-on-fabrication", action="store_true",
1319
+ help="Exit non-zero if any verifiable claim disagrees with the KB.")
1320
+ args = p.parse_args(argv)
1321
+ _health_env(args)
1322
+
1323
+ import json as _json
1324
+
1325
+ from ingestion_validation.config import load_settings, project_config_file
1326
+ from ingestion_validation.grounding import render_report, run_audit
1327
+ from ingestion_validation.providers import get_adapter
1328
+
1329
+ if args.project and project_config_file(args.project) is None:
1330
+ print(f" Project '{args.project}' not found (config/projects/{args.project}.yaml).")
1331
+ return 2
1332
+ settings = load_settings(project=args.project, env=args.env or None)
1333
+
1334
+ # Load the eval record(s).
1335
+ records: list[dict] = []
1336
+ try:
1337
+ if args.eval_result:
1338
+ records = [_json.loads(Path(args.eval_result).read_text(encoding="utf-8"))]
1339
+ else:
1340
+ for line in Path(args.eval_results).read_text(encoding="utf-8").splitlines():
1341
+ if line.strip():
1342
+ records.append(_json.loads(line))
1343
+ except Exception as exc: # noqa: BLE001 - clean message, never a traceback
1344
+ print(f" Could not read eval input: {exc}")
1345
+ return 2
1346
+
1347
+ adapter = get_adapter("vectorstore", settings.redis.provider, config=settings.redis)
1348
+ results = [run_audit(rec, adapter, settings.eval) for rec in records]
1349
+
1350
+ if args.format == "json":
1351
+ from dataclasses import asdict
1352
+ body = _json.dumps([asdict(r) for r in results], indent=2, default=str)
1353
+ else:
1354
+ body = ("\n\n".join(render_report(r, project=args.project or "") for r in results))
1355
+
1356
+ if args.output:
1357
+ Path(args.output).parent.mkdir(parents=True, exist_ok=True)
1358
+ Path(args.output).write_text(body + "\n", encoding="utf-8")
1359
+ print(f" Wrote {args.output}")
1360
+ else:
1361
+ print(body)
1362
+
1363
+ fabricated = sum(1 for r in results if r.wrong_claims)
1364
+ if fabricated:
1365
+ print(f"\n {fabricated}/{len(results)} case(s) contain claims that disagree with the KB.")
1366
+ if args.fail_on_fabrication:
1367
+ return 1
1368
+ return 0
1369
+
1370
+
1305
1371
  # --------------------------------------------------------------------------- #
1306
1372
  # compass selftest - prove exit-code propagation in THIS shell (BUG-14/15 diagnosis)
1307
1373
  # --------------------------------------------------------------------------- #
@@ -1355,6 +1421,7 @@ Commands:
1355
1421
  compare compare retrieval against a ground-truth file (precision/recall/Jaccard)
1356
1422
  quality score chunk quality (density, self-containedness, boilerplate) - no LLM
1357
1423
  freshness flag stale sources + orphan documents (source object gone)
1424
+ grounding-audit check an eval's grounding finding against the KB (count claims) - no LLM
1358
1425
  demo generate a sample report from synthetic data (no backend needed)
1359
1426
  init [name] scaffold a project (config + .env.example + marker). -i/--interactive wizard; --graph graph block; --ci workflow
1360
1427
  lint statically check a project config for silent-failure traps (no backend)
@@ -1399,6 +1466,8 @@ def main(argv: list[str] | None = None) -> int:
1399
1466
  return cmd_compare(rest)
1400
1467
  if cmd == "quality":
1401
1468
  return cmd_quality(rest)
1469
+ if cmd == "grounding-audit":
1470
+ return cmd_grounding_audit(rest)
1402
1471
  if cmd == "freshness":
1403
1472
  return cmd_freshness(rest)
1404
1473
  if cmd == "selftest":
@@ -534,6 +534,22 @@ class DiscoveryConfig:
534
534
  extra: dict[str, Any] = field(default_factory=dict)
535
535
 
536
536
 
537
+ @dataclass
538
+ class CorpusConfig:
539
+ """Corpus-level expectations: what SHOULD be in the knowledge base.
540
+
541
+ The natural home for the expected document set, so completeness alerts on drift. This
542
+ is the block users reach for (`corpus:`); previously it was neither recognized nor read
543
+ (the values had to live under `discovery.extra`, which few discover), so a
544
+ `corpus.expected_documents` was silently ignored. Now first-class and read directly.
545
+ """
546
+
547
+ expected_documents: Any = None # int, or "auto" (baseline from last run)
548
+ expected_folders: list[str] = field(default_factory=list)
549
+ expected_document_names: list[str] = field(default_factory=list)
550
+ extra: dict[str, Any] = field(default_factory=dict)
551
+
552
+
537
553
  @dataclass
538
554
  class EmbeddingConfig:
539
555
  """How to turn a natural-language query into a vector for `compass ask`.
@@ -582,6 +598,21 @@ class EvalConfig:
582
598
  gt_variant_path: str = ""
583
599
  gt_variant_filter: list[str] = field(default_factory=list)
584
600
 
601
+ # --- Grounding audit (compass grounding-audit) ------------------------------------
602
+ # Verify an eval's grounding finding against the live KB. Every field name here comes
603
+ # from config, never assumed - a KB whose ingestion named things differently only
604
+ # changes these values, not code.
605
+ gt_response_path: str = "response" # the answer text in the eval record
606
+ gt_context_path: str = "context.knowledge_retrieval" # the retrieved context the eval saw
607
+ gt_scores_path: str = "scores" # eval scores (shown in the report)
608
+ # Structured-query verification: which fields the KB can aggregate to check a count
609
+ # claim. Each entry: {field: <group-by field>, distinct_by: <doc-identity field>,
610
+ # label: <optional human label>}. Empty = count claims are not verified.
611
+ aggregatable_fields: list[dict] = field(default_factory=list)
612
+ # Extra query-intent regexes, merged with the built-in defaults (how many / count / by
613
+ # type / list all / most recent ...). Lets a domain add its own phrasings.
614
+ structured_query_patterns: list[str] = field(default_factory=list)
615
+
585
616
 
586
617
  @dataclass
587
618
  class ConsistencyConfig:
@@ -640,6 +671,7 @@ class Settings:
640
671
  embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
641
672
  eval: EvalConfig = field(default_factory=EvalConfig)
642
673
  consistency: ConsistencyConfig = field(default_factory=ConsistencyConfig)
674
+ corpus: CorpusConfig = field(default_factory=CorpusConfig)
643
675
  # Non-service metadata carried through from config files.
644
676
  project_name: str = ""
645
677
  display_name: str = ""
@@ -661,7 +693,7 @@ _META_KEYS = frozenset({"default_env", "display_name", "environments", "skip_ste
661
693
  # new block can never be added to one and forgotten in the other (which is exactly how
662
694
  # `consistency` came to be silently dropped at the top level).
663
695
  _SERVICE_BLOCKS = ("api", "s3", "graphdb", "redis", "neptune", "pipeline",
664
- "retrieval", "discovery", "embedding", "eval", "consistency")
696
+ "retrieval", "discovery", "embedding", "eval", "consistency", "corpus")
665
697
 
666
698
 
667
699
  def _deep_merge(base: dict, overlay: dict) -> dict:
@@ -787,6 +819,7 @@ def load_settings(
787
819
  embedding=_hydrate(EmbeddingConfig, flat.get("embedding")),
788
820
  eval=_hydrate(EvalConfig, flat.get("eval")),
789
821
  consistency=_hydrate(ConsistencyConfig, flat.get("consistency")),
822
+ corpus=_hydrate(CorpusConfig, flat.get("corpus")),
790
823
  project_name=project or "",
791
824
  display_name=merged.get("display_name", ""),
792
825
  environment=resolved_env,
@@ -0,0 +1,341 @@
1
+ """Grounding audit: adjudicate an eval's grounding finding against the live KB.
2
+
3
+ An eval (often an LLM judge) flags "this answer looks ungrounded". That verdict can be
4
+ wrong, so this module supplies the DETERMINISTIC ground truth the verdict is checked
5
+ against: it queries the KB COMPASS already knows how to reach and asks factual, checkable
6
+ questions - is this claim actually in the KB, and did retrieval surface it? - then
7
+ attributes the failure to a layer (retrieval vs the model). It does NOT score answer
8
+ quality; that stays in the eval layer. No LLM in any verdict path.
9
+
10
+ Phase 1 (this module) implements the STRUCTURED path - count/aggregation claims verified
11
+ exactly against the KB via the adapter's ``aggregate_count`` (e.g. "the bot said 24
12
+ documents; the KB has 127"). It is deterministic and unambiguous. The semantic-claim path
13
+ (free-text claim extraction) is a separate, measurement-gated effort; see ``run_audit``.
14
+
15
+ Backend-agnostic and schema-agnostic by construction: field names come from config, and
16
+ aggregation goes through the adapter - a KB whose ingestion is shaped differently only
17
+ changes config values, never this code. Backends that cannot aggregate exactly return
18
+ None and the report says so, rather than asserting a wrong actual count.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from dataclasses import dataclass, field
25
+ from typing import Any, Optional
26
+
27
+ # Built-in query-intent patterns. A KB can add its own via eval.structured_query_patterns;
28
+ # these are merged, never replaced, so the defaults always hold.
29
+ _STRUCTURED_PATTERNS: tuple[tuple[str, str], ...] = (
30
+ (r"\bhow many\b", "count"),
31
+ (r"\bcount\b", "count"),
32
+ (r"\bnumber of\b", "count"),
33
+ (r"\btotal\b.*\b(document|doc|record|item|file)", "count"),
34
+ (r"\bby type\b", "groupby"),
35
+ (r"\bbreakdown\b", "groupby"),
36
+ (r"\bwhat types\b", "groupby"),
37
+ (r"\blist all\b", "enumerate"),
38
+ (r"\bmost recent\b", "sort"),
39
+ (r"\blatest\b", "sort"),
40
+ (r"\boldest\b", "sort"),
41
+ )
42
+
43
+
44
+ def classify_query(query: str, extra_patterns: Optional[list[str]] = None) -> tuple[str, list[str]]:
45
+ """Classify a query as 'structured' (aggregation/count/list) or 'semantic' (topic).
46
+
47
+ Deterministic regex intent detection. Returns (kind, sorted-unique-intents).
48
+ """
49
+ q = (query or "").lower()
50
+ intents: set[str] = set()
51
+ for pattern, intent in _STRUCTURED_PATTERNS:
52
+ if re.search(pattern, q):
53
+ intents.add(intent)
54
+ for pattern in extra_patterns or []:
55
+ try:
56
+ if re.search(pattern, q):
57
+ intents.add("count") # user-declared structured phrasing
58
+ except re.error:
59
+ continue
60
+ return ("structured" if intents else "semantic"), sorted(intents)
61
+
62
+
63
+ @dataclass
64
+ class NumericClaim:
65
+ label: str # what the count is about (a group value, or "total")
66
+ count: int # the number the answer asserted
67
+ source: str # how it was extracted (table | inline)
68
+
69
+
70
+ def extract_numeric_claims(response: str) -> list[NumericClaim]:
71
+ """Extract (label, count) claims from an answer - generically, no hardcoded labels.
72
+
73
+ Labels are whatever the answer names (a document type, a category, ...); they are
74
+ matched against real KB values later, never against a fixed set. Catches markdown
75
+ table rows and inline "N documents" phrasings.
76
+ """
77
+ text = response or ""
78
+ claims: list[NumericClaim] = []
79
+ seen: set[tuple[str, int]] = set()
80
+
81
+ def _add(label: str, count: int, source: str) -> None:
82
+ label = label.strip().strip("*").strip()
83
+ key = (label.lower(), count)
84
+ if label and key not in seen:
85
+ seen.add(key)
86
+ claims.append(NumericClaim(label=label, count=count, source=source))
87
+
88
+ # Markdown table rows: first cell is the label, the last standalone integer cell is
89
+ # the count. Header/separator rows (label in a known non-data set) are skipped.
90
+ _skip = {"type", "total", "metric", "count", "category", "documents", "document type", ""}
91
+ for row in re.findall(r"^\s*\|(.+)\|\s*$", text, flags=re.MULTILINE):
92
+ cells = [c.strip().strip("*").strip() for c in row.split("|")]
93
+ if not cells or all(set(c) <= set("-: ") for c in cells):
94
+ continue # separator row
95
+ label = cells[0]
96
+ if label.lower() in _skip:
97
+ continue
98
+ nums = [int(c.replace(",", "")) for c in cells[1:] if re.fullmatch(r"[\d,]+", c)]
99
+ if nums:
100
+ _add(label, nums[-1], "table")
101
+
102
+ # Inline "N documents / docs / files / records / PDFs".
103
+ for m in re.finditer(r"\*{0,2}([\d,]+)\*{0,2}\s+(?:total\s+)?(?:documents?|docs|files?|records?|pdfs?)\b",
104
+ text, flags=re.IGNORECASE):
105
+ _add("total", int(m.group(1).replace(",", "")), "inline")
106
+
107
+ return claims
108
+
109
+
110
+ @dataclass
111
+ class ClaimVerdict:
112
+ label: str
113
+ claimed: int
114
+ actual: Optional[int] # None = not verifiable (backend can't aggregate / field absent)
115
+ verifiable: bool
116
+ correct: bool
117
+ detail: str
118
+
119
+
120
+ def verify_structured_claims(adapter: Any, claims: list[NumericClaim],
121
+ aggregatable_fields: list[dict]) -> tuple[list[ClaimVerdict], dict]:
122
+ """Verify count claims against exact KB aggregations via the adapter.
123
+
124
+ Returns (verdicts, meta). ``meta`` carries why verification could not run (e.g. the
125
+ backend does not support aggregation) so the report degrades honestly instead of
126
+ asserting a wrong actual value.
127
+ """
128
+ meta: dict = {"aggregated": False, "reason": ""}
129
+ if not getattr(adapter, "supports_aggregation", False):
130
+ meta["reason"] = (f"{type(adapter).__name__} does not support exact aggregation - "
131
+ "count claims cannot be verified on this backend.")
132
+ return [ClaimVerdict(c.label, c.count, None, False, False, "backend cannot aggregate")
133
+ for c in claims], meta
134
+ if not aggregatable_fields:
135
+ meta["reason"] = ("no eval.aggregatable_fields configured - declare the group-by and "
136
+ "doc-identity fields to verify count claims.")
137
+ return [ClaimVerdict(c.label, c.count, None, False, False, "no aggregatable fields configured")
138
+ for c in claims], meta
139
+
140
+ # Build a value -> count map across every configured aggregation, plus an overall
141
+ # total (distinct documents) for "total" claims.
142
+ value_counts: dict[str, int] = {}
143
+ total_docs = 0
144
+ for spec in aggregatable_fields:
145
+ gfield = spec.get("field", "")
146
+ dfield = spec.get("distinct_by") or None
147
+ counts = adapter.aggregate_count(gfield, dfield) if gfield else None
148
+ if counts:
149
+ meta["aggregated"] = True
150
+ for k, v in counts.items():
151
+ value_counts[str(k).lower()] = value_counts.get(str(k).lower(), 0) + int(v)
152
+ total_docs += int(v)
153
+ if not meta["aggregated"]:
154
+ meta["reason"] = "aggregation returned nothing (empty index, or the configured fields are absent)."
155
+
156
+ verdicts: list[ClaimVerdict] = []
157
+ for c in claims:
158
+ if c.label.lower() == "total":
159
+ actual = total_docs if meta["aggregated"] else None
160
+ else:
161
+ actual = value_counts.get(c.label.lower())
162
+ if actual is None:
163
+ verdicts.append(ClaimVerdict(c.label, c.count, None, False, False,
164
+ "no matching KB value" if meta["aggregated"] else meta["reason"]))
165
+ continue
166
+ correct = actual == c.count
167
+ if correct:
168
+ detail = "matches KB"
169
+ else:
170
+ ratio = (actual / c.count) if c.count else float("inf")
171
+ direction = "undercount" if actual > c.count else "overcount"
172
+ detail = f"off by {abs(actual - c.count)} ({ratio:.1f}x {direction})"
173
+ verdicts.append(ClaimVerdict(c.label, c.count, actual, True, correct, detail))
174
+ return verdicts, meta
175
+
176
+
177
+ def _is_empty_context(ctx: Any) -> bool:
178
+ """True when the eval's retrieved context is effectively empty (null/blank fields)."""
179
+ if not ctx:
180
+ return True
181
+ s = str(ctx).strip()
182
+ return s in ("", "{}", "[]", "null", "None") or bool(re.fullmatch(r"[\s{}\[\]:,'\"]*", s))
183
+
184
+
185
+ @dataclass
186
+ class GroundingResult:
187
+ test_id: str
188
+ query: str
189
+ query_type: str
190
+ intents: list[str]
191
+ claim_verdicts: list[ClaimVerdict] = field(default_factory=list)
192
+ context_empty: bool = True
193
+ eval_scores: dict = field(default_factory=dict)
194
+ meta: dict = field(default_factory=dict)
195
+
196
+ @property
197
+ def wrong_claims(self) -> list[ClaimVerdict]:
198
+ return [v for v in self.claim_verdicts if v.verifiable and not v.correct]
199
+
200
+ @property
201
+ def verified_claims(self) -> list[ClaimVerdict]:
202
+ return [v for v in self.claim_verdicts if v.verifiable]
203
+
204
+ def layer_diagnosis(self) -> str:
205
+ """Deterministic layer attribution - which stage is implicated, no quality score."""
206
+ if self.query_type == "structured" and self.wrong_claims:
207
+ if self.context_empty:
208
+ return ("HALLUCINATION + QUERY-ROUTING FAILURE: retrieval returned nothing and the "
209
+ "model fabricated counts. An aggregation query was answered by semantic search.")
210
+ return "FABRICATION: the model's counts disagree with the KB despite retrieved context."
211
+ if self.query_type == "structured" and self.verified_claims and not self.wrong_claims:
212
+ return "GROUNDED: every verifiable count matches the KB."
213
+ if not self.verified_claims:
214
+ return "UNVERIFIED: no count claim could be checked (see notes)."
215
+ return "MIXED: some counts match, others do not."
216
+
217
+ def severity(self) -> str:
218
+ if self.wrong_claims:
219
+ return "HIGH" # confident wrong data reaching users
220
+ if not self.verified_claims:
221
+ return "LOW"
222
+ return "INFO"
223
+
224
+
225
+ def get_path(obj: Any, dotted: str) -> Any:
226
+ """Read a dotted path (e.g. 'context.knowledge_retrieval') from a nested dict."""
227
+ cur = obj
228
+ for part in (dotted or "").split("."):
229
+ if isinstance(cur, dict) and part in cur:
230
+ cur = cur[part]
231
+ else:
232
+ return None
233
+ return cur
234
+
235
+
236
+ def run_audit(eval_record: dict, adapter: Any, eval_cfg: Any) -> GroundingResult:
237
+ """Adjudicate one eval record against the KB. Deterministic; the structured path only.
238
+
239
+ ``eval_cfg`` supplies the dotted paths (gt_question_path, gt_response_path,
240
+ gt_context_path, gt_scores_path) and ``aggregatable_fields`` - all schema-agnostic.
241
+ """
242
+ query = str(get_path(eval_record, eval_cfg.gt_question_path) or "")
243
+ response = str(get_path(eval_record, getattr(eval_cfg, "gt_response_path", "response")) or "")
244
+ context = get_path(eval_record, getattr(eval_cfg, "gt_context_path", "context.knowledge_retrieval"))
245
+ scores = get_path(eval_record, getattr(eval_cfg, "gt_scores_path", "scores")) or {}
246
+ test_id = str(eval_record.get("test_id") or eval_record.get("id") or "")
247
+
248
+ qtype, intents = classify_query(query, getattr(eval_cfg, "structured_query_patterns", []))
249
+ result = GroundingResult(test_id=test_id, query=query, query_type=qtype, intents=intents,
250
+ context_empty=_is_empty_context(context),
251
+ eval_scores=scores if isinstance(scores, dict) else {})
252
+ if qtype == "structured":
253
+ claims = extract_numeric_claims(response)
254
+ result.claim_verdicts, result.meta = verify_structured_claims(
255
+ adapter, claims, getattr(eval_cfg, "aggregatable_fields", []) or [])
256
+ else:
257
+ # Semantic path (free-text claim verification) is a separate, measurement-gated
258
+ # mode; for now say so rather than run a fragile extractor and imply a verdict.
259
+ result.meta = {"semantic": True,
260
+ "note": "Semantic claim verification is not yet enabled; only the "
261
+ "retrieved-context check ran."}
262
+ return result
263
+
264
+
265
+ def render_report(result: GroundingResult, *, project: str = "") -> str:
266
+ """Render a portable, evidence-first defect report (issue tracker / wiki / chat ready).
267
+
268
+ COMPASS's authoritative content is the evidence (claimed vs actual, from the KB) and
269
+ the deterministic layer attribution. The recommendations are clearly-labelled generic
270
+ hints, never presented as bespoke analysis or an answer-quality score.
271
+ """
272
+ L: list[str] = []
273
+ diagnosis = result.layer_diagnosis()
274
+ L.append(f"## Grounding Audit: {result.test_id or 'case'} - \"{result.query}\"")
275
+ L.append("")
276
+ L.append(f"**Severity:** {result.severity()} ")
277
+ L.append(f"**Query type:** {result.query_type}"
278
+ + (f" ({', '.join(result.intents)})" if result.intents else "") + " ")
279
+ L.append(f"**Verdict (deterministic, KB-verified):** {diagnosis}")
280
+ L.append("")
281
+
282
+ if result.eval_scores:
283
+ L.append("### Eval scores (as reported by the eval - shown for context)")
284
+ L.append("")
285
+ L.append("| Metric | Score | Target | Status |")
286
+ L.append("|---|---|---|---|")
287
+ for metric, data in result.eval_scores.items():
288
+ if isinstance(data, dict):
289
+ score, target = data.get("score", 0), data.get("target", 0)
290
+ else:
291
+ score, target = data, 0
292
+ status = "PASS" if (isinstance(score, (int, float)) and score >= target) else "FAIL"
293
+ L.append(f"| {metric} | {score} | {target} | {status} |")
294
+ L.append("")
295
+
296
+ if result.claim_verdicts:
297
+ L.append("### Claim verification against the KB")
298
+ L.append("")
299
+ L.append("| Claim | Bot said | KB actual | Result |")
300
+ L.append("|---|---|---|---|")
301
+ for v in result.claim_verdicts:
302
+ actual = v.actual if v.actual is not None else "unverifiable"
303
+ mark = "OK" if v.correct else ("FAIL" if v.verifiable else "N/A")
304
+ L.append(f"| {v.label} | {v.claimed} | {actual} | {mark} - {v.detail} |")
305
+ L.append("")
306
+ n_ok = sum(1 for v in result.claim_verdicts if v.correct)
307
+ n_ver = len(result.verified_claims)
308
+ if n_ver:
309
+ L.append(f"**{n_ok}/{n_ver} verifiable claim(s) match the KB.**")
310
+ if result.meta.get("reason"):
311
+ L.append(f"> Note: {result.meta['reason']}")
312
+ L.append("")
313
+
314
+ L.append("### Retrieved context")
315
+ L.append("")
316
+ L.append("`[EMPTY - retrieval returned nothing]`" if result.context_empty
317
+ else "_(non-empty; see the eval record)_")
318
+ L.append("")
319
+
320
+ # Recommendations: generic, template-selected by the diagnosis. Labelled as hints.
321
+ L.append("### Suggested next steps (generic guidance, not KB-specific analysis)")
322
+ L.append("")
323
+ if result.query_type == "structured" and result.wrong_claims:
324
+ L += [
325
+ "- Route count/aggregation questions to a **direct aggregation** (the KB can answer",
326
+ " these exactly), not semantic search.",
327
+ "- On **empty retrieval, refuse or flag uncertainty** rather than letting the model fill the gap.",
328
+ ]
329
+ elif result.query_type == "semantic":
330
+ L += [
331
+ "- This is a topic query; run the semantic grounding mode (when enabled) to verify",
332
+ " individual claims against the KB.",
333
+ ]
334
+ else:
335
+ L.append("- Counts verified against the KB; no action indicated for this case.")
336
+ L.append("")
337
+ L.append("---")
338
+ L.append("_Generated by `compass grounding-audit`"
339
+ + (f" (project {project})" if project else "") + ". "
340
+ "Evidence is deterministic and KB-verified; it does not judge answer quality._")
341
+ return "\n".join(L) + "\n"
@@ -581,9 +581,8 @@ def suggest_config_from_run(results: list, documents: list) -> str:
581
581
  "",
582
582
  "# Documents discovered this run. Pin it to alert on drift (or use 'auto' to",
583
583
  "# baseline against the previous run automatically):",
584
- "discovery:",
585
- " extra:",
586
- f" expected_documents: {n_docs}",
584
+ "corpus:",
585
+ f" expected_documents: {n_docs}",
587
586
  ]
588
587
  if avg_chars:
589
588
  median_size = int(statistics.median(avg_chars))