compass-kb-validation 3.3.0__tar.gz → 3.4.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.3.0 → compass_kb_validation-3.4.0}/PKG-INFO +1 -1
  2. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/__init__.py +1 -1
  3. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/citation.py +50 -1
  4. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/eval_enrich.py +86 -11
  5. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass_kb_validation.egg-info/PKG-INFO +1 -1
  6. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_citation.py +29 -0
  7. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_eval_enrich.py +26 -0
  8. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/LICENSE +0 -0
  9. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/README.md +0 -0
  10. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/__main__.py +0 -0
  11. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/ask.py +0 -0
  12. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/baseline.py +0 -0
  13. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/chunk_coherence.py +0 -0
  14. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/cli.py +0 -0
  15. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/config/__init__.py +0 -0
  16. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/config_lint.py +0 -0
  17. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/content_inspect.py +0 -0
  18. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/corpus.py +0 -0
  19. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/corpus_health.py +0 -0
  20. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/diagnostics.py +0 -0
  21. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/embedding_drift.py +0 -0
  22. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/eval_interop.py +0 -0
  23. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/evidence.py +0 -0
  24. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/grounding.py +0 -0
  25. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/init_wizard.py +0 -0
  26. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/live_dashboard.py +0 -0
  27. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/mcp_server.py +0 -0
  28. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/models/__init__.py +0 -0
  29. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/notifications.py +0 -0
  30. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/pipeline/__init__.py +0 -0
  31. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/profiler.py +0 -0
  32. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/provenance.py +0 -0
  33. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/providers/__init__.py +0 -0
  34. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/providers/embedding.py +0 -0
  35. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/providers/graph.py +0 -0
  36. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/providers/source.py +0 -0
  37. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/providers/storage.py +0 -0
  38. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/providers/vectorstore.py +0 -0
  39. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/remediation.py +0 -0
  40. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/report/__init__.py +0 -0
  41. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/report/_report_css.py +0 -0
  42. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/report/_report_js.py +0 -0
  43. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/report/analysis.py +0 -0
  44. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/report/audit.py +0 -0
  45. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/report/export.py +0 -0
  46. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/report/shell.py +0 -0
  47. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/retrieval_analysis.py +0 -0
  48. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/text_probes.py +0 -0
  49. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/trend_tracker.py +0 -0
  50. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/utils/__init__.py +0 -0
  51. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/validators/__init__.py +0 -0
  52. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/validators/api_upload.py +0 -0
  53. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/validators/base.py +0 -0
  54. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/validators/base_sparql.py +0 -0
  55. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/validators/cross_stage.py +0 -0
  56. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/validators/graphdb_metadata.py +0 -0
  57. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/validators/neptune_metadata.py +0 -0
  58. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/validators/redis_chunks.py +0 -0
  59. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/validators/registry.py +0 -0
  60. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/validators/retrieval_quality.py +0 -0
  61. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass/validators/s3_storage.py +0 -0
  62. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass_kb_validation.egg-info/SOURCES.txt +0 -0
  63. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass_kb_validation.egg-info/dependency_links.txt +0 -0
  64. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass_kb_validation.egg-info/entry_points.txt +0 -0
  65. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass_kb_validation.egg-info/requires.txt +0 -0
  66. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/compass_kb_validation.egg-info/top_level.txt +0 -0
  67. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/discover_and_validate.py +0 -0
  68. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/ingestion_validation/__init__.py +0 -0
  69. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/profile_kb.py +0 -0
  70. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/pyproject.toml +0 -0
  71. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/run_validation.py +0 -0
  72. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/setup.cfg +0 -0
  73. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_alt_adapters.py +0 -0
  74. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_analysis_report.py +0 -0
  75. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_api_upload.py +0 -0
  76. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_ask.py +0 -0
  77. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_baseline.py +0 -0
  78. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_benchmark.py +0 -0
  79. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_check_catalog.py +0 -0
  80. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_cli.py +0 -0
  81. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_compat_shim.py +0 -0
  82. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_config.py +0 -0
  83. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_config_lint.py +0 -0
  84. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_content_inspect.py +0 -0
  85. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_corpus.py +0 -0
  86. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_corpus_health.py +0 -0
  87. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_coverage.py +0 -0
  88. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_cross_stage.py +0 -0
  89. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_diagnostics.py +0 -0
  90. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_discover_cli.py +0 -0
  91. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_discovery_parallel.py +0 -0
  92. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_embedding_drift.py +0 -0
  93. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_eval_interop.py +0 -0
  94. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_evidence.py +0 -0
  95. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_gate.py +0 -0
  96. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_graph_transport.py +0 -0
  97. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_graph_validators.py +0 -0
  98. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_grounding.py +0 -0
  99. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_init_wizard.py +0 -0
  100. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_manifest.py +0 -0
  101. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_mcp.py +0 -0
  102. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_migration.py +0 -0
  103. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_models.py +0 -0
  104. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_neptune_bugfixes.py +0 -0
  105. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_neptune_bugfixes2.py +0 -0
  106. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_notifications.py +0 -0
  107. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_pipeline.py +0 -0
  108. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_profiler.py +0 -0
  109. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_providers.py +0 -0
  110. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_redis_chunks.py +0 -0
  111. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_registry.py +0 -0
  112. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_remediation.py +0 -0
  113. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_reporting.py +0 -0
  114. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_retrieval_assertions.py +0 -0
  115. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_retrieval_quality.py +0 -0
  116. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_review_hardening.py +0 -0
  117. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_run_cli.py +0 -0
  118. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_sources.py +0 -0
  119. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_storage_validator.py +0 -0
  120. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_trends.py +0 -0
  121. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_universal_concerns.py +0 -0
  122. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.0}/tests/test_utils.py +0 -0
  123. {compass_kb_validation-3.3.0 → compass_kb_validation-3.4.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.3.0
3
+ Version: 3.4.0
4
4
  Summary: Knowledge-base readiness validation for RAG & AI agents — config-driven, deterministic pipeline integrity checks.
5
5
  Author: COMPASS
6
6
  License: MIT
@@ -5,4 +5,4 @@ end-to-end: API upload -> object storage -> knowledge graph -> vector store ->
5
5
  retrieval quality, plus cross-store consistency.
6
6
  """
7
7
 
8
- __version__ = "3.3.0"
8
+ __version__ = "3.4.0"
@@ -377,7 +377,19 @@ def verify_level1(citations: list[Citation], eval_chunks: list[dict], cfg: Any)
377
377
  v.terms_missing.append(cv.raw)
378
378
  v._missing.append(cv)
379
379
  if not cit.values:
380
- v.l1_verdict, v.severity = "UNVERIFIABLE", "INFO" # no quantitative claim to check
380
+ # No numeric claim. If the claim carries distinctive entities (proper nouns / quoted
381
+ # phrases) and they are ALL present in the cited chunk, it is TERM_SUPPORTED - a
382
+ # weaker, lexical tier counted in a SEPARATE term-grounding rate, never the strict
383
+ # numeric one. Missing entities stay UNVERIFIABLE (lexical absence can be paraphrase).
384
+ ents = [] if cit.negated else _extract_entities(cit.claim)
385
+ if ents:
386
+ hay = _squash(_haystack(chunk, text_field))
387
+ v.entity_missing = [e for e in ents if _squash(e) not in hay]
388
+ v.entities_checked = True
389
+ v.l1_verdict, v.severity = (("TERM_SUPPORTED", "INFO") if not v.entity_missing
390
+ else ("UNVERIFIABLE", "INFO"))
391
+ else:
392
+ v.l1_verdict, v.severity = "UNVERIFIABLE", "INFO" # no quantitative claim to check
381
393
  if cit.negated:
382
394
  v.note = "claim is negated - lexical verification not applicable"
383
395
  elif not v.terms_missing:
@@ -539,6 +551,7 @@ _ATTRIB = {
539
551
  "citation_error": ("citation_error", "MEDIUM"), # right fact, wrong cited location
540
552
  "fabricated_source": ("fabricated_source", "HIGH"),
541
553
  "none": ("none", "INFO"),
554
+ "term_supported": ("term_supported", "INFO"), # distinctive entities present in cited chunk
542
555
  "unverifiable": ("unverifiable", "INFO"), # no quantitative term; lexical check N/A
543
556
  "unknown": ("unknown", "MEDIUM"), # a miss we could not attribute (no live KB)
544
557
  }
@@ -558,6 +571,8 @@ def attribute(verdicts: list[CitationVerdict], l1_ran: bool) -> None:
558
571
  key = "none"
559
572
  elif l2 == "FABRICATED_SOURCE":
560
573
  key = "fabricated_source" # cited a document that isn't in the KB
574
+ elif l1 == "TERM_SUPPORTED":
575
+ key = "term_supported" # distinctive entities present; weaker than numeric
561
576
  elif l2 in _L2_UNVERIFIABLE:
562
577
  key = "unverifiable" # source unresolved / too large / no numeric claim
563
578
  elif l1 == "UNVERIFIABLE" and not l2:
@@ -650,6 +665,7 @@ def audit_citations(response: str, eval_chunks: Optional[list[dict]], adapter: A
650
665
  summary = summarize(verdicts, response, l1_ran, l2_ran)
651
666
  if l1_ran:
652
667
  summary.update(context_utilization(citations, eval_chunks or [], cfg))
668
+ summary.update(text_utilization(response, eval_chunks or [], cfg))
653
669
  # DEFECT-003 safety net: if the response clearly carries citation-like patterns but none
654
670
  # were extracted, say so rather than reporting a silent total_citations=0 that reads as
655
671
  # "no citations exist" (which was the DEFECT-001 failure mode).
@@ -680,6 +696,31 @@ def context_utilization(citations: list[Citation], eval_chunks: list[dict], cfg:
680
696
  "context_utilization_pct": round(100.0 * used / total, 1)}
681
697
 
682
698
 
699
+ def _ngrams(text: str, n: int = 3) -> set:
700
+ toks = re.findall(r"[a-z0-9]+", (text or "").lower())
701
+ return {tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)} if len(toks) >= n else set()
702
+
703
+
704
+ def text_utilization(response: str, eval_chunks: list[dict], cfg: Any,
705
+ n: int = 3, min_overlap: float = 0.1) -> dict:
706
+ """How much of the retrieved CONTENT actually appears in the response, by n-gram overlap -
707
+ distinct from ``context_utilization_pct`` (which measures whether a chunk's PAGE was cited).
708
+ A chunk counts as used when at least ``min_overlap`` of its n-grams recur in the response."""
709
+ text_field = getattr(cfg, "gt_chunk_text_field", "text")
710
+ total = len(eval_chunks or [])
711
+ if not total:
712
+ return {}
713
+ resp = _ngrams(response, n)
714
+ if not resp:
715
+ return {"text_utilization_pct": 0.0}
716
+ used = 0
717
+ for ch in eval_chunks:
718
+ cg = _ngrams(_haystack(ch, text_field), n)
719
+ if cg and len(cg & resp) / len(cg) >= min_overlap:
720
+ used += 1
721
+ return {"text_utilization_pct": round(100.0 * used / total, 1)}
722
+
723
+
683
724
  def summarize(verdicts: list[CitationVerdict], response: str, l1_ran: bool, l2_ran: bool) -> dict:
684
725
  """Aggregate signal for the audit: how many citations are sound, and the fault mix."""
685
726
  total = len(verdicts)
@@ -693,6 +734,11 @@ def summarize(verdicts: list[CitationVerdict], response: str, l1_ran: bool, l2_r
693
734
  checkable = sound + fault_total
694
735
  # Accuracy is over CHECKABLE citations only; unverifiable/unknown are excluded, not scored.
695
736
  accuracy = round(100.0 * sound / checkable, 1) if checkable else None
737
+ # Term grounding: a SEPARATE, weaker tier for qualitative citations whose distinctive entities
738
+ # are present in the cited chunk. Never folded into the strict numeric rate above.
739
+ term_supported = sum(1 for v in verdicts if v.attribution == "term_supported")
740
+ term_checkable = sum(1 for v in verdicts if v.entities_checked)
741
+ term_rate = round(100.0 * term_supported / term_checkable, 1) if term_checkable else None
696
742
  # Citation coverage: sentences with a checkable quantitative claim that carry NO citation.
697
743
  sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", response or "") if s.strip()]
698
744
  numeric = [s for s in sentences if extract_terms(s)]
@@ -704,6 +750,9 @@ def summarize(verdicts: list[CitationVerdict], response: str, l1_ran: bool, l2_r
704
750
  "checkable": checkable,
705
751
  "unverifiable": unverifiable,
706
752
  "accuracy_pct": accuracy,
753
+ "term_supported": term_supported,
754
+ "term_checkable": term_checkable,
755
+ "term_grounding_rate_pct": term_rate,
707
756
  "faults": faults,
708
757
  "coverage_pct": coverage,
709
758
  "uncited_numeric_claims": len(uncited),
@@ -21,10 +21,34 @@ Two capabilities:
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
+ import re
24
25
  from typing import Any, Optional
25
26
 
26
27
  from compass.eval_interop import get_path
27
28
 
29
+ # Minimal stop-word set for the lexical context-support signal (deterministic, no NLP lib).
30
+ _STOP = frozenset(
31
+ "the a an and or of to in for on with at by from is are was were be been being it its this that "
32
+ "these those as but if then than so we you they he she i our your their his her not no do does did "
33
+ "have has had will would can could may might should must about into over under between out up down".split())
34
+ _WORD_RE = re.compile(r"[a-z0-9][a-z0-9\-]{2,}")
35
+
36
+
37
+ def _resolve_chunks(record: dict, cfg: Any) -> list:
38
+ """The case's retrieved chunks, via the configured path then common nested fallbacks."""
39
+ chunks = get_path(record, getattr(cfg, "gt_chunks_path", "context.chunks"))
40
+ if isinstance(chunks, list) and chunks:
41
+ return chunks
42
+ for alt in ("context.chunks", "chunks"):
43
+ v = get_path(record, alt)
44
+ if isinstance(v, list) and v:
45
+ return v
46
+ return []
47
+
48
+
49
+ def _content_terms(text: str) -> set:
50
+ return {t for t in _WORD_RE.findall((text or "").lower()) if t not in _STOP}
51
+
28
52
  # Deterministic signal codes COMPASS owns. KB-derived only - no answer-quality / guardrail / SQL.
29
53
  GROUNDED, PARTIAL, UNGROUNDED = "COMPASS_GROUNDED", "COMPASS_PARTIAL_GROUND", "COMPASS_UNGROUNDED"
30
54
  _FAULT_CODES = {
@@ -40,6 +64,7 @@ DEFAULTS = {
40
64
  "context_waste_below_pct": 20.0,
41
65
  "chunk_quality_floor": 0.5,
42
66
  "uncited_numeric_max": 3,
67
+ "context_support_floor": 0.4, # lexical (weaker) proxy floor, only used when no citations
43
68
  }
44
69
 
45
70
 
@@ -56,14 +81,8 @@ def _case_chunk_quality(record: dict, cfg: Any, min_chars: int) -> Optional[dict
56
81
  """Score the chunks THIS case was given, deterministically. None if no chunks are present."""
57
82
  from compass.corpus_health import _chunk_quality
58
83
 
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:
84
+ chunks = _resolve_chunks(record, cfg)
85
+ if not chunks:
67
86
  return None
68
87
  text_field = getattr(cfg, "gt_chunk_text_field", "text") or "text"
69
88
  scores, not_self_contained = [], 0
@@ -83,7 +102,46 @@ def _case_chunk_quality(record: dict, cfg: Any, min_chars: int) -> Optional[dict
83
102
  }
84
103
 
85
104
 
86
- def _derive_signals(grounding: dict, chunk_quality: Optional[dict], thr: dict) -> list[dict]:
105
+ def _context_support(record: dict, cfg: Any, *, min_overlap: float = 0.25) -> Optional[dict]:
106
+ """A SEPARATE, weaker lexical signal - never the citation grounding rate. The share of
107
+ response sentences whose content terms are echoed by the retrieved context chunks. It
108
+ estimates support for responses that may carry NO citations, but it is only a lexical proxy,
109
+ and is computed ONLY when the case actually has retrieved chunks (a refusal / non-retrieval
110
+ case has none, so it never fabricates a misleading 'grounding' number)."""
111
+ chunks = _resolve_chunks(record, cfg)
112
+ if not chunks:
113
+ return None
114
+ from compass.grounding import _resolve_field
115
+
116
+ r_val, _, _ = _resolve_field(record, getattr(cfg, "gt_response_path", "response"), "response")
117
+ response = str(r_val or "").strip()
118
+ if not response:
119
+ return None
120
+ text_field = getattr(cfg, "gt_chunk_text_field", "text") or "text"
121
+ ctx_terms: set = set()
122
+ for c in chunks:
123
+ if isinstance(c, dict):
124
+ ctx_terms |= _content_terms(c.get(text_field) or c.get("text") or "")
125
+ if not ctx_terms:
126
+ return None
127
+ scored = supported = 0
128
+ for sent in re.split(r"(?<=[.!?])\s+", response):
129
+ terms = _content_terms(sent)
130
+ if len(terms) < 3: # too short to judge overlap meaningfully
131
+ continue
132
+ scored += 1
133
+ if len(terms & ctx_terms) / len(terms) >= min_overlap:
134
+ supported += 1
135
+ if not scored:
136
+ return None
137
+ return {"context_support_rate": round(supported / scored, 3),
138
+ "sentences_scored": scored, "supported": supported,
139
+ "method": "lexical_term_overlap",
140
+ "note": "Weaker lexical proxy for responses without citations - NOT the citation grounding rate."}
141
+
142
+
143
+ def _derive_signals(grounding: dict, chunk_quality: Optional[dict],
144
+ context_support: Optional[dict], thr: dict) -> list[dict]:
87
145
  """Turn the deterministic measurements into COMPASS_* signal codes. Every code is
88
146
  check-backed; there is no heuristic guessing here."""
89
147
  signals: list[dict] = []
@@ -113,6 +171,14 @@ def _derive_signals(grounding: dict, chunk_quality: Optional[dict], thr: dict) -
113
171
  if (grounding.get("uncited_numeric_claims") or 0) > thr["uncited_numeric_max"]:
114
172
  signals.append({"code": "COMPASS_UNCITED_NUMERIC", "severity": "MEDIUM",
115
173
  "detail": f"{grounding['uncited_numeric_claims']} numeric claim(s) carry no citation"})
174
+ # Separate, weaker lexical signal - only when there is no strong citation grounding to rely on,
175
+ # so it complements (never overrides) the citation grounding rate.
176
+ if context_support and rate is None:
177
+ csr = context_support["context_support_rate"]
178
+ if csr < thr["context_support_floor"]:
179
+ signals.append({"code": "COMPASS_LOW_CONTEXT_SUPPORT", "severity": "LOW",
180
+ "detail": f"only {csr:.0%} of response sentences are lexically echoed "
181
+ f"by the retrieved context (weak proxy; no verifiable citations)"})
116
182
  return signals
117
183
 
118
184
 
@@ -128,13 +194,19 @@ def enrich_case(record: dict, cfg: Any, adapter: Any = None, *,
128
194
  thr = {**DEFAULTS, **(thresholds or {})}
129
195
  res = run_audit(record, adapter, cfg)
130
196
  summary = res.citations.get("summary", {}) if isinstance(res.citations, dict) else {}
197
+ tr = summary.get("term_grounding_rate_pct")
131
198
  grounding = {
132
199
  "citation_grounding_rate": _citation_grounding_rate(summary),
133
200
  "total_citations": summary.get("total_citations", 0),
134
201
  "checkable": summary.get("checkable", 0),
135
202
  "sound": summary.get("sound", 0),
203
+ # Separate, weaker tier: distinctive entities present in the cited chunk (not numeric).
204
+ "term_grounding_rate": (tr / 100.0 if tr is not None else None),
205
+ "term_supported": summary.get("term_supported", 0),
206
+ "term_checkable": summary.get("term_checkable", 0),
136
207
  "faults": summary.get("faults", {}),
137
- "context_utilization_pct": summary.get("context_utilization_pct"),
208
+ "context_utilization_pct": summary.get("context_utilization_pct"), # cited-page coverage
209
+ "text_utilization_pct": summary.get("text_utilization_pct"), # actual content overlap
138
210
  "uncited_numeric_claims": summary.get("uncited_numeric_claims", 0),
139
211
  "context_empty": res.context_empty,
140
212
  "levels": summary.get("levels", "none"),
@@ -142,10 +214,13 @@ def enrich_case(record: dict, cfg: Any, adapter: Any = None, *,
142
214
  if summary.get("diagnostic"):
143
215
  grounding["diagnostic"] = summary["diagnostic"]
144
216
  chunk_quality = _case_chunk_quality(record, cfg, min_chars)
217
+ context_support = _context_support(record, cfg)
145
218
  block: dict = {"grounding": grounding, "method": "compass_deterministic"}
146
219
  if chunk_quality:
147
220
  block["chunk_quality"] = chunk_quality
148
- block["signals"] = _derive_signals(grounding, chunk_quality, thr)
221
+ if context_support:
222
+ block["context_support"] = context_support
223
+ block["signals"] = _derive_signals(grounding, chunk_quality, context_support, thr)
149
224
  return block
150
225
 
151
226
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compass-kb-validation
3
- Version: 3.3.0
3
+ Version: 3.4.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
@@ -472,3 +472,32 @@ def test_diagnostic_when_pattern_matches_but_nothing_extracted():
472
472
  res = citation.audit_citations("(SOURCE, 2026, p. 1)", None, None, cfg)
473
473
  assert res["summary"]["total_citations"] == 0
474
474
  assert "matched by regex but 0 were extracted" in res["summary"].get("diagnostic", "")
475
+
476
+
477
+ def test_term_supported_is_a_separate_tier_not_the_strict_sound_rate():
478
+ # Problem 1: a qualitative citation whose distinctive entity is present in the cited chunk is
479
+ # TERM_SUPPORTED - counted in a SEPARATE term rate, never inflating the strict numeric rate.
480
+ from types import SimpleNamespace
481
+ cfg = SimpleNamespace(citation_patterns=[], citation_page_tolerance=0, citation_verify_entities=False,
482
+ gt_chunk_doc_field="document_title", gt_chunk_page_field="page_start",
483
+ gt_chunk_text_field="text")
484
+ resp = "The Federal Reserve signaled patience. (Doc, 2026, p. 1) Markets calmed."
485
+ chunks = [{"text": "The Federal Reserve signaled patience on rates.", "document_title": "Doc", "page_start": 1}]
486
+ s = citation.audit_citations(resp, chunks, None, cfg)["summary"]
487
+ assert s["term_supported"] == 1 and s["term_grounding_rate_pct"] == 100.0
488
+ assert s["sound"] == 0 and s["checkable"] == 0 # strict numeric rate untouched
489
+ # entity absent -> stays unverifiable, never a fault (lexical absence can be paraphrase)
490
+ s2 = citation.audit_citations(resp, [{"text": "Unrelated weather note.", "document_title": "Doc",
491
+ "page_start": 1}], None, cfg)["summary"]
492
+ assert s2["term_supported"] == 0 and not s2["faults"]
493
+
494
+
495
+ def test_text_utilization_measures_content_overlap_distinct_from_page_coverage():
496
+ # Problem 4: text_utilization_pct is n-gram content overlap, separate from context_utilization
497
+ # (which is cited-page coverage).
498
+ from types import SimpleNamespace
499
+ cfg = SimpleNamespace(gt_chunk_text_field="text")
500
+ chunks = [{"text": "quarterly revenue increased sharply on strong product demand"}]
501
+ hi = citation.text_utilization("quarterly revenue increased sharply on strong product demand", chunks, cfg)
502
+ lo = citation.text_utilization("completely unrelated sentence about the weather today", chunks, cfg)
503
+ assert hi["text_utilization_pct"] == 100.0 and lo["text_utilization_pct"] == 0.0
@@ -107,3 +107,29 @@ def test_extract_judge_scores_handles_varied_shapes():
107
107
  assert extract_judge_scores(c3, "Answer Relevance") == {"judge_x": 0.9}
108
108
  # metric absent -> empty
109
109
  assert extract_judge_scores({"scores": {"other": {"j": 0.5}}}, "groundedness") == {}
110
+
111
+
112
+ def test_context_support_is_separate_weak_signal_and_scoped_to_retrieval_cases():
113
+ # Problem 3: a citation-free response gets a SEPARATE lexical context_support (never the
114
+ # citation grounding rate), and only when the case actually carries retrieved chunks.
115
+ cfg = Settings().eval
116
+ echoed = [{"input": "q", "output": "The economy is slowing and inflation is easing gradually.",
117
+ "context": {"chunks": [{"text": "The economy is slowing while inflation eases gradually.",
118
+ "document_title": "D", "page_start": 1}]}}]
119
+ b = enrich_cases(echoed, cfg)[0][0]["compass"]
120
+ assert b["grounding"]["citation_grounding_rate"] is None # no citation -> strict rate stays None
121
+ assert b["context_support"]["context_support_rate"] == 1.0 # separate lexical proxy
122
+ assert "NOT the citation grounding rate" in b["context_support"]["note"]
123
+ # a refusal / non-retrieval case (no chunks) gets NO context_support - no misleading score
124
+ refusal = [{"input": "do X", "output": "I can't help with that.", "context": {}}]
125
+ assert "context_support" not in enrich_cases(refusal, cfg)[0][0]["compass"]
126
+
127
+
128
+ def test_term_grounding_rate_is_separate_from_citation_rate():
129
+ cfg = Settings().eval
130
+ recs = [{"input": "q", "output": "The Federal Reserve held rates steady. (Doc, 2026, p. 1) End.",
131
+ "context": {"chunks": [{"text": "The Federal Reserve held rates steady this month.",
132
+ "document_title": "Doc", "page_start": 1}]}}]
133
+ g = enrich_cases(recs, cfg)[0][0]["compass"]["grounding"]
134
+ assert g["citation_grounding_rate"] is None # no numeric claim -> strict rate None
135
+ assert g["term_grounding_rate"] == 1.0 # but the entity is supported (separate tier)