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/contracts.py ADDED
@@ -0,0 +1,514 @@
1
+ """Versioned frozen-pool reranking contracts.
2
+
3
+ This module separates a candidate's observable input from reference labels.
4
+ Only :meth:`CandidateRequest.to_dict` may be serialized to a candidate process.
5
+ It builds a fresh allowlisted mapping; it does not serialize a case or reference
6
+ dataclass recursively.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import math
14
+ import re
15
+ from dataclasses import dataclass
16
+ from typing import Any, Mapping, Sequence
17
+
18
+ INTERFACE_VERSION = "sphynx.rag.rerank.v1"
19
+ POOL_SCHEMA_VERSION = "sphynx.rag.rerank.pool.v1"
20
+ REQUEST_SCHEMA_VERSION = "sphynx.rag.rerank.request.v1"
21
+ RESPONSE_SCHEMA_VERSION = "sphynx.rag.rerank.response.v1"
22
+ CASE_SCHEMA_VERSION = "rag-evals.dataset.v2"
23
+ PACK_ID = "rag.rerank.v1"
24
+ SABLE_RERANK_DEV_PACK_ID = "rag.rerank.sable.dev.v1"
25
+ PUBLIC_RERANK_PACK_ID = "rag.rerank.beir.v1"
26
+
27
+ _ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
28
+ _DATASET_SPLITS = frozenset({"dev", "test", "holdout"})
29
+ _SUITE_TYPES = frozenset(
30
+ {"core", "customer_acceptance", "production_failure", "adversarial_robustness", "untouched_holdout"}
31
+ )
32
+
33
+
34
+ class ContractError(ValueError):
35
+ """A versioned SDK contract is malformed or does not match its request."""
36
+
37
+
38
+ def _id(value: object, field: str) -> str:
39
+ if not isinstance(value, str) or not _ID.fullmatch(value):
40
+ raise ContractError(f"{field} must be a nonempty stable identifier")
41
+ return value
42
+
43
+
44
+ def _text(value: object, field: str) -> str:
45
+ if not isinstance(value, str) or not value.strip() or "\x00" in value:
46
+ raise ContractError(f"{field} must be nonempty text without NUL bytes")
47
+ return value
48
+
49
+
50
+ def _finite_number(value: object, field: str) -> float:
51
+ if type(value) not in (int, float):
52
+ raise ContractError(f"{field} must be a finite JSON number")
53
+ try:
54
+ number = float(value)
55
+ except OverflowError as exc:
56
+ raise ContractError(f"{field} must be a finite JSON number") from exc
57
+ if not math.isfinite(number):
58
+ raise ContractError(f"{field} must be a finite JSON number")
59
+ return number
60
+
61
+
62
+ def _tuple_of_ids(values: object, field: str, *, empty: bool = True) -> tuple[str, ...]:
63
+ if isinstance(values, (str, bytes)) or not isinstance(values, Sequence):
64
+ raise ContractError(f"{field} must be a sequence of identifiers")
65
+ result = tuple(_id(value, f"{field}[{index}]") for index, value in enumerate(values))
66
+ if not empty and not result:
67
+ raise ContractError(f"{field} must not be empty")
68
+ if len(set(result)) != len(result):
69
+ raise ContractError(f"{field} must contain unique identifiers")
70
+ return result
71
+
72
+
73
+ def canonical_json(value: Any) -> str:
74
+ """Canonical UTF-8 JSON form for v1 digests and wire messages.
75
+
76
+ Object keys are lexical, arrays retain order, whitespace is omitted, Unicode
77
+ is not ASCII-escaped, and NaN/Infinity are rejected. The caller supplies only
78
+ JSON data (not dataclasses). The digest hashes the UTF-8 encoded result.
79
+ """
80
+
81
+ try:
82
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
83
+ except (TypeError, ValueError) as exc:
84
+ raise ContractError(f"value is not canonical JSON: {exc}") from exc
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class CandidateSpec:
89
+ """A versioned candidate command, invoked by argv without a shell."""
90
+
91
+ name: str
92
+ version: str
93
+ argv: tuple[str, ...]
94
+ interface_version: str
95
+
96
+ def __post_init__(self) -> None:
97
+ _id(self.name, "name")
98
+ _id(self.version, "version")
99
+ _id(self.interface_version, "interface_version")
100
+ if isinstance(self.argv, (str, bytes)) or not isinstance(self.argv, Sequence):
101
+ raise ContractError("argv must be a nonempty sequence of command tokens")
102
+ argv = tuple(self.argv)
103
+ if not argv:
104
+ raise ContractError("argv must not be empty")
105
+ for index, token in enumerate(argv):
106
+ _text(token, f"argv[{index}]")
107
+ object.__setattr__(self, "argv", argv)
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class CorpusVersions:
112
+ corpus: str
113
+ index: str
114
+ chunking: str
115
+ embedding: str
116
+
117
+ def __post_init__(self) -> None:
118
+ for field in ("corpus", "index", "chunking", "embedding"):
119
+ _id(getattr(self, field), f"versions.{field}")
120
+
121
+ def to_dict(self) -> dict[str, str]:
122
+ return {
123
+ "corpus": self.corpus,
124
+ "index": self.index,
125
+ "chunking": self.chunking,
126
+ "embedding": self.embedding,
127
+ }
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class FrozenCandidate:
132
+ """An ordered passage in an immutable pool, without grading labels."""
133
+
134
+ candidate_id: str
135
+ document_id: str
136
+ source_id: str
137
+ chunk_id: str
138
+ url: str
139
+ input: str
140
+ dense_rank: int
141
+ dense_score: float
142
+
143
+ def __post_init__(self) -> None:
144
+ for field in ("candidate_id", "document_id", "source_id", "chunk_id"):
145
+ _id(getattr(self, field), field)
146
+ _text(self.url, "url")
147
+ _text(self.input, "input")
148
+ if type(self.dense_rank) is not int or self.dense_rank < 1:
149
+ raise ContractError("dense_rank must be a positive integer")
150
+ _finite_number(self.dense_score, "dense_score")
151
+
152
+ def _digest_dict(self) -> dict[str, str | int | float]:
153
+ return {
154
+ "candidate_id": self.candidate_id,
155
+ "document_id": self.document_id,
156
+ "source_id": self.source_id,
157
+ "chunk_id": self.chunk_id,
158
+ "url": self.url,
159
+ "input": self.input,
160
+ "dense_rank": self.dense_rank,
161
+ "dense_score": self.dense_score,
162
+ }
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class FrozenPoolCase:
167
+ """The label-free identity and cohort of one frozen-pool test case.
168
+
169
+ Cohort fields stay with the case and its digest, not the candidate request.
170
+ """
171
+
172
+ dataset_id: str
173
+ dataset_version: str
174
+ case_id: str
175
+ query_id: str
176
+ query: str
177
+ split: str
178
+ split_group_id: str
179
+ cluster_id: str
180
+ suite_type: str
181
+ versions: CorpusVersions
182
+ candidates: tuple[FrozenCandidate, ...]
183
+
184
+ def __post_init__(self) -> None:
185
+ for field in ("dataset_id", "dataset_version", "case_id", "query_id"):
186
+ _id(getattr(self, field), field)
187
+ _text(self.query, "query")
188
+ _id(self.split_group_id, "split_group_id")
189
+ _id(self.cluster_id, "cluster_id")
190
+ if not isinstance(self.split, str) or self.split not in _DATASET_SPLITS:
191
+ raise ContractError("split must be dev, test, or holdout")
192
+ if not isinstance(self.suite_type, str) or self.suite_type not in _SUITE_TYPES:
193
+ raise ContractError("suite_type must be a supported dataset-v2 suite type")
194
+ if (self.split == "holdout") != (self.suite_type == "untouched_holdout"):
195
+ raise ContractError("holdout split and untouched_holdout suite_type must be used together")
196
+ if not isinstance(self.versions, CorpusVersions):
197
+ raise ContractError("versions must be CorpusVersions")
198
+ if isinstance(self.candidates, (str, bytes)) or not isinstance(self.candidates, Sequence):
199
+ raise ContractError("candidates must be an ordered sequence")
200
+ candidates = tuple(self.candidates)
201
+ if not candidates:
202
+ raise ContractError("candidates must not be empty")
203
+ if any(not isinstance(candidate, FrozenCandidate) for candidate in candidates):
204
+ raise ContractError("candidates must contain FrozenCandidate values")
205
+ ids = [candidate.candidate_id for candidate in candidates]
206
+ if len(set(ids)) != len(ids):
207
+ raise ContractError("candidate IDs must be unique within a pool")
208
+ for index, candidate in enumerate(candidates, start=1):
209
+ if candidate.dense_rank != index:
210
+ raise ContractError(f"candidate {candidate.candidate_id} dense_rank must equal pool position {index}")
211
+ object.__setattr__(self, "candidates", candidates)
212
+
213
+ @property
214
+ def pool_digest(self) -> str:
215
+ return pool_digest(self)
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class ReferenceCandidate:
220
+ """Reference judgment. Never pass this type to a candidate request."""
221
+
222
+ candidate_id: str
223
+ relevance_grade: int
224
+ evidence_group_ids: tuple[str, ...]
225
+
226
+ def __post_init__(self) -> None:
227
+ _id(self.candidate_id, "candidate_id")
228
+ if type(self.relevance_grade) is not int or not 0 <= self.relevance_grade <= 3:
229
+ raise ContractError("relevance_grade must be an integer from 0 through 3")
230
+ object.__setattr__(self, "evidence_group_ids", _tuple_of_ids(self.evidence_group_ids, "evidence_group_ids"))
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class EvidenceGroup:
235
+ evidence_group_id: str
236
+ description: str
237
+ required: bool
238
+ acceptable_candidate_ids: tuple[str, ...]
239
+
240
+ def __post_init__(self) -> None:
241
+ _id(self.evidence_group_id, "evidence_group_id")
242
+ _text(self.description, "description")
243
+ if type(self.required) is not bool:
244
+ raise ContractError("required must be a boolean")
245
+ object.__setattr__(
246
+ self,
247
+ "acceptable_candidate_ids",
248
+ _tuple_of_ids(self.acceptable_candidate_ids, "acceptable_candidate_ids"),
249
+ )
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class CaseReferences:
254
+ """Protected-by-convention labels; local SDK use cannot make them secret."""
255
+
256
+ case_id: str
257
+ label_authority: str
258
+ candidates: tuple[ReferenceCandidate, ...]
259
+ evidence_groups: tuple[EvidenceGroup, ...]
260
+
261
+ def __post_init__(self) -> None:
262
+ _id(self.case_id, "case_id")
263
+ if self.label_authority not in ("synthetic_authored", "human_adjudicated", "provisional"):
264
+ raise ContractError("label_authority must be synthetic_authored, human_adjudicated, or provisional")
265
+ candidates = tuple(self.candidates)
266
+ groups = tuple(self.evidence_groups)
267
+ if any(not isinstance(item, ReferenceCandidate) for item in candidates):
268
+ raise ContractError("reference candidates must be ReferenceCandidate values")
269
+ if any(not isinstance(item, EvidenceGroup) for item in groups):
270
+ raise ContractError("evidence_groups must contain EvidenceGroup values")
271
+ if len({item.candidate_id for item in candidates}) != len(candidates):
272
+ raise ContractError("reference candidate IDs must be unique")
273
+ if len({item.evidence_group_id for item in groups}) != len(groups):
274
+ raise ContractError("evidence-group IDs must be unique")
275
+ object.__setattr__(self, "candidates", candidates)
276
+ object.__setattr__(self, "evidence_groups", groups)
277
+
278
+
279
+ def pool_digest(case: FrozenPoolCase) -> str:
280
+ """Hash ordered pool, cohort, source IDs/text, scores, query, and versions.
281
+
282
+ Protected reference labels and evidence-group links are intentionally absent.
283
+ The versioned preimage is canonical JSON encoded as UTF-8; array order matters.
284
+ """
285
+
286
+ if not isinstance(case, FrozenPoolCase):
287
+ raise ContractError("pool_digest requires FrozenPoolCase")
288
+ preimage = {
289
+ "schema_version": POOL_SCHEMA_VERSION,
290
+ "dataset_id": case.dataset_id,
291
+ "dataset_version": case.dataset_version,
292
+ "case_id": case.case_id,
293
+ "query_id": case.query_id,
294
+ "query": case.query,
295
+ "split": case.split,
296
+ "split_group_id": case.split_group_id,
297
+ "cluster_id": case.cluster_id,
298
+ "suite_type": case.suite_type,
299
+ "versions": case.versions.to_dict(),
300
+ "candidates": [candidate._digest_dict() for candidate in case.candidates],
301
+ }
302
+ return "sha256:" + hashlib.sha256(canonical_json(preimage).encode("utf-8")).hexdigest()
303
+
304
+
305
+ @dataclass(frozen=True)
306
+ class RequestCandidate:
307
+ candidate_id: str
308
+ input: str
309
+ dense_rank: int
310
+ dense_score: float
311
+
312
+ def to_dict(self) -> dict[str, str | int | float]:
313
+ return {
314
+ "candidate_id": self.candidate_id,
315
+ "input": self.input,
316
+ "dense_rank": self.dense_rank,
317
+ "dense_score": self.dense_score,
318
+ }
319
+
320
+
321
+ @dataclass(frozen=True)
322
+ class CandidateRequest:
323
+ """The only supported serialization boundary for a candidate process."""
324
+
325
+ pack_id: str
326
+ case_id: str
327
+ query_id: str
328
+ query: str
329
+ versions: CorpusVersions
330
+ pool_digest: str
331
+ candidates: tuple[RequestCandidate, ...]
332
+
333
+ def to_dict(self) -> dict[str, Any]:
334
+ # Keep the allowlist explicit. Never use dataclasses.asdict(case/references).
335
+ return {
336
+ "schema_version": REQUEST_SCHEMA_VERSION,
337
+ "pack_id": self.pack_id,
338
+ "case_id": self.case_id,
339
+ "query_id": self.query_id,
340
+ "query": self.query,
341
+ "versions": self.versions.to_dict(),
342
+ "pool_digest": self.pool_digest,
343
+ "candidates": [candidate.to_dict() for candidate in self.candidates],
344
+ }
345
+
346
+ def to_json(self) -> str:
347
+ return canonical_json(self.to_dict())
348
+
349
+
350
+
351
+ def validate_candidate_request(value: object) -> CandidateRequest:
352
+ """Validate the exact label-free v1 wire shape, without exposing payloads.
353
+
354
+ The digest is an opaque identity supplied by the runner: the label-free
355
+ request omits cohort/source fields needed to recompute the frozen pool.
356
+ """
357
+
358
+ fields = {"schema_version", "pack_id", "case_id", "query_id", "query", "versions", "pool_digest", "candidates"}
359
+ if not isinstance(value, Mapping) or set(value) != fields:
360
+ raise ContractError(f"request fields must be exactly {sorted(fields)}")
361
+ if value["schema_version"] != REQUEST_SCHEMA_VERSION:
362
+ raise ContractError(f"request schema_version must be {REQUEST_SCHEMA_VERSION}")
363
+ if value["pack_id"] not in (PACK_ID, SABLE_RERANK_DEV_PACK_ID, PUBLIC_RERANK_PACK_ID):
364
+ raise ContractError("request pack_id must be a supported v1 reranking pack")
365
+ case_id = _id(value["case_id"], "request case_id")
366
+ query_id = _id(value["query_id"], "request query_id")
367
+ query = _text(value["query"], "request query")
368
+ digest = value["pool_digest"]
369
+ if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
370
+ raise ContractError("request pool_digest must be a sha256 digest")
371
+ versions = value["versions"]
372
+ version_fields = {"corpus", "index", "chunking", "embedding"}
373
+ if not isinstance(versions, Mapping) or set(versions) != version_fields:
374
+ raise ContractError(f"request versions fields must be exactly {sorted(version_fields)}")
375
+ parsed_versions = CorpusVersions(**versions)
376
+ raw_candidates = value["candidates"]
377
+ if not isinstance(raw_candidates, list) or not raw_candidates:
378
+ raise ContractError("request candidates must be a nonempty JSON array")
379
+ candidates: list[RequestCandidate] = []
380
+ seen: set[str] = set()
381
+ for index, item in enumerate(raw_candidates):
382
+ prefix = f"request candidates[{index}]"
383
+ if not isinstance(item, Mapping) or set(item) != {"candidate_id", "input", "dense_rank", "dense_score"}:
384
+ raise ContractError(f"{prefix} must contain exactly candidate_id, input, dense_rank, dense_score")
385
+ candidate_id = _id(item["candidate_id"], f"{prefix}.candidate_id")
386
+ if candidate_id in seen:
387
+ raise ContractError("request candidate IDs must be unique")
388
+ seen.add(candidate_id)
389
+ passage = _text(item["input"], f"{prefix}.input")
390
+ rank = item["dense_rank"]
391
+ if type(rank) is not int or rank != index + 1:
392
+ raise ContractError(f"{prefix}.dense_rank must equal pool position {index + 1}")
393
+ number = item["dense_score"]
394
+ _finite_number(number, f"{prefix}.dense_score")
395
+ candidates.append(RequestCandidate(candidate_id, passage, rank, number))
396
+ return CandidateRequest(value["pack_id"], case_id, query_id, query, parsed_versions, digest, tuple(candidates))
397
+
398
+
399
+ def parse_candidate_request(payload: str | bytes) -> CandidateRequest:
400
+ """Parse one strict UTF-8 JSON request; reject repeated keys and constants."""
401
+
402
+ def unique_fields(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
403
+ result: dict[str, Any] = {}
404
+ for key, item in pairs:
405
+ if key in result:
406
+ raise ContractError("request JSON contains a duplicate field")
407
+ result[key] = item
408
+ return result
409
+
410
+ def invalid_constant(value: str) -> None:
411
+ raise ContractError("request JSON contains a non-finite number")
412
+
413
+ if not isinstance(payload, (str, bytes)):
414
+ raise ContractError("request payload must be UTF-8 JSON text or bytes")
415
+ try:
416
+ text = payload.decode("utf-8", errors="strict") if isinstance(payload, bytes) else payload
417
+ value = json.loads(text, object_pairs_hook=unique_fields, parse_constant=invalid_constant)
418
+ except ContractError:
419
+ raise
420
+ except (UnicodeError, ValueError, RecursionError) as exc:
421
+ raise ContractError("request must contain one valid UTF-8 JSON object") from exc
422
+ return validate_candidate_request(value)
423
+
424
+
425
+ def build_candidate_request(case: FrozenPoolCase, pack_id: str = PACK_ID) -> CandidateRequest:
426
+ if not isinstance(case, FrozenPoolCase):
427
+ raise ContractError("build_candidate_request requires FrozenPoolCase")
428
+ if pack_id not in (PACK_ID, SABLE_RERANK_DEV_PACK_ID, PUBLIC_RERANK_PACK_ID):
429
+ raise ContractError(f"request builder does not support {pack_id!r}")
430
+ return CandidateRequest(
431
+ pack_id=pack_id,
432
+ case_id=case.case_id,
433
+ query_id=case.query_id,
434
+ query=case.query,
435
+ versions=case.versions,
436
+ pool_digest=case.pool_digest,
437
+ candidates=tuple(
438
+ RequestCandidate(item.candidate_id, item.input, item.dense_rank, item.dense_score)
439
+ for item in case.candidates
440
+ ),
441
+ )
442
+
443
+
444
+ @dataclass(frozen=True)
445
+ class CandidateScore:
446
+ candidate_id: str
447
+ ranking_score: float
448
+
449
+
450
+ @dataclass(frozen=True)
451
+ class CandidateResponse:
452
+ query_id: str
453
+ pool_digest: str
454
+ scores: tuple[CandidateScore, ...]
455
+
456
+ def to_dict(self) -> dict[str, Any]:
457
+ return {
458
+ "schema_version": RESPONSE_SCHEMA_VERSION,
459
+ "query_id": self.query_id,
460
+ "pool_digest": self.pool_digest,
461
+ "scores": [
462
+ {"candidate_id": score.candidate_id, "ranking_score": score.ranking_score}
463
+ for score in self.scores
464
+ ],
465
+ }
466
+
467
+
468
+ def validate_candidate_response(value: object, request: CandidateRequest) -> CandidateResponse:
469
+ """Validate exact response shape, full ID coverage, and request identity."""
470
+
471
+ if not isinstance(request, CandidateRequest):
472
+ raise ContractError("request must be CandidateRequest")
473
+ if not isinstance(value, Mapping):
474
+ raise ContractError("response must be a JSON object")
475
+ expected_fields = {"schema_version", "query_id", "pool_digest", "scores"}
476
+ if set(value) != expected_fields:
477
+ raise ContractError(f"response fields must be exactly {sorted(expected_fields)}")
478
+ if value["schema_version"] != RESPONSE_SCHEMA_VERSION:
479
+ raise ContractError(f"response schema_version must be {RESPONSE_SCHEMA_VERSION}")
480
+ if value["query_id"] != request.query_id:
481
+ raise ContractError("response query_id does not match request")
482
+ if value["pool_digest"] != request.pool_digest:
483
+ raise ContractError("response pool_digest does not match request")
484
+ raw_scores = value["scores"]
485
+ if not isinstance(raw_scores, list):
486
+ raise ContractError("response scores must be a JSON array")
487
+ scores: list[CandidateScore] = []
488
+ seen: set[str] = set()
489
+ for index, item in enumerate(raw_scores):
490
+ if not isinstance(item, Mapping) or set(item) != {"candidate_id", "ranking_score"}:
491
+ raise ContractError(f"scores[{index}] must contain exactly candidate_id and ranking_score")
492
+ candidate_id = _id(item["candidate_id"], f"scores[{index}].candidate_id")
493
+ if candidate_id in seen:
494
+ raise ContractError(f"duplicate score for candidate_id {candidate_id}")
495
+ seen.add(candidate_id)
496
+ scores.append(CandidateScore(candidate_id, _finite_number(item["ranking_score"], f"scores[{index}].ranking_score")))
497
+ expected_ids = {item.candidate_id for item in request.candidates}
498
+ if seen != expected_ids:
499
+ raise ContractError(f"response candidate IDs mismatch: missing={sorted(expected_ids - seen)}, extra={sorted(seen - expected_ids)}")
500
+ return CandidateResponse(query_id=request.query_id, pool_digest=request.pool_digest, scores=tuple(scores))
501
+
502
+
503
+ def ranked_candidate_ids(response: CandidateResponse, request: CandidateRequest) -> tuple[str, ...]:
504
+ """Descending score; ties follow the original frozen-pool order."""
505
+
506
+ if response.query_id != request.query_id or response.pool_digest != request.pool_digest:
507
+ raise ContractError("response identity does not match this request")
508
+ positions = {item.candidate_id: index for index, item in enumerate(request.candidates)}
509
+ if len(response.scores) != len(positions) or {item.candidate_id for item in response.scores} != set(positions):
510
+ raise ContractError("response scores do not cover this request")
511
+ return tuple(
512
+ item.candidate_id
513
+ for item in sorted(response.scores, key=lambda item: (-item.ranking_score, positions[item.candidate_id]))
514
+ )