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/runner.py ADDED
@@ -0,0 +1,544 @@
1
+ """Pack-owned frozen-pool candidate execution and label-free capture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Mapping, Sequence
17
+
18
+ from .contracts import (
19
+ CandidateRequest,
20
+ CandidateSpec,
21
+ ContractError,
22
+ FrozenPoolCase,
23
+ build_candidate_request,
24
+ canonical_json,
25
+ ranked_candidate_ids,
26
+ validate_candidate_response,
27
+ )
28
+ from .evidence import (
29
+ COVERAGE_SCHEMA_VERSION,
30
+ EVENT_SCHEMA_VERSION,
31
+ RUN_SCHEMA_VERSION,
32
+ RunRef,
33
+ sha256_bytes,
34
+ write_blob,
35
+ write_json,
36
+ write_jsonl,
37
+ )
38
+ from .packs import PackDescriptor, check_candidate, get_pack
39
+ from .records import CaptureIdentity, EvidenceCapability
40
+
41
+ MAX_STREAM_BYTES = 1_048_576
42
+ ENV_POLICY_ID = "minimal-candidate-runtime.v1"
43
+ _ENV_ALLOWLIST = frozenset(
44
+ {
45
+ "PATH",
46
+ "PATHEXT",
47
+ "SYSTEMROOT",
48
+ "WINDIR",
49
+ "COMSPEC",
50
+ "TEMP",
51
+ "TMP",
52
+ "TMPDIR",
53
+ "HOME",
54
+ "USERPROFILE",
55
+ "APPDATA",
56
+ "LOCALAPPDATA",
57
+ "PROGRAMDATA",
58
+ "LANG",
59
+ "LC_ALL",
60
+ }
61
+ )
62
+
63
+
64
+ def _wall_time() -> str:
65
+ return datetime.now(timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
66
+
67
+
68
+ def _candidate_environment() -> dict[str, str]:
69
+ """Pass only ordinary process-bootstrap variables, never sync credentials.
70
+
71
+ Candidates requiring their own service credentials must load them through
72
+ their own wrapper/configuration. Environment values are not recorded in the
73
+ bundle. This is credential separation, not an OS sandbox.
74
+ """
75
+
76
+ env = {key: value for key, value in os.environ.items() if key.upper() in _ENV_ALLOWLIST}
77
+ env["PYTHONUTF8"] = "1"
78
+ return env
79
+
80
+
81
+ @dataclass
82
+ class _BoundedStream:
83
+ retained: bytearray = field(default_factory=bytearray)
84
+ observed_bytes: int = 0
85
+ eof: bool = False
86
+ error: str | None = None
87
+
88
+ def drain(self, stream: Any) -> None:
89
+ try:
90
+ while True:
91
+ chunk = stream.read(65536)
92
+ if not chunk:
93
+ self.eof = True
94
+ break
95
+ self.observed_bytes += len(chunk)
96
+ remaining = MAX_STREAM_BYTES - len(self.retained)
97
+ if remaining > 0:
98
+ self.retained.extend(chunk[:remaining])
99
+ except OSError as exc:
100
+ self.error = f"{type(exc).__name__}: {exc}"
101
+
102
+ @property
103
+ def capped(self) -> bool:
104
+ return self.observed_bytes > MAX_STREAM_BYTES
105
+
106
+
107
+ @dataclass
108
+ class _InputWrite:
109
+ complete: bool = False
110
+ error: str | None = None
111
+
112
+ def send(self, stream: Any, payload: bytes) -> None:
113
+ try:
114
+ stream.write(payload)
115
+ stream.flush()
116
+ self.complete = True
117
+ except OSError as exc:
118
+ self.error = f"{type(exc).__name__}: {exc}"
119
+ finally:
120
+ try:
121
+ stream.close()
122
+ except OSError:
123
+ pass
124
+
125
+
126
+ def _parse_response(stdout: bytes, request: CandidateRequest) -> tuple[tuple[str, ...] | None, str | None, str | None]:
127
+ try:
128
+ text = stdout.decode("utf-8", errors="strict")
129
+
130
+ def unique_fields(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
131
+ result: dict[str, Any] = {}
132
+ for key, value in pairs:
133
+ if key in result:
134
+ raise ValueError(f"duplicate JSON field {key!r}")
135
+ result[key] = value
136
+ return result
137
+
138
+ def invalid_constant(value: str) -> None:
139
+ raise ValueError(f"non-finite JSON number {value}")
140
+
141
+ value = json.loads(text, object_pairs_hook=unique_fields, parse_constant=invalid_constant)
142
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError) as exc:
143
+ code = "non_finite_score" if "non-finite JSON number" in str(exc) else "malformed_json"
144
+ return None, code, f"{type(exc).__name__}: {exc}"
145
+ try:
146
+ response = validate_candidate_response(value, request)
147
+ return ranked_candidate_ids(response, request), None, None
148
+ except ContractError as exc:
149
+ reason = str(exc)
150
+ if reason.startswith("response query_id does not match"):
151
+ code = "wrong_query_id"
152
+ elif reason.startswith("response pool_digest does not match"):
153
+ code = "wrong_pool_digest"
154
+ elif reason.startswith("duplicate score for candidate_id"):
155
+ code = "duplicate_candidate_ids"
156
+ elif "must be a finite JSON number" in reason:
157
+ code = "non_finite_score"
158
+ elif reason.startswith("response candidate IDs mismatch"):
159
+ ids = {item.candidate_id for item in request.candidates}
160
+ returned = {item.get("candidate_id") for item in value.get("scores", []) if isinstance(item, dict)}
161
+ missing, extra = ids - returned, returned - ids
162
+ code = "missing_and_extra_candidate_ids" if missing and extra else "missing_candidate_ids" if missing else "extra_candidate_ids"
163
+ else:
164
+ code = "invalid_response"
165
+ return None, code, reason
166
+
167
+
168
+ def _case_snapshot(case: FrozenPoolCase) -> dict[str, Any]:
169
+ """Explicit label-free case snapshot. Never recurse through references."""
170
+
171
+ return {
172
+ "schema_version": "sphynx.rag.rerank.pool.v1",
173
+ "dataset_id": case.dataset_id,
174
+ "dataset_version": case.dataset_version,
175
+ "case_id": case.case_id,
176
+ "query_id": case.query_id,
177
+ "query": case.query,
178
+ "split": case.split,
179
+ "split_group_id": case.split_group_id,
180
+ "cluster_id": case.cluster_id,
181
+ "suite_type": case.suite_type,
182
+ "versions": case.versions.to_dict(),
183
+ "candidates": [item._digest_dict() for item in case.candidates],
184
+ "pool_digest": case.pool_digest,
185
+ }
186
+
187
+
188
+ def _capture_one(
189
+ run_path: Path,
190
+ run_id: str,
191
+ candidate: CandidateSpec,
192
+ case: FrozenPoolCase,
193
+ pack_id: str,
194
+ trial_index: int,
195
+ timeout_seconds: float,
196
+ ) -> tuple[str, str, str]:
197
+ capture_id = "cap-" + uuid.uuid4().hex
198
+ identity = CaptureIdentity(run_id, capture_id, case.case_id, trial_index, 1, case.pool_digest)
199
+ capture_dir = run_path / "captures" / capture_id
200
+ capture_dir.mkdir(parents=True, exist_ok=False)
201
+ request = build_candidate_request(case, pack_id)
202
+ request_bytes = (request.to_json() + "\n").encode("utf-8")
203
+ snapshot_bytes = (canonical_json(_case_snapshot(case)) + "\n").encode("utf-8")
204
+
205
+ events: list[dict[str, Any]] = []
206
+
207
+ def event(event_type: str, *, status: str, details: dict[str, Any] | None = None) -> None:
208
+ events.append(
209
+ {
210
+ "schema_version": EVENT_SCHEMA_VERSION,
211
+ "event_id": "evt-" + uuid.uuid4().hex,
212
+ "sequence": len(events) + 1,
213
+ "capture_id": capture_id,
214
+ "actor": "runner",
215
+ "source": "runner_observed",
216
+ "event_type": event_type,
217
+ "status": status,
218
+ "wall_time_utc": _wall_time(),
219
+ "monotonic_ns": time.monotonic_ns(),
220
+ "clock_domain": "python.monotonic_ns.process_local",
221
+ "details": details or {},
222
+ }
223
+ )
224
+
225
+ started_wall = _wall_time()
226
+ started_ns = time.monotonic_ns()
227
+ event("case_started", status="started", details={"request_sha256": sha256_bytes(request_bytes)})
228
+ stdout = _BoundedStream()
229
+ stderr = _BoundedStream()
230
+ input_write = _InputWrite()
231
+ process: subprocess.Popen[bytes] | None = None
232
+ exit_code: int | None = None
233
+ timed_out = False
234
+ spawn_error: str | None = None
235
+ process_error: str | None = None
236
+ process_ended_ns: int | None = None
237
+ stream_threads: list[threading.Thread] = []
238
+ writer_thread: threading.Thread | None = None
239
+ try:
240
+ process = subprocess.Popen(
241
+ candidate.argv,
242
+ shell=False,
243
+ stdin=subprocess.PIPE,
244
+ stdout=subprocess.PIPE,
245
+ stderr=subprocess.PIPE,
246
+ env=_candidate_environment(),
247
+ bufsize=0,
248
+ )
249
+ event("process_started", status="started", details={"pid": process.pid})
250
+ assert process.stdin is not None and process.stdout is not None and process.stderr is not None
251
+ for collector, stream in ((stdout, process.stdout), (stderr, process.stderr)):
252
+ thread = threading.Thread(target=collector.drain, args=(stream,), daemon=True)
253
+ thread.start()
254
+ stream_threads.append(thread)
255
+ writer_thread = threading.Thread(target=input_write.send, args=(process.stdin, request_bytes), daemon=True)
256
+ writer_thread.start()
257
+ try:
258
+ remaining = max(0.0, timeout_seconds - (time.monotonic_ns() - started_ns) / 1_000_000_000)
259
+ exit_code = process.wait(timeout=remaining)
260
+ except subprocess.TimeoutExpired:
261
+ timed_out = True
262
+ try:
263
+ process.kill()
264
+ except ProcessLookupError:
265
+ pass
266
+ try:
267
+ exit_code = process.wait(timeout=5)
268
+ except subprocess.TimeoutExpired:
269
+ process_error = "candidate process did not exit after kill"
270
+ process_ended_ns = time.monotonic_ns()
271
+ event("process_exited", status="timeout" if timed_out else "exited", details={"exit_code": exit_code})
272
+ except OSError as exc:
273
+ if process is None:
274
+ spawn_error = f"{type(exc).__name__}: {exc}"
275
+ event("process_spawn_failed", status="failed", details={"error_type": type(exc).__name__})
276
+ else:
277
+ process_error = f"{type(exc).__name__}: {exc}"
278
+ process_ended_ns = time.monotonic_ns()
279
+ event("process_failed", status="failed", details={"error_type": type(exc).__name__})
280
+ finally:
281
+ if writer_thread is not None:
282
+ writer_thread.join(timeout=1)
283
+ for thread in stream_threads:
284
+ thread.join(timeout=1)
285
+ if process is not None:
286
+ for stream in (process.stdin, process.stdout, process.stderr):
287
+ if stream is not None and (stream is process.stdin or all(not thread.is_alive() for thread in stream_threads)):
288
+ try:
289
+ stream.close()
290
+ except OSError:
291
+ pass
292
+
293
+ if spawn_error is not None:
294
+ status, failure_code, failure_reason = "spawn_error", "spawn_error", spawn_error
295
+ elif timed_out:
296
+ status, failure_code, failure_reason = "timeout", "timeout", f"candidate exceeded {timeout_seconds:g} seconds"
297
+ elif process_error is not None:
298
+ status, failure_code, failure_reason = "process_error", "process_error", process_error
299
+ elif not input_write.complete:
300
+ status, failure_code, failure_reason = "input_write_error", "input_write_error", input_write.error or "input write did not finish"
301
+ elif exit_code != 0:
302
+ status, failure_code, failure_reason = "crash", "nonzero_exit", f"candidate exited with code {exit_code}"
303
+ elif stdout.capped or not stdout.eof or stdout.error:
304
+ status, failure_code, failure_reason = "output_truncated", "output_truncated", stdout.error or "stdout was capped or incomplete"
305
+ else:
306
+ ranked, failure_code, failure_reason = _parse_response(bytes(stdout.retained), request)
307
+ status = "success" if failure_code is None else "invalid_response"
308
+ if status != "success":
309
+ ranked = None
310
+ event("response_checked", status="valid" if status == "success" else "invalid", details={"failure_code": failure_code})
311
+ event("case_terminal", status=status, details={"failure_code": failure_code})
312
+ ended_ns = time.monotonic_ns()
313
+ ended_wall = _wall_time()
314
+
315
+ stdout_complete = process is not None and not timed_out and stdout.eof and not stdout.capped and stdout.error is None
316
+ stderr_complete = process is not None and not timed_out and stderr.eof and not stderr.capped and stderr.error is None
317
+ artifacts = [
318
+ write_blob(capture_dir, role="candidate_request", data=request_bytes, media_type="application/json", observed_bytes=len(request_bytes), complete=True),
319
+ write_blob(capture_dir, role="case_snapshot", data=snapshot_bytes, media_type="application/json", observed_bytes=len(snapshot_bytes), complete=True),
320
+ write_blob(
321
+ capture_dir,
322
+ role="candidate_stdout",
323
+ data=bytes(stdout.retained),
324
+ media_type="application/json" if status == "success" else "application/octet-stream",
325
+ observed_bytes=stdout.observed_bytes,
326
+ complete=stdout_complete,
327
+ observed=process is not None,
328
+ ),
329
+ write_blob(
330
+ capture_dir,
331
+ role="candidate_stderr",
332
+ data=bytes(stderr.retained),
333
+ media_type="text/plain",
334
+ observed_bytes=stderr.observed_bytes,
335
+ complete=stderr_complete,
336
+ observed=process is not None,
337
+ ),
338
+ ]
339
+
340
+ def stream_capability(name: str, collector: _BoundedStream, complete: bool) -> EvidenceCapability:
341
+ if process is None:
342
+ return EvidenceCapability(name, "v1", "not_captured", 0, "candidate process did not start")
343
+ if not complete:
344
+ return EvidenceCapability(name, "v1", "truncated", int(bool(collector.retained)), "process timed out, stream exceeded cap, or stream did not close cleanly")
345
+ return EvidenceCapability(name, "v1", "available", int(bool(collector.retained)))
346
+
347
+ capabilities = [
348
+ EvidenceCapability("candidate_input", "v1", "available", 1),
349
+ EvidenceCapability("case_snapshot", "v1", "available", 1),
350
+ stream_capability("candidate_stdout_raw", stdout, stdout_complete),
351
+ stream_capability("candidate_stderr_raw", stderr, stderr_complete),
352
+ EvidenceCapability(
353
+ "candidate_response_validated",
354
+ "v1",
355
+ "available" if status == "success" else "not_captured",
356
+ 1 if status == "success" else 0,
357
+ None if status == "success" else f"terminal outcome: {failure_code}",
358
+ ),
359
+ EvidenceCapability("runner_timing", "v1", "available", 1),
360
+ ]
361
+ coverage = {
362
+ "schema_version": COVERAGE_SCHEMA_VERSION,
363
+ "capture_id": capture_id,
364
+ "capabilities": [
365
+ {
366
+ "capability_id": item.capability_id,
367
+ "capability_schema_version": item.capability_schema_version,
368
+ "state": item.state,
369
+ "observed_count": item.observed_count,
370
+ "reason": item.reason,
371
+ }
372
+ for item in capabilities
373
+ ],
374
+ }
375
+ artifacts_sha = write_jsonl(capture_dir / "artifacts.jsonl", artifacts)
376
+ events_sha = write_jsonl(capture_dir / "events.jsonl", events)
377
+ coverage_sha = write_json(capture_dir / "coverage.json", coverage)
378
+ capture_manifest = {
379
+ "schema_version": identity.schema_version,
380
+ "identity": {
381
+ "schema_version": identity.schema_version,
382
+ "run_id": identity.run_id,
383
+ "capture_id": identity.capture_id,
384
+ "case_id": identity.case_id,
385
+ "trial_index": identity.trial_index,
386
+ "attempt_index": identity.attempt_index,
387
+ "pool_digest": identity.pool_digest,
388
+ "trust_tier": identity.trust_tier,
389
+ },
390
+ "query_id": case.query_id,
391
+ "status": status,
392
+ "failure_code": failure_code,
393
+ "failure_reason": failure_reason,
394
+ "exit_code": exit_code,
395
+ "ranked_candidate_ids": list(ranked) if ranked is not None else None,
396
+ "timing": {
397
+ "clock_domain": "python.monotonic_ns.process_local",
398
+ "started_monotonic_ns": started_ns,
399
+ "ended_monotonic_ns": ended_ns,
400
+ "elapsed_ns": ended_ns - started_ns,
401
+ "candidate_latency_ns": process_ended_ns - started_ns if process_ended_ns is not None else None,
402
+ "started_wall_time_utc": started_wall,
403
+ "ended_wall_time_utc": ended_wall,
404
+ },
405
+ "events_sha256": events_sha,
406
+ "artifacts_sha256": artifacts_sha,
407
+ "coverage_sha256": coverage_sha,
408
+ "artifact_roles": [item["role"] for item in artifacts],
409
+ "source": "runner_observed",
410
+ }
411
+ capture_hash = write_json(capture_dir / "manifest.json", capture_manifest)
412
+ return capture_id, status, capture_hash
413
+
414
+
415
+ def _run_with_cases(
416
+ candidate: CandidateSpec,
417
+ descriptor: PackDescriptor,
418
+ cases: Sequence[FrozenPoolCase],
419
+ output_dir: str | os.PathLike[str],
420
+ ) -> RunRef:
421
+ """Internal testable executor; official case selection comes from the pack."""
422
+
423
+ if not isinstance(candidate, CandidateSpec):
424
+ raise TypeError("candidate must be CandidateSpec")
425
+ if not descriptor.runnable:
426
+ raise ValueError(f"pack {descriptor.pack_id} is not runnable")
427
+ if candidate.interface_version != descriptor.interface_version:
428
+ raise ContractError("candidate interface does not match pack")
429
+ cases = tuple(cases)
430
+ if not cases or any(not isinstance(case, FrozenPoolCase) for case in cases):
431
+ raise ValueError("runnable pack must supply FrozenPoolCase values")
432
+ if len({case.case_id for case in cases}) != len(cases):
433
+ raise ValueError("pack contains duplicate case IDs")
434
+ if descriptor.case_count is not None and descriptor.case_count != len(cases):
435
+ raise ValueError("pack case_count does not match loaded cases")
436
+ policy = descriptor.execution_policy
437
+ if type(policy.trials) is not int or policy.trials < 1 or not 0 < policy.per_case_timeout_seconds < float("inf"):
438
+ raise ValueError("pack execution policy has invalid trials or timeout")
439
+
440
+ output_root = Path(output_dir).expanduser().resolve()
441
+ run_id = "run-" + uuid.uuid4().hex
442
+ run_path = output_root / "runs" / run_id
443
+ run_path.mkdir(parents=True, exist_ok=False)
444
+ started_wall = _wall_time()
445
+ capture_ids: list[str] = []
446
+ capture_hashes: dict[str, str] = {}
447
+ statuses: list[str] = []
448
+ for case in cases:
449
+ for trial_index in range(1, policy.trials + 1):
450
+ capture_id, status, capture_hash = _capture_one(
451
+ run_path,
452
+ run_id,
453
+ candidate,
454
+ case,
455
+ descriptor.pack_id,
456
+ trial_index,
457
+ policy.per_case_timeout_seconds,
458
+ )
459
+ capture_ids.append(capture_id)
460
+ capture_hashes[capture_id] = capture_hash
461
+ statuses.append(status)
462
+ manifest = {
463
+ "schema_version": RUN_SCHEMA_VERSION,
464
+ "run_id": run_id,
465
+ "pack_id": descriptor.pack_id,
466
+ "pack_release": descriptor.pack_release,
467
+ "track": descriptor.track,
468
+ "candidate": {
469
+ "name": candidate.name,
470
+ "version": candidate.version,
471
+ "interface_version": candidate.interface_version,
472
+ "argv_sha256": sha256_bytes(canonical_json(list(candidate.argv)).encode("utf-8")),
473
+ },
474
+ "execution_policy": {
475
+ "policy_version": policy.policy_version,
476
+ "trials": policy.trials,
477
+ "per_case_timeout_seconds": policy.per_case_timeout_seconds,
478
+ "resource_policy_id": policy.resource_policy_id,
479
+ "environment_policy_id": ENV_POLICY_ID,
480
+ "stdout_stderr_retention_limit_bytes_each": MAX_STREAM_BYTES,
481
+ },
482
+ "cases": [
483
+ {
484
+ "dataset_id": case.dataset_id,
485
+ "dataset_version": case.dataset_version,
486
+ "case_id": case.case_id,
487
+ "query_id": case.query_id,
488
+ "pool_digest": case.pool_digest,
489
+ "split": case.split,
490
+ "split_group_id": case.split_group_id,
491
+ "cluster_id": case.cluster_id,
492
+ "suite_type": case.suite_type,
493
+ "versions": case.versions.to_dict(),
494
+ }
495
+ for case in cases
496
+ ],
497
+ "trust_tier": "local_unverified",
498
+ "provenance": "runner_observed",
499
+ "runner": {
500
+ "release": "v1",
501
+ "python_version": platform.python_version(),
502
+ "platform": sys.platform,
503
+ "clock_domain": "python.monotonic_ns.process_local",
504
+ },
505
+ "started_wall_time_utc": started_wall,
506
+ "ended_wall_time_utc": _wall_time(),
507
+ "status": "completed" if all(status == "success" for status in statuses) else "completed_with_failures",
508
+ "case_count": len(cases),
509
+ "capture_count": len(capture_ids),
510
+ "success_count": sum(status == "success" for status in statuses),
511
+ "failure_count": sum(status != "success" for status in statuses),
512
+ "capture_ids": capture_ids,
513
+ "capture_manifest_hashes": capture_hashes,
514
+ }
515
+ write_json(run_path / "manifest.json", manifest)
516
+ return RunRef(run_id, run_path, descriptor.pack_id)
517
+
518
+
519
+ def run(candidate: CandidateSpec, pack_id: str, output_dir: str | os.PathLike[str], *,
520
+ parameters: Mapping[str, Any] | None = None) -> RunRef:
521
+ """Run every installed case and trial fixed by a runnable pack.
522
+
523
+ Each case starts a fresh argv subprocess in the caller's current directory.
524
+ The subprocess gets only the versioned request on stdin and a minimal OS
525
+ environment. A failed case remains a terminal, replayable capture.
526
+
527
+ Optional parameters are resolved before filesystem/process work. The v1
528
+ adapter accepts only its existing test settings; contract-only definitions
529
+ require a future execution adapter. Reporting preferences do not render a
530
+ report here. The legacy run manifest and request schemas remain unchanged.
531
+ """
532
+
533
+ if parameters is not None or pack_id in {"rag.rerank.v2", "audio.similarity.v1"}:
534
+ from .evals import require_legacy_rerank
535
+ require_legacy_rerank(pack_id, parameters)
536
+ check = check_candidate(candidate, pack_id)
537
+ if not check.compatible:
538
+ raise ContractError("; ".join(check.reasons))
539
+ descriptor = get_pack(pack_id)
540
+ if not descriptor.runnable:
541
+ raise ValueError(f"pack {pack_id} is {descriptor.status}; installed cases are required before run")
542
+ from .rag import load_cases
543
+
544
+ return _run_with_cases(candidate, descriptor, load_cases(pack_id), output_dir)