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/_local_eval.py ADDED
@@ -0,0 +1,268 @@
1
+ """Shared, local-only immutable capture primitives for external RAG packs.
2
+
3
+ Hashes detect changed records; they are not authentication or OS isolation.
4
+ Owner-held inputs are deliberately not copied into installed package data.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import math
10
+ import platform
11
+ import random
12
+ import uuid
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Mapping
16
+
17
+ from .contracts import ContractError, canonical_json
18
+ from .retrieval import _strict_json
19
+
20
+
21
+ def digest(raw: bytes) -> str:
22
+ return "sha256:" + hashlib.sha256(raw).hexdigest()
23
+
24
+
25
+ def encoded(value: Any) -> bytes:
26
+ return canonical_json(value).encode("utf-8")
27
+
28
+
29
+ def identity(value: Any) -> str:
30
+ return digest(encoded(value))
31
+
32
+
33
+ def read_json(path: Path) -> Any:
34
+ return _strict_json(path.read_bytes())
35
+
36
+
37
+ def require(condition: bool, message: str) -> None:
38
+ if not condition:
39
+ raise ContractError(message)
40
+
41
+
42
+ def text(value: Any, field: str) -> str:
43
+ require(isinstance(value, str) and bool(value.strip()), field + " must be nonempty text")
44
+ return value
45
+
46
+
47
+ def pinned_file(root: Path, relative: str, expected: Mapping[str, Any]) -> bytes:
48
+ path = Path(relative)
49
+ require(not path.is_absolute() and not path.drive and ".." not in path.parts
50
+ and "\\" not in relative and ":" not in relative, "unsafe manifest path")
51
+ target = (root / path).resolve(strict=True)
52
+ require(target.is_relative_to(root), "manifest path leaves dataset directory")
53
+ raw = target.read_bytes()
54
+ require(digest(raw) == expected.get("sha256") and len(raw) == expected.get("size_bytes"),
55
+ "manifest file bytes differ from pinned identity")
56
+ return raw
57
+
58
+
59
+ def metric(values: list[float | None], *, missing_status: str = "incomplete") -> dict[str, Any]:
60
+ observed = [v for v in values if v is not None]
61
+ mean = sum(observed) / len(observed) if observed else None
62
+ return {"value": mean if len(observed) == len(values) else None,
63
+ "observed_mean": mean, "eligible_count": len(values),
64
+ "observed_count": len(observed), "missing_count": len(values) - len(observed),
65
+ "undefined_count": int(not values),
66
+ "status": "undefined" if not values else missing_status if len(observed) != len(values) else "measured"}
67
+
68
+
69
+ def percentile(values: list[float], fraction: float) -> float | None:
70
+ if not values:
71
+ return None
72
+ rows = sorted(values)
73
+ position = (len(rows) - 1) * fraction
74
+ lo = math.floor(position)
75
+ hi = math.ceil(position)
76
+ return rows[lo] + (rows[hi] - rows[lo]) * (position - lo)
77
+
78
+
79
+ def clustered_interval(rows: list[tuple[str, str, float]], samples: int = 2000) -> list[float] | None:
80
+ """Resample whole families within customer/domain; no utterance pseudoreplication."""
81
+ if not rows:
82
+ return None
83
+ strata: dict[str, dict[str, list[float]]] = {}
84
+ for stratum, cluster, value in rows:
85
+ strata.setdefault(stratum, {}).setdefault(cluster, []).append(value)
86
+ rng = random.Random(0)
87
+ means = []
88
+ for _ in range(samples):
89
+ draw = []
90
+ for clusters in strata.values():
91
+ groups = list(clusters.values())
92
+ for _ in groups:
93
+ draw.extend(rng.choice(groups))
94
+ means.append(sum(draw) / len(draw))
95
+ return [percentile(means, .025), percentile(means, .975)]
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class LocalRunRef:
100
+ path: Path
101
+ manifest_sha256: str
102
+
103
+ @property
104
+ def run_id(self) -> str:
105
+ return self.path.name
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class LocalEvaluationRef:
110
+ path: Path
111
+ scorecard_sha256: str
112
+ run: LocalRunRef
113
+
114
+ @property
115
+ def evaluation_id(self) -> str:
116
+ return self.path.name
117
+
118
+
119
+ def open_local_run(path: str | Path, *, expected_sha256: str | None = None) -> LocalRunRef:
120
+ """Reopen retained evidence. Grading still validates the external pack and every file."""
121
+ root = Path(path).expanduser().resolve(strict=True)
122
+ raw = (root / "manifest.json").read_bytes()
123
+ actual = digest(raw)
124
+ if expected_sha256 is not None:
125
+ require(actual == "sha256:" + expected_sha256.removeprefix("sha256:"), "capture manifest SHA-256 differs")
126
+ value = _strict_json(raw)
127
+ require(value.get("schema_version") == "sphynx.local-capture.v1" and value.get("run_id") == root.name, "unsupported local capture")
128
+ return LocalRunRef(root, actual)
129
+
130
+
131
+ def open_local_evaluation(path: str | Path) -> LocalEvaluationRef:
132
+ root = Path(path).expanduser().resolve(strict=True)
133
+ require(root.parent.name == "evaluations", "evaluation must be inside its original run")
134
+ raw = (root / "scorecard.json").read_bytes()
135
+ value = _strict_json(raw)
136
+ require(value.get("evaluation_id") == root.name, "evaluation path identity differs")
137
+ run = open_local_run(root.parent.parent, expected_sha256=value.get("capture_manifest_sha256"))
138
+ return LocalEvaluationRef(root, digest(raw), run)
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class LocalScorecard:
143
+ data: Mapping[str, Any]
144
+
145
+ @property
146
+ def metrics(self) -> Mapping[str, Any]:
147
+ return self.data["metrics"]
148
+
149
+ def to_dict(self) -> dict[str, Any]:
150
+ return _strict_json(encoded(dict(self.data)))
151
+
152
+ def to_json(self) -> str:
153
+ return canonical_json(self.to_dict())
154
+
155
+ def to_text(self) -> str:
156
+ from .metric_views import summarize_metrics
157
+ return summarize_metrics(self.data).to_text()
158
+
159
+ def to_markdown(self) -> str:
160
+ from .metric_views import summarize_metrics
161
+ return summarize_metrics(self.data).to_markdown()
162
+
163
+
164
+ def capture(pack_identity: dict[str, Any], candidate: dict[str, Any], policy: dict[str, Any],
165
+ requests: list[dict[str, Any]], execute: Callable, output_dir: str | Path) -> LocalRunRef:
166
+ root = Path(output_dir).expanduser().resolve() / "runs" / ("run-" + uuid.uuid4().hex)
167
+ root.mkdir(parents=True, exist_ok=False)
168
+ records = []
169
+ for index, request in enumerate(requests):
170
+ raw_request = encoded(request)
171
+ stdout, stderr, exit_code, failure, latency_ms, extra = execute(raw_request, request)
172
+ files = {}
173
+ for suffix, raw in (("request.json", raw_request), ("stdout.bin", stdout),
174
+ ("stderr.bin", stderr), ("observed.json", encoded(extra))):
175
+ relative = f"{index:06d}.{suffix}"
176
+ (root / relative).write_bytes(raw)
177
+ files[suffix] = {"path": relative, "sha256": digest(raw), "size_bytes": len(raw)}
178
+ records.append({"case_id": request["case_id"], "files": files, "exit_code": exit_code,
179
+ "failure": failure, "runner_latency_ms": latency_ms})
180
+ value = {"schema_version": "sphynx.local-capture.v1", "run_id": root.name,
181
+ "pack": pack_identity, "candidate": candidate, "execution_policy": policy,
182
+ "runtime": {"python": platform.python_version(), "platform": platform.platform()},
183
+ "trust_tier": "local_unverified", "captures": records}
184
+ raw = encoded(value)
185
+ (root / "manifest.json").write_bytes(raw)
186
+ return LocalRunRef(root, digest(raw))
187
+
188
+
189
+ def verified_capture(run: LocalRunRef, pack_identity: dict[str, Any],
190
+ requests: list[dict[str, Any]], policy: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
191
+ require(isinstance(run, LocalRunRef), "run must be a LocalRunRef from this SDK")
192
+ root = run.path.resolve(strict=True)
193
+ raw = (root / "manifest.json").read_bytes()
194
+ require(digest(raw) == run.manifest_sha256, "capture manifest changed")
195
+ manifest = _strict_json(raw)
196
+ require(manifest.get("schema_version") == "sphynx.local-capture.v1"
197
+ and manifest.get("run_id") == run.run_id and manifest.get("pack") == pack_identity
198
+ and manifest.get("execution_policy") == policy, "capture pack or policy identity differs")
199
+ captures = manifest.get("captures")
200
+ require(isinstance(captures, list) and len(captures) == len(requests), "capture cohort differs")
201
+ rows = []
202
+ for index, (row, request) in enumerate(zip(captures, requests)):
203
+ require(row.get("case_id") == request["case_id"], "capture case order differs")
204
+ require(set(row.get("files", {})) == {"request.json", "stdout.bin", "stderr.bin", "observed.json"},
205
+ "capture file inventory differs")
206
+ payloads = {}
207
+ for suffix, entry in row["files"].items():
208
+ require(entry.get("path") == f"{index:06d}.{suffix}", "capture file path differs")
209
+ payloads[suffix] = pinned_file(root, entry["path"], entry)
210
+ require(payloads["request.json"] == encoded(request), "capture request differs from pinned case")
211
+ span = row.get("runner_latency_ms")
212
+ require(type(span) in (int, float) and math.isfinite(span) and span >= 0, "invalid runner span")
213
+ failure = row.get("failure")
214
+ require(failure is None or isinstance(failure, str), "invalid failure record")
215
+ require(type(row.get("exit_code")) is int or row.get("exit_code") is None, "invalid exit record")
216
+ response = None
217
+ if failure is None:
218
+ try:
219
+ response = _strict_json(payloads["stdout.bin"])
220
+ except (ValueError, ContractError, UnicodeError, RecursionError):
221
+ failure = "invalid_json"
222
+ rows.append({**row, "failure": failure, "response": response,
223
+ "observed": _strict_json(payloads["observed.json"])})
224
+ return manifest, rows
225
+
226
+
227
+ def save_evaluation(run: LocalRunRef, data: dict[str, Any], annotations: dict[str, Any] | None = None) -> LocalEvaluationRef:
228
+ payload = {**data, "run_id": run.run_id, "capture_manifest_sha256": run.manifest_sha256,
229
+ "annotations": annotations, "annotations_sha256": identity(annotations)}
230
+ evaluation_id = "eval-" + identity(payload).split(":")[1]
231
+ raw = encoded({**payload, "evaluation_id": evaluation_id})
232
+ root = run.path / "evaluations" / evaluation_id
233
+ if root.exists():
234
+ require((root / "scorecard.json").read_bytes() == raw, "existing evaluation bytes changed")
235
+ else:
236
+ root.mkdir(parents=True, exist_ok=False)
237
+ (root / "scorecard.json").write_bytes(raw)
238
+ return LocalEvaluationRef(root, digest(raw), run)
239
+
240
+
241
+ def report_evaluation(evaluation: LocalEvaluationRef, recompute: Callable) -> LocalScorecard:
242
+ require(isinstance(evaluation, LocalEvaluationRef), "evaluation must be a LocalEvaluationRef")
243
+ raw = (evaluation.path / "scorecard.json").read_bytes()
244
+ require(digest(raw) == evaluation.scorecard_sha256, "evaluation bytes changed")
245
+ saved = _strict_json(raw)
246
+ base = recompute(saved["annotations"])
247
+ expected = {**base, "run_id": evaluation.run.run_id,
248
+ "capture_manifest_sha256": evaluation.run.manifest_sha256,
249
+ "annotations": saved["annotations"], "annotations_sha256": identity(saved["annotations"])}
250
+ evaluation_id = "eval-" + identity(expected).split(":")[1]
251
+ require(evaluation_id == evaluation.evaluation_id
252
+ and saved == {**expected, "evaluation_id": evaluation_id}, "evaluation does not match regraded captures")
253
+ # Raw annotations and references stay in the owner bundle, not the report.
254
+ return LocalScorecard({k: v for k, v in saved.items() if k != "annotations"})
255
+
256
+
257
+ def compare_scorecards(a: LocalScorecard, b: LocalScorecard) -> dict[str, Any]:
258
+ for field in ("pack", "execution_policy", "metric_release", "grader_release", "runtime"):
259
+ require(a.data[field] == b.data[field], "comparison requires matching " + field)
260
+ deltas = {}
261
+ for key, left in a.metrics.items():
262
+ right = b.metrics[key]
263
+ av, bv = left.get("value"), right.get("value")
264
+ deltas[key] = {"a": av, "b": bv, "delta_b_minus_a": bv - av if av is not None and bv is not None else None,
265
+ "status": "measured" if av is not None and bv is not None else "incomplete"}
266
+ return {"schema_version": "sphynx.local-comparison.v1", "trust_tier": "local_unverified",
267
+ "pack": a.data["pack"], "candidate_a": a.data["candidate"], "candidate_b": b.data["candidate"],
268
+ "metrics": deltas}
@@ -0,0 +1,243 @@
1
+ """Pinned local fast-search trace validation and stage attribution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping, Sequence
7
+ from typing import cast
8
+
9
+ from ._rag_ranking import ndcg, recall_at, reciprocal_rank
10
+ from typing import Any
11
+ JsonValue = Any
12
+
13
+ STAGES = ("N11", "N12", "N13", "N14", "N15", "N16", "N17")
14
+ CHANGE_COMPONENTS = frozenset({"chunking", "embedding", "reranker", "fusion", "gating", "top_k"})
15
+
16
+
17
+ class Level2Error(ValueError):
18
+ """Raised for incomplete traces or uncontrolled pipeline comparisons."""
19
+
20
+
21
+ def _flatten(value: Mapping[str, object], prefix: str = "") -> dict[str, object]:
22
+ result: dict[str, object] = {}
23
+ for key, item in value.items():
24
+ path = f"{prefix}.{key}" if prefix else key
25
+ if isinstance(item, Mapping):
26
+ result.update(_flatten(cast("Mapping[str, object]", item), path))
27
+ else:
28
+ result[path] = item
29
+ return result
30
+
31
+
32
+ def validate_controlled_change(
33
+ baseline: Mapping[str, object],
34
+ challenger: Mapping[str, object],
35
+ declared_paths: Sequence[str],
36
+ ) -> str:
37
+ """Require changes to exactly one declared component and no hidden field."""
38
+
39
+ left, right = _flatten(baseline), _flatten(challenger)
40
+ changed = {path for path in left.keys() | right.keys() if left.get(path) != right.get(path)}
41
+ if changed != set(declared_paths) or not changed:
42
+ raise Level2Error(f"controlled changes differ; actual={sorted(changed)}")
43
+ components = {path.split(".", 1)[0] for path in changed}
44
+ if len(components) != 1 or next(iter(components)) not in CHANGE_COMPONENTS:
45
+ raise Level2Error("comparison changes more than one approved component")
46
+ return next(iter(components))
47
+
48
+
49
+ def validate_fast_search_pin(
50
+ pin: Mapping[str, object], actual_revision: str, available_paths: Sequence[str]
51
+ ) -> None:
52
+ if pin.get("repository") != "sable-inc/fast-search":
53
+ raise Level2Error("fast-search repository identity is invalid")
54
+ if pin.get("revision") != actual_revision:
55
+ raise Level2Error("local fast-search revision does not match the pin")
56
+ required = cast("list[str]", pin.get("required_paths"))
57
+ missing = set(required) - set(available_paths)
58
+ if missing:
59
+ raise Level2Error(f"pinned checkout lacks required paths: {sorted(missing)}")
60
+
61
+
62
+ def _ordered(candidates: Sequence[Mapping[str, object]], stage: str) -> list[Mapping[str, object]]:
63
+ if stage == "N11":
64
+ return sorted(candidates, key=lambda value: cast("int", value["dense_rank"]))
65
+ if stage in {"N12", "N13"}:
66
+ return sorted(
67
+ candidates,
68
+ key=lambda value: (
69
+ -cast("float", value["reranker_logit"]),
70
+ cast("str", value["candidate_id"]),
71
+ ),
72
+ )
73
+ if stage == "N14":
74
+ return sorted(candidates, key=lambda value: cast("int", value["fusion_rank"]))
75
+ if stage == "N15":
76
+ selected = [value for value in candidates if value["dedup_decision"] == "keep"]
77
+ return sorted(selected, key=lambda value: cast("int", value["post_dedup_rank"]))
78
+ if stage == "N16":
79
+ selected = [
80
+ value
81
+ for value in candidates
82
+ if value["dedup_decision"] == "keep" and value["gate_decision"] == "pass"
83
+ ]
84
+ return sorted(selected, key=lambda value: cast("int", value["post_dedup_rank"]))
85
+ selected = [value for value in candidates if value["final_rank"] is not None]
86
+ return sorted(selected, key=lambda value: cast("int", value["final_rank"]))
87
+
88
+
89
+ def _percentile(values: Sequence[float], fraction: float) -> float | None:
90
+ if not values:
91
+ return None
92
+ ordered = sorted(values)
93
+ index = min(math.ceil(fraction * len(ordered)) - 1, len(ordered) - 1)
94
+ return ordered[max(index, 0)]
95
+
96
+
97
+ def evaluate_level2_run(run: Mapping[str, object]) -> dict[str, JsonValue]:
98
+ """Validate N11-N17 identity/order and report causal stage/latency metrics."""
99
+
100
+ queries = cast("list[Mapping[str, object]]", run.get("queries"))
101
+ if not queries:
102
+ raise Level2Error("level2 run requires queries")
103
+ stage_values: dict[str, list[dict[str, float | int | None]]] = {stage: [] for stage in STAGES}
104
+ zero_result_answerable = 0
105
+ unsupported_final = 0
106
+ unsupported_query_count = 0
107
+ final_count = 0
108
+ first_degradation: dict[str, str | None] = {}
109
+ transition_rescue = transition_harm = 0
110
+ latency: dict[str, dict[str, list[float]]] = {
111
+ state: {name: [] for name in ("retrieval", "reranker", "fusion", "dedup", "gate", "total")}
112
+ for state in ("cold", "warm")
113
+ }
114
+ for query_index, query in enumerate(queries):
115
+ for field in ("construction", "embedding", "input_contract"):
116
+ if not isinstance(query.get(field), Mapping):
117
+ raise Level2Error(f"query {query_index} lacks {field} trace")
118
+ candidates = cast("list[Mapping[str, object]]", query.get("candidates"))
119
+ identities = [cast("str", candidate["candidate_id"]) for candidate in candidates]
120
+ if len(identities) != len(set(identities)):
121
+ raise Level2Error("N11 candidate identities must be unique")
122
+ for candidate in candidates:
123
+ required = {
124
+ "candidate_id",
125
+ "dense_rank",
126
+ "dense_score",
127
+ "reranker_logit",
128
+ "reranker_rank",
129
+ "fusion_score",
130
+ "fusion_rank",
131
+ "dedup_decision",
132
+ "post_dedup_rank",
133
+ "gate_decision",
134
+ "gate_threshold",
135
+ "final_rank",
136
+ "qrel",
137
+ "evidence_group_ids",
138
+ }
139
+ if not required <= candidate.keys():
140
+ raise Level2Error("candidate trace does not cover N11 through N17")
141
+ reranker = _ordered(candidates, "N13")
142
+ if [candidate["reranker_rank"] for candidate in reranker] != list(
143
+ range(1, len(reranker) + 1)
144
+ ):
145
+ raise Level2Error("N13 ranks do not follow reranker logits")
146
+ fusion = _ordered(candidates, "N14")
147
+ if [candidate["fusion_rank"] for candidate in fusion] != list(range(1, len(fusion) + 1)):
148
+ raise Level2Error("N14 fusion ranks are not contiguous")
149
+ threshold = {cast("float", candidate["gate_threshold"]) for candidate in candidates}
150
+ if len(threshold) > 1:
151
+ raise Level2Error("N16 thresholds differ inside one query")
152
+ top_k = cast("int", cast("Mapping[str, object]", run["controlled_config"])["top_k"])
153
+ final = _ordered(candidates, "N17")
154
+ if len(final) > top_k:
155
+ raise Level2Error("N17 exceeds configured top_k")
156
+ if [candidate["final_rank"] for candidate in final] != list(range(1, len(final) + 1)):
157
+ raise Level2Error("N17 final ranks are not contiguous")
158
+ n16_ids = {
159
+ cast("str", candidate["candidate_id"]) for candidate in _ordered(candidates, "N16")
160
+ }
161
+ if not {cast("str", candidate["candidate_id"]) for candidate in final} <= n16_ids:
162
+ raise Level2Error("N17 contains a candidate removed before final top-k")
163
+
164
+ prior_recall: float | None = None
165
+ degraded: str | None = None
166
+ prior_top: set[str] = set()
167
+ for stage in STAGES:
168
+ ordered = _ordered(candidates, stage)
169
+ grades = [cast("int", candidate["qrel"]) for candidate in ordered]
170
+ current_recall = recall_at(grades, 5, 2)
171
+ stage_values[stage].append(
172
+ {
173
+ "candidate_count": len(ordered),
174
+ "recall_at_5": current_recall,
175
+ "ndcg_at_5": ndcg(grades, 5),
176
+ "mrr": reciprocal_rank(grades, 2),
177
+ }
178
+ )
179
+ current_top = {
180
+ cast("str", candidate["candidate_id"])
181
+ for candidate in ordered[:5]
182
+ if cast("int", candidate["qrel"]) >= 2
183
+ }
184
+ if prior_recall is not None and current_recall is not None:
185
+ transition_rescue += len(current_top - prior_top)
186
+ transition_harm += len(prior_top - current_top)
187
+ if degraded is None and current_recall < prior_recall:
188
+ degraded = stage
189
+ prior_recall, prior_top = current_recall, current_top
190
+ first_degradation[cast("str", query["query_id"])] = degraded
191
+ answerable = bool(query["answerable"])
192
+ zero_result_answerable += int(answerable and not final)
193
+ unsupported_query_count += int(not answerable)
194
+ unsupported_final += int(not answerable and bool(final))
195
+ final_count += len(final)
196
+ state = cast("str", query.get("latency_state", "cold" if query_index == 0 else "warm"))
197
+ if state not in latency:
198
+ raise Level2Error("latency_state must be cold or warm")
199
+ for name, value in cast("Mapping[str, object]", query["latency_ms"]).items():
200
+ number = float(cast("int | float", value))
201
+ if name in latency[state] and math.isfinite(number) and number >= 0:
202
+ latency[state][name].append(number)
203
+
204
+ summary: dict[str, JsonValue] = {}
205
+ for stage, observations in stage_values.items():
206
+ summary[stage] = {
207
+ metric: sum(
208
+ cast("float", observation[metric])
209
+ for observation in observations
210
+ if observation[metric] is not None
211
+ )
212
+ / max(sum(observation[metric] is not None for observation in observations), 1)
213
+ for metric in ("candidate_count", "recall_at_5", "ndcg_at_5", "mrr")
214
+ }
215
+ return {
216
+ "stage_metrics": summary,
217
+ "first_degradation_stage": cast("dict[str, JsonValue]", first_degradation),
218
+ "transition_rescue_count": transition_rescue,
219
+ "transition_harm_count": transition_harm,
220
+ "zero_result_answerable_count": zero_result_answerable,
221
+ "unsupported_leakage_rate": (
222
+ None if unsupported_query_count == 0 else unsupported_final / unsupported_query_count
223
+ ),
224
+ "context_precision": sum(
225
+ cast("int", candidate["qrel"]) >= 2
226
+ for query in queries
227
+ for candidate in _ordered(
228
+ cast("list[Mapping[str, object]]", query["candidates"]), "N17"
229
+ )
230
+ )
231
+ / max(final_count, 1),
232
+ "latency_ms": {
233
+ state: {
234
+ name: {
235
+ "p50": _percentile(values, 0.5),
236
+ "p90": _percentile(values, 0.9),
237
+ "p95": _percentile(values, 0.95),
238
+ }
239
+ for name, values in components.items()
240
+ }
241
+ for state, components in latency.items()
242
+ },
243
+ }