sable-sphynx 0.7.0.dev0__py3-none-any.whl

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 (174) hide show
  1. sable_sphynx-0.7.0.dev0.dist-info/METADATA +152 -0
  2. sable_sphynx-0.7.0.dev0.dist-info/RECORD +174 -0
  3. sable_sphynx-0.7.0.dev0.dist-info/WHEEL +5 -0
  4. sable_sphynx-0.7.0.dev0.dist-info/top_level.txt +1 -0
  5. sphynx/__init__.py +469 -0
  6. sphynx/__init__.pyi +376 -0
  7. sphynx/_analytical_client.py +77 -0
  8. sphynx/_analytical_values.py +252 -0
  9. sphynx/_audit_client.py +173 -0
  10. sphynx/_comparison_adapters.py +113 -0
  11. sphynx/_componentized_full_rag.py +34 -0
  12. sphynx/_composed_pipeline.py +109 -0
  13. sphynx/_full_rag_grading.py +218 -0
  14. sphynx/_full_rag_trace.py +221 -0
  15. sphynx/_local_eval.py +268 -0
  16. sphynx/_pipeline_core.py +243 -0
  17. sphynx/_pr_client.py +237 -0
  18. sphynx/_rag_diagnostics_calibration.py +285 -0
  19. sphynx/_rag_diagnostics_catalog.py +39 -0
  20. sphynx/_rag_diagnostics_common.py +26 -0
  21. sphynx/_rag_diagnostics_contracts.py +186 -0
  22. sphynx/_rag_diagnostics_engine.py +243 -0
  23. sphynx/_rag_diagnostics_importers.py +242 -0
  24. sphynx/_rag_diagnostics_ranking.py +81 -0
  25. sphynx/_rag_diagnostics_semantic.py +325 -0
  26. sphynx/_rag_ranking.py +149 -0
  27. sphynx/_speech_benchmark_scoring.py +140 -0
  28. sphynx/_speech_benchmark_sources.py +275 -0
  29. sphynx/_speech_complete_scoring.py +218 -0
  30. sphynx/_speech_complete_source.py +118 -0
  31. sphynx/_speech_kido.py +294 -0
  32. sphynx/_speech_scoring.py +77 -0
  33. sphynx/_track_capture.py +213 -0
  34. sphynx/analytical_views.py +515 -0
  35. sphynx/assessment_contracts.py +378 -0
  36. sphynx/assessment_selection.py +57 -0
  37. sphynx/audit_access.py +245 -0
  38. sphynx/audit_contracts.py +204 -0
  39. sphynx/audit_requests.py +180 -0
  40. sphynx/authoring.py +188 -0
  41. sphynx/benchmarks.py +568 -0
  42. sphynx/candidates.py +62 -0
  43. sphynx/catalog.py +272 -0
  44. sphynx/chunking.py +372 -0
  45. sphynx/clickhouse_reader.py +169 -0
  46. sphynx/client.py +486 -0
  47. sphynx/comparison_core.py +149 -0
  48. sphynx/comparison_views.py +223 -0
  49. sphynx/components.py +1661 -0
  50. sphynx/contracts.py +514 -0
  51. sphynx/customer_rerank.py +299 -0
  52. sphynx/customer_retrieval.py +146 -0
  53. sphynx/data/benchmark-releases.v1.json +164 -0
  54. sphynx/data/public-benchmark-release.v1.json +354 -0
  55. sphynx/data/rag-diagnostics-synthetic.v1.json +6982 -0
  56. sphynx/data/rag-paper-references.v1.json +176 -0
  57. sphynx/data/rag-paper-schemas.v1.json +1069 -0
  58. sphynx/data/rag-rerank-v1.json +507 -0
  59. sphynx/dataset_catalog_data.py +1214 -0
  60. sphynx/dataset_readiness.py +138 -0
  61. sphynx/dataset_v3.py +999 -0
  62. sphynx/demo.py +130 -0
  63. sphynx/demo_candidates/__init__.py +1 -0
  64. sphynx/demo_candidates/dense.py +11 -0
  65. sphynx/demo_candidates/lexical.py +21 -0
  66. sphynx/diagnostics.py +207 -0
  67. sphynx/documentation.py +737 -0
  68. sphynx/documentation_candidate.py +939 -0
  69. sphynx/evals.py +538 -0
  70. sphynx/evaluation_protocols.py +124 -0
  71. sphynx/evaluator_bindings.py +370 -0
  72. sphynx/evidence.py +352 -0
  73. sphynx/execution_capabilities.py +109 -0
  74. sphynx/fast_search.py +285 -0
  75. sphynx/full_rag.py +341 -0
  76. sphynx/hosted/__init__.py +18 -0
  77. sphynx/hosted/benchmark_intake.py +218 -0
  78. sphynx/hosted/contracts.py +361 -0
  79. sphynx/hosted/model_admission.py +130 -0
  80. sphynx/hosted/model_artifacts.py +298 -0
  81. sphynx/hosted/model_contracts.py +63 -0
  82. sphynx/hosted/model_gcs.py +181 -0
  83. sphynx/hosted/model_history.py +84 -0
  84. sphynx/hosted/model_session.py +233 -0
  85. sphynx/hosted/public_suite.py +74 -0
  86. sphynx/hosted/registry.py +97 -0
  87. sphynx/hosted/revisions.py +126 -0
  88. sphynx/hosted/sandbox.py +105 -0
  89. sphynx/hosted/submission.py +287 -0
  90. sphynx/hosted_workflow.py +451 -0
  91. sphynx/http_transport.py +86 -0
  92. sphynx/integrated_rag.py +352 -0
  93. sphynx/integrated_workflow.py +552 -0
  94. sphynx/integration_contracts.py +474 -0
  95. sphynx/invocation.py +791 -0
  96. sphynx/invocation_candidates.py +392 -0
  97. sphynx/invocation_catalog_data.py +40 -0
  98. sphynx/learned_catalog_data.py +30 -0
  99. sphynx/learned_rerank.py +267 -0
  100. sphynx/legacy_full_rag.py +67 -0
  101. sphynx/lifecycle.py +885 -0
  102. sphynx/lifecycle_workflow.py +490 -0
  103. sphynx/metric_views.py +474 -0
  104. sphynx/metrics.py +282 -0
  105. sphynx/model_adapters.py +438 -0
  106. sphynx/model_catalog_data.py +30 -0
  107. sphynx/official_benchmarks.py +385 -0
  108. sphynx/packs.py +258 -0
  109. sphynx/pipeline.py +313 -0
  110. sphynx/pipeline_workflow.py +551 -0
  111. sphynx/pr_evaluations.py +560 -0
  112. sphynx/progress.py +89 -0
  113. sphynx/public_bundle.py +173 -0
  114. sphynx/public_eval.py +62 -0
  115. sphynx/public_rerank.py +355 -0
  116. sphynx/py.typed +0 -0
  117. sphynx/pytest_plugin.py +62 -0
  118. sphynx/question_robustness.py +437 -0
  119. sphynx/rag.py +329 -0
  120. sphynx/rag_diagnostics.py +309 -0
  121. sphynx/rag_papers/__init__.py +29 -0
  122. sphynx/rag_papers/__main__.py +45 -0
  123. sphynx/rag_papers/candidates.py +19 -0
  124. sphynx/rag_papers/contracts.py +282 -0
  125. sphynx/rag_papers/datasets.py +291 -0
  126. sphynx/rag_papers/experiments.py +116 -0
  127. sphynx/rag_papers/judges.py +308 -0
  128. sphynx/rag_papers/registry.py +33 -0
  129. sphynx/rag_papers/runner.py +176 -0
  130. sphynx/rag_papers/sable.py +135 -0
  131. sphynx/rag_papers/schemas.py +12 -0
  132. sphynx/rag_papers/scoring.py +249 -0
  133. sphynx/rag_papers/semantic.py +288 -0
  134. sphynx/rag_papers/trec.py +80 -0
  135. sphynx/recorded_scores.py +352 -0
  136. sphynx/records.py +124 -0
  137. sphynx/reflex_traces.py +193 -0
  138. sphynx/report_rendering.py +71 -0
  139. sphynx/rerank_family_contracts.py +150 -0
  140. sphynx/rerank_workflow.py +424 -0
  141. sphynx/research_candidate.py +190 -0
  142. sphynx/results.py +852 -0
  143. sphynx/retrieval.py +750 -0
  144. sphynx/retrieval_candidates.py +295 -0
  145. sphynx/retrieval_catalog_data.py +19 -0
  146. sphynx/retrieval_recordings.py +125 -0
  147. sphynx/reviewer_audit.py +266 -0
  148. sphynx/routing_policies.py +375 -0
  149. sphynx/runner.py +544 -0
  150. sphynx/sable_rerank.py +489 -0
  151. sphynx/score_reliability.py +393 -0
  152. sphynx/speech_benchmarks.py +204 -0
  153. sphynx/speech_campaigns.py +378 -0
  154. sphynx/speech_complete.py +236 -0
  155. sphynx/speech_contracts.py +225 -0
  156. sphynx/speech_evidence.py +429 -0
  157. sphynx/speech_listening.py +115 -0
  158. sphynx/speech_multilingual.py +71 -0
  159. sphynx/speech_tts.py +479 -0
  160. sphynx/sql/001_sync_v1.sql +30 -0
  161. sphynx/stage_contracts.py +199 -0
  162. sphynx/statistical_catalog_data.py +53 -0
  163. sphynx/statistical_rerank.py +232 -0
  164. sphynx/sync.py +797 -0
  165. sphynx/workbench.py +378 -0
  166. sphynx/workbench_catalog_data.py +13 -0
  167. sphynx/workbench_tracks.py +78 -0
  168. sphynx/workflow_async.py +197 -0
  169. sphynx/workflow_contracts.py +594 -0
  170. sphynx/workflow_decisions.py +211 -0
  171. sphynx/workflow_defaults.py +16 -0
  172. sphynx/workflow_extensions.py +212 -0
  173. sphynx/workflow_registry.py +513 -0
  174. sphynx/workflows.py +792 -0
@@ -0,0 +1,109 @@
1
+ """Label-free component composition and source-bound chunk truth for grading."""
2
+ from __future__ import annotations
3
+ from copy import deepcopy
4
+ import hashlib
5
+ from .contracts import ContractError
6
+
7
+
8
+ def _require(ok,message):
9
+ if not ok:raise ContractError(message)
10
+
11
+
12
+ def validate_chunks(chunks,documents):
13
+ _require(type(chunks) is list and len(chunks)<=4096,"composed pipeline chunk count differs")
14
+ known={row["document_id"]:row["text"] for row in documents};result={}
15
+ for row in chunks:
16
+ _require(type(row) is dict and set(row)=={"chunk_id","document_id","start","end","text"},"chunk fields differ")
17
+ source=known.get(row["document_id"])
18
+ _require(type(row["chunk_id"]) is str and row["chunk_id"] and row["chunk_id"] not in result
19
+ and type(row["start"]) is int and type(row["end"]) is int and source is not None
20
+ and 0<=row["start"]<row["end"]<=len(source) and source[row["start"]:row["end"]]==row["text"],"chunk source bounds/text/identity drift")
21
+ result[row["chunk_id"]]={"candidate_id":row["chunk_id"],"chunk_id":row["chunk_id"],"document_id":row["document_id"],
22
+ "start":row["start"],"end":row["end"],"input":row["text"],"input_sha256":hashlib.sha256(row["text"].encode()).hexdigest()}
23
+ return result
24
+
25
+
26
+ def truth_for_chunks(task,chunks):
27
+ """Canonical Level2 rule: inherit truth only when every source span is contained.
28
+
29
+ This is a sealed grader transformation of predeclared source labels, not new
30
+ human judgments of arbitrary chunks. Unknown source truth stays unavailable.
31
+ """
32
+ source=task["candidates"];spans={row["source_span_id"]:row for row in task["source_spans"]}
33
+ if any(row["judgment_status"]!="judged" or row["qrel"] is None or row["qrel"]>0 and not row["source_span_ids"] for row in source):
34
+ return None
35
+ result={}
36
+ for cid,chunk in chunks.items():
37
+ document_truth=[row for row in source if row["document_id"]==chunk["document_id"]]
38
+ if not document_truth:return None
39
+ grade=0;groups=set()
40
+ for row in document_truth:
41
+ if not row["source_span_ids"]:continue
42
+ if all(span in spans and chunk["start"]<=spans[span]["start"] and chunk["end"]>=spans[span]["end"] for span in row["source_span_ids"]):
43
+ grade=max(grade,row["qrel"]);groups.update(row["evidence_group_ids"])
44
+ result[cid]={**chunk,"qrel":grade,"judgment_status":"judged","evidence_group_ids":sorted(groups)}
45
+ return result
46
+
47
+
48
+ def compose_pipeline(request,invoke):
49
+ """Invoke chunker→embedder→retriever→reranker→fusion→dedup→gate in order.
50
+
51
+ ``invoke(kind, operation, payload)`` is an explicitly registered component
52
+ call. Every payload is copied; component outputs cannot mutate earlier input.
53
+ """
54
+ records={}
55
+ def call(kind,operation,payload):
56
+ output=invoke(kind,operation,deepcopy(payload));_require(type(output) is dict,"component output must be a dict")
57
+ records[kind]=deepcopy(output);return output
58
+ chunking=call("chunker","chunk",{"task_id":request["case_id"],"documents":request["documents"]})
59
+ raw_chunks=chunking.get("chunks")
60
+ _require(type(raw_chunks) is list and all(type(row) is dict for row in raw_chunks),"chunker output differs")
61
+ for row in raw_chunks:
62
+ _require(row.get("candidate_id",row.get("chunk_id"))==row.get("chunk_id")
63
+ and row.get("input",row.get("text"))==row.get("text"),"chunker aliases differ from source-bound identity/text")
64
+ normalized_chunks=[{key:row[key] for key in ("chunk_id","document_id","start","end","text")} for row in raw_chunks]
65
+ chunks=validate_chunks(normalized_chunks,request["documents"])
66
+ rows=[{**row,"text":row["input"]} for row in chunks.values()]
67
+ query_id="query:"+request["case_id"]
68
+ embedding=call("embedder","embed",{"items":[{"candidate_id":query_id,"text":request["query"],"input":request["query"]},*rows]})
69
+ values=embedding.get("embeddings")
70
+ _require(type(values) is list and all(type(row) is dict for row in values),"embedding outputs differ")
71
+ vectors={row.get("candidate_id"):row.get("vector") for row in values}
72
+ _require(len(vectors)==len(values) and set(vectors)==set(chunks)|{query_id},"embedding membership changed")
73
+ import math
74
+ dimensions=set()
75
+ for vector in vectors.values():
76
+ _require(type(vector) is list and vector and all(type(v) in (int,float) and math.isfinite(v) for v in vector),"invalid embedding vector")
77
+ dimensions.add(len(vector))
78
+ _require(len(dimensions)==1,"embedding dimensions differ")
79
+ rows=[{**row,"embedding":vectors[row["candidate_id"]]} for row in rows]
80
+ def ranked(kind,operation,base,rank_key,score_key):
81
+ output=call(kind,operation,{"task_id":request["case_id"],"query":request["query"],"query_embedding":{"candidate_id":query_id,"vector":vectors[query_id]},"candidates":base})
82
+ actual=output.get("candidates");_require(type(actual) is list,"ranked component candidates differ")
83
+ by_id={row.get("candidate_id"):row for row in actual if type(row) is dict}
84
+ _require(len(by_id)==len(actual) and set(by_id)==set(chunks),"ranked component membership changed")
85
+ ordered=sorted(by_id.values(),key=lambda row:row[rank_key])
86
+ _require([row[rank_key] for row in ordered]==list(range(1,len(ordered)+1)),"ranked component rank order differs")
87
+ before={row["candidate_id"]:row for row in base}
88
+ return [{**before[row["candidate_id"]],rank_key:row[rank_key],score_key:row[score_key]} for row in ordered]
89
+ rows=ranked("retriever","retrieve",rows,"dense_rank","dense_score")
90
+ rows=ranked("reranker","rerank",rows,"reranker_rank","reranker_score")
91
+ rows=ranked("fusion","fuse",rows,"fusion_rank","fusion_score")
92
+ for kind,operation,keys in (("dedup","deduplicate",("dedup_decision","post_dedup_rank")),("gate","gate",("gate_decision","gate_threshold","final_rank"))):
93
+ output=call(kind,operation,{"task_id":request["case_id"],"query":request["query"],"candidates":rows})
94
+ actual=output.get("candidates");_require(type(actual) is list,"component candidates differ")
95
+ by_id={row.get("candidate_id"):row for row in actual if type(row) is dict}
96
+ _require(len(by_id)==len(actual) and set(by_id)==set(chunks),"component candidate membership changed")
97
+ rows=[{**row,**{key:by_id[row["candidate_id"]][key] for key in keys}} for row in rows]
98
+ trace=[]
99
+ for row in rows:
100
+ trace.append({key:row[key] for key in ("candidate_id","dense_rank","dense_score","reranker_rank","fusion_score","fusion_rank",
101
+ "dedup_decision","post_dedup_rank","gate_decision","gate_threshold","final_rank")}|{"reranker_logit":row["reranker_score"]})
102
+ timings={}
103
+ for kind,key in (("chunker","chunking"),("embedder","embedding"),("retriever","retrieval"),("reranker","reranker"),("fusion","fusion"),("dedup","dedup"),("gate","gate")):
104
+ value=records[kind].get("latency_ms")
105
+ if type(value) in (int,float) and math.isfinite(value) and value>=0:timings[key]=float(value)
106
+ if len(timings)==7:timings["total"]=sum(timings.values())
107
+ return {"schema_version":"sphynx.rag.pipeline.composed.response.v1","case_id":request["case_id"],"input_digest":request["input_digest"],
108
+ "construction":{"boundary":"source_bound_chunks","chunks":normalized_chunks},"embedding":{"dimension":next(iter(dimensions))},
109
+ "candidates":trace,"latency_state":"cold","latency_ms":timings,"stages":records}
@@ -0,0 +1,218 @@
1
+ """Deterministic-first Level 3 grading and fixed-generator counterfactuals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import dataclass
9
+ from typing import Literal, cast
10
+
11
+ from ._full_rag_trace import CONTEXT_CONDITIONS, Level3Trace
12
+ from typing import Any
13
+ JsonValue = Any
14
+ from .contracts import canonical_json
15
+ def canonical_json_bytes(value):
16
+ return canonical_json(value).encode("utf-8")
17
+
18
+
19
+ class Level3GradingError(ValueError):
20
+ """Raised for incomplete truth, invalid graders, or uncontrolled counterfactuals."""
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class ClaimVerdict:
25
+ start: int
26
+ end: int
27
+ supported_by_evidence_ids: tuple[str, ...]
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ModelGraderVerdict:
32
+ criterion_id: str
33
+ verdict: Literal["fail", "pass", "unknown"]
34
+ evidence_ids: tuple[str, ...]
35
+ rationale: str
36
+ prompt_sha256: str
37
+ provider: str
38
+ model: str
39
+ revision: str
40
+ temperature: float
41
+ seed: int | None
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class GraderAudit:
46
+ independent_labels: int
47
+ accuracy: float
48
+ unknown_rate: float
49
+ kappa: float
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class CounterfactualObservation:
54
+ condition: str
55
+ trace: Level3Trace
56
+ generator_config_sha256: str
57
+ prompt_sha256: str
58
+ required_fact_coverage: float | None
59
+
60
+
61
+ def _normalized(text: str) -> str:
62
+ return " ".join(re.findall(r"[a-z0-9]+", text.casefold()))
63
+
64
+
65
+ def _ratio(numerator: int, denominator: int) -> float | None:
66
+ return None if denominator == 0 else numerator / denominator
67
+
68
+
69
+ def grade_level3(
70
+ task: Mapping[str, object],
71
+ trace: Level3Trace,
72
+ *,
73
+ required_facts: Mapping[str, Sequence[str]],
74
+ exact_values: Sequence[str],
75
+ claims: Sequence[ClaimVerdict],
76
+ ) -> dict[str, JsonValue]:
77
+ """Grade observable behavior; unavailable truth yields null instead of guessed scores."""
78
+
79
+ if trace.task_id != task.get("task_id"):
80
+ raise Level3GradingError("trace task identity differs from gold task")
81
+ answer = trace.answer or ""
82
+ normalized_answer = _normalized(answer)
83
+ actual_call = trace.actual_rag_call_count > 0
84
+ expected_call = bool(task["should_call_rag"])
85
+ call_tp = int(expected_call and actual_call)
86
+ call_fp = int(not expected_call and actual_call)
87
+ call_fn = int(expected_call and not actual_call)
88
+ rewritten = trace.rewritten_query or trace.as_uttered_query
89
+ original_tokens = set(_normalized(trace.as_uttered_query).split())
90
+ rewritten_tokens = set(_normalized(rewritten).split())
91
+ rewrite_preservation = _ratio(len(original_tokens & rewritten_tokens), len(original_tokens))
92
+
93
+ groups = cast("list[Mapping[str, object]]", task["evidence_groups"])
94
+ required_groups = [group for group in groups if group["requirement"] == "required"]
95
+ handed = set(trace.handed_context_ids)
96
+ covered_groups = sum(
97
+ bool(handed & set(cast("list[str]", group["acceptable_candidate_ids"])))
98
+ for group in required_groups
99
+ )
100
+ fact_hits = {
101
+ fact_id: any(_normalized(answer_value) in normalized_answer for answer_value in accepted)
102
+ for fact_id, accepted in required_facts.items()
103
+ }
104
+ exact_hits = [
105
+ bool(_normalized(value)) and _normalized(value) in normalized_answer
106
+ for value in exact_values
107
+ ]
108
+ if any(
109
+ claim.start < 0 or claim.end <= claim.start or claim.end > len(answer) for claim in claims
110
+ ):
111
+ raise Level3GradingError("claim spans must bind the exact answer")
112
+ supported_claims = sum(bool(claim.supported_by_evidence_ids) for claim in claims)
113
+ valid_citations = [citation for citation in trace.citations if citation.evidence_id in handed]
114
+ supported_citations = sum(
115
+ any(
116
+ citation.start < claim.end
117
+ and citation.end > claim.start
118
+ and citation.evidence_id in claim.supported_by_evidence_ids
119
+ for claim in claims
120
+ )
121
+ for citation in valid_citations
122
+ )
123
+ cited_groups = sum(
124
+ bool(
125
+ {citation.evidence_id for citation in valid_citations}
126
+ & set(cast("list[str]", group["acceptable_candidate_ids"]))
127
+ )
128
+ for group in required_groups
129
+ )
130
+ expected_abstention = task["expected_behavior"] == "abstain"
131
+ return {
132
+ "rag_call_true_positive": call_tp,
133
+ "rag_call_false_positive": call_fp,
134
+ "rag_call_false_negative": call_fn,
135
+ "rag_call_precision": _ratio(call_tp, call_tp + call_fp),
136
+ "rag_call_recall": _ratio(call_tp, call_tp + call_fn),
137
+ "rewrite_preservation": rewrite_preservation,
138
+ "evidence_handoff_completeness": _ratio(covered_groups, len(required_groups)),
139
+ "answer_required_fact_coverage": _ratio(sum(fact_hits.values()), len(fact_hits)),
140
+ "exact_value_unit_date_accuracy": _ratio(sum(exact_hits), len(exact_hits)),
141
+ "faithfulness": _ratio(supported_claims, len(claims)),
142
+ "unsupported_claim_rate": _ratio(len(claims) - supported_claims, len(claims)),
143
+ "citation_precision": _ratio(len(valid_citations), len(trace.citations)),
144
+ "citation_recall": _ratio(cited_groups, len(required_groups)),
145
+ "citation_support": _ratio(supported_citations, len(valid_citations)),
146
+ "correct_abstention": trace.abstained == expected_abstention,
147
+ "answer_completeness": _ratio(sum(fact_hits.values()), len(fact_hits)),
148
+ "subject_error": trace.call_outcome == "error",
149
+ "latency_ms": cast("dict[str, JsonValue]", trace.latency_ms),
150
+ }
151
+
152
+
153
+ def validate_model_grader_verdict(
154
+ verdict: ModelGraderVerdict,
155
+ *,
156
+ criterion_id: str,
157
+ allowed_evidence_ids: frozenset[str],
158
+ ) -> None:
159
+ """Keep model evidence criterion-scoped and preserve an explicit unknown state."""
160
+
161
+ if verdict.criterion_id != criterion_id:
162
+ raise Level3GradingError("model grader crossed its criterion scope")
163
+ if not set(verdict.evidence_ids) <= allowed_evidence_ids:
164
+ raise Level3GradingError("model grader cited evidence outside its scoped packet")
165
+ if verdict.verdict != "unknown" and not verdict.evidence_ids:
166
+ raise Level3GradingError("non-unknown model verdict requires cited evidence")
167
+ if not verdict.rationale or re.fullmatch(r"[0-9a-f]{64}", verdict.prompt_sha256) is None:
168
+ raise Level3GradingError("model grader configuration or rationale is incomplete")
169
+ if not all((verdict.provider, verdict.model, verdict.revision)):
170
+ raise Level3GradingError("model grader identity is incomplete")
171
+
172
+
173
+ def model_grader_may_gate(audit: GraderAudit) -> bool:
174
+ """Apply the predeclared independent-human validation threshold."""
175
+
176
+ return (
177
+ audit.independent_labels >= 100
178
+ and audit.accuracy >= 0.90
179
+ and audit.unknown_rate <= 0.05
180
+ and audit.kappa >= 0.80
181
+ )
182
+
183
+
184
+ def evaluate_counterfactuals(
185
+ observations: Sequence[CounterfactualObservation],
186
+ ) -> dict[str, JsonValue]:
187
+ """Require the five conditions under one generator and expose causal gaps."""
188
+
189
+ by_condition = {observation.condition: observation for observation in observations}
190
+ if set(by_condition) != CONTEXT_CONDITIONS or len(observations) != len(CONTEXT_CONDITIONS):
191
+ raise Level3GradingError(
192
+ "counterfactual matrix must contain each fixed condition exactly once"
193
+ )
194
+ generator_hashes = {observation.generator_config_sha256 for observation in observations}
195
+ prompt_hashes = {observation.prompt_sha256 for observation in observations}
196
+ task_ids = {observation.trace.task_id for observation in observations}
197
+ if len(generator_hashes) != 1 or len(prompt_hashes) != 1 or len(task_ids) != 1:
198
+ raise Level3GradingError("counterfactuals must freeze task, generator, and prompt")
199
+ coverage = {key: value.required_fact_coverage for key, value in by_condition.items()}
200
+
201
+ def gap(left: str, right: str) -> float | None:
202
+ return (
203
+ None
204
+ if coverage[left] is None or coverage[right] is None
205
+ else cast("float", coverage[left]) - cast("float", coverage[right])
206
+ )
207
+
208
+ payload: dict[str, JsonValue] = {
209
+ "condition_coverage": cast("dict[str, JsonValue]", coverage),
210
+ "retrieval_ranking_gap": gap("oracle", "challenger"),
211
+ "challenger_vs_a0": gap("challenger", "a0"),
212
+ "a0_ranking_contribution": gap("a0", "dense"),
213
+ "rag_dependency": gap("challenger", "empty"),
214
+ "generator_config_sha256": next(iter(generator_hashes)),
215
+ "prompt_sha256": next(iter(prompt_hashes)),
216
+ }
217
+ payload["matrix_sha256"] = hashlib.sha256(canonical_json_bytes(payload)).hexdigest()
218
+ return payload
@@ -0,0 +1,221 @@
1
+ """Versioned full-RAG request payload and untrusted trace validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Literal, cast
9
+
10
+ from typing import Any
11
+ JsonValue = Any
12
+
13
+ TRACE_SCHEMA_VERSION = "sable.rag.level3-trace.v1"
14
+ CONTEXT_CONDITIONS = frozenset({"oracle", "dense", "a0", "challenger", "empty"})
15
+
16
+
17
+ class Level3TraceError(ValueError):
18
+ """Raised when a Level 3 output is missing causal trace evidence."""
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Citation:
23
+ citation_id: str
24
+ evidence_id: str
25
+ start: int
26
+ end: int
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Level3Trace:
31
+ task_id: str
32
+ expected_invocation: bool
33
+ actual_rag_call_count: int
34
+ call_outcome: Literal["error", "no_call", "success"]
35
+ as_uttered_query: str
36
+ rewritten_query: str | None
37
+ retrieved_candidate_ids: tuple[str, ...]
38
+ retrieved_context_ids: tuple[str, ...]
39
+ handed_context_ids: tuple[str, ...]
40
+ answer: str | None
41
+ citations: tuple[Citation, ...]
42
+ abstained: bool
43
+ deferred: bool
44
+ prompt_tokens: int
45
+ completion_tokens: int
46
+ latency_ms: dict[str, float]
47
+ error: dict[str, JsonValue] | None
48
+
49
+
50
+ def make_level3_input(
51
+ task: Mapping[str, object],
52
+ *,
53
+ context_condition: str,
54
+ context_candidate_ids: list[str],
55
+ ) -> dict[str, JsonValue]:
56
+ if context_condition not in CONTEXT_CONDITIONS:
57
+ raise Level3TraceError("context condition is not in the fixed counterfactual matrix")
58
+ query = task.get("query")
59
+ if not isinstance(query, str) or not query:
60
+ raise Level3TraceError("task query must be non-empty")
61
+ return {
62
+ "operation": "full_rag",
63
+ "task_id": cast("str", task["task_id"]),
64
+ "customer_id": cast("str", task["customer_id"]),
65
+ "query": query,
66
+ "expected_invocation": bool(task["should_call_rag"]),
67
+ "context_condition": context_condition,
68
+ "context_candidate_ids": cast("list[JsonValue]", context_candidate_ids),
69
+ }
70
+
71
+
72
+ def _strings(value: object, context: str) -> tuple[str, ...]:
73
+ if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value):
74
+ raise Level3TraceError(f"{context} must be an array of non-empty IDs")
75
+ result = tuple(cast("list[str]", value))
76
+ if len(result) != len(set(result)):
77
+ raise Level3TraceError(f"{context} must preserve unique identities")
78
+ return result
79
+
80
+
81
+ def validate_level3_trace(raw: object) -> Level3Trace:
82
+ """Fail closed unless invocation, retrieval, handoff, answer, and latency are observable."""
83
+
84
+ if not isinstance(raw, Mapping):
85
+ raise Level3TraceError("trace must be an object")
86
+ value = cast("Mapping[str, object]", raw)
87
+ required = {
88
+ "schema_version",
89
+ "task_id",
90
+ "expected_invocation",
91
+ "actual_rag_call_count",
92
+ "call_outcome",
93
+ "error",
94
+ "as_uttered_query",
95
+ "rewritten_query",
96
+ "retrieved_candidate_ids",
97
+ "retrieved_context_ids",
98
+ "handed_context_ids",
99
+ "answer",
100
+ "citations",
101
+ "abstained",
102
+ "deferred",
103
+ "token_counts",
104
+ "latency_ms",
105
+ }
106
+ if value.keys() != required:
107
+ raise Level3TraceError("trace fields differ from the versioned contract")
108
+ if value["schema_version"] != TRACE_SCHEMA_VERSION:
109
+ raise Level3TraceError("trace schema version is unsupported")
110
+ task_id = value["task_id"]
111
+ query = value["as_uttered_query"]
112
+ if not isinstance(task_id, str) or not task_id or not isinstance(query, str) or not query:
113
+ raise Level3TraceError("task and as-uttered query identities are required")
114
+ expected = value["expected_invocation"]
115
+ calls = value["actual_rag_call_count"]
116
+ if (
117
+ not isinstance(expected, bool)
118
+ or isinstance(calls, bool)
119
+ or not isinstance(calls, int)
120
+ or calls < 0
121
+ ):
122
+ raise Level3TraceError("invocation fields are invalid")
123
+ outcome = value["call_outcome"]
124
+ if outcome not in {"success", "no_call", "error"}:
125
+ raise Level3TraceError("call_outcome is invalid")
126
+ if (outcome == "no_call") != (calls == 0):
127
+ raise Level3TraceError("call outcome and call count disagree")
128
+ error_raw = value["error"]
129
+ if (outcome == "error") != isinstance(error_raw, Mapping):
130
+ raise Level3TraceError("error outcome must preserve structured error evidence")
131
+ error: dict[str, JsonValue] | None = None
132
+ if isinstance(error_raw, Mapping):
133
+ error_code = error_raw.get("code")
134
+ error_message = error_raw.get("message")
135
+ if not isinstance(error_code, str) or not isinstance(error_message, str):
136
+ raise Level3TraceError("error evidence requires code and message")
137
+ error = {"code": error_code, "message": error_message}
138
+ rewritten = value["rewritten_query"]
139
+ if rewritten is not None and (not isinstance(rewritten, str) or not rewritten):
140
+ raise Level3TraceError("rewritten_query must be null or non-empty")
141
+ retrieved = _strings(value["retrieved_candidate_ids"], "retrieved_candidate_ids")
142
+ contexts = _strings(value["retrieved_context_ids"], "retrieved_context_ids")
143
+ handed = _strings(value["handed_context_ids"], "handed_context_ids")
144
+ if not set(contexts) <= set(retrieved):
145
+ raise Level3TraceError("retrieved contexts must be a subset of retrieved candidates")
146
+ if [item for item in retrieved if item in set(contexts)] != list(contexts):
147
+ raise Level3TraceError("retrieved context order must preserve candidate retrieval order")
148
+ if not set(handed) <= set(contexts):
149
+ raise Level3TraceError("generator handoff must be a subset of retrieved contexts")
150
+ if [item for item in contexts if item in set(handed)] != list(handed):
151
+ raise Level3TraceError("generator handoff order must preserve retrieval order")
152
+ answer = value["answer"]
153
+ if answer is not None and not isinstance(answer, str):
154
+ raise Level3TraceError("answer must be text or null")
155
+ abstained, deferred = value["abstained"], value["deferred"]
156
+ if not isinstance(abstained, bool) or not isinstance(deferred, bool):
157
+ raise Level3TraceError("abstention and deferral must be explicit booleans")
158
+ citations_raw = value["citations"]
159
+ if not isinstance(citations_raw, list):
160
+ raise Level3TraceError("citations must be an array")
161
+ citations: list[Citation] = []
162
+ for index, raw_citation in enumerate(citations_raw):
163
+ if not isinstance(raw_citation, Mapping):
164
+ raise Level3TraceError(f"citation {index} must be an object")
165
+ citation = cast("Mapping[str, object]", raw_citation)
166
+ if citation.keys() != {"citation_id", "evidence_id", "start", "end"}:
167
+ raise Level3TraceError(f"citation {index} fields differ")
168
+ start, end = citation["start"], citation["end"]
169
+ if not isinstance(start, int) or not isinstance(end, int) or start < 0 or end <= start:
170
+ raise Level3TraceError(f"citation {index} has invalid answer offsets")
171
+ if not isinstance(answer, str) or end > len(answer):
172
+ raise Level3TraceError(f"citation {index} is outside the answer")
173
+ citations.append(
174
+ Citation(
175
+ cast("str", citation["citation_id"]),
176
+ cast("str", citation["evidence_id"]),
177
+ start,
178
+ end,
179
+ )
180
+ )
181
+ counts = value["token_counts"]
182
+ if not isinstance(counts, Mapping) or counts.keys() != {"prompt", "completion"}:
183
+ raise Level3TraceError("token_counts fields differ")
184
+ count_map = cast("Mapping[str, object]", counts)
185
+ prompt_tokens, completion_tokens = count_map["prompt"], count_map["completion"]
186
+ if any(
187
+ isinstance(count, bool) or not isinstance(count, int) or count < 0
188
+ for count in (prompt_tokens, completion_tokens)
189
+ ):
190
+ raise Level3TraceError("token counts must be nonnegative integers")
191
+ latency_raw = value["latency_ms"]
192
+ required_latency = {"routing", "rewrite", "retrieval", "generation", "total"}
193
+ if not isinstance(latency_raw, Mapping) or latency_raw.keys() != required_latency:
194
+ raise Level3TraceError("latency decomposition fields differ")
195
+ latency = {
196
+ key: float(cast("int | float", item))
197
+ for key, item in cast("Mapping[str, object]", latency_raw).items()
198
+ }
199
+ if any(not math.isfinite(item) or item < 0 for item in latency.values()):
200
+ raise Level3TraceError("latency values must be finite and nonnegative")
201
+ if latency["total"] + 1e-9 < sum(latency[key] for key in required_latency - {"total"}):
202
+ raise Level3TraceError("total latency cannot be smaller than component latency")
203
+ return Level3Trace(
204
+ task_id,
205
+ expected,
206
+ calls,
207
+ cast("Literal['error', 'no_call', 'success']", outcome),
208
+ query,
209
+ rewritten,
210
+ retrieved,
211
+ contexts,
212
+ handed,
213
+ answer,
214
+ tuple(citations),
215
+ abstained,
216
+ deferred,
217
+ cast("int", prompt_tokens),
218
+ cast("int", completion_tokens),
219
+ latency,
220
+ error,
221
+ )