compass-kb-validation 3.2.1__tar.gz → 3.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 (123) hide show
  1. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/PKG-INFO +15 -1
  2. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/README.md +14 -0
  3. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/__init__.py +1 -1
  4. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/cli.py +158 -0
  5. compass_kb_validation-3.3.0/compass/eval_enrich.py +305 -0
  6. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/grounding.py +5 -0
  7. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass_kb_validation.egg-info/PKG-INFO +15 -1
  8. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass_kb_validation.egg-info/SOURCES.txt +2 -0
  9. compass_kb_validation-3.3.0/tests/test_eval_enrich.py +109 -0
  10. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/LICENSE +0 -0
  11. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/__main__.py +0 -0
  12. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/ask.py +0 -0
  13. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/baseline.py +0 -0
  14. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/chunk_coherence.py +0 -0
  15. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/citation.py +0 -0
  16. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/config/__init__.py +0 -0
  17. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/config_lint.py +0 -0
  18. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/content_inspect.py +0 -0
  19. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/corpus.py +0 -0
  20. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/corpus_health.py +0 -0
  21. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/diagnostics.py +0 -0
  22. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/embedding_drift.py +0 -0
  23. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/eval_interop.py +0 -0
  24. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/evidence.py +0 -0
  25. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/init_wizard.py +0 -0
  26. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/live_dashboard.py +0 -0
  27. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/mcp_server.py +0 -0
  28. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/models/__init__.py +0 -0
  29. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/notifications.py +0 -0
  30. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/pipeline/__init__.py +0 -0
  31. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/profiler.py +0 -0
  32. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/provenance.py +0 -0
  33. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/providers/__init__.py +0 -0
  34. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/providers/embedding.py +0 -0
  35. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/providers/graph.py +0 -0
  36. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/providers/source.py +0 -0
  37. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/providers/storage.py +0 -0
  38. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/providers/vectorstore.py +0 -0
  39. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/remediation.py +0 -0
  40. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/report/__init__.py +0 -0
  41. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/report/_report_css.py +0 -0
  42. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/report/_report_js.py +0 -0
  43. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/report/analysis.py +0 -0
  44. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/report/audit.py +0 -0
  45. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/report/export.py +0 -0
  46. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/report/shell.py +0 -0
  47. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/retrieval_analysis.py +0 -0
  48. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/text_probes.py +0 -0
  49. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/trend_tracker.py +0 -0
  50. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/utils/__init__.py +0 -0
  51. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/validators/__init__.py +0 -0
  52. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/validators/api_upload.py +0 -0
  53. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/validators/base.py +0 -0
  54. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/validators/base_sparql.py +0 -0
  55. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/validators/cross_stage.py +0 -0
  56. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/validators/graphdb_metadata.py +0 -0
  57. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/validators/neptune_metadata.py +0 -0
  58. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/validators/redis_chunks.py +0 -0
  59. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/validators/registry.py +0 -0
  60. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/validators/retrieval_quality.py +0 -0
  61. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass/validators/s3_storage.py +0 -0
  62. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
  63. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass_kb_validation.egg-info/entry_points.txt +0 -0
  64. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass_kb_validation.egg-info/requires.txt +0 -0
  65. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/compass_kb_validation.egg-info/top_level.txt +0 -0
  66. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/discover_and_validate.py +0 -0
  67. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/ingestion_validation/__init__.py +0 -0
  68. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/profile_kb.py +0 -0
  69. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/pyproject.toml +0 -0
  70. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/run_validation.py +0 -0
  71. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/setup.cfg +0 -0
  72. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_alt_adapters.py +0 -0
  73. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_analysis_report.py +0 -0
  74. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_api_upload.py +0 -0
  75. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_ask.py +0 -0
  76. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_baseline.py +0 -0
  77. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_benchmark.py +0 -0
  78. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_check_catalog.py +0 -0
  79. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_citation.py +0 -0
  80. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_cli.py +0 -0
  81. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_compat_shim.py +0 -0
  82. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_config.py +0 -0
  83. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_config_lint.py +0 -0
  84. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_content_inspect.py +0 -0
  85. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_corpus.py +0 -0
  86. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_corpus_health.py +0 -0
  87. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_coverage.py +0 -0
  88. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_cross_stage.py +0 -0
  89. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_diagnostics.py +0 -0
  90. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_discover_cli.py +0 -0
  91. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_discovery_parallel.py +0 -0
  92. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_embedding_drift.py +0 -0
  93. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_eval_interop.py +0 -0
  94. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_evidence.py +0 -0
  95. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_gate.py +0 -0
  96. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_graph_transport.py +0 -0
  97. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_graph_validators.py +0 -0
  98. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_grounding.py +0 -0
  99. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_init_wizard.py +0 -0
  100. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_manifest.py +0 -0
  101. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_mcp.py +0 -0
  102. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_migration.py +0 -0
  103. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_models.py +0 -0
  104. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_neptune_bugfixes.py +0 -0
  105. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_neptune_bugfixes2.py +0 -0
  106. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_notifications.py +0 -0
  107. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_pipeline.py +0 -0
  108. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_profiler.py +0 -0
  109. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_providers.py +0 -0
  110. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_redis_chunks.py +0 -0
  111. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_registry.py +0 -0
  112. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_remediation.py +0 -0
  113. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_reporting.py +0 -0
  114. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_retrieval_assertions.py +0 -0
  115. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_retrieval_quality.py +0 -0
  116. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_review_hardening.py +0 -0
  117. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_run_cli.py +0 -0
  118. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_sources.py +0 -0
  119. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_storage_validator.py +0 -0
  120. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_trends.py +0 -0
  121. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_universal_concerns.py +0 -0
  122. {compass_kb_validation-3.2.1 → compass_kb_validation-3.3.0}/tests/test_utils.py +0 -0
  123. {compass_kb_validation-3.2.1 → compass_kb_validation-3.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: 3.2.1
3
+ Version: 3.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
@@ -147,6 +147,7 @@ Two modes: **live** validation as documents are ingested, and **post-hoc discove
147
147
  ✅ **Pluggable steps** — register custom validators in a step registry; the report adapts automatically
148
148
  ✅ **Deterministic & cheap** — structural/byte/schema checks, no LLM, CI-gateable
149
149
  ✅ **Query & analyze the KB** — ask it questions, expose it to agents over MCP, and preview embedding-model migrations / map retrieval coverage — all deterministic, read-only (see *Beyond validation*)
150
+ ✅ **Eval oracle** — enrich any eval with deterministic KB-grounding signals, then measure how accurate its LLM judges are against that ground truth (`compass enrich` / `compass calibrate-judges`) — the eval framework guesses, COMPASS checks the KB
150
151
  ✅ **Interactive reporting** — self-contained HTML dashboard + JSON + JUnit XML
151
152
  ✅ **Trend Tracking** — SQLite historical performance analysis
152
153
  ✅ **Notifications** — Slack/Teams webhooks for alerts
@@ -238,8 +239,21 @@ compass freshness --project my-kb --stale-after 30
238
239
  # chunks when present (Level 1) and the live KB (Level 2); the answer text + a reachable KB alone
239
240
  # still catches fabricated figures and phantom citations.
240
241
  compass grounding-audit --project my-kb --eval-result case.json --fail-on-fabrication
242
+
243
+ # Eval oracle (deterministic, no LLM): enrich an eval with KB-grounded signals, then measure how
244
+ # accurate the eval's OWN LLM judges are against that deterministic truth. LLM judges GUESS whether
245
+ # a response is grounded; COMPASS CHECKS it against the KB - so it can tell you which judge is right.
246
+ compass enrich --project my-kb --input eval.json --output enriched.json
247
+ compass calibrate-judges --input enriched.json --metric groundedness
241
248
  ```
242
249
 
250
+ `enrich` adds a `compass` block per case (a **citation grounding rate** over the verifiable
251
+ citation+numeric subset, citation fidelity, chunk quality, context utilization, `COMPASS_*` signal
252
+ codes). It is **schema-agnostic** — every field resolves through your `eval.gt_*` config, so it
253
+ works whatever shape your eval JSON takes. `calibrate-judges` then reports each judge's error,
254
+ bias, accuracy, and an evidence-based **recommended weight** — something no eval framework can
255
+ compute, because it needs KB access.
256
+
243
257
  `migrate` and `coverage` also render a **self-contained HTML report** (same look as the
244
258
  validation dashboard) with `--html [path] --open` — a coverage heatmap, a migration
245
259
  stability distribution, verdicts, and the most-changed / dead-zone tables.
@@ -80,6 +80,7 @@ Two modes: **live** validation as documents are ingested, and **post-hoc discove
80
80
  ✅ **Pluggable steps** — register custom validators in a step registry; the report adapts automatically
81
81
  ✅ **Deterministic & cheap** — structural/byte/schema checks, no LLM, CI-gateable
82
82
  ✅ **Query & analyze the KB** — ask it questions, expose it to agents over MCP, and preview embedding-model migrations / map retrieval coverage — all deterministic, read-only (see *Beyond validation*)
83
+ ✅ **Eval oracle** — enrich any eval with deterministic KB-grounding signals, then measure how accurate its LLM judges are against that ground truth (`compass enrich` / `compass calibrate-judges`) — the eval framework guesses, COMPASS checks the KB
83
84
  ✅ **Interactive reporting** — self-contained HTML dashboard + JSON + JUnit XML
84
85
  ✅ **Trend Tracking** — SQLite historical performance analysis
85
86
  ✅ **Notifications** — Slack/Teams webhooks for alerts
@@ -171,8 +172,21 @@ compass freshness --project my-kb --stale-after 30
171
172
  # chunks when present (Level 1) and the live KB (Level 2); the answer text + a reachable KB alone
172
173
  # still catches fabricated figures and phantom citations.
173
174
  compass grounding-audit --project my-kb --eval-result case.json --fail-on-fabrication
175
+
176
+ # Eval oracle (deterministic, no LLM): enrich an eval with KB-grounded signals, then measure how
177
+ # accurate the eval's OWN LLM judges are against that deterministic truth. LLM judges GUESS whether
178
+ # a response is grounded; COMPASS CHECKS it against the KB - so it can tell you which judge is right.
179
+ compass enrich --project my-kb --input eval.json --output enriched.json
180
+ compass calibrate-judges --input enriched.json --metric groundedness
174
181
  ```
175
182
 
183
+ `enrich` adds a `compass` block per case (a **citation grounding rate** over the verifiable
184
+ citation+numeric subset, citation fidelity, chunk quality, context utilization, `COMPASS_*` signal
185
+ codes). It is **schema-agnostic** — every field resolves through your `eval.gt_*` config, so it
186
+ works whatever shape your eval JSON takes. `calibrate-judges` then reports each judge's error,
187
+ bias, accuracy, and an evidence-based **recommended weight** — something no eval framework can
188
+ compute, because it needs KB access.
189
+
176
190
  `migrate` and `coverage` also render a **self-contained HTML report** (same look as the
177
191
  validation dashboard) with `--html [path] --open` — a coverage heatmap, a migration
178
192
  stability distribution, verdicts, and the most-changed / dead-zone tables.
@@ -5,4 +5,4 @@ end-to-end: API upload -> object storage -> knowledge graph -> vector store ->
5
5
  retrieval quality, plus cross-store consistency.
6
6
  """
7
7
 
8
- __version__ = "3.2.1"
8
+ __version__ = "3.3.0"
@@ -1701,6 +1701,158 @@ def cmd_selftest(argv: list[str]) -> int:
1701
1701
  # --------------------------------------------------------------------------- #
1702
1702
  # Dispatcher
1703
1703
  # --------------------------------------------------------------------------- #
1704
+ def cmd_enrich(argv: list[str]) -> int:
1705
+ p = argparse.ArgumentParser(
1706
+ prog="compass enrich",
1707
+ description="Add deterministic, KB-derived signals to an eval file: a citation grounding "
1708
+ "rate (over the verifiable citation+numeric subset), citation fidelity, chunk "
1709
+ "quality, context utilization, and COMPASS_* signal codes. Read-only, no LLM. "
1710
+ "Schema-agnostic - field locations come from your eval.gt_* config.")
1711
+ p.add_argument("--project")
1712
+ p.add_argument("--env", default="")
1713
+ p.add_argument("--input", required=True, metavar="FILE",
1714
+ help="Eval file: a JSON array of cases, or a JSON object carrying a case array")
1715
+ p.add_argument("--output", metavar="FILE", help="Write enriched JSON here (default: stdout)")
1716
+ p.add_argument("--no-retrieval", action="store_true",
1717
+ help="Skip live-KB (Level 2) verification; use only the eval's own chunks "
1718
+ "(Level 1). No backend needed.")
1719
+ p.add_argument("--json", action="store_true", help="(default) JSON output")
1720
+ args = p.parse_args(argv)
1721
+ _health_env(args)
1722
+
1723
+ import json as _json
1724
+
1725
+ from compass.config import load_settings, project_config_file
1726
+ from compass.eval_enrich import discover_cases, enrich_cases
1727
+
1728
+ if args.project and project_config_file(args.project) is None:
1729
+ print(f" Project '{args.project}' not found (config/projects/{args.project}.yaml).", file=sys.stderr)
1730
+ return 2
1731
+ try:
1732
+ settings = load_settings(project=args.project, env=args.env or None)
1733
+ except Exception as exc: # noqa: BLE001 - clean message, not a traceback
1734
+ print(f" Config error: {exc}", file=sys.stderr)
1735
+ return 2
1736
+ try:
1737
+ with open(args.input, encoding="utf-8") as f:
1738
+ data = _json.load(f)
1739
+ except (OSError, ValueError) as exc:
1740
+ print(f" Cannot read {args.input}: {exc}", file=sys.stderr)
1741
+ return 2
1742
+ cases = discover_cases(data)
1743
+ if not cases:
1744
+ print(f" No case array found in {args.input}.", file=sys.stderr)
1745
+ return 2
1746
+
1747
+ # A reachable KB enables Level 2 (KB-verified) attribution; without it, Level 1 signals from
1748
+ # the eval's own chunks still compute. Never fail the command over an unreachable KB.
1749
+ adapter = None
1750
+ if args.project and not args.no_retrieval:
1751
+ try:
1752
+ from compass.providers import get_adapter
1753
+ adapter = get_adapter("vectorstore", settings.redis.provider, config=settings.redis)
1754
+ if not getattr(adapter, "ping", lambda: True)():
1755
+ adapter = None
1756
+ except Exception: # noqa: BLE001 - degrade to Level 1
1757
+ adapter = None
1758
+
1759
+ print(f" Enriching {len(cases)} case(s) with deterministic KB signals "
1760
+ f"(level {'1+2' if adapter else '1'}) ...", file=sys.stderr)
1761
+ enriched, summary = enrich_cases(cases, settings.eval, adapter)
1762
+ if isinstance(data, dict):
1763
+ data["compass_summary"] = summary
1764
+ out_obj = data
1765
+ else:
1766
+ out_obj = enriched # a list: each case now carries a "compass" block; shape preserved
1767
+ body = _json.dumps(out_obj, indent=2, ensure_ascii=False)
1768
+ mean = summary["mean_citation_grounding_rate"]
1769
+ note = (f"{summary['cases_enriched']} enriched, {summary['cases_with_verifiable_claims']} "
1770
+ f"with verifiable claims" + (f", mean grounding rate {mean:.0%}" if mean is not None else ""))
1771
+ if args.output:
1772
+ Path(args.output).parent.mkdir(parents=True, exist_ok=True)
1773
+ Path(args.output).write_text(body + "\n", encoding="utf-8")
1774
+ print(f" Wrote {args.output} ({note})", file=sys.stderr)
1775
+ else:
1776
+ print(body)
1777
+ print(f" {note}", file=sys.stderr)
1778
+ return 0
1779
+
1780
+
1781
+ def _format_calibration(report: dict, metric: str) -> str:
1782
+ lines = [f"Judge calibration for '{metric}' vs COMPASS deterministic truth "
1783
+ f"({report['cases_scored']} case(s), tolerance +/-{report['tolerance']}):", ""]
1784
+ rows = sorted(report["judges"].items(),
1785
+ key=lambda kv: (kv[1]["avg_error"] is None, kv[1]["avg_error"] or 0))
1786
+ lines.append(f" {'judge':<18} {'n':>4} {'error':>8} {'bias':>8} {'acc':>7} {'weight':>8}")
1787
+ for name, v in rows:
1788
+ lean = "" if v["bias"] is None else (" (lenient)" if v["bias"] > 0.03 else
1789
+ " (strict)" if v["bias"] < -0.03 else "")
1790
+ lines.append(f" {name:<18} {v['n']:>4} {v['avg_error']:>8.3f} {v['bias']:>+8.3f} "
1791
+ f"{v['accuracy']:>6.0%} {v['recommended_weight']:>8.3f}{lean}")
1792
+ if report.get("most_accurate"):
1793
+ lines += ["", f" Most accurate: {report['most_accurate']}. Recommended weights are "
1794
+ "inverse-error (a more accurate judge is trusted more than equal weighting)."]
1795
+ lines += ["", " " + report["note"]]
1796
+ return "\n".join(lines)
1797
+
1798
+
1799
+ def cmd_calibrate_judges(argv: list[str]) -> int:
1800
+ p = argparse.ArgumentParser(
1801
+ prog="compass calibrate-judges",
1802
+ description="Measure how accurate each LLM judge is against COMPASS's deterministic "
1803
+ "grounding truth: per-judge error, bias, accuracy, and an inverse-error "
1804
+ "recommended weight. Input cases must carry both COMPASS truth (run "
1805
+ "'compass enrich' first) and the eval's per-judge scores. Schema-agnostic.")
1806
+ p.add_argument("--input", required=True, metavar="FILE")
1807
+ p.add_argument("--metric", required=True,
1808
+ help="Judge metric to calibrate (e.g. 'groundedness'); matched case-insensitively")
1809
+ p.add_argument("--truth-path", default="compass.grounding.citation_grounding_rate",
1810
+ help="Dotted path to COMPASS truth per case (default: the 'enrich' output field)")
1811
+ p.add_argument("--scores-path", default="",
1812
+ help="Dotted path to the per-case scores block (default: auto-discover)")
1813
+ p.add_argument("--tolerance", type=float, default=0.2,
1814
+ help="A judge score within this of the truth counts as accurate (default 0.2)")
1815
+ p.add_argument("--json", action="store_true")
1816
+ args = p.parse_args(argv)
1817
+
1818
+ import json as _json
1819
+
1820
+ from compass.eval_enrich import calibrate_judges, discover_cases, extract_judge_scores
1821
+ from compass.eval_interop import get_path
1822
+
1823
+ try:
1824
+ with open(args.input, encoding="utf-8") as f:
1825
+ data = _json.load(f)
1826
+ except (OSError, ValueError) as exc:
1827
+ print(f" Cannot read {args.input}: {exc}", file=sys.stderr)
1828
+ return 2
1829
+ cases = discover_cases(data)
1830
+ if not cases:
1831
+ print(f" No case array found in {args.input}.", file=sys.stderr)
1832
+ return 2
1833
+
1834
+ pairs, truth_found, judges_found = [], 0, 0
1835
+ for c in cases:
1836
+ truth = get_path(c, args.truth_path)
1837
+ judges = extract_judge_scores(c, args.metric, args.scores_path or None)
1838
+ if isinstance(truth, (int, float)) and not isinstance(truth, bool):
1839
+ truth_found += 1
1840
+ if judges:
1841
+ judges_found += 1
1842
+ if isinstance(truth, (int, float)) and not isinstance(truth, bool) and judges:
1843
+ pairs.append({"truth": float(truth), "judges": judges})
1844
+ if not pairs:
1845
+ print(f" No comparable cases. COMPASS truth found in {truth_found}/{len(cases)} case(s) "
1846
+ f"(path '{args.truth_path}'); judge scores for metric '{args.metric}' in "
1847
+ f"{judges_found}/{len(cases)}. Run 'compass enrich' first, and check --metric, "
1848
+ f"--scores-path, and --truth-path.", file=sys.stderr)
1849
+ return 2
1850
+ report = calibrate_judges(pairs, tolerance=args.tolerance)
1851
+ print(_json.dumps(report, indent=2, ensure_ascii=False) if args.json
1852
+ else _format_calibration(report, args.metric))
1853
+ return 0
1854
+
1855
+
1704
1856
  _HELP = """compass - knowledge-base readiness validation
1705
1857
 
1706
1858
  Usage: compass <command> [options]
@@ -1716,6 +1868,8 @@ Commands:
1716
1868
  freshness flag stale sources + orphan documents (source object gone)
1717
1869
  evidence produce live, reproducible PROOF of flagged issues (chunks + queries) - no LLM
1718
1870
  grounding-audit check an eval's grounding finding against the KB (count claims) - no LLM
1871
+ enrich add deterministic KB signals (grounding rate, chunk quality) to an eval file
1872
+ calibrate-judges measure each LLM judge's accuracy/bias vs COMPASS's deterministic truth
1719
1873
  demo generate a sample report from synthetic data (no backend needed)
1720
1874
  init [name] scaffold a project (config + .env.example + marker). -i/--interactive wizard; --graph graph block; --ci workflow
1721
1875
  lint statically check a project config for silent-failure traps (no backend)
@@ -1764,6 +1918,10 @@ def main(argv: list[str] | None = None) -> int:
1764
1918
  return cmd_evidence(rest)
1765
1919
  if cmd == "grounding-audit":
1766
1920
  return cmd_grounding_audit(rest)
1921
+ if cmd == "enrich":
1922
+ return cmd_enrich(rest)
1923
+ if cmd == "calibrate-judges":
1924
+ return cmd_calibrate_judges(rest)
1767
1925
  if cmd == "freshness":
1768
1926
  return cmd_freshness(rest)
1769
1927
  if cmd == "selftest":
@@ -0,0 +1,305 @@
1
+ """Deterministic eval enrichment + judge calibration - the "ground-truth oracle".
2
+
3
+ COMPASS emits deterministic, KB-derived signals for eval cases, and measures how accurate an
4
+ eval's own (LLM-judge) scores are against that deterministic truth. This is the read-only query
5
+ surface applied to an eval pipeline: it imports nothing from the validation pipeline, never calls
6
+ an LLM, and never changes a validation pass/fail - additive, exactly like ``ask`` / ``compare``.
7
+
8
+ HONEST SCOPE. A fully deterministic groundedness score over *every* claim is not possible without
9
+ an LLM (general prose-claim extraction needs one). COMPASS scores the VERIFIABLE SUBSET only -
10
+ citation-bearing and numeric/aggregation claims - which happens to be exactly where LLM judges
11
+ disagree most, so it is the sharp edge, not a limitation to hide. The signal is therefore named a
12
+ *citation grounding rate*, never "Groundedness", so nothing implies full-claim LLM parity.
13
+
14
+ Two capabilities:
15
+ * ``enrich_cases`` - per-case deterministic signals (citation grounding rate + fidelity, chunk
16
+ quality, context utilization) plus COMPASS_* signal codes.
17
+ * ``calibrate_judges`` - given each case's deterministic truth and the eval's per-judge scores,
18
+ report each judge's error, bias, accuracy, and an evidence-based recommended weight. This is
19
+ the part no eval framework can produce without KB access.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Optional
25
+
26
+ from compass.eval_interop import get_path
27
+
28
+ # Deterministic signal codes COMPASS owns. KB-derived only - no answer-quality / guardrail / SQL.
29
+ GROUNDED, PARTIAL, UNGROUNDED = "COMPASS_GROUNDED", "COMPASS_PARTIAL_GROUND", "COMPASS_UNGROUNDED"
30
+ _FAULT_CODES = {
31
+ "fabricated_source": "COMPASS_FABRICATION",
32
+ "retrieval": "COMPASS_RETRIEVAL_GAP",
33
+ "generation": "COMPASS_GENERATION_HALLUCINATION",
34
+ "citation_error": "COMPASS_CITATION_ERROR",
35
+ }
36
+ # Thresholds (deterministic, overridable by the caller).
37
+ DEFAULTS = {
38
+ "grounded_at": 0.8, # citation grounding rate at/above this -> GROUNDED
39
+ "partial_at": 0.5, # between partial_at and grounded_at -> PARTIAL_GROUND
40
+ "context_waste_below_pct": 20.0,
41
+ "chunk_quality_floor": 0.5,
42
+ "uncited_numeric_max": 3,
43
+ }
44
+
45
+
46
+ def _citation_grounding_rate(summary: dict) -> Optional[float]:
47
+ """Sound / checkable over the VERIFIABLE subset. None when nothing was checkable (so it is
48
+ never confused with a real 0.0 - "no verifiable claims" is not "ungrounded")."""
49
+ checkable = summary.get("checkable", 0) or 0
50
+ if not checkable:
51
+ return None
52
+ return round((summary.get("sound", 0) or 0) / checkable, 4)
53
+
54
+
55
+ def _case_chunk_quality(record: dict, cfg: Any, min_chars: int) -> Optional[dict]:
56
+ """Score the chunks THIS case was given, deterministically. None if no chunks are present."""
57
+ from compass.corpus_health import _chunk_quality
58
+
59
+ chunks = get_path(record, getattr(cfg, "gt_chunks_path", "context.chunks"))
60
+ if not isinstance(chunks, list) or not chunks:
61
+ for alt in ("context.chunks", "chunks"):
62
+ v = get_path(record, alt)
63
+ if isinstance(v, list) and v:
64
+ chunks = v
65
+ break
66
+ if not isinstance(chunks, list) or not chunks:
67
+ return None
68
+ text_field = getattr(cfg, "gt_chunk_text_field", "text") or "text"
69
+ scores, not_self_contained = [], 0
70
+ for c in chunks:
71
+ text = (c.get(text_field) or c.get("text") or "") if isinstance(c, dict) else ""
72
+ q = _chunk_quality(text, min_chars, boilerplate_lines=set())
73
+ scores.append(q["score"])
74
+ if not q["self_contained"]:
75
+ not_self_contained += 1
76
+ if not scores:
77
+ return None
78
+ return {
79
+ "mean_score": round(sum(scores) / len(scores), 3),
80
+ "min_score": round(min(scores), 3),
81
+ "not_self_contained_pct": round(100.0 * not_self_contained / len(scores), 1),
82
+ "chunks_scored": len(scores),
83
+ }
84
+
85
+
86
+ def _derive_signals(grounding: dict, chunk_quality: Optional[dict], thr: dict) -> list[dict]:
87
+ """Turn the deterministic measurements into COMPASS_* signal codes. Every code is
88
+ check-backed; there is no heuristic guessing here."""
89
+ signals: list[dict] = []
90
+ rate = grounding.get("citation_grounding_rate")
91
+ if rate is not None:
92
+ if rate >= thr["grounded_at"]:
93
+ code, sev = GROUNDED, "INFO"
94
+ elif rate >= thr["partial_at"]:
95
+ code, sev = PARTIAL, "MEDIUM"
96
+ else:
97
+ code, sev = UNGROUNDED, "HIGH"
98
+ signals.append({"code": code, "severity": sev,
99
+ "detail": f"citation grounding rate {rate:.0%} over "
100
+ f"{grounding.get('checkable', 0)} verifiable claim(s)"})
101
+ for fault, n in (grounding.get("faults") or {}).items():
102
+ code = _FAULT_CODES.get(fault)
103
+ if code and n:
104
+ signals.append({"code": code, "severity": "HIGH", "detail": f"{n} {fault} fault(s)"})
105
+ util = grounding.get("context_utilization_pct")
106
+ if util is not None and util < thr["context_waste_below_pct"]:
107
+ signals.append({"code": "COMPASS_CONTEXT_WASTE", "severity": "LOW",
108
+ "detail": f"only {util:.0f}% of retrieved chunks were cited"})
109
+ if chunk_quality and chunk_quality["min_score"] < thr["chunk_quality_floor"]:
110
+ signals.append({"code": "COMPASS_CHUNK_QUALITY", "severity": "LOW",
111
+ "detail": f"worst chunk score {chunk_quality['min_score']:.2f}, "
112
+ f"{chunk_quality['not_self_contained_pct']:.0f}% not self-contained"})
113
+ if (grounding.get("uncited_numeric_claims") or 0) > thr["uncited_numeric_max"]:
114
+ signals.append({"code": "COMPASS_UNCITED_NUMERIC", "severity": "MEDIUM",
115
+ "detail": f"{grounding['uncited_numeric_claims']} numeric claim(s) carry no citation"})
116
+ return signals
117
+
118
+
119
+ def enrich_case(record: dict, cfg: Any, adapter: Any = None, *,
120
+ min_chars: int = 50, thresholds: Optional[dict] = None) -> dict:
121
+ """Deterministic ``compass`` block for one eval record.
122
+
123
+ ``adapter`` optional: with a reachable KB the citation audit also runs Level 2 (KB-verified);
124
+ without it, Level 1 (against the case's own chunks) still produces a grounding rate. No LLM.
125
+ """
126
+ from compass.grounding import run_audit
127
+
128
+ thr = {**DEFAULTS, **(thresholds or {})}
129
+ res = run_audit(record, adapter, cfg)
130
+ summary = res.citations.get("summary", {}) if isinstance(res.citations, dict) else {}
131
+ grounding = {
132
+ "citation_grounding_rate": _citation_grounding_rate(summary),
133
+ "total_citations": summary.get("total_citations", 0),
134
+ "checkable": summary.get("checkable", 0),
135
+ "sound": summary.get("sound", 0),
136
+ "faults": summary.get("faults", {}),
137
+ "context_utilization_pct": summary.get("context_utilization_pct"),
138
+ "uncited_numeric_claims": summary.get("uncited_numeric_claims", 0),
139
+ "context_empty": res.context_empty,
140
+ "levels": summary.get("levels", "none"),
141
+ }
142
+ if summary.get("diagnostic"):
143
+ grounding["diagnostic"] = summary["diagnostic"]
144
+ chunk_quality = _case_chunk_quality(record, cfg, min_chars)
145
+ block: dict = {"grounding": grounding, "method": "compass_deterministic"}
146
+ if chunk_quality:
147
+ block["chunk_quality"] = chunk_quality
148
+ block["signals"] = _derive_signals(grounding, chunk_quality, thr)
149
+ return block
150
+
151
+
152
+ def enrich_cases(records: list[dict], cfg: Any, adapter: Any = None, *,
153
+ min_chars: int = 50, thresholds: Optional[dict] = None) -> tuple[list[dict], dict]:
154
+ """Enrich every record (adds ``record["compass"]``) and return (records, aggregate summary)."""
155
+ rates: list[float] = []
156
+ code_counts: dict[str, int] = {}
157
+ enriched = 0
158
+ for rec in records:
159
+ if not isinstance(rec, dict):
160
+ continue
161
+ block = enrich_case(rec, cfg, adapter, min_chars=min_chars, thresholds=thresholds)
162
+ rec["compass"] = block
163
+ enriched += 1
164
+ r = block["grounding"].get("citation_grounding_rate")
165
+ if r is not None:
166
+ rates.append(r)
167
+ for s in block["signals"]:
168
+ code_counts[s["code"]] = code_counts.get(s["code"], 0) + 1
169
+ summary = {
170
+ "cases_enriched": enriched,
171
+ "cases_with_verifiable_claims": len(rates),
172
+ "mean_citation_grounding_rate": round(sum(rates) / len(rates), 4) if rates else None,
173
+ "signal_breakdown": dict(sorted(code_counts.items(), key=lambda kv: (-kv[1], kv[0]))),
174
+ "note": "Deterministic KB-derived signals over the verifiable (citation + numeric) subset. "
175
+ "Not an answer-quality judgement.",
176
+ }
177
+ return records, summary
178
+
179
+
180
+ def discover_cases(data: Any) -> list[dict]:
181
+ """Find the array of case objects in an arbitrary eval file - COMPASS assumes no fixed shape.
182
+
183
+ Accepts a top-level list, or a dict carrying the cases under a common key
184
+ (cases/results/records/evaluations/data/items), or - failing that - the largest list of
185
+ objects anywhere at the top level. Returns [] if none is found.
186
+ """
187
+ if isinstance(data, list):
188
+ return [c for c in data if isinstance(c, dict)]
189
+ if isinstance(data, dict):
190
+ for key in ("cases", "results", "records", "evaluations", "data", "items"):
191
+ v = data.get(key)
192
+ if isinstance(v, list) and v and isinstance(v[0], dict):
193
+ return v
194
+ best: list[dict] = []
195
+ for v in data.values():
196
+ if isinstance(v, list) and v and isinstance(v[0], dict) and len(v) > len(best):
197
+ best = v
198
+ return best
199
+ return []
200
+
201
+
202
+ def _numbers_from(obj: Any) -> dict[str, float]:
203
+ """Pull judge_name -> score out of whatever sub-shape a metric block uses: a dict of
204
+ name->number, a dict with a 'judges'/'scores' sub-map, or a list of {judge/model/name, score}."""
205
+ out: dict[str, float] = {}
206
+ if isinstance(obj, dict):
207
+ inner = obj.get("judges") or obj.get("scores") or obj.get("raters")
208
+ if isinstance(inner, (dict, list)):
209
+ return _numbers_from(inner)
210
+ for k, v in obj.items():
211
+ if isinstance(v, (int, float)) and not isinstance(v, bool) and k.lower() not in (
212
+ "score", "spread", "mean", "avg", "average", "std", "count", "n"):
213
+ out[str(k)] = float(v)
214
+ elif isinstance(obj, list):
215
+ for i, item in enumerate(obj):
216
+ if isinstance(item, dict):
217
+ name = str(item.get("judge") or item.get("model") or item.get("name") or f"judge_{i}")
218
+ score = item.get("score")
219
+ if isinstance(score, (int, float)) and not isinstance(score, bool):
220
+ out[name] = float(score)
221
+ return out
222
+
223
+
224
+ def extract_judge_scores(case: dict, metric: str, scores_path: Optional[str] = None) -> dict[str, float]:
225
+ """Best-effort, schema-agnostic extraction of per-judge scores for ``metric`` from one case.
226
+
227
+ ``scores_path`` (optional) points at the scores block; otherwise common locations
228
+ (scores/metrics/evaluations) are tried. The metric is matched case-insensitively (spaces and
229
+ underscores treated alike). Returns {judge_name: score}; empty if nothing matched.
230
+ """
231
+ blocks = []
232
+ if scores_path:
233
+ blocks.append(get_path(case, scores_path))
234
+ else:
235
+ for key in ("scores", "metrics", "evaluations", "judges"):
236
+ if key in case:
237
+ blocks.append(case.get(key))
238
+ want = metric.lower().replace(" ", "_")
239
+ for block in blocks:
240
+ if not isinstance(block, dict):
241
+ continue
242
+ for name, detail in block.items():
243
+ if name.lower().replace(" ", "_") == want:
244
+ nums = _numbers_from(detail)
245
+ if nums:
246
+ return nums
247
+ return {}
248
+
249
+
250
+ def calibrate_judges(cases: list[dict], *, tolerance: float = 0.2) -> dict:
251
+ """Measure each LLM judge against COMPASS's deterministic truth - the oracle capability.
252
+
253
+ ``cases``: ``[{"truth": float 0..1, "judges": {judge_name: score 0..1}}]`` where ``truth`` is
254
+ COMPASS's deterministic grounding rate for that case. For each judge, over the cases where both
255
+ a truth and that judge's score exist, report:
256
+ * ``avg_error`` - mean absolute error vs the deterministic truth
257
+ * ``bias`` - mean signed error (+ = lenient / scores high, - = strict / scores low)
258
+ * ``accuracy`` - share of scores within ``tolerance`` of the truth
259
+ * ``recommended_weight`` - inverse-error weight (normalised across judges), so a more
260
+ accurate judge is trusted more than an equal-weight consensus would.
261
+ Deterministic and reproducible. No eval framework can compute this - it needs the KB truth.
262
+ """
263
+ stats: dict[str, dict] = {}
264
+ scored = 0
265
+ for c in cases:
266
+ truth = c.get("truth")
267
+ judges = c.get("judges") or {}
268
+ if not isinstance(truth, (int, float)) or not judges:
269
+ continue
270
+ scored += 1
271
+ for name, score in judges.items():
272
+ if not isinstance(score, (int, float)):
273
+ continue
274
+ s = stats.setdefault(name, {"abs": 0.0, "signed": 0.0, "hits": 0, "n": 0})
275
+ err = score - truth
276
+ s["abs"] += abs(err)
277
+ s["signed"] += err
278
+ s["hits"] += 1 if abs(err) <= tolerance else 0
279
+ s["n"] += 1
280
+
281
+ judges_out: dict[str, dict] = {}
282
+ for name, s in stats.items():
283
+ n = s["n"]
284
+ judges_out[name] = {
285
+ "n": n,
286
+ "avg_error": round(s["abs"] / n, 4) if n else None,
287
+ "bias": round(s["signed"] / n, 4) if n else None,
288
+ "accuracy": round(s["hits"] / n, 4) if n else None,
289
+ }
290
+ # Recommended weights: inverse average error, normalised. A judge with no scored cases gets 0.
291
+ inv = {name: (1.0 / (v["avg_error"] + 1e-6)) if v["avg_error"] is not None else 0.0
292
+ for name, v in judges_out.items()}
293
+ total_inv = sum(inv.values())
294
+ for name, v in judges_out.items():
295
+ v["recommended_weight"] = round(inv[name] / total_inv, 4) if total_inv else None
296
+ ranked = sorted(judges_out, key=lambda nm: (judges_out[nm]["avg_error"] is None,
297
+ judges_out[nm]["avg_error"] if judges_out[nm]["avg_error"] is not None else 1e9))
298
+ return {
299
+ "cases_scored": scored,
300
+ "tolerance": tolerance,
301
+ "judges": judges_out,
302
+ "most_accurate": ranked[0] if ranked else None,
303
+ "note": "Judge scores measured against COMPASS's deterministic grounding truth "
304
+ "(citation + numeric subset). Recommended weights are inverse-error, normalised.",
305
+ }
@@ -422,6 +422,11 @@ def run_audit(eval_record: dict, adapter: Any, eval_cfg: Any, doc_exists: Any =
422
422
  get_path(eval_record, "chunks"))
423
423
  has_chunks = any(isinstance(c, list) and len(c) > 0 for c in _chunk_candidates)
424
424
  context_empty = _is_empty_context(context) and not has_chunks
425
+ # If the configured chunk path did not resolve but a common nested one did, use it for the
426
+ # citation audit too - otherwise a default-path mismatch silently disables Level 1
427
+ # verification (the same class of schema mismatch as the empty-retrieval false positive).
428
+ if not (isinstance(eval_chunks, list) and eval_chunks):
429
+ eval_chunks = next((c for c in _chunk_candidates if isinstance(c, list) and c), eval_chunks)
425
430
 
426
431
  qtype, intents = classify_query(query, getattr(eval_cfg, "structured_query_patterns", []))
427
432
  result = GroundingResult(test_id=test_id, query=query, query_type=qtype, intents=intents,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compass-kb-validation
3
- Version: 3.2.1
3
+ Version: 3.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
@@ -147,6 +147,7 @@ Two modes: **live** validation as documents are ingested, and **post-hoc discove
147
147
  ✅ **Pluggable steps** — register custom validators in a step registry; the report adapts automatically
148
148
  ✅ **Deterministic & cheap** — structural/byte/schema checks, no LLM, CI-gateable
149
149
  ✅ **Query & analyze the KB** — ask it questions, expose it to agents over MCP, and preview embedding-model migrations / map retrieval coverage — all deterministic, read-only (see *Beyond validation*)
150
+ ✅ **Eval oracle** — enrich any eval with deterministic KB-grounding signals, then measure how accurate its LLM judges are against that ground truth (`compass enrich` / `compass calibrate-judges`) — the eval framework guesses, COMPASS checks the KB
150
151
  ✅ **Interactive reporting** — self-contained HTML dashboard + JSON + JUnit XML
151
152
  ✅ **Trend Tracking** — SQLite historical performance analysis
152
153
  ✅ **Notifications** — Slack/Teams webhooks for alerts
@@ -238,8 +239,21 @@ compass freshness --project my-kb --stale-after 30
238
239
  # chunks when present (Level 1) and the live KB (Level 2); the answer text + a reachable KB alone
239
240
  # still catches fabricated figures and phantom citations.
240
241
  compass grounding-audit --project my-kb --eval-result case.json --fail-on-fabrication
242
+
243
+ # Eval oracle (deterministic, no LLM): enrich an eval with KB-grounded signals, then measure how
244
+ # accurate the eval's OWN LLM judges are against that deterministic truth. LLM judges GUESS whether
245
+ # a response is grounded; COMPASS CHECKS it against the KB - so it can tell you which judge is right.
246
+ compass enrich --project my-kb --input eval.json --output enriched.json
247
+ compass calibrate-judges --input enriched.json --metric groundedness
241
248
  ```
242
249
 
250
+ `enrich` adds a `compass` block per case (a **citation grounding rate** over the verifiable
251
+ citation+numeric subset, citation fidelity, chunk quality, context utilization, `COMPASS_*` signal
252
+ codes). It is **schema-agnostic** — every field resolves through your `eval.gt_*` config, so it
253
+ works whatever shape your eval JSON takes. `calibrate-judges` then reports each judge's error,
254
+ bias, accuracy, and an evidence-based **recommended weight** — something no eval framework can
255
+ compute, because it needs KB access.
256
+
243
257
  `migrate` and `coverage` also render a **self-contained HTML report** (same look as the
244
258
  validation dashboard) with `--html [path] --open` — a coverage heatmap, a migration
245
259
  stability distribution, verdicts, and the most-changed / dead-zone tables.
@@ -17,6 +17,7 @@ compass/corpus.py
17
17
  compass/corpus_health.py
18
18
  compass/diagnostics.py
19
19
  compass/embedding_drift.py
20
+ compass/eval_enrich.py
20
21
  compass/eval_interop.py
21
22
  compass/evidence.py
22
23
  compass/grounding.py
@@ -86,6 +87,7 @@ tests/test_diagnostics.py
86
87
  tests/test_discover_cli.py
87
88
  tests/test_discovery_parallel.py
88
89
  tests/test_embedding_drift.py
90
+ tests/test_eval_enrich.py
89
91
  tests/test_eval_interop.py
90
92
  tests/test_evidence.py
91
93
  tests/test_gate.py
@@ -0,0 +1,109 @@
1
+ """Deterministic eval enrichment + judge calibration (the ground-truth oracle).
2
+
3
+ All synthetic and generic - no vendor/domain data, no live services. The point is the
4
+ deterministic scoring math and the schema-agnostic field/shape discovery.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from compass.config import Settings
10
+ from compass.eval_enrich import (
11
+ calibrate_judges,
12
+ discover_cases,
13
+ enrich_cases,
14
+ extract_judge_scores,
15
+ )
16
+
17
+
18
+ def _case(output, chunk_text, doc="Doc A", page=1):
19
+ return {"input": "q", "output": output,
20
+ "context": {"chunks": [{"text": chunk_text, "document_title": doc, "page_start": page}]}}
21
+
22
+
23
+ def test_enrich_scores_a_grounded_numeric_claim():
24
+ cfg = Settings().eval
25
+ cases = [_case("Revenue rose 8%. (Doc A, 2026, p. 1) End.", "Revenue rose 8% year over year.")]
26
+ enriched, summary = enrich_cases(cases, cfg) # no adapter -> Level 1, no backend
27
+ g = enriched[0]["compass"]["grounding"]
28
+ assert g["citation_grounding_rate"] == 1.0 and g["checkable"] == 1 and g["levels"] == "L1"
29
+ assert "COMPASS_GROUNDED" in [s["code"] for s in enriched[0]["compass"]["signals"]]
30
+ assert summary["mean_citation_grounding_rate"] == 1.0
31
+ assert enriched[0]["compass"]["method"] == "compass_deterministic"
32
+
33
+
34
+ def test_grounding_rate_is_none_when_nothing_is_verifiable():
35
+ # Honest subset: a purely qualitative cited claim has nothing deterministically checkable,
36
+ # so the rate is None (NOT a real 0.0 - "no verifiable claims" is not "ungrounded").
37
+ cfg = Settings().eval
38
+ cases = [_case("Growth stays robust. (Doc A, 2026, p. 1) Good.", "Growth stays robust next year.")]
39
+ enriched, _ = enrich_cases(cases, cfg)
40
+ g = enriched[0]["compass"]["grounding"]
41
+ assert g["citation_grounding_rate"] is None and g["checkable"] == 0
42
+ assert g["total_citations"] == 1 # the citation was still extracted
43
+
44
+
45
+ def test_enrich_is_schema_agnostic_via_config_paths():
46
+ # A completely different eval shape resolves purely from eval.gt_* config - nothing hardcoded.
47
+ cfg = Settings().eval
48
+ cfg.gt_response_path = "answer"
49
+ cfg.gt_chunks_path = "retrieval.docs"
50
+ cfg.gt_chunk_text_field = "chunk_text"
51
+ cfg.gt_chunk_doc_field = "src"
52
+ cfg.gt_chunk_page_field = "pg"
53
+ rec = {"query": "q", "answer": "Margins hit 21%. (D, 2026, p. 2) End.",
54
+ "retrieval": {"docs": [{"chunk_text": "Margins hit 21% in the period.", "src": "D", "pg": 2}]}}
55
+ enriched, _ = enrich_cases([rec], cfg)
56
+ assert enriched[0]["compass"]["grounding"]["citation_grounding_rate"] == 1.0
57
+
58
+
59
+ def test_calibrate_judges_ranks_by_error_and_weights_inversely():
60
+ # judge_a is close to the deterministic truth; judge_b is systematically low. The more
61
+ # accurate judge must rank first and earn the larger recommended weight.
62
+ cases = [
63
+ {"truth": 0.9, "judges": {"judge_a": 0.85, "judge_b": 0.55}},
64
+ {"truth": 0.8, "judges": {"judge_a": 0.82, "judge_b": 0.50}},
65
+ {"truth": 1.0, "judges": {"judge_a": 0.95, "judge_b": 0.60}},
66
+ ]
67
+ r = calibrate_judges(cases, tolerance=0.2)
68
+ assert r["cases_scored"] == 3 and r["most_accurate"] == "judge_a"
69
+ a, b = r["judges"]["judge_a"], r["judges"]["judge_b"]
70
+ assert a["avg_error"] < b["avg_error"]
71
+ assert a["recommended_weight"] > b["recommended_weight"]
72
+ assert a["accuracy"] == 1.0 and b["accuracy"] == 0.0
73
+ assert b["bias"] < 0 # judge_b scores low (strict)
74
+ # weights are a normalised distribution
75
+ assert abs(a["recommended_weight"] + b["recommended_weight"] - 1.0) < 1e-6
76
+
77
+
78
+ def test_calibrate_ignores_cases_missing_truth_or_judges():
79
+ cases = [
80
+ {"truth": 0.9, "judges": {"j": 0.9}},
81
+ {"truth": None, "judges": {"j": 0.5}}, # no truth -> skipped
82
+ {"judges": {"j": 0.5}}, # no truth key -> skipped
83
+ {"truth": 0.8}, # no judges -> skipped
84
+ ]
85
+ r = calibrate_judges(cases)
86
+ assert r["cases_scored"] == 1 and r["judges"]["j"]["n"] == 1
87
+
88
+
89
+ def test_discover_cases_finds_the_array_in_varied_shapes():
90
+ assert len(discover_cases([{"a": 1}, {"a": 2}])) == 2 # top-level list
91
+ assert len(discover_cases({"cases": [{"a": 1}]})) == 1 # common key
92
+ assert len(discover_cases({"results": [{"a": 1}, {"a": 2}]})) == 2
93
+ # largest list of objects wins when no known key
94
+ assert len(discover_cases({"meta": {}, "rows": [{"a": 1}, {"a": 2}, {"a": 3}]})) == 3
95
+ assert discover_cases({"x": 1}) == []
96
+
97
+
98
+ def test_extract_judge_scores_handles_varied_shapes():
99
+ # dict of name->number nested under 'judges'
100
+ c1 = {"scores": {"Groundedness": {"judges": {"j1": 0.8, "j2": 0.6}}}}
101
+ assert extract_judge_scores(c1, "groundedness") == {"j1": 0.8, "j2": 0.6}
102
+ # list of {judge, score}
103
+ c2 = {"metrics": {"groundedness": [{"judge": "j1", "score": 0.7}, {"model": "j2", "score": 0.5}]}}
104
+ assert extract_judge_scores(c2, "Groundedness") == {"j1": 0.7, "j2": 0.5}
105
+ # case/space-insensitive metric match; aggregate keys (score/spread) are not judges
106
+ c3 = {"scores": {"answer_relevance": {"judge_x": 0.9, "score": 0.9, "spread": 0.1}}}
107
+ assert extract_judge_scores(c3, "Answer Relevance") == {"judge_x": 0.9}
108
+ # metric absent -> empty
109
+ assert extract_judge_scores({"scores": {"other": {"j": 0.5}}}, "groundedness") == {}