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/_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}
|
sphynx/_pipeline_core.py
ADDED
|
@@ -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
|
+
}
|