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
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"]}}