sable-sphynx 0.7.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sable_sphynx-0.7.0.dev0.dist-info/METADATA +152 -0
- sable_sphynx-0.7.0.dev0.dist-info/RECORD +174 -0
- sable_sphynx-0.7.0.dev0.dist-info/WHEEL +5 -0
- sable_sphynx-0.7.0.dev0.dist-info/top_level.txt +1 -0
- sphynx/__init__.py +469 -0
- sphynx/__init__.pyi +376 -0
- sphynx/_analytical_client.py +77 -0
- sphynx/_analytical_values.py +252 -0
- sphynx/_audit_client.py +173 -0
- sphynx/_comparison_adapters.py +113 -0
- sphynx/_componentized_full_rag.py +34 -0
- sphynx/_composed_pipeline.py +109 -0
- sphynx/_full_rag_grading.py +218 -0
- sphynx/_full_rag_trace.py +221 -0
- sphynx/_local_eval.py +268 -0
- sphynx/_pipeline_core.py +243 -0
- sphynx/_pr_client.py +237 -0
- sphynx/_rag_diagnostics_calibration.py +285 -0
- sphynx/_rag_diagnostics_catalog.py +39 -0
- sphynx/_rag_diagnostics_common.py +26 -0
- sphynx/_rag_diagnostics_contracts.py +186 -0
- sphynx/_rag_diagnostics_engine.py +243 -0
- sphynx/_rag_diagnostics_importers.py +242 -0
- sphynx/_rag_diagnostics_ranking.py +81 -0
- sphynx/_rag_diagnostics_semantic.py +325 -0
- sphynx/_rag_ranking.py +149 -0
- sphynx/_speech_benchmark_scoring.py +140 -0
- sphynx/_speech_benchmark_sources.py +275 -0
- sphynx/_speech_complete_scoring.py +218 -0
- sphynx/_speech_complete_source.py +118 -0
- sphynx/_speech_kido.py +294 -0
- sphynx/_speech_scoring.py +77 -0
- sphynx/_track_capture.py +213 -0
- sphynx/analytical_views.py +515 -0
- sphynx/assessment_contracts.py +378 -0
- sphynx/assessment_selection.py +57 -0
- sphynx/audit_access.py +245 -0
- sphynx/audit_contracts.py +204 -0
- sphynx/audit_requests.py +180 -0
- sphynx/authoring.py +188 -0
- sphynx/benchmarks.py +568 -0
- sphynx/candidates.py +62 -0
- sphynx/catalog.py +272 -0
- sphynx/chunking.py +372 -0
- sphynx/clickhouse_reader.py +169 -0
- sphynx/client.py +486 -0
- sphynx/comparison_core.py +149 -0
- sphynx/comparison_views.py +223 -0
- sphynx/components.py +1661 -0
- sphynx/contracts.py +514 -0
- sphynx/customer_rerank.py +299 -0
- sphynx/customer_retrieval.py +146 -0
- sphynx/data/benchmark-releases.v1.json +164 -0
- sphynx/data/public-benchmark-release.v1.json +354 -0
- sphynx/data/rag-diagnostics-synthetic.v1.json +6982 -0
- sphynx/data/rag-paper-references.v1.json +176 -0
- sphynx/data/rag-paper-schemas.v1.json +1069 -0
- sphynx/data/rag-rerank-v1.json +507 -0
- sphynx/dataset_catalog_data.py +1214 -0
- sphynx/dataset_readiness.py +138 -0
- sphynx/dataset_v3.py +999 -0
- sphynx/demo.py +130 -0
- sphynx/demo_candidates/__init__.py +1 -0
- sphynx/demo_candidates/dense.py +11 -0
- sphynx/demo_candidates/lexical.py +21 -0
- sphynx/diagnostics.py +207 -0
- sphynx/documentation.py +737 -0
- sphynx/documentation_candidate.py +939 -0
- sphynx/evals.py +538 -0
- sphynx/evaluation_protocols.py +124 -0
- sphynx/evaluator_bindings.py +370 -0
- sphynx/evidence.py +352 -0
- sphynx/execution_capabilities.py +109 -0
- sphynx/fast_search.py +285 -0
- sphynx/full_rag.py +341 -0
- sphynx/hosted/__init__.py +18 -0
- sphynx/hosted/benchmark_intake.py +218 -0
- sphynx/hosted/contracts.py +361 -0
- sphynx/hosted/model_admission.py +130 -0
- sphynx/hosted/model_artifacts.py +298 -0
- sphynx/hosted/model_contracts.py +63 -0
- sphynx/hosted/model_gcs.py +181 -0
- sphynx/hosted/model_history.py +84 -0
- sphynx/hosted/model_session.py +233 -0
- sphynx/hosted/public_suite.py +74 -0
- sphynx/hosted/registry.py +97 -0
- sphynx/hosted/revisions.py +126 -0
- sphynx/hosted/sandbox.py +105 -0
- sphynx/hosted/submission.py +287 -0
- sphynx/hosted_workflow.py +451 -0
- sphynx/http_transport.py +86 -0
- sphynx/integrated_rag.py +352 -0
- sphynx/integrated_workflow.py +552 -0
- sphynx/integration_contracts.py +474 -0
- sphynx/invocation.py +791 -0
- sphynx/invocation_candidates.py +392 -0
- sphynx/invocation_catalog_data.py +40 -0
- sphynx/learned_catalog_data.py +30 -0
- sphynx/learned_rerank.py +267 -0
- sphynx/legacy_full_rag.py +67 -0
- sphynx/lifecycle.py +885 -0
- sphynx/lifecycle_workflow.py +490 -0
- sphynx/metric_views.py +474 -0
- sphynx/metrics.py +282 -0
- sphynx/model_adapters.py +438 -0
- sphynx/model_catalog_data.py +30 -0
- sphynx/official_benchmarks.py +385 -0
- sphynx/packs.py +258 -0
- sphynx/pipeline.py +313 -0
- sphynx/pipeline_workflow.py +551 -0
- sphynx/pr_evaluations.py +560 -0
- sphynx/progress.py +89 -0
- sphynx/public_bundle.py +173 -0
- sphynx/public_eval.py +62 -0
- sphynx/public_rerank.py +355 -0
- sphynx/py.typed +0 -0
- sphynx/pytest_plugin.py +62 -0
- sphynx/question_robustness.py +437 -0
- sphynx/rag.py +329 -0
- sphynx/rag_diagnostics.py +309 -0
- sphynx/rag_papers/__init__.py +29 -0
- sphynx/rag_papers/__main__.py +45 -0
- sphynx/rag_papers/candidates.py +19 -0
- sphynx/rag_papers/contracts.py +282 -0
- sphynx/rag_papers/datasets.py +291 -0
- sphynx/rag_papers/experiments.py +116 -0
- sphynx/rag_papers/judges.py +308 -0
- sphynx/rag_papers/registry.py +33 -0
- sphynx/rag_papers/runner.py +176 -0
- sphynx/rag_papers/sable.py +135 -0
- sphynx/rag_papers/schemas.py +12 -0
- sphynx/rag_papers/scoring.py +249 -0
- sphynx/rag_papers/semantic.py +288 -0
- sphynx/rag_papers/trec.py +80 -0
- sphynx/recorded_scores.py +352 -0
- sphynx/records.py +124 -0
- sphynx/reflex_traces.py +193 -0
- sphynx/report_rendering.py +71 -0
- sphynx/rerank_family_contracts.py +150 -0
- sphynx/rerank_workflow.py +424 -0
- sphynx/research_candidate.py +190 -0
- sphynx/results.py +852 -0
- sphynx/retrieval.py +750 -0
- sphynx/retrieval_candidates.py +295 -0
- sphynx/retrieval_catalog_data.py +19 -0
- sphynx/retrieval_recordings.py +125 -0
- sphynx/reviewer_audit.py +266 -0
- sphynx/routing_policies.py +375 -0
- sphynx/runner.py +544 -0
- sphynx/sable_rerank.py +489 -0
- sphynx/score_reliability.py +393 -0
- sphynx/speech_benchmarks.py +204 -0
- sphynx/speech_campaigns.py +378 -0
- sphynx/speech_complete.py +236 -0
- sphynx/speech_contracts.py +225 -0
- sphynx/speech_evidence.py +429 -0
- sphynx/speech_listening.py +115 -0
- sphynx/speech_multilingual.py +71 -0
- sphynx/speech_tts.py +479 -0
- sphynx/sql/001_sync_v1.sql +30 -0
- sphynx/stage_contracts.py +199 -0
- sphynx/statistical_catalog_data.py +53 -0
- sphynx/statistical_rerank.py +232 -0
- sphynx/sync.py +797 -0
- sphynx/workbench.py +378 -0
- sphynx/workbench_catalog_data.py +13 -0
- sphynx/workbench_tracks.py +78 -0
- sphynx/workflow_async.py +197 -0
- sphynx/workflow_contracts.py +594 -0
- sphynx/workflow_decisions.py +211 -0
- sphynx/workflow_defaults.py +16 -0
- sphynx/workflow_extensions.py +212 -0
- sphynx/workflow_registry.py +513 -0
- sphynx/workflows.py +792 -0
sphynx/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)
|