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
sphynx/retrieval.py
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
"""Owner-held full retrieval architecture evaluation.
|
|
2
|
+
|
|
3
|
+
Candidates own retrieval, fusion, reranking and context selection. Questions,
|
|
4
|
+
source bytes and provisional references are fixed; candidate pools may differ.
|
|
5
|
+
Local adapters are not sandboxed. Captures contain no reference labels and are
|
|
6
|
+
not currently compatible with the installed-pack cloud synchronization API.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import platform
|
|
15
|
+
import random
|
|
16
|
+
import statistics
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Callable, Iterable, Mapping
|
|
23
|
+
|
|
24
|
+
from .contracts import ContractError, canonical_json
|
|
25
|
+
from .evidence import EvidenceError
|
|
26
|
+
|
|
27
|
+
RETRIEVAL_PACK_ID = "rag.retrieve.v1"
|
|
28
|
+
RETRIEVAL_INTERFACE_VERSION = "sphynx.rag.retrieve.v1"
|
|
29
|
+
RETRIEVAL_DATASET_SCHEMA = "sphynx.rag.retrieve.dataset.v1"
|
|
30
|
+
RETRIEVAL_REQUEST_SCHEMA = "sphynx.rag.retrieve.request.v1"
|
|
31
|
+
RETRIEVAL_RESPONSE_SCHEMA = "sphynx.rag.retrieve.response.v1"
|
|
32
|
+
RETRIEVAL_METRIC_RELEASE = "rag.retrieve.metric.v1"
|
|
33
|
+
_RUN_SCHEMA = "sphynx.rag.retrieve.run.v1"
|
|
34
|
+
_EVAL_SCHEMA = "sphynx.rag.retrieve.evaluation.v1"
|
|
35
|
+
_STAGES = ("candidate", "fused", "reranked", "context")
|
|
36
|
+
_CHUNK_FIELDS = ("chunk_id", "document_id", "customer", "text", "text_sha256", "source_class", "source_uri")
|
|
37
|
+
_QUALITY = ("complete_evidence_success", "candidate_evidence_recall_at_50", "final_evidence_group_recall",
|
|
38
|
+
"ndcg_at_5", "hit_at_5", "mrr_at_5", "fused_evidence_group_recall", "reranked_evidence_group_recall")
|
|
39
|
+
_QUALIFICATION = "Provisional machine-generated source-anchor recovery; unjudged passages assumed zero for development only."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _digest(value: Any) -> str:
|
|
43
|
+
return "sha256:" + hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _bytes_digest(raw: bytes) -> str:
|
|
47
|
+
return "sha256:" + hashlib.sha256(raw).hexdigest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _text(value: Any, field: str) -> str:
|
|
51
|
+
if not isinstance(value, str) or not value.strip() or "\x00" in value:
|
|
52
|
+
raise ContractError(f"{field} must be nonempty text without NUL")
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _unique_ids(value: Any, field: str, *, empty: bool = True) -> list[str]:
|
|
57
|
+
if not isinstance(value, list) or (not empty and not value):
|
|
58
|
+
raise ContractError(f"{field} must be a {'nonempty ' if not empty else ''}list")
|
|
59
|
+
result = [_text(item, field) for item in value]
|
|
60
|
+
if len(result) != len(set(result)):
|
|
61
|
+
raise ContractError(f"{field} contains duplicate identifiers")
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _strict_json(raw: bytes) -> Any:
|
|
66
|
+
def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
67
|
+
result: dict[str, Any] = {}
|
|
68
|
+
for key, value in items:
|
|
69
|
+
if key in result:
|
|
70
|
+
raise ContractError("JSON contains duplicate keys")
|
|
71
|
+
result[key] = value
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
def invalid_constant(value: str) -> Any:
|
|
75
|
+
raise ContractError("JSON contains a nonfinite number")
|
|
76
|
+
|
|
77
|
+
def finite_float(value: str) -> float:
|
|
78
|
+
parsed = float(value)
|
|
79
|
+
if not math.isfinite(parsed):
|
|
80
|
+
raise ContractError("JSON floating-point exponent overflows to a nonfinite number")
|
|
81
|
+
return parsed
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
return json.loads(raw, object_pairs_hook=pairs, parse_constant=invalid_constant, parse_float=finite_float)
|
|
85
|
+
except (UnicodeError, json.JSONDecodeError) as exc:
|
|
86
|
+
raise ContractError("expected UTF-8 JSON") from exc
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class RetrievalPack:
|
|
91
|
+
dataset_digest: str
|
|
92
|
+
corpus_digest: str
|
|
93
|
+
cohort_digest: str
|
|
94
|
+
labels_digest: str
|
|
95
|
+
metadata: Mapping[str, Any]
|
|
96
|
+
corpus: tuple[Mapping[str, Any], ...]
|
|
97
|
+
cases: tuple[Mapping[str, Any], ...]
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def pack_id(self) -> str:
|
|
101
|
+
return RETRIEVAL_PACK_ID
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class RetrievalRun:
|
|
106
|
+
run_id: str
|
|
107
|
+
path: Path
|
|
108
|
+
manifest_sha256: str
|
|
109
|
+
pack_id: str = RETRIEVAL_PACK_ID
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> dict[str, Any]:
|
|
112
|
+
return {"run_id": self.run_id, "path": str(self.path), "manifest_sha256": self.manifest_sha256,
|
|
113
|
+
"pack_id": self.pack_id}
|
|
114
|
+
|
|
115
|
+
def to_json(self) -> str:
|
|
116
|
+
return canonical_json(self.to_dict())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class RetrievalEvaluation:
|
|
121
|
+
evaluation_id: str
|
|
122
|
+
path: Path
|
|
123
|
+
artifact_sha256: str
|
|
124
|
+
data: Mapping[str, Any]
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def metrics(self) -> Mapping[str, Mapping[str, Any]]:
|
|
128
|
+
return self.data["metrics"]
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def cases(self) -> tuple[Mapping[str, Any], ...]:
|
|
132
|
+
return tuple(self.data["cases"])
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def customer_slices(self) -> Mapping[str, Any]:
|
|
136
|
+
return self.data["customer_slices"]
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def run_id(self) -> str:
|
|
140
|
+
return self.data["run_id"]
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def status(self) -> str:
|
|
144
|
+
return self.data["status"]
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def pack_id(self) -> str:
|
|
148
|
+
return RETRIEVAL_PACK_ID
|
|
149
|
+
|
|
150
|
+
def to_dict(self) -> dict[str, Any]:
|
|
151
|
+
return _strict_json(canonical_json(dict(self.data)).encode("utf-8"))
|
|
152
|
+
|
|
153
|
+
def to_json(self) -> str:
|
|
154
|
+
return canonical_json(self.to_dict())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def load_retrieval_pack(dataset_path: str | Path, *, expected_sha256: str | None = None) -> RetrievalPack:
|
|
158
|
+
"""Load an external corpus and answerable cohort without installing data.
|
|
159
|
+
|
|
160
|
+
This initial release supports only the explicitly qualified development
|
|
161
|
+
anchor proxy. It cannot relabel machine-generated anchors as human qrels.
|
|
162
|
+
"""
|
|
163
|
+
raw = Path(dataset_path).expanduser().resolve(strict=True).read_bytes()
|
|
164
|
+
digest = _bytes_digest(raw)
|
|
165
|
+
if expected_sha256 is not None and "sha256:" + expected_sha256.removeprefix("sha256:") != digest:
|
|
166
|
+
raise ContractError("retrieval dataset differs from expected SHA-256")
|
|
167
|
+
value = _strict_json(raw)
|
|
168
|
+
if not isinstance(value, dict) or value.get("schema_version") != RETRIEVAL_DATASET_SCHEMA:
|
|
169
|
+
raise ContractError("unsupported retrieval dataset schema")
|
|
170
|
+
meta = value.get("metadata")
|
|
171
|
+
expected_meta = {"origin": "reconstructed_development", "label_status": "provisional",
|
|
172
|
+
"label_basis": "machine_generated_source_anchor_recovery",
|
|
173
|
+
"unjudged_policy": "assume_zero_development", "development_only": True}
|
|
174
|
+
if not isinstance(meta, dict) or any(meta.get(key) != expected for key, expected in expected_meta.items()):
|
|
175
|
+
raise ContractError("retrieval metadata must explicitly qualify provisional development labels")
|
|
176
|
+
if type(meta.get("development_only")) is not bool:
|
|
177
|
+
raise ContractError("development_only must be a JSON boolean")
|
|
178
|
+
corpus = value.get("corpus")
|
|
179
|
+
if not isinstance(corpus, list) or not corpus:
|
|
180
|
+
raise ContractError("retrieval corpus must be a nonempty list")
|
|
181
|
+
chunk_map: dict[str, dict[str, Any]] = {}
|
|
182
|
+
for item in corpus:
|
|
183
|
+
if not isinstance(item, dict):
|
|
184
|
+
raise ContractError("corpus chunks must be objects")
|
|
185
|
+
chunk = {field: _text(item.get(field), "chunk." + field) for field in _CHUNK_FIELDS}
|
|
186
|
+
text_sha = hashlib.sha256(chunk["text"].encode("utf-8")).hexdigest()
|
|
187
|
+
if chunk["text_sha256"].removeprefix("sha256:") != text_sha:
|
|
188
|
+
raise ContractError("corpus text digest does not match pinned bytes")
|
|
189
|
+
chunk["text_sha256"] = text_sha
|
|
190
|
+
if chunk["chunk_id"] in chunk_map:
|
|
191
|
+
raise ContractError("corpus chunk IDs must be globally unique")
|
|
192
|
+
chunk_map[chunk["chunk_id"]] = chunk
|
|
193
|
+
cases = value.get("cases")
|
|
194
|
+
if not isinstance(cases, list) or not cases:
|
|
195
|
+
raise ContractError("retrieval cases must be a nonempty list")
|
|
196
|
+
normalized: list[dict[str, Any]] = []
|
|
197
|
+
query_ids: set[str] = set()
|
|
198
|
+
for case in cases:
|
|
199
|
+
if not isinstance(case, dict):
|
|
200
|
+
raise ContractError("retrieval cases must be objects")
|
|
201
|
+
row = {field: _text(case.get(field), "case." + field)
|
|
202
|
+
for field in ("query_id", "query", "customer", "cluster_id")}
|
|
203
|
+
if row["query_id"] in query_ids:
|
|
204
|
+
raise ContractError("query IDs must be unique")
|
|
205
|
+
query_ids.add(row["query_id"])
|
|
206
|
+
context = case.get("conversation_context", [])
|
|
207
|
+
if not isinstance(context, (str, list)):
|
|
208
|
+
raise ContractError("conversation_context must be a string or JSON list")
|
|
209
|
+
# Preserve the frozen conversation rather than flattening differently
|
|
210
|
+
# for each architecture. No owner reference keys are serialized here.
|
|
211
|
+
row["conversation_context"] = _strict_json(canonical_json(context).encode("utf-8"))
|
|
212
|
+
refs = case.get("references")
|
|
213
|
+
if not isinstance(refs, dict):
|
|
214
|
+
raise ContractError("each case requires owner references")
|
|
215
|
+
groups = refs.get("evidence_groups")
|
|
216
|
+
qrels = refs.get("graded_relevance")
|
|
217
|
+
if not isinstance(groups, list) or not groups or not isinstance(qrels, dict):
|
|
218
|
+
raise ContractError("answerable cases require groups and graded relevance")
|
|
219
|
+
group_rows: list[dict[str, Any]] = []
|
|
220
|
+
group_ids: set[str] = set()
|
|
221
|
+
for group in groups:
|
|
222
|
+
if not isinstance(group, dict) or type(group.get("required")) is not bool:
|
|
223
|
+
raise ContractError("evidence groups require a boolean required field")
|
|
224
|
+
group_id = _text(group.get("evidence_group_id"), "evidence_group_id")
|
|
225
|
+
if group_id in group_ids:
|
|
226
|
+
raise ContractError("evidence group IDs must be unique within a case")
|
|
227
|
+
group_ids.add(group_id)
|
|
228
|
+
anchors = _unique_ids(group.get("acceptable_chunk_ids"), "acceptable_chunk_ids", empty=False)
|
|
229
|
+
for anchor in anchors:
|
|
230
|
+
if anchor not in chunk_map or chunk_map[anchor]["customer"] != row["customer"]:
|
|
231
|
+
raise ContractError("references must resolve within the case customer corpus")
|
|
232
|
+
if type(qrels.get(anchor)) is not int or not 1 <= qrels[anchor] <= 3:
|
|
233
|
+
raise ContractError("every acceptable anchor needs a positive graded relevance judgment")
|
|
234
|
+
group_rows.append({"evidence_group_id": group_id, "required": group["required"],
|
|
235
|
+
"acceptable_chunk_ids": sorted(anchors)})
|
|
236
|
+
if not any(group["required"] for group in group_rows):
|
|
237
|
+
raise ContractError("this pack requires corpus-answerable cases with required evidence")
|
|
238
|
+
for anchor, relevance in qrels.items():
|
|
239
|
+
if (anchor not in chunk_map or chunk_map[anchor]["customer"] != row["customer"]
|
|
240
|
+
or type(relevance) is not int or not 0 <= relevance <= 3):
|
|
241
|
+
raise ContractError("graded relevance must be 0..3 for same-customer corpus chunks")
|
|
242
|
+
row["references"] = {"evidence_groups": sorted(group_rows, key=lambda group: group["evidence_group_id"]),
|
|
243
|
+
"graded_relevance": dict(sorted(qrels.items()))}
|
|
244
|
+
normalized.append(row)
|
|
245
|
+
corpus_rows = tuple(chunk_map[key] for key in sorted(chunk_map))
|
|
246
|
+
case_rows = tuple(normalized)
|
|
247
|
+
cohort = [{key: row[key] for key in ("query_id", "query", "customer", "cluster_id", "conversation_context")}
|
|
248
|
+
for row in case_rows]
|
|
249
|
+
labels = [{"query_id": row["query_id"], "references": row["references"]} for row in case_rows]
|
|
250
|
+
return RetrievalPack(digest, _digest(corpus_rows), _digest(cohort), _digest(labels), meta, corpus_rows, case_rows)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _policy(max_context_bytes: int) -> dict[str, Any]:
|
|
254
|
+
if type(max_context_bytes) is not int or max_context_bytes <= 0:
|
|
255
|
+
raise ContractError("max_context_bytes must be a positive integer")
|
|
256
|
+
return {"policy_version": "v1", "candidate_depth": 50, "ranking_depth": 5,
|
|
257
|
+
"max_context_bytes": max_context_bytes, "context_separator": "\n\n",
|
|
258
|
+
"token_budget_policy": "utf8_byte_budget_proxy.v1", "trials": 1,
|
|
259
|
+
"execution_isolation": "local_unsandboxed"}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def build_retrieval_request(pack: RetrievalPack, case: str | Mapping[str, Any], *, max_context_bytes: int = 16000) -> dict[str, Any]:
|
|
263
|
+
"""Construct the only candidate-visible request, using an explicit allowlist."""
|
|
264
|
+
query_id = case if isinstance(case, str) else case.get("query_id")
|
|
265
|
+
row = next((item for item in pack.cases if item["query_id"] == query_id), None)
|
|
266
|
+
if row is None:
|
|
267
|
+
raise ContractError("query does not belong to retrieval pack")
|
|
268
|
+
value = {"schema_version": RETRIEVAL_REQUEST_SCHEMA, "pack_id": RETRIEVAL_PACK_ID,
|
|
269
|
+
"interface_version": RETRIEVAL_INTERFACE_VERSION, "query_id": row["query_id"],
|
|
270
|
+
"query": row["query"], "customer": row["customer"],
|
|
271
|
+
"conversation_context": _strict_json(canonical_json(row["conversation_context"]).encode("utf-8")),
|
|
272
|
+
"corpus_digest": pack.corpus_digest, "execution_policy": _policy(max_context_bytes),
|
|
273
|
+
"corpus": [{key: chunk[key] for key in _CHUNK_FIELDS}
|
|
274
|
+
for chunk in pack.corpus if chunk["customer"] == row["customer"]]}
|
|
275
|
+
value["request_sha256"] = _digest(value)
|
|
276
|
+
return value
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _candidate(candidate: Mapping[str, Any]) -> dict[str, Any]:
|
|
280
|
+
if not isinstance(candidate, Mapping):
|
|
281
|
+
raise ContractError("candidate metadata must be a mapping")
|
|
282
|
+
value = {"name": _text(candidate.get("name"), "candidate.name"),
|
|
283
|
+
"version": _text(candidate.get("version"), "candidate.version"),
|
|
284
|
+
"interface_version": candidate.get("interface_version", RETRIEVAL_INTERFACE_VERSION)}
|
|
285
|
+
if value["interface_version"] != RETRIEVAL_INTERFACE_VERSION:
|
|
286
|
+
raise ContractError("candidate interface must match retrieval pack")
|
|
287
|
+
architecture = candidate.get("architecture")
|
|
288
|
+
required = ("embedding", "index", "fusion", "reranker", "chunking", "context_selection", "code_revision")
|
|
289
|
+
if not isinstance(architecture, Mapping) or any(key not in architecture or not isinstance(architecture[key], (str, Mapping))
|
|
290
|
+
or not architecture[key] for key in required):
|
|
291
|
+
raise ContractError("architecture must explicitly pin embedding, index, fusion, reranker, chunking, context_selection and code_revision")
|
|
292
|
+
value["architecture"] = _strict_json(canonical_json(dict(architecture)).encode("utf-8"))
|
|
293
|
+
value["configuration_sha256"] = _digest(value)
|
|
294
|
+
value["configuration_authority"] = "caller_declared_not_code_attestation"
|
|
295
|
+
return value
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _response(raw: Any, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
299
|
+
if not isinstance(raw, dict) or raw.get("schema_version") != RETRIEVAL_RESPONSE_SCHEMA:
|
|
300
|
+
raise ContractError("unsupported retrieval response schema")
|
|
301
|
+
for field in ("query_id", "request_sha256", "corpus_digest"):
|
|
302
|
+
if raw.get(field) != request[field]:
|
|
303
|
+
raise ContractError("response does not echo its request identity")
|
|
304
|
+
stages = raw.get("stages")
|
|
305
|
+
if not isinstance(stages, dict) or set(stages) != set(_STAGES):
|
|
306
|
+
raise ContractError("response requires candidate, fused, reranked and context stages")
|
|
307
|
+
allowed = {chunk["chunk_id"]: chunk for chunk in request["corpus"]}
|
|
308
|
+
previous: set[str] | None = None
|
|
309
|
+
normalized: dict[str, list[str]] = {}
|
|
310
|
+
for stage in _STAGES:
|
|
311
|
+
ids = _unique_ids(stages[stage], "stages." + stage)
|
|
312
|
+
if any(chunk_id not in allowed for chunk_id in ids):
|
|
313
|
+
raise ContractError("response contains unknown or wrong-customer chunks")
|
|
314
|
+
if stage == "candidate" and len(ids) > 50:
|
|
315
|
+
raise ContractError("candidate stage exceeds depth 50")
|
|
316
|
+
if previous is not None and not set(ids) <= previous:
|
|
317
|
+
raise ContractError("each stage must be a subset of its preceding stage")
|
|
318
|
+
normalized[stage] = ids
|
|
319
|
+
previous = set(ids)
|
|
320
|
+
text = "\n\n".join(allowed[chunk_id]["text"] for chunk_id in normalized["context"])
|
|
321
|
+
if len(text.encode("utf-8")) > request["execution_policy"]["max_context_bytes"]:
|
|
322
|
+
raise ContractError("selected context exceeds the UTF-8 byte budget")
|
|
323
|
+
if "context_text" in raw and raw["context_text"] != text:
|
|
324
|
+
raise ContractError("candidate context text differs from pinned source text")
|
|
325
|
+
return {"schema_version": RETRIEVAL_RESPONSE_SCHEMA,
|
|
326
|
+
**{field: raw[field] for field in ("query_id", "request_sha256", "corpus_digest")},
|
|
327
|
+
"stages": normalized, "context_text": text, "context_bytes": len(text.encode("utf-8")),
|
|
328
|
+
"stage_ranks": {stage: [{"chunk_id": chunk_id, "rank": rank} for rank, chunk_id in enumerate(ids, 1)]
|
|
329
|
+
for stage, ids in normalized.items()}}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _write(path: Path, value: Any) -> str:
|
|
333
|
+
raw = (canonical_json(value) + "\n").encode("utf-8")
|
|
334
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
with path.open("xb") as stream:
|
|
336
|
+
stream.write(raw)
|
|
337
|
+
return _bytes_digest(raw)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _checked(root: Path, entry: Mapping[str, Any]) -> Any:
|
|
341
|
+
if not isinstance(entry, Mapping):
|
|
342
|
+
raise EvidenceError("artifact index entry must be an object")
|
|
343
|
+
relative = entry.get("path")
|
|
344
|
+
if not isinstance(relative, str) or Path(relative).is_absolute():
|
|
345
|
+
raise EvidenceError("artifact path is invalid")
|
|
346
|
+
target = (root / relative).resolve()
|
|
347
|
+
if not target.is_relative_to(root.resolve()):
|
|
348
|
+
raise EvidenceError("artifact path escapes bundle")
|
|
349
|
+
try:
|
|
350
|
+
raw = target.read_bytes()
|
|
351
|
+
except OSError as exc:
|
|
352
|
+
raise EvidenceError("artifact is missing") from exc
|
|
353
|
+
if _bytes_digest(raw) != entry.get("sha256"):
|
|
354
|
+
raise EvidenceError("retrieval artifact hash mismatch")
|
|
355
|
+
return _strict_json(raw)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _persist_capture(candidate: Mapping[str, Any], pack: RetrievalPack, responses: Iterable[Mapping[str, Any]],
|
|
359
|
+
output_dir: str | Path, max_context_bytes: int, provenance: str,
|
|
360
|
+
elapsed: Mapping[str, float] | None, runner_metadata: Mapping[str, Any] | None) -> RetrievalRun:
|
|
361
|
+
identity = _candidate(candidate)
|
|
362
|
+
policy = _policy(max_context_bytes)
|
|
363
|
+
known = {case["query_id"] for case in pack.cases}
|
|
364
|
+
by_query: dict[str, Mapping[str, Any]] = {}
|
|
365
|
+
for response in responses:
|
|
366
|
+
if not isinstance(response, Mapping) or response.get("query_id") not in known:
|
|
367
|
+
raise ContractError("capture responses must identify a known query")
|
|
368
|
+
query_id = response["query_id"]
|
|
369
|
+
if query_id in by_query:
|
|
370
|
+
raise ContractError("capture contains duplicate query responses")
|
|
371
|
+
# Batch generators may reuse a mutable mapping between yields. Freeze
|
|
372
|
+
# each observation immediately instead of retaining caller aliases.
|
|
373
|
+
try:
|
|
374
|
+
by_query[query_id] = _strict_json(canonical_json(dict(response)).encode("utf-8"))
|
|
375
|
+
except ContractError:
|
|
376
|
+
by_query[query_id] = {"query_id": query_id, "response": {"unserializable_response": "caller_supplied"}}
|
|
377
|
+
run_id = "run-" + uuid.uuid4().hex
|
|
378
|
+
root = Path(output_dir).expanduser().resolve() / run_id
|
|
379
|
+
root.mkdir(parents=True, exist_ok=False)
|
|
380
|
+
rows: list[dict[str, Any]] = []
|
|
381
|
+
for case in pack.cases:
|
|
382
|
+
request = build_retrieval_request(pack, case, max_context_bytes=max_context_bytes)
|
|
383
|
+
prefix = "captures/cap-" + uuid.uuid4().hex + "/"
|
|
384
|
+
artifacts: dict[str, Any] = {}
|
|
385
|
+
|
|
386
|
+
def save(role: str, value: Any) -> None:
|
|
387
|
+
relative = prefix + role + ".json"
|
|
388
|
+
artifacts[role] = {"path": relative, "sha256": _write(root / relative, value)}
|
|
389
|
+
|
|
390
|
+
save("request", request)
|
|
391
|
+
supplied = by_query.get(case["query_id"])
|
|
392
|
+
error_code: str | None = None
|
|
393
|
+
status = "missing_response"
|
|
394
|
+
if supplied is not None:
|
|
395
|
+
wrapper = "response" in supplied and "schema_version" not in supplied
|
|
396
|
+
raw = supplied.get("response") if wrapper else dict(supplied)
|
|
397
|
+
if wrapper and raw is None:
|
|
398
|
+
status, error_code = "candidate_error", str(supplied.get("error_class", "CandidateError"))
|
|
399
|
+
else:
|
|
400
|
+
try:
|
|
401
|
+
save("raw_response", raw)
|
|
402
|
+
validated = _response(raw, request)
|
|
403
|
+
save("validated_response", validated)
|
|
404
|
+
status = "success"
|
|
405
|
+
except ContractError:
|
|
406
|
+
if "raw_response" not in artifacts:
|
|
407
|
+
save("raw_response", {"unserializable_response": type(raw).__name__})
|
|
408
|
+
status, error_code = "invalid_response", "ContractError"
|
|
409
|
+
observed_ms = elapsed.get(case["query_id"]) if elapsed is not None else None
|
|
410
|
+
if observed_ms is not None and (type(observed_ms) not in (float, int) or not math.isfinite(observed_ms) or observed_ms < 0):
|
|
411
|
+
raise ContractError("observed adapter latency must be finite and nonnegative")
|
|
412
|
+
rows.append({"query_id": case["query_id"], "customer": case["customer"], "cluster_id": case["cluster_id"],
|
|
413
|
+
"status": status, "error_code": error_code, "artifacts": artifacts,
|
|
414
|
+
"adapter_wall_ms": observed_ms, "timing_authority": "adapter_observed" if observed_ms is not None else "unavailable"})
|
|
415
|
+
manifest = {"schema_version": _RUN_SCHEMA, "run_id": run_id, "pack_id": RETRIEVAL_PACK_ID,
|
|
416
|
+
"status": "completed" if all(row["status"] == "success" for row in rows) else "completed_with_failures",
|
|
417
|
+
"dataset_digest": pack.dataset_digest, "corpus_digest": pack.corpus_digest,
|
|
418
|
+
"cohort_digest": pack.cohort_digest, "labels_digest": pack.labels_digest,
|
|
419
|
+
"candidate": identity, "execution_policy": policy, "metric_release": RETRIEVAL_METRIC_RELEASE,
|
|
420
|
+
"trust_tier": "local_unverified", "provenance": provenance,
|
|
421
|
+
"runner": {"release": "sphynx.retrieval.local.v1", "python": f"{sys.version_info.major}.{sys.version_info.minor}",
|
|
422
|
+
"platform": platform.system(), "machine": platform.machine(),
|
|
423
|
+
"clock_domain": "python.perf_counter_ns.process_local"},
|
|
424
|
+
"runner_metadata": dict(runner_metadata or {}), "runner_metadata_authority": "caller_declared",
|
|
425
|
+
"candidate_reported_observations_authority": "candidate_reported_not_runner_verified",
|
|
426
|
+
"cases": rows, "case_count": len(rows)}
|
|
427
|
+
digest = _write(root / "manifest.json", manifest)
|
|
428
|
+
with (root / "manifest.sha256").open("x", encoding="ascii") as stream:
|
|
429
|
+
stream.write(digest + "\n")
|
|
430
|
+
return RetrievalRun(run_id, root, digest)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def capture_retrieval(candidate: Mapping[str, Any], dataset_path: str | Path, responses: Iterable[Mapping[str, Any]],
|
|
434
|
+
output_dir: str | Path, *, max_context_bytes: int = 16000,
|
|
435
|
+
expected_sha256: str | None = None, runner_metadata: Mapping[str, Any] | None = None) -> RetrievalRun:
|
|
436
|
+
"""Save batch responses as caller-supplied evidence, without timing claims.
|
|
437
|
+
|
|
438
|
+
Missing/invalid responses remain failed captures. An empty, valid retrieval
|
|
439
|
+
is a genuine zero-quality observation. Candidate timing/cost diagnostics
|
|
440
|
+
are retained in raw responses but are not runner-verified measurements.
|
|
441
|
+
"""
|
|
442
|
+
pack = load_retrieval_pack(dataset_path, expected_sha256=expected_sha256)
|
|
443
|
+
return _persist_capture(candidate, pack, responses, output_dir, max_context_bytes, "caller_supplied", None, runner_metadata)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def run_retrieval(candidate: Mapping[str, Any], adapter: Callable[[dict[str, Any]], Mapping[str, Any]],
|
|
447
|
+
dataset_path: str | Path, output_dir: str | Path, *, max_context_bytes: int = 16000,
|
|
448
|
+
expected_sha256: str | None = None, runner_metadata: Mapping[str, Any] | None = None) -> RetrievalRun:
|
|
449
|
+
"""Run all cases in-process and measure complete adapter wall time.
|
|
450
|
+
|
|
451
|
+
This local callable has no timeout or OS isolation. Use only trusted local
|
|
452
|
+
development candidates. Initialization outside the callable is excluded
|
|
453
|
+
and must be separately recorded if evaluating indexing/startup cost.
|
|
454
|
+
"""
|
|
455
|
+
pack = load_retrieval_pack(dataset_path, expected_sha256=expected_sha256)
|
|
456
|
+
_candidate(candidate)
|
|
457
|
+
_policy(max_context_bytes)
|
|
458
|
+
responses: list[Mapping[str, Any]] = []
|
|
459
|
+
elapsed: dict[str, float] = {}
|
|
460
|
+
for case in pack.cases:
|
|
461
|
+
request = build_retrieval_request(pack, case, max_context_bytes=max_context_bytes)
|
|
462
|
+
start = time.perf_counter_ns()
|
|
463
|
+
try:
|
|
464
|
+
raw = adapter(request)
|
|
465
|
+
# Copy immediately: adapters may reuse the same dict on every case.
|
|
466
|
+
try:
|
|
467
|
+
raw = _strict_json(canonical_json(dict(raw) if isinstance(raw, Mapping) else raw).encode("utf-8"))
|
|
468
|
+
except ContractError:
|
|
469
|
+
raw = {"unserializable_response": type(raw).__name__}
|
|
470
|
+
response = {"query_id": case["query_id"], "response": raw}
|
|
471
|
+
except Exception as exc:
|
|
472
|
+
response = {"query_id": case["query_id"], "response": None, "error_class": type(exc).__name__}
|
|
473
|
+
elapsed[case["query_id"]] = (time.perf_counter_ns() - start) / 1_000_000
|
|
474
|
+
responses.append(response)
|
|
475
|
+
return _persist_capture(candidate, pack, responses, output_dir, max_context_bytes, "adapter_observed", elapsed, runner_metadata)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def verify_retrieval_run(run: RetrievalRun | str | Path) -> dict[str, Any]:
|
|
479
|
+
"""Verify immutable artifact bytes; hashes are not a remote trust boundary."""
|
|
480
|
+
root = (run.path if isinstance(run, RetrievalRun) else Path(run)).expanduser().resolve()
|
|
481
|
+
try:
|
|
482
|
+
stored = (root / "manifest.sha256").read_text(encoding="ascii").strip()
|
|
483
|
+
except OSError as exc:
|
|
484
|
+
raise EvidenceError("retrieval manifest checksum is missing") from exc
|
|
485
|
+
if isinstance(run, RetrievalRun) and stored != run.manifest_sha256:
|
|
486
|
+
raise EvidenceError("retrieval run reference checksum differs")
|
|
487
|
+
manifest = _checked(root, {"path": "manifest.json", "sha256": stored})
|
|
488
|
+
if not isinstance(manifest, dict) or manifest.get("schema_version") != _RUN_SCHEMA:
|
|
489
|
+
raise EvidenceError("unsupported retrieval run manifest")
|
|
490
|
+
if manifest.get("run_id") != root.name or manifest.get("pack_id") != RETRIEVAL_PACK_ID:
|
|
491
|
+
raise EvidenceError("retrieval run identity differs")
|
|
492
|
+
rows = manifest.get("cases")
|
|
493
|
+
if not isinstance(rows, list) or manifest.get("case_count") != len(rows):
|
|
494
|
+
raise EvidenceError("retrieval run case inventory differs")
|
|
495
|
+
for row in rows:
|
|
496
|
+
if not isinstance(row, dict) or not isinstance(row.get("artifacts"), dict):
|
|
497
|
+
raise EvidenceError("retrieval capture inventory is invalid")
|
|
498
|
+
for entry in row["artifacts"].values():
|
|
499
|
+
_checked(root, entry)
|
|
500
|
+
return manifest
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _recall(groups: list[Mapping[str, Any]], ids: list[str]) -> float:
|
|
504
|
+
present = set(ids)
|
|
505
|
+
required = [group for group in groups if group["required"]]
|
|
506
|
+
return sum(bool(present.intersection(group["acceptable_chunk_ids"])) for group in required) / len(required)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _case_metrics(case: Mapping[str, Any], validated: Mapping[str, Any] | None) -> dict[str, float | None]:
|
|
510
|
+
if validated is None:
|
|
511
|
+
return dict.fromkeys(_QUALITY)
|
|
512
|
+
refs = case["references"]
|
|
513
|
+
stages = validated["stages"]
|
|
514
|
+
recalls = {stage: _recall(refs["evidence_groups"], stages[stage]) for stage in _STAGES}
|
|
515
|
+
ranking = stages["reranked"][:5]
|
|
516
|
+
grades = refs["graded_relevance"]
|
|
517
|
+
dcg = sum((2 ** grades.get(chunk_id, 0) - 1) / math.log2(rank + 1) for rank, chunk_id in enumerate(ranking, 1))
|
|
518
|
+
ideal = sum((2 ** grade - 1) / math.log2(rank + 1)
|
|
519
|
+
for rank, grade in enumerate(sorted(grades.values(), reverse=True)[:5], 1))
|
|
520
|
+
first = next((rank for rank, chunk_id in enumerate(ranking, 1) if grades.get(chunk_id, 0) > 0), None)
|
|
521
|
+
return {"complete_evidence_success": float(recalls["context"] == 1.0),
|
|
522
|
+
"candidate_evidence_recall_at_50": recalls["candidate"], "final_evidence_group_recall": recalls["context"],
|
|
523
|
+
"ndcg_at_5": dcg / ideal, "hit_at_5": float(first is not None), "mrr_at_5": 1 / first if first else 0.0,
|
|
524
|
+
"fused_evidence_group_recall": recalls["fused"], "reranked_evidence_group_recall": recalls["reranked"]}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _percentile(values: list[float], fraction: float) -> float:
|
|
528
|
+
ordered = sorted(values)
|
|
529
|
+
index = (len(ordered) - 1) * fraction
|
|
530
|
+
lo, hi = math.floor(index), math.ceil(index)
|
|
531
|
+
return ordered[lo] + (ordered[hi] - ordered[lo]) * (index - lo)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _aggregate(rows: list[Mapping[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
535
|
+
total = len(rows)
|
|
536
|
+
result: dict[str, dict[str, Any]] = {}
|
|
537
|
+
for metric in _QUALITY:
|
|
538
|
+
values = [row["values"][metric] for row in rows if row["values"][metric] is not None]
|
|
539
|
+
complete = len(values) == total
|
|
540
|
+
result[metric] = {"value": statistics.fmean(values) if complete and values else None,
|
|
541
|
+
"complete": complete, "eligible_cases": total, "scored_cases": len(values),
|
|
542
|
+
"missing_cases": total - len(values), "unit": "ratio", "direction": "higher",
|
|
543
|
+
"undefined_reason": None if complete else "missing_or_invalid_capture",
|
|
544
|
+
"qualification": _QUALIFICATION}
|
|
545
|
+
valid = sum(row["status"] == "success" for row in rows)
|
|
546
|
+
for name, numerator in (("valid_response_coverage", valid), ("failure_rate", total - valid)):
|
|
547
|
+
result[name] = {"value": numerator / total if total else None, "complete": True,
|
|
548
|
+
"eligible_cases": total, "scored_cases": total, "missing_cases": 0,
|
|
549
|
+
"unit": "ratio", "direction": "higher" if name == "valid_response_coverage" else "lower",
|
|
550
|
+
"undefined_reason": None}
|
|
551
|
+
timing = [row["adapter_wall_ms"] for row in rows if row["adapter_wall_ms"] is not None]
|
|
552
|
+
for metric, percentile in (("retrieval_latency_p50_ms", .5), ("retrieval_latency_p95_ms", .95)):
|
|
553
|
+
complete = len(timing) == total
|
|
554
|
+
result[metric] = {"value": _percentile(timing, percentile) if complete and timing else None,
|
|
555
|
+
"complete": complete, "eligible_cases": total, "scored_cases": len(timing),
|
|
556
|
+
"missing_cases": total - len(timing), "unit": "ms", "direction": "lower",
|
|
557
|
+
"undefined_reason": None if complete else "adapter_timing_not_observed",
|
|
558
|
+
"qualification": "Local adapter wall time; excludes initialization outside callable, no stage attribution."}
|
|
559
|
+
for metric, reason in (("grounded_answer_success", "planned_answer_grader_not_implemented"),
|
|
560
|
+
("cost_per_evidence_complete_query", "no_runner_verified_cost_observations")):
|
|
561
|
+
result[metric] = {"value": None, "complete": False, "eligible_cases": total, "scored_cases": 0,
|
|
562
|
+
"missing_cases": total, "unit": "ratio" if metric.startswith("grounded") else "USD",
|
|
563
|
+
"direction": "higher" if metric.startswith("grounded") else "lower", "undefined_reason": reason}
|
|
564
|
+
for record in result.values():
|
|
565
|
+
record.update({"eligible_count": record["eligible_cases"], "observed_count": record["scored_cases"],
|
|
566
|
+
"missing_count": record["missing_cases"], "undefined_count": 0})
|
|
567
|
+
if record.get("undefined_reason") in {"planned_answer_grader_not_implemented", "no_runner_verified_cost_observations", "adapter_timing_not_observed"}:
|
|
568
|
+
record["status"] = "unavailable"
|
|
569
|
+
else:
|
|
570
|
+
record["status"] = "complete" if record["complete"] else "incomplete"
|
|
571
|
+
return result
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def grade_retrieval(run: RetrievalRun | str | Path, dataset_path: str | Path, *, expected_sha256: str | None = None) -> RetrievalEvaluation:
|
|
575
|
+
"""Regrade only verified captures and persist a fresh immutable evaluation."""
|
|
576
|
+
pack = load_retrieval_pack(dataset_path, expected_sha256=expected_sha256)
|
|
577
|
+
root = (run.path if isinstance(run, RetrievalRun) else Path(run)).expanduser().resolve()
|
|
578
|
+
manifest = verify_retrieval_run(run)
|
|
579
|
+
for field in ("dataset_digest", "corpus_digest", "cohort_digest", "labels_digest"):
|
|
580
|
+
if manifest.get(field) != getattr(pack, field):
|
|
581
|
+
raise ContractError("retrieval run requires the exact matching dataset, corpus, cohort and labels")
|
|
582
|
+
if manifest.get("metric_release") != RETRIEVAL_METRIC_RELEASE or manifest.get("trust_tier") != "local_unverified":
|
|
583
|
+
raise EvidenceError("unsupported retrieval metric release or trust identity")
|
|
584
|
+
policy = manifest.get("execution_policy")
|
|
585
|
+
if not isinstance(policy, dict) or policy != _policy(policy.get("max_context_bytes")):
|
|
586
|
+
raise EvidenceError("retrieval execution policy differs")
|
|
587
|
+
if manifest.get("candidate") != _candidate(manifest.get("candidate", {})):
|
|
588
|
+
raise EvidenceError("candidate configuration digest differs")
|
|
589
|
+
if manifest.get("provenance") not in {"caller_supplied", "adapter_observed"}:
|
|
590
|
+
raise EvidenceError("retrieval capture provenance is invalid")
|
|
591
|
+
captures = manifest["cases"]
|
|
592
|
+
if len(captures) != len(pack.cases):
|
|
593
|
+
raise EvidenceError("retrieval run does not cover the complete cohort")
|
|
594
|
+
rows: list[dict[str, Any]] = []
|
|
595
|
+
for case, capture in zip(pack.cases, captures):
|
|
596
|
+
if any(capture.get(field) != case[field] for field in ("query_id", "customer", "cluster_id")):
|
|
597
|
+
raise EvidenceError("retrieval capture identity or order differs from cohort")
|
|
598
|
+
request = build_retrieval_request(pack, case, max_context_bytes=policy["max_context_bytes"])
|
|
599
|
+
artifacts = capture["artifacts"]
|
|
600
|
+
if "request" not in artifacts or _checked(root, artifacts["request"]) != request:
|
|
601
|
+
raise EvidenceError("saved request differs from label-free pinned input")
|
|
602
|
+
status = capture.get("status")
|
|
603
|
+
validated = None
|
|
604
|
+
if status == "success":
|
|
605
|
+
if "raw_response" not in artifacts or "validated_response" not in artifacts:
|
|
606
|
+
raise EvidenceError("successful capture lacks raw and validated response")
|
|
607
|
+
validated = _response(_checked(root, artifacts["raw_response"]), request)
|
|
608
|
+
if validated != _checked(root, artifacts["validated_response"]):
|
|
609
|
+
raise EvidenceError("saved stages/context differ from raw response")
|
|
610
|
+
elif status == "invalid_response":
|
|
611
|
+
if "validated_response" in artifacts or "raw_response" not in artifacts:
|
|
612
|
+
raise EvidenceError("invalid capture has an inconsistent artifact inventory")
|
|
613
|
+
try:
|
|
614
|
+
_response(_checked(root, artifacts["raw_response"]), request)
|
|
615
|
+
except ContractError:
|
|
616
|
+
pass
|
|
617
|
+
else:
|
|
618
|
+
raise EvidenceError("invalid capture contains a valid response")
|
|
619
|
+
elif status in {"missing_response", "candidate_error"}:
|
|
620
|
+
if "validated_response" in artifacts or "raw_response" in artifacts:
|
|
621
|
+
raise EvidenceError("failed capture contains a response")
|
|
622
|
+
else:
|
|
623
|
+
raise EvidenceError("unsupported capture status")
|
|
624
|
+
elapsed = capture.get("adapter_wall_ms")
|
|
625
|
+
if elapsed is not None and (manifest["provenance"] != "adapter_observed" or type(elapsed) not in (int, float)
|
|
626
|
+
or not math.isfinite(elapsed) or elapsed < 0):
|
|
627
|
+
raise EvidenceError("capture claims unsupported timing observations")
|
|
628
|
+
rows.append({"query_id": case["query_id"], "customer": case["customer"], "cluster_id": case["cluster_id"],
|
|
629
|
+
"status": status, "values": _case_metrics(case, validated), "adapter_wall_ms": elapsed,
|
|
630
|
+
"stage_counts": {stage: len(validated["stages"][stage]) for stage in _STAGES} if validated else None,
|
|
631
|
+
"context_bytes": validated["context_bytes"] if validated else None})
|
|
632
|
+
evaluation_id = "eval-" + uuid.uuid4().hex
|
|
633
|
+
data = {"schema_version": _EVAL_SCHEMA, "evaluation_id": evaluation_id, "run_id": manifest["run_id"],
|
|
634
|
+
"pack_id": RETRIEVAL_PACK_ID, "status": "completed" if all(row["status"] == "success" for row in rows) else "incomplete",
|
|
635
|
+
**{field: getattr(pack, field) for field in ("dataset_digest", "corpus_digest", "cohort_digest", "labels_digest")},
|
|
636
|
+
"origin": pack.metadata["origin"], "label_status": pack.metadata["label_status"],
|
|
637
|
+
"label_basis": pack.metadata["label_basis"], "unjudged_policy": pack.metadata["unjudged_policy"],
|
|
638
|
+
"development_only": True, "qualification": _QUALIFICATION, "trust_tier": "local_unverified",
|
|
639
|
+
"metric_semantics": _QUALIFICATION + " Complete-evidence means complete known anchor groups, not reviewed answer sufficiency. Context uses a UTF-8 byte budget proxy, not model tokens.",
|
|
640
|
+
"candidate": manifest["candidate"], "execution_policy": policy, "runner": manifest["runner"],
|
|
641
|
+
"source_run_manifest_sha256": (root / "manifest.sha256").read_text(encoding="ascii").strip(),
|
|
642
|
+
"provenance": manifest["provenance"], "runner_metadata": manifest["runner_metadata"],
|
|
643
|
+
"metric_release": RETRIEVAL_METRIC_RELEASE, "case_count": len(rows), "cases": rows,
|
|
644
|
+
"success_count": sum(row["status"] == "success" for row in rows),
|
|
645
|
+
"failure_count": sum(row["status"] != "success" for row in rows),
|
|
646
|
+
"metrics": _aggregate(rows), "customer_slices": {customer: {"case_count": sum(row["customer"] == customer for row in rows),
|
|
647
|
+
"metrics": _aggregate([row for row in rows if row["customer"] == customer])}
|
|
648
|
+
for customer in sorted({row["customer"] for row in rows})}}
|
|
649
|
+
evaluation_path = root / "evaluations" / evaluation_id
|
|
650
|
+
digest = _write(evaluation_path / "evaluation.json", data)
|
|
651
|
+
with (evaluation_path / "evaluation.sha256").open("x", encoding="ascii") as stream:
|
|
652
|
+
stream.write(digest + "\n")
|
|
653
|
+
return RetrievalEvaluation(evaluation_id, evaluation_path, digest, data)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _read_evaluation(evaluation: RetrievalEvaluation | str | Path) -> dict[str, Any]:
|
|
657
|
+
path = evaluation.path if isinstance(evaluation, RetrievalEvaluation) else Path(evaluation).expanduser().resolve()
|
|
658
|
+
try:
|
|
659
|
+
stored = (path / "evaluation.sha256").read_text(encoding="ascii").strip()
|
|
660
|
+
except OSError as exc:
|
|
661
|
+
raise EvidenceError("evaluation checksum is missing") from exc
|
|
662
|
+
if isinstance(evaluation, RetrievalEvaluation) and stored != evaluation.artifact_sha256:
|
|
663
|
+
raise EvidenceError("evaluation reference checksum differs")
|
|
664
|
+
data = _checked(path, {"path": "evaluation.json", "sha256": stored})
|
|
665
|
+
if not isinstance(data, dict) or data.get("schema_version") != _EVAL_SCHEMA or data.get("evaluation_id") != path.name:
|
|
666
|
+
raise EvidenceError("evaluation schema or identity differs")
|
|
667
|
+
if isinstance(evaluation, RetrievalEvaluation) and data != evaluation.to_dict():
|
|
668
|
+
raise EvidenceError("in-memory evaluation differs from its verified artifact")
|
|
669
|
+
# An evaluation remains tied to its source captures, not only its cached
|
|
670
|
+
# aggregate bytes. A changed source artifact also invalidates comparison.
|
|
671
|
+
run_root = path.parent.parent
|
|
672
|
+
run_manifest = verify_retrieval_run(RetrievalRun(data["run_id"], run_root, data["source_run_manifest_sha256"]))
|
|
673
|
+
for field in ("corpus_digest", "cohort_digest", "labels_digest", "candidate", "execution_policy", "runner", "provenance"):
|
|
674
|
+
if data.get(field) != run_manifest.get(field):
|
|
675
|
+
raise EvidenceError("evaluation does not match its source run")
|
|
676
|
+
return data
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _bootstrap(pairs: list[dict[str, Any]], *, samples: int = 2000) -> dict[str, Any]:
|
|
680
|
+
groups: dict[tuple[str, str], list[float]] = {}
|
|
681
|
+
for pair in pairs:
|
|
682
|
+
groups.setdefault((pair["customer"], pair["cluster_id"]), []).append(pair["delta_b_minus_a"])
|
|
683
|
+
customers = sorted({key[0] for key in groups})
|
|
684
|
+
keys_by_customer = {customer: sorted(key for key in groups if key[0] == customer) for customer in customers}
|
|
685
|
+
if not groups:
|
|
686
|
+
return {"lower": None, "upper": None, "confidence": .95, "samples": 0, "cluster_count": 0}
|
|
687
|
+
rng = random.Random(0)
|
|
688
|
+
values = []
|
|
689
|
+
for _ in range(samples):
|
|
690
|
+
# Resample whole families within each customer, retaining the original
|
|
691
|
+
# customer mixture even when family sizes differ.
|
|
692
|
+
weighted = 0.0
|
|
693
|
+
for customer, keys in keys_by_customer.items():
|
|
694
|
+
sampled = [delta for _ in keys for delta in groups[keys[rng.randrange(len(keys))]]]
|
|
695
|
+
weight = sum(len(groups[key]) for key in keys) / len(pairs)
|
|
696
|
+
weighted += statistics.fmean(sampled) * weight
|
|
697
|
+
values.append(weighted)
|
|
698
|
+
return {"lower": _percentile(values, .025), "upper": _percentile(values, .975),
|
|
699
|
+
"confidence": .95, "samples": samples, "cluster_count": len(groups), "seed": 0,
|
|
700
|
+
"unit": "paired_cluster_bootstrap_stratified_by_customer", "qualification": "Development cohort uncertainty; not population generalization."}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def compare_retrieval(a: RetrievalEvaluation | str | Path, b: RetrievalEvaluation | str | Path) -> dict[str, Any]:
|
|
704
|
+
"""Pair quality across fixed corpus/cohort/labels, allowing different pools.
|
|
705
|
+
|
|
706
|
+
Timing deltas additionally require matching measurement scopes and declared
|
|
707
|
+
environment metadata. Existing frozen-pool reranker guards are unchanged.
|
|
708
|
+
"""
|
|
709
|
+
left, right = _read_evaluation(a), _read_evaluation(b)
|
|
710
|
+
fields = ("pack_id", "corpus_digest", "cohort_digest", "labels_digest", "metric_release", "execution_policy",
|
|
711
|
+
"label_basis", "label_status", "unjudged_policy", "trust_tier", "runner")
|
|
712
|
+
for field in fields:
|
|
713
|
+
if left.get(field) != right.get(field):
|
|
714
|
+
raise ContractError("retrieval comparisons require matching " + field)
|
|
715
|
+
if [row["query_id"] for row in left["cases"]] != [row["query_id"] for row in right["cases"]]:
|
|
716
|
+
raise ContractError("retrieval comparisons require the complete ordered cohort")
|
|
717
|
+
timing_metadata = left["runner_metadata"]
|
|
718
|
+
timing_ok = (left["provenance"] == right["provenance"] == "adapter_observed"
|
|
719
|
+
and timing_metadata == right["runner_metadata"]
|
|
720
|
+
and isinstance(timing_metadata, dict)
|
|
721
|
+
and bool(timing_metadata.get("environment")) and bool(timing_metadata.get("timing_scope")))
|
|
722
|
+
metrics: dict[str, Any] = {}
|
|
723
|
+
for metric in _QUALITY:
|
|
724
|
+
pairs = [{"query_id": x["query_id"], "customer": x["customer"], "cluster_id": x["cluster_id"],
|
|
725
|
+
"a": x["values"][metric], "b": y["values"][metric],
|
|
726
|
+
"delta_b_minus_a": y["values"][metric] - x["values"][metric]}
|
|
727
|
+
for x, y in zip(left["cases"], right["cases"])
|
|
728
|
+
if x["values"][metric] is not None and y["values"][metric] is not None]
|
|
729
|
+
av, bv = left["metrics"][metric]["value"], right["metrics"][metric]["value"]
|
|
730
|
+
complete = len(pairs) == left["case_count"]
|
|
731
|
+
metrics[metric] = {"a": av, "b": bv, "delta_b_minus_a": bv - av if av is not None and bv is not None else None,
|
|
732
|
+
"paired_case_count": len(pairs), "eligible_case_count": left["case_count"], "complete": complete,
|
|
733
|
+
"paired_cases": pairs, "paired_cluster_bootstrap_95_ci": _bootstrap(pairs) if complete else {
|
|
734
|
+
"lower": None, "upper": None, "undefined_reason": "incomplete_paired_cohort"}}
|
|
735
|
+
for metric in ("retrieval_latency_p50_ms", "retrieval_latency_p95_ms"):
|
|
736
|
+
av, bv = left["metrics"][metric]["value"], right["metrics"][metric]["value"]
|
|
737
|
+
metrics[metric] = {"a": av, "b": bv, "delta_b_minus_a": bv - av if timing_ok and av is not None and bv is not None else None,
|
|
738
|
+
"comparison_eligible": timing_ok,
|
|
739
|
+
"undefined_reason": None if timing_ok and av is not None and bv is not None else "timing_scope_or_observation_mismatch"}
|
|
740
|
+
return {"schema_version": "sphynx.rag.retrieve.comparison.v1", "pack_id": RETRIEVAL_PACK_ID,
|
|
741
|
+
"a_evaluation_id": left["evaluation_id"], "b_evaluation_id": right["evaluation_id"],
|
|
742
|
+
"corpus_digest": left["corpus_digest"], "cohort_digest": left["cohort_digest"], "labels_digest": left["labels_digest"],
|
|
743
|
+
"case_count": left["case_count"], "qualification": _QUALIFICATION, "development_only": True,
|
|
744
|
+
"metrics": metrics, "customer_slices": {customer: {metric: {
|
|
745
|
+
"a": left["customer_slices"][customer]["metrics"][metric]["value"],
|
|
746
|
+
"b": right["customer_slices"][customer]["metrics"][metric]["value"],
|
|
747
|
+
"delta_b_minus_a": right["customer_slices"][customer]["metrics"][metric]["value"] - left["customer_slices"][customer]["metrics"][metric]["value"]
|
|
748
|
+
if left["customer_slices"][customer]["metrics"][metric]["value"] is not None
|
|
749
|
+
and right["customer_slices"][customer]["metrics"][metric]["value"] is not None else None}
|
|
750
|
+
for metric in _QUALITY} for customer in left["customer_slices"]}}
|