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.
- sable_sphynx-0.7.0.dev0.dist-info/METADATA +152 -0
- sable_sphynx-0.7.0.dev0.dist-info/RECORD +174 -0
- sable_sphynx-0.7.0.dev0.dist-info/WHEEL +5 -0
- sable_sphynx-0.7.0.dev0.dist-info/top_level.txt +1 -0
- sphynx/__init__.py +469 -0
- sphynx/__init__.pyi +376 -0
- sphynx/_analytical_client.py +77 -0
- sphynx/_analytical_values.py +252 -0
- sphynx/_audit_client.py +173 -0
- sphynx/_comparison_adapters.py +113 -0
- sphynx/_componentized_full_rag.py +34 -0
- sphynx/_composed_pipeline.py +109 -0
- sphynx/_full_rag_grading.py +218 -0
- sphynx/_full_rag_trace.py +221 -0
- sphynx/_local_eval.py +268 -0
- sphynx/_pipeline_core.py +243 -0
- sphynx/_pr_client.py +237 -0
- sphynx/_rag_diagnostics_calibration.py +285 -0
- sphynx/_rag_diagnostics_catalog.py +39 -0
- sphynx/_rag_diagnostics_common.py +26 -0
- sphynx/_rag_diagnostics_contracts.py +186 -0
- sphynx/_rag_diagnostics_engine.py +243 -0
- sphynx/_rag_diagnostics_importers.py +242 -0
- sphynx/_rag_diagnostics_ranking.py +81 -0
- sphynx/_rag_diagnostics_semantic.py +325 -0
- sphynx/_rag_ranking.py +149 -0
- sphynx/_speech_benchmark_scoring.py +140 -0
- sphynx/_speech_benchmark_sources.py +275 -0
- sphynx/_speech_complete_scoring.py +218 -0
- sphynx/_speech_complete_source.py +118 -0
- sphynx/_speech_kido.py +294 -0
- sphynx/_speech_scoring.py +77 -0
- sphynx/_track_capture.py +213 -0
- sphynx/analytical_views.py +515 -0
- sphynx/assessment_contracts.py +378 -0
- sphynx/assessment_selection.py +57 -0
- sphynx/audit_access.py +245 -0
- sphynx/audit_contracts.py +204 -0
- sphynx/audit_requests.py +180 -0
- sphynx/authoring.py +188 -0
- sphynx/benchmarks.py +568 -0
- sphynx/candidates.py +62 -0
- sphynx/catalog.py +272 -0
- sphynx/chunking.py +372 -0
- sphynx/clickhouse_reader.py +169 -0
- sphynx/client.py +486 -0
- sphynx/comparison_core.py +149 -0
- sphynx/comparison_views.py +223 -0
- sphynx/components.py +1661 -0
- sphynx/contracts.py +514 -0
- sphynx/customer_rerank.py +299 -0
- sphynx/customer_retrieval.py +146 -0
- sphynx/data/benchmark-releases.v1.json +164 -0
- sphynx/data/public-benchmark-release.v1.json +354 -0
- sphynx/data/rag-diagnostics-synthetic.v1.json +6982 -0
- sphynx/data/rag-paper-references.v1.json +176 -0
- sphynx/data/rag-paper-schemas.v1.json +1069 -0
- sphynx/data/rag-rerank-v1.json +507 -0
- sphynx/dataset_catalog_data.py +1214 -0
- sphynx/dataset_readiness.py +138 -0
- sphynx/dataset_v3.py +999 -0
- sphynx/demo.py +130 -0
- sphynx/demo_candidates/__init__.py +1 -0
- sphynx/demo_candidates/dense.py +11 -0
- sphynx/demo_candidates/lexical.py +21 -0
- sphynx/diagnostics.py +207 -0
- sphynx/documentation.py +737 -0
- sphynx/documentation_candidate.py +939 -0
- sphynx/evals.py +538 -0
- sphynx/evaluation_protocols.py +124 -0
- sphynx/evaluator_bindings.py +370 -0
- sphynx/evidence.py +352 -0
- sphynx/execution_capabilities.py +109 -0
- sphynx/fast_search.py +285 -0
- sphynx/full_rag.py +341 -0
- sphynx/hosted/__init__.py +18 -0
- sphynx/hosted/benchmark_intake.py +218 -0
- sphynx/hosted/contracts.py +361 -0
- sphynx/hosted/model_admission.py +130 -0
- sphynx/hosted/model_artifacts.py +298 -0
- sphynx/hosted/model_contracts.py +63 -0
- sphynx/hosted/model_gcs.py +181 -0
- sphynx/hosted/model_history.py +84 -0
- sphynx/hosted/model_session.py +233 -0
- sphynx/hosted/public_suite.py +74 -0
- sphynx/hosted/registry.py +97 -0
- sphynx/hosted/revisions.py +126 -0
- sphynx/hosted/sandbox.py +105 -0
- sphynx/hosted/submission.py +287 -0
- sphynx/hosted_workflow.py +451 -0
- sphynx/http_transport.py +86 -0
- sphynx/integrated_rag.py +352 -0
- sphynx/integrated_workflow.py +552 -0
- sphynx/integration_contracts.py +474 -0
- sphynx/invocation.py +791 -0
- sphynx/invocation_candidates.py +392 -0
- sphynx/invocation_catalog_data.py +40 -0
- sphynx/learned_catalog_data.py +30 -0
- sphynx/learned_rerank.py +267 -0
- sphynx/legacy_full_rag.py +67 -0
- sphynx/lifecycle.py +885 -0
- sphynx/lifecycle_workflow.py +490 -0
- sphynx/metric_views.py +474 -0
- sphynx/metrics.py +282 -0
- sphynx/model_adapters.py +438 -0
- sphynx/model_catalog_data.py +30 -0
- sphynx/official_benchmarks.py +385 -0
- sphynx/packs.py +258 -0
- sphynx/pipeline.py +313 -0
- sphynx/pipeline_workflow.py +551 -0
- sphynx/pr_evaluations.py +560 -0
- sphynx/progress.py +89 -0
- sphynx/public_bundle.py +173 -0
- sphynx/public_eval.py +62 -0
- sphynx/public_rerank.py +355 -0
- sphynx/py.typed +0 -0
- sphynx/pytest_plugin.py +62 -0
- sphynx/question_robustness.py +437 -0
- sphynx/rag.py +329 -0
- sphynx/rag_diagnostics.py +309 -0
- sphynx/rag_papers/__init__.py +29 -0
- sphynx/rag_papers/__main__.py +45 -0
- sphynx/rag_papers/candidates.py +19 -0
- sphynx/rag_papers/contracts.py +282 -0
- sphynx/rag_papers/datasets.py +291 -0
- sphynx/rag_papers/experiments.py +116 -0
- sphynx/rag_papers/judges.py +308 -0
- sphynx/rag_papers/registry.py +33 -0
- sphynx/rag_papers/runner.py +176 -0
- sphynx/rag_papers/sable.py +135 -0
- sphynx/rag_papers/schemas.py +12 -0
- sphynx/rag_papers/scoring.py +249 -0
- sphynx/rag_papers/semantic.py +288 -0
- sphynx/rag_papers/trec.py +80 -0
- sphynx/recorded_scores.py +352 -0
- sphynx/records.py +124 -0
- sphynx/reflex_traces.py +193 -0
- sphynx/report_rendering.py +71 -0
- sphynx/rerank_family_contracts.py +150 -0
- sphynx/rerank_workflow.py +424 -0
- sphynx/research_candidate.py +190 -0
- sphynx/results.py +852 -0
- sphynx/retrieval.py +750 -0
- sphynx/retrieval_candidates.py +295 -0
- sphynx/retrieval_catalog_data.py +19 -0
- sphynx/retrieval_recordings.py +125 -0
- sphynx/reviewer_audit.py +266 -0
- sphynx/routing_policies.py +375 -0
- sphynx/runner.py +544 -0
- sphynx/sable_rerank.py +489 -0
- sphynx/score_reliability.py +393 -0
- sphynx/speech_benchmarks.py +204 -0
- sphynx/speech_campaigns.py +378 -0
- sphynx/speech_complete.py +236 -0
- sphynx/speech_contracts.py +225 -0
- sphynx/speech_evidence.py +429 -0
- sphynx/speech_listening.py +115 -0
- sphynx/speech_multilingual.py +71 -0
- sphynx/speech_tts.py +479 -0
- sphynx/sql/001_sync_v1.sql +30 -0
- sphynx/stage_contracts.py +199 -0
- sphynx/statistical_catalog_data.py +53 -0
- sphynx/statistical_rerank.py +232 -0
- sphynx/sync.py +797 -0
- sphynx/workbench.py +378 -0
- sphynx/workbench_catalog_data.py +13 -0
- sphynx/workbench_tracks.py +78 -0
- sphynx/workflow_async.py +197 -0
- sphynx/workflow_contracts.py +594 -0
- sphynx/workflow_decisions.py +211 -0
- sphynx/workflow_defaults.py +16 -0
- sphynx/workflow_extensions.py +212 -0
- sphynx/workflow_registry.py +513 -0
- 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
|
+
)
|