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/sync.py
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
"""Closed-snapshot Sphynx archive and commit-gated ClickHouse projection.
|
|
2
|
+
|
|
3
|
+
The sync process is separate from candidate execution. Its credentials never
|
|
4
|
+
enter a candidate environment or an evidence bundle. Transport interfaces are
|
|
5
|
+
small so the ordering and failure behavior can be verified without cloud access.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import stat
|
|
17
|
+
import tempfile
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
import zipfile
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from importlib.resources import files as resource_files
|
|
24
|
+
from pathlib import Path, PurePosixPath
|
|
25
|
+
from typing import Any, Mapping, Protocol
|
|
26
|
+
|
|
27
|
+
from .contracts import canonical_json
|
|
28
|
+
from .evidence import RunRef, open_run, sha256_bytes, verify_run
|
|
29
|
+
from .http_transport import open_no_redirect
|
|
30
|
+
from .records import EVALUATION_SCHEMA_VERSION
|
|
31
|
+
|
|
32
|
+
SNAPSHOT_SCHEMA = "sphynx.snapshot.v1"
|
|
33
|
+
PROJECTION_SCHEMA = "sphynx.projection.v1"
|
|
34
|
+
FACT_SCHEMA = "sphynx.fact.v1"
|
|
35
|
+
COMMIT_SCHEMA = "sphynx.projection-commit.v1"
|
|
36
|
+
_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
37
|
+
_RUN_ID = re.compile(r"run-[0-9a-f]{32}\Z")
|
|
38
|
+
_GENERATION = re.compile(r"[1-9][0-9]*\Z")
|
|
39
|
+
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
40
|
+
_EVAL_ID = re.compile(r"^eval-[A-Za-z0-9]+$")
|
|
41
|
+
_REPARSE = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SyncError(ValueError):
|
|
45
|
+
"""A local snapshot or stored projection failed its integrity gate."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SyncTransportError(RuntimeError):
|
|
49
|
+
"""Remote storage failed; its original exception is intentionally hidden."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class StorageConfig:
|
|
54
|
+
"""Explicit Sphynx destinations and identities; secrets are never rendered.
|
|
55
|
+
|
|
56
|
+
Keyless ADC/workload identity is the default. Optional service-account JSON
|
|
57
|
+
is read only by the lazy GCS adapter. Supply a separately scoped ClickHouse
|
|
58
|
+
identity with INSERT/SELECT on Sphynx tables.
|
|
59
|
+
The database and tables must be provisioned with the package migration.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
gcs_bucket: str
|
|
63
|
+
gcs_prefix: str
|
|
64
|
+
gcs_identity: str
|
|
65
|
+
clickhouse_url: str
|
|
66
|
+
clickhouse_database: str
|
|
67
|
+
clickhouse_user: str
|
|
68
|
+
clickhouse_password: str = field(repr=False)
|
|
69
|
+
gcs_service_account_json: str | None = field(default=None, repr=False)
|
|
70
|
+
|
|
71
|
+
def __post_init__(self) -> None:
|
|
72
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9._-]{2,221}", self.gcs_bucket or "") or "litmus" in self.gcs_bucket.lower():
|
|
73
|
+
raise ValueError("an explicit non-Litmus GCS bucket is required")
|
|
74
|
+
_safe_relative(self.gcs_prefix)
|
|
75
|
+
if "litmus" in self.gcs_prefix.lower():
|
|
76
|
+
raise ValueError("a non-Litmus GCS prefix is required")
|
|
77
|
+
if not self.gcs_identity or self.gcs_identity.lower() in {"default", "litmus"}:
|
|
78
|
+
raise ValueError("an explicit Sphynx GCS identity is required")
|
|
79
|
+
parsed = urllib.parse.urlsplit(self.clickhouse_url)
|
|
80
|
+
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
81
|
+
raise ValueError("ClickHouse URL must be an HTTPS endpoint without credentials or query")
|
|
82
|
+
if not _IDENT.fullmatch(self.clickhouse_database or "") or self.clickhouse_database.lower() in {"default", "litmus"}:
|
|
83
|
+
raise ValueError("an explicit non-default Sphynx ClickHouse database is required")
|
|
84
|
+
if not _IDENT.fullmatch(self.clickhouse_user or "") or self.clickhouse_user.lower() in {"default", "litmus"}:
|
|
85
|
+
raise ValueError("an explicit non-default Sphynx ClickHouse user is required")
|
|
86
|
+
if not self.clickhouse_password:
|
|
87
|
+
raise ValueError("a separate ClickHouse credential is required")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class SyncResult:
|
|
92
|
+
status: str
|
|
93
|
+
projection_id: str | None
|
|
94
|
+
bundle_digest: str | None
|
|
95
|
+
archive_uri: str | None
|
|
96
|
+
archive_generation: str | None
|
|
97
|
+
stage: str | None = None
|
|
98
|
+
reason: str | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class FactRow:
|
|
103
|
+
projection_id: str
|
|
104
|
+
row_id: str
|
|
105
|
+
row_hash: str
|
|
106
|
+
fact_type: str
|
|
107
|
+
run_id: str
|
|
108
|
+
case_id: str
|
|
109
|
+
capture_id: str
|
|
110
|
+
evaluation_id: str
|
|
111
|
+
metric_id: str
|
|
112
|
+
capability_id: str
|
|
113
|
+
status: str
|
|
114
|
+
numeric_value: float | None
|
|
115
|
+
payload_json: str
|
|
116
|
+
|
|
117
|
+
def to_dict(self) -> dict[str, Any]:
|
|
118
|
+
return vars(self).copy()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class CommitRow:
|
|
123
|
+
projection_id: str
|
|
124
|
+
commit_hash: str
|
|
125
|
+
payload_json: str
|
|
126
|
+
|
|
127
|
+
def to_dict(self) -> dict[str, str]:
|
|
128
|
+
return vars(self).copy()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ArchiveStore(Protocol):
|
|
132
|
+
def put_create_only(self, key: str, data: bytes) -> str: ...
|
|
133
|
+
def latest_generation(self, key: str) -> str | None: ...
|
|
134
|
+
def read_generation(self, key: str, generation: str) -> bytes: ...
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class FactStore(Protocol):
|
|
138
|
+
def insert_facts(self, rows: list[FactRow]) -> None: ...
|
|
139
|
+
def read_facts(self, projection_id: str) -> list[FactRow]: ...
|
|
140
|
+
def insert_commit(self, row: CommitRow) -> None: ...
|
|
141
|
+
def read_commits(self, projection_id: str) -> list[CommitRow]: ...
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def migration_sql(database: str) -> str:
|
|
145
|
+
"""Return the package migration for an already-provisioned database."""
|
|
146
|
+
|
|
147
|
+
if not _IDENT.fullmatch(database) or database.lower() in {"default", "litmus"}:
|
|
148
|
+
raise ValueError("invalid Sphynx database name")
|
|
149
|
+
return resource_files("sphynx").joinpath("sql/001_sync_v1.sql").read_text(encoding="utf-8").replace("{database}", database)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _safe_relative(value: str, *, allow_empty: bool = False) -> str:
|
|
153
|
+
if allow_empty and value == "":
|
|
154
|
+
return ""
|
|
155
|
+
if not isinstance(value, str) or not value or "\\" in value or ":" in value or value.startswith("/") or value.endswith("/"):
|
|
156
|
+
raise SyncError("unsafe relative path")
|
|
157
|
+
parts = value.split("/")
|
|
158
|
+
if any(part in {"", ".", ".."} for part in parts) or PurePosixPath(value).as_posix() != value:
|
|
159
|
+
raise SyncError("unsafe relative path")
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _json_bytes(value: Any) -> bytes:
|
|
164
|
+
return (canonical_json(value) + "\n").encode("utf-8")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _read_object(data: bytes, label: str) -> dict[str, Any]:
|
|
168
|
+
try:
|
|
169
|
+
value = json.loads(data)
|
|
170
|
+
except (UnicodeError, json.JSONDecodeError, RecursionError) as exc:
|
|
171
|
+
raise SyncError(f"invalid {label} JSON") from None
|
|
172
|
+
if not isinstance(value, dict):
|
|
173
|
+
raise SyncError(f"invalid {label} object")
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _is_reparse(mode: os.stat_result) -> bool:
|
|
178
|
+
return stat.S_ISLNK(mode.st_mode) or bool(getattr(mode, "st_file_attributes", 0) & _REPARSE)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _walk_tree(root: Path) -> tuple[set[str], set[str]]:
|
|
182
|
+
"""Inventory regular files and directories, rejecting all reparse points."""
|
|
183
|
+
|
|
184
|
+
root_stat = root.lstat()
|
|
185
|
+
if _is_reparse(root_stat) or not stat.S_ISDIR(root_stat.st_mode):
|
|
186
|
+
raise SyncError("run directory is a link or non-directory")
|
|
187
|
+
found_files: set[str] = set()
|
|
188
|
+
found_dirs: set[str] = set()
|
|
189
|
+
|
|
190
|
+
def visit(directory: Path, prefix: str) -> None:
|
|
191
|
+
with os.scandir(directory) as entries:
|
|
192
|
+
for entry in entries:
|
|
193
|
+
relative = f"{prefix}/{entry.name}" if prefix else entry.name
|
|
194
|
+
_safe_relative(relative)
|
|
195
|
+
info = entry.stat(follow_symlinks=False)
|
|
196
|
+
if _is_reparse(info):
|
|
197
|
+
raise SyncError("snapshot contains a link or junction")
|
|
198
|
+
if stat.S_ISDIR(info.st_mode):
|
|
199
|
+
found_dirs.add(relative)
|
|
200
|
+
visit(Path(entry.path), relative)
|
|
201
|
+
elif stat.S_ISREG(info.st_mode):
|
|
202
|
+
found_files.add(relative)
|
|
203
|
+
else:
|
|
204
|
+
raise SyncError("snapshot contains a non-regular file")
|
|
205
|
+
|
|
206
|
+
visit(root, "")
|
|
207
|
+
return found_files, found_dirs
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _expected_files(root: Path, run_id: str) -> set[str]:
|
|
211
|
+
manifest = _read_object((root / "manifest.json").read_bytes(), "run manifest")
|
|
212
|
+
if manifest.get("run_id") != run_id:
|
|
213
|
+
raise SyncError("run identity changed")
|
|
214
|
+
capture_ids = manifest.get("capture_ids")
|
|
215
|
+
if not isinstance(capture_ids, list) or any(not isinstance(cid, str) or not re.fullmatch(r"cap-[A-Za-z0-9]+", cid) for cid in capture_ids):
|
|
216
|
+
raise SyncError("invalid capture inventory")
|
|
217
|
+
expected = {"manifest.json"}
|
|
218
|
+
for cid in capture_ids:
|
|
219
|
+
base = f"captures/{cid}"
|
|
220
|
+
expected.update(f"{base}/{name}" for name in ("manifest.json", "events.jsonl", "artifacts.jsonl", "coverage.json"))
|
|
221
|
+
try:
|
|
222
|
+
lines = (root / base / "artifacts.jsonl").read_text(encoding="utf-8").splitlines()
|
|
223
|
+
artifacts = [json.loads(line) for line in lines]
|
|
224
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
225
|
+
raise SyncError("invalid artifact inventory") from None
|
|
226
|
+
for artifact in artifacts:
|
|
227
|
+
if not isinstance(artifact, dict):
|
|
228
|
+
raise SyncError("invalid artifact inventory")
|
|
229
|
+
path = _safe_relative(artifact.get("path"))
|
|
230
|
+
if not path.startswith("blobs/"):
|
|
231
|
+
raise SyncError("invalid artifact path")
|
|
232
|
+
expected.add(f"{base}/{path}")
|
|
233
|
+
|
|
234
|
+
evaluations = root / "evaluations"
|
|
235
|
+
if evaluations.exists():
|
|
236
|
+
if not evaluations.is_dir():
|
|
237
|
+
raise SyncError("invalid evaluations directory")
|
|
238
|
+
for eval_dir in evaluations.iterdir():
|
|
239
|
+
evaluation_id = eval_dir.name
|
|
240
|
+
if not _EVAL_ID.fullmatch(evaluation_id) or not eval_dir.is_dir():
|
|
241
|
+
raise SyncError("invalid evaluation directory")
|
|
242
|
+
base = f"evaluations/{evaluation_id}"
|
|
243
|
+
manifest_path = eval_dir / "manifest.json"
|
|
244
|
+
if not manifest_path.is_file():
|
|
245
|
+
raise SyncError("incomplete evaluation directory")
|
|
246
|
+
evaluation = _read_object(manifest_path.read_bytes(), "evaluation manifest")
|
|
247
|
+
if evaluation.get("schema_version") != EVALUATION_SCHEMA_VERSION or evaluation.get("run_id") != run_id or evaluation.get("evaluation_id") != evaluation_id:
|
|
248
|
+
raise SyncError("evaluation identity or schema mismatch")
|
|
249
|
+
indexed = evaluation.get("files")
|
|
250
|
+
if not isinstance(indexed, dict) or set(indexed) != {"judgments.jsonl", "metrics.json"}:
|
|
251
|
+
raise SyncError("evaluation file inventory missing")
|
|
252
|
+
expected.add(f"{base}/manifest.json")
|
|
253
|
+
for path, record in indexed.items():
|
|
254
|
+
_safe_relative(path)
|
|
255
|
+
if path == "manifest.json" or not isinstance(record, dict) or set(record) != {"sha256", "size_bytes"}:
|
|
256
|
+
raise SyncError("invalid evaluation inventory item")
|
|
257
|
+
digest, size = record["sha256"], record["size_bytes"]
|
|
258
|
+
if not isinstance(digest, str) or not _DIGEST.fullmatch(digest) or type(size) is not int or size < 0:
|
|
259
|
+
raise SyncError("invalid evaluation digest or size")
|
|
260
|
+
relative = f"{base}/{path}"
|
|
261
|
+
data = (root / relative).read_bytes()
|
|
262
|
+
if sha256_bytes(data) != digest or len(data) != size:
|
|
263
|
+
raise SyncError("evaluation file hash or size mismatch")
|
|
264
|
+
expected.add(relative)
|
|
265
|
+
metrics = _read_object((eval_dir / "metrics.json").read_bytes(), "metrics")
|
|
266
|
+
if metrics.get("schema_version") != "sphynx.metrics.v1" or metrics.get("run_id") != run_id or metrics.get("evaluation_id") != evaluation_id:
|
|
267
|
+
raise SyncError("metrics identity or schema mismatch")
|
|
268
|
+
try:
|
|
269
|
+
judgments = [json.loads(line) for line in (eval_dir / "judgments.jsonl").read_text(encoding="utf-8").splitlines()]
|
|
270
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
271
|
+
raise SyncError("invalid judgments") from None
|
|
272
|
+
if any(not isinstance(row, dict) or row.get("schema_version") != "sphynx.judgment.v1" or row.get("run_id") != run_id or row.get("evaluation_id") != evaluation_id for row in judgments):
|
|
273
|
+
raise SyncError("judgment identity or schema mismatch")
|
|
274
|
+
# The grader owns the semantic judgment/aggregate contract. A
|
|
275
|
+
# self-consistent hash inventory alone cannot prove that counts,
|
|
276
|
+
# releases, case identities, and aggregate metrics are correct.
|
|
277
|
+
from .results import verify_evaluation
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
verify_evaluation(eval_dir)
|
|
281
|
+
except Exception:
|
|
282
|
+
raise SyncError("evaluation semantic validation failed") from None
|
|
283
|
+
return expected
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _validate_tree(root: Path, run_id: str) -> list[dict[str, Any]]:
|
|
287
|
+
found, directories = _walk_tree(root)
|
|
288
|
+
from .results import _verify_gradable_run
|
|
289
|
+
|
|
290
|
+
_verify_gradable_run(RunRef(run_id, root, "rag.rerank.v1"))
|
|
291
|
+
expected = _expected_files(root, run_id)
|
|
292
|
+
expected_dirs = {str(PurePosixPath(path).parent) for path in expected if "/" in path}
|
|
293
|
+
expected_dirs |= {str(parent) for path in expected for parent in PurePosixPath(path).parents if str(parent) != "."}
|
|
294
|
+
if found != expected or directories != expected_dirs:
|
|
295
|
+
raise SyncError("snapshot has missing, extra, or unindexed files or directories")
|
|
296
|
+
return [
|
|
297
|
+
{"path": path, "size_bytes": len(data), "sha256": sha256_bytes(data)}
|
|
298
|
+
for path in sorted(found)
|
|
299
|
+
for data in [(root / Path(*path.split("/"))).read_bytes()]
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _stage_snapshot(ref: RunRef) -> tuple[bytes, str, Path, tempfile.TemporaryDirectory[str]]:
|
|
304
|
+
original_inventory = _validate_tree(ref.path, ref.run_id)
|
|
305
|
+
temporary = tempfile.TemporaryDirectory(prefix="sphynx-snapshot-")
|
|
306
|
+
staged = Path(temporary.name) / "runs" / ref.run_id
|
|
307
|
+
staged.mkdir(parents=True)
|
|
308
|
+
try:
|
|
309
|
+
for item in original_inventory:
|
|
310
|
+
relative = Path(*item["path"].split("/"))
|
|
311
|
+
destination = staged / relative
|
|
312
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
source = ref.path / relative
|
|
314
|
+
before = source.lstat()
|
|
315
|
+
if _is_reparse(before) or not stat.S_ISREG(before.st_mode):
|
|
316
|
+
raise SyncError("source changed during snapshot")
|
|
317
|
+
data = source.read_bytes()
|
|
318
|
+
after = source.lstat()
|
|
319
|
+
if before.st_size != after.st_size or before.st_mtime_ns != after.st_mtime_ns or sha256_bytes(data) != item["sha256"]:
|
|
320
|
+
raise SyncError("source changed during snapshot")
|
|
321
|
+
destination.write_bytes(data)
|
|
322
|
+
# Both trees must still contain exactly the same bytes and inventory.
|
|
323
|
+
if _validate_tree(ref.path, ref.run_id) != original_inventory or _validate_tree(staged, ref.run_id) != original_inventory:
|
|
324
|
+
raise SyncError("source changed during snapshot")
|
|
325
|
+
snapshot = {"schema_version": SNAPSHOT_SCHEMA, "run_id": ref.run_id, "files": original_inventory}
|
|
326
|
+
snapshot_bytes = _json_bytes(snapshot)
|
|
327
|
+
bundle_digest = sha256_bytes(snapshot_bytes)
|
|
328
|
+
archive = _make_archive(staged, snapshot_bytes, original_inventory)
|
|
329
|
+
_check_archive(archive, bundle_digest)
|
|
330
|
+
return archive, bundle_digest, staged, temporary
|
|
331
|
+
except BaseException:
|
|
332
|
+
temporary.cleanup()
|
|
333
|
+
raise
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _make_archive(staged: Path, snapshot_bytes: bytes, inventory: list[dict[str, Any]]) -> bytes:
|
|
337
|
+
output = io.BytesIO()
|
|
338
|
+
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
|
|
339
|
+
for name, data in [("snapshot.json", snapshot_bytes)] + [
|
|
340
|
+
(item["path"], (staged / Path(*item["path"].split("/"))).read_bytes()) for item in inventory
|
|
341
|
+
]:
|
|
342
|
+
info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0))
|
|
343
|
+
info.compress_type = zipfile.ZIP_STORED
|
|
344
|
+
info.external_attr = 0o100644 << 16
|
|
345
|
+
info.create_system = 3
|
|
346
|
+
archive.writestr(info, data)
|
|
347
|
+
return output.getvalue()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _check_archive(data: bytes, bundle_digest: str) -> dict[str, Any]:
|
|
351
|
+
try:
|
|
352
|
+
with zipfile.ZipFile(io.BytesIO(data), "r") as archive:
|
|
353
|
+
names = archive.namelist()
|
|
354
|
+
if len(names) != len(set(names)) or not names or names[0] != "snapshot.json":
|
|
355
|
+
raise SyncError("invalid archive inventory")
|
|
356
|
+
if archive.comment or any(
|
|
357
|
+
info.date_time != (1980, 1, 1, 0, 0, 0)
|
|
358
|
+
or info.compress_type != zipfile.ZIP_STORED
|
|
359
|
+
or info.external_attr != 0o100644 << 16
|
|
360
|
+
or info.extra or info.comment
|
|
361
|
+
for info in archive.infolist()
|
|
362
|
+
):
|
|
363
|
+
raise SyncError("archive metadata is not normalized")
|
|
364
|
+
snapshot_bytes = archive.read("snapshot.json")
|
|
365
|
+
if sha256_bytes(snapshot_bytes) != bundle_digest:
|
|
366
|
+
raise SyncError("bundle digest mismatch")
|
|
367
|
+
snapshot = _read_object(snapshot_bytes, "snapshot")
|
|
368
|
+
if snapshot_bytes != _json_bytes(snapshot):
|
|
369
|
+
raise SyncError("snapshot JSON is not canonical")
|
|
370
|
+
if snapshot.get("schema_version") != SNAPSHOT_SCHEMA or not isinstance(snapshot.get("run_id"), str):
|
|
371
|
+
raise SyncError("unsupported snapshot")
|
|
372
|
+
inventory = snapshot.get("files")
|
|
373
|
+
if not isinstance(inventory, list) or names[1:] != [item.get("path") for item in inventory if isinstance(item, dict)] or names[1:] != sorted(names[1:]):
|
|
374
|
+
raise SyncError("archive file inventory mismatch")
|
|
375
|
+
for item in inventory:
|
|
376
|
+
if not isinstance(item, dict) or set(item) != {"path", "size_bytes", "sha256"}:
|
|
377
|
+
raise SyncError("invalid snapshot entry")
|
|
378
|
+
path = _safe_relative(item["path"])
|
|
379
|
+
payload = archive.read(path)
|
|
380
|
+
if type(item["size_bytes"]) is not int or len(payload) != item["size_bytes"] or sha256_bytes(payload) != item["sha256"]:
|
|
381
|
+
raise SyncError("archive file hash or size mismatch")
|
|
382
|
+
return snapshot
|
|
383
|
+
except (zipfile.BadZipFile, KeyError, OSError, ValueError, RecursionError):
|
|
384
|
+
raise SyncError("invalid archive bytes") from None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _projection_id(run_id: str, bundle_digest: str) -> str:
|
|
388
|
+
return "projection-" + hashlib.sha256(canonical_json([PROJECTION_SCHEMA, run_id, bundle_digest]).encode("utf-8")).hexdigest()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _fact(projection_id: str, run_id: str, fact_type: str, key: list[str], payload: Mapping[str, Any]) -> FactRow:
|
|
392
|
+
payload_json = canonical_json(dict(payload))
|
|
393
|
+
row_id = "fact-" + hashlib.sha256(canonical_json([fact_type, key]).encode("utf-8")).hexdigest()
|
|
394
|
+
row_hash = sha256_bytes(canonical_json([fact_type, payload_json]).encode("utf-8"))
|
|
395
|
+
value = payload.get("value")
|
|
396
|
+
numeric = float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
|
|
397
|
+
return FactRow(
|
|
398
|
+
projection_id, row_id, row_hash, fact_type, run_id,
|
|
399
|
+
str(payload.get("case_id") or ""), str(payload.get("capture_id") or ""),
|
|
400
|
+
str(payload.get("evaluation_id") or ""), str(payload.get("metric_id") or ""),
|
|
401
|
+
str(payload.get("capability_id") or ""), str(payload.get("status") or ""), numeric, payload_json,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _facts(staged: Path, projection_id: str) -> list[FactRow]:
|
|
406
|
+
run = _read_object((staged / "manifest.json").read_bytes(), "run")
|
|
407
|
+
run_id = run["run_id"]
|
|
408
|
+
facts: list[FactRow] = []
|
|
409
|
+
facts.append(_fact(projection_id, run_id, "run", [run_id], {
|
|
410
|
+
"run_id": run_id, "pack_id": run.get("pack_id"), "pack_release": run.get("pack_release"),
|
|
411
|
+
"track": run.get("track"), "candidate": run.get("candidate"), "trust_tier": run.get("trust_tier"),
|
|
412
|
+
"status": run.get("status"), "case_count": run.get("case_count"), "capture_count": run.get("capture_count"),
|
|
413
|
+
"success_count": run.get("success_count"), "failure_count": run.get("failure_count"),
|
|
414
|
+
}))
|
|
415
|
+
for case in run["cases"]:
|
|
416
|
+
facts.append(_fact(projection_id, run_id, "case", [run_id, case["case_id"]], {"run_id": run_id, **case}))
|
|
417
|
+
for capture_id in run["capture_ids"]:
|
|
418
|
+
base = staged / "captures" / capture_id
|
|
419
|
+
capture = _read_object((base / "manifest.json").read_bytes(), "capture")
|
|
420
|
+
identity = capture["identity"]
|
|
421
|
+
facts.append(_fact(projection_id, run_id, "capture", [run_id, capture_id], {
|
|
422
|
+
"run_id": run_id, "capture_id": capture_id, "case_id": identity["case_id"],
|
|
423
|
+
"trial_index": identity["trial_index"], "attempt_index": identity["attempt_index"],
|
|
424
|
+
"pool_digest": identity["pool_digest"], "status": capture["status"],
|
|
425
|
+
"failure_code": capture.get("failure_code"), "elapsed_ns": capture["timing"]["elapsed_ns"],
|
|
426
|
+
"candidate_latency_ns": capture["timing"].get("candidate_latency_ns"),
|
|
427
|
+
}))
|
|
428
|
+
coverage = _read_object((base / "coverage.json").read_bytes(), "coverage")
|
|
429
|
+
for capability in coverage["capabilities"]:
|
|
430
|
+
capability_id = capability["capability_id"]
|
|
431
|
+
facts.append(_fact(projection_id, run_id, "capability", [run_id, capture_id, capability_id], {
|
|
432
|
+
"run_id": run_id, "capture_id": capture_id, "case_id": identity["case_id"],
|
|
433
|
+
"capability_id": capability_id, "capability_schema_version": capability["capability_schema_version"],
|
|
434
|
+
"status": capability["state"], "observed_count": capability["observed_count"],
|
|
435
|
+
}))
|
|
436
|
+
evaluations = staged / "evaluations"
|
|
437
|
+
if evaluations.exists():
|
|
438
|
+
for directory in sorted(evaluations.iterdir()):
|
|
439
|
+
manifest = _read_object((directory / "manifest.json").read_bytes(), "evaluation")
|
|
440
|
+
eid = manifest["evaluation_id"]
|
|
441
|
+
facts.append(_fact(projection_id, run_id, "evaluation", [run_id, eid], {
|
|
442
|
+
"run_id": run_id, "evaluation_id": eid, "schema_version": manifest["schema_version"],
|
|
443
|
+
"grader_release": manifest.get("grader_release"), "metric_release": manifest.get("metric_release"),
|
|
444
|
+
"status": manifest.get("status", "completed"),
|
|
445
|
+
}))
|
|
446
|
+
metrics = _read_object((directory / "metrics.json").read_bytes(), "metrics")
|
|
447
|
+
facts.extend(_metric_facts(projection_id, run_id, eid, metrics))
|
|
448
|
+
for line in (directory / "judgments.jsonl").read_text(encoding="utf-8").splitlines():
|
|
449
|
+
judgment = json.loads(line)
|
|
450
|
+
values = judgment.get("values")
|
|
451
|
+
if not isinstance(values, dict):
|
|
452
|
+
raise SyncError("judgment metric values missing")
|
|
453
|
+
case_id = judgment["case_id"]
|
|
454
|
+
for metric_id, value in values.items():
|
|
455
|
+
if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float)) or not float("-inf") < float(value) < float("inf")):
|
|
456
|
+
raise SyncError("invalid judgment metric value")
|
|
457
|
+
facts.append(_fact(projection_id, run_id, "metric", [run_id, eid, "case", case_id, metric_id], {
|
|
458
|
+
"run_id": run_id, "evaluation_id": eid, "case_id": case_id,
|
|
459
|
+
"capture_id": judgment.get("capture_id"), "scope": "case", "metric_id": metric_id,
|
|
460
|
+
"value": value, "status": judgment["status"] if value is None else "available",
|
|
461
|
+
}))
|
|
462
|
+
ids = [fact.row_id for fact in facts]
|
|
463
|
+
if len(ids) != len(set(ids)):
|
|
464
|
+
raise SyncError("duplicate logical fact identity")
|
|
465
|
+
return sorted(facts, key=lambda fact: fact.row_id)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _metric_facts(projection_id: str, run_id: str, evaluation_id: str, metrics: Mapping[str, Any]) -> list[FactRow]:
|
|
469
|
+
"""Project every versioned aggregate, preserving missingness counts."""
|
|
470
|
+
out: list[FactRow] = []
|
|
471
|
+
values = metrics.get("metrics")
|
|
472
|
+
if not isinstance(values, dict):
|
|
473
|
+
raise SyncError("aggregate metrics missing")
|
|
474
|
+
for metric_id, entry in sorted(values.items()):
|
|
475
|
+
if not isinstance(metric_id, str) or not metric_id or not isinstance(entry, dict) or entry.get("metric_id") != metric_id:
|
|
476
|
+
raise SyncError("invalid aggregate metric")
|
|
477
|
+
value = entry.get("value")
|
|
478
|
+
if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float)) or not float("-inf") < float(value) < float("inf")):
|
|
479
|
+
raise SyncError("non-finite or invalid metric")
|
|
480
|
+
payload = {"run_id": run_id, "evaluation_id": evaluation_id, "case_id": "", "scope": "aggregate",
|
|
481
|
+
"metric_id": metric_id, "release": entry.get("release"), "unit": entry.get("unit"),
|
|
482
|
+
"direction": entry.get("direction"), "value": value,
|
|
483
|
+
"p95": entry.get("p95"), "eligible_count": entry.get("eligible_count"),
|
|
484
|
+
"observed_count": entry.get("observed_count"), "missing_count": entry.get("missing_count"),
|
|
485
|
+
"undefined_count": entry.get("undefined_count"), "complete": entry.get("complete"),
|
|
486
|
+
"status": "available" if value is not None else "undefined"}
|
|
487
|
+
out.append(_fact(projection_id, run_id, "metric", [run_id, evaluation_id, "aggregate", "", metric_id], payload))
|
|
488
|
+
return out
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _fact_summary(rows: list[FactRow]) -> tuple[dict[str, int], str]:
|
|
492
|
+
counts: dict[str, int] = {}
|
|
493
|
+
for row in rows:
|
|
494
|
+
counts[row.fact_type] = counts.get(row.fact_type, 0) + 1
|
|
495
|
+
digest = sha256_bytes(canonical_json([[row.row_id, row.row_hash] for row in sorted(rows, key=lambda row: row.row_id)]).encode("utf-8"))
|
|
496
|
+
return counts, digest
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _gate_facts(expected: list[FactRow], physical: list[FactRow]) -> None:
|
|
500
|
+
by_id: dict[str, FactRow] = {}
|
|
501
|
+
for row in physical:
|
|
502
|
+
if row.row_hash != sha256_bytes(canonical_json([row.fact_type, row.payload_json]).encode("utf-8")):
|
|
503
|
+
raise SyncError("stored fact hash mismatch")
|
|
504
|
+
prior = by_id.setdefault(row.row_id, row)
|
|
505
|
+
if prior != row:
|
|
506
|
+
raise SyncError("conflicting duplicate fact")
|
|
507
|
+
expected_map = {row.row_id: row for row in expected}
|
|
508
|
+
if by_id != expected_map:
|
|
509
|
+
raise SyncError("missing, extra, or altered facts")
|
|
510
|
+
if _fact_summary(list(by_id.values())) != _fact_summary(expected):
|
|
511
|
+
raise SyncError("fact count or digest mismatch")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _commit(projection_id: str, run_id: str, bundle_digest: str, uri: str, generation: str, archive_sha: str, facts: list[FactRow]) -> CommitRow:
|
|
515
|
+
counts, fact_digest = _fact_summary(facts)
|
|
516
|
+
payload = {"schema_version": COMMIT_SCHEMA, "projection_id": projection_id, "run_id": run_id,
|
|
517
|
+
"bundle_digest": bundle_digest, "archive_uri": uri, "archive_generation": generation,
|
|
518
|
+
"archive_sha256": archive_sha, "fact_counts": counts, "fact_digest": fact_digest}
|
|
519
|
+
content = canonical_json(payload)
|
|
520
|
+
return CommitRow(projection_id, sha256_bytes(content.encode("utf-8")), content)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _gate_commits(expected: CommitRow, physical: list[CommitRow]) -> None:
|
|
524
|
+
if not physical:
|
|
525
|
+
raise SyncError("projection has no commit")
|
|
526
|
+
for row in physical:
|
|
527
|
+
if row != expected or row.commit_hash != sha256_bytes(row.payload_json.encode("utf-8")):
|
|
528
|
+
raise SyncError("conflicting or corrupt commit")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _key(config: StorageConfig, run_id: str, digest: str) -> str:
|
|
532
|
+
stem = f"runs/{run_id}/snapshots/{digest}"
|
|
533
|
+
return f"{config.gcs_prefix}/{stem}" if config.gcs_prefix else stem
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _archive_gate(store: ArchiveStore, key: str, generation: str, expected_bytes: bytes, digest: str) -> None:
|
|
537
|
+
stored = store.read_generation(key, generation)
|
|
538
|
+
if stored != expected_bytes or sha256_bytes(stored) != sha256_bytes(expected_bytes):
|
|
539
|
+
raise SyncError("stored archive bytes differ from closed snapshot")
|
|
540
|
+
_check_archive(stored, digest)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _pending(stage: str, reason: str, projection_id: str | None = None, digest: str | None = None, uri: str | None = None, generation: str | None = None) -> SyncResult:
|
|
544
|
+
return SyncResult("pending", projection_id, digest, uri, generation, stage, reason)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def sync(run_ref: RunRef | str | Path, storage_config: StorageConfig, *, archive_store: ArchiveStore | None = None, fact_store: FactStore | None = None) -> SyncResult:
|
|
548
|
+
"""Archive a validated closed snapshot, then commit visible facts last.
|
|
549
|
+
|
|
550
|
+
A pending result is safe to retry. Local validation and staging precede all
|
|
551
|
+
remote operations. Existing physical duplicates are accepted only when
|
|
552
|
+
every row is byte-equivalent to its deterministic expected logical row.
|
|
553
|
+
"""
|
|
554
|
+
|
|
555
|
+
if not isinstance(storage_config, StorageConfig):
|
|
556
|
+
raise TypeError("storage_config must be StorageConfig")
|
|
557
|
+
temporary: tempfile.TemporaryDirectory[str] | None = None
|
|
558
|
+
try:
|
|
559
|
+
raw_path = run_ref.path if isinstance(run_ref, RunRef) else Path(run_ref)
|
|
560
|
+
absolute_path = Path(raw_path).expanduser().absolute()
|
|
561
|
+
if _is_reparse(absolute_path.lstat()):
|
|
562
|
+
raise SyncError("run path is a link or junction")
|
|
563
|
+
_walk_tree(absolute_path)
|
|
564
|
+
ref = open_run(run_ref)
|
|
565
|
+
if not _RUN_ID.fullmatch(ref.run_id):
|
|
566
|
+
raise SyncError("invalid run ID for projection")
|
|
567
|
+
archive, digest, staged, temporary = _stage_snapshot(ref)
|
|
568
|
+
projection_id = _projection_id(ref.run_id, digest)
|
|
569
|
+
facts = _facts(staged, projection_id)
|
|
570
|
+
except Exception:
|
|
571
|
+
if temporary is not None:
|
|
572
|
+
temporary.cleanup()
|
|
573
|
+
return _pending("local_validation", "invalid_or_changing_snapshot")
|
|
574
|
+
try:
|
|
575
|
+
try:
|
|
576
|
+
gcs = archive_store if archive_store is not None else GCSArchiveStore(storage_config)
|
|
577
|
+
clickhouse = fact_store if fact_store is not None else ClickHouseFactStore(storage_config)
|
|
578
|
+
except Exception:
|
|
579
|
+
return _pending("configuration", "transport_unavailable", projection_id, digest)
|
|
580
|
+
key = _key(storage_config, ref.run_id, digest)
|
|
581
|
+
uri = f"gs://{storage_config.gcs_bucket}/{key}"
|
|
582
|
+
generation: str | None = None
|
|
583
|
+
try:
|
|
584
|
+
generation = gcs.put_create_only(key, archive)
|
|
585
|
+
except Exception:
|
|
586
|
+
# A lost response may follow successful creation. Fetch the current
|
|
587
|
+
# immutable generation and compare exact bytes before continuing.
|
|
588
|
+
try:
|
|
589
|
+
generation = gcs.latest_generation(key)
|
|
590
|
+
except Exception:
|
|
591
|
+
pass
|
|
592
|
+
if generation is None:
|
|
593
|
+
return _pending("archive", "upload_or_lookup_failed", projection_id, digest, uri)
|
|
594
|
+
if not isinstance(generation, str) or not _GENERATION.fullmatch(generation):
|
|
595
|
+
return _pending("archive", "invalid_archive_generation", projection_id, digest, uri)
|
|
596
|
+
try:
|
|
597
|
+
_archive_gate(gcs, key, generation, archive, digest)
|
|
598
|
+
except Exception:
|
|
599
|
+
return _pending("archive", "archive_readback_failed_or_conflicted", projection_id, digest, uri, generation)
|
|
600
|
+
expected_commit = _commit(projection_id, ref.run_id, digest, uri, generation, sha256_bytes(archive), facts)
|
|
601
|
+
try:
|
|
602
|
+
existing = clickhouse.read_commits(projection_id)
|
|
603
|
+
if existing:
|
|
604
|
+
_gate_commits(expected_commit, existing)
|
|
605
|
+
_gate_facts(facts, clickhouse.read_facts(projection_id))
|
|
606
|
+
return SyncResult("committed", projection_id, digest, uri, generation)
|
|
607
|
+
except Exception:
|
|
608
|
+
return _pending("commit_read", "existing_commit_invalid_or_unavailable", projection_id, digest, uri, generation)
|
|
609
|
+
try:
|
|
610
|
+
clickhouse.insert_facts(facts)
|
|
611
|
+
_gate_facts(facts, clickhouse.read_facts(projection_id))
|
|
612
|
+
except Exception:
|
|
613
|
+
return _pending("facts", "insert_or_readback_failed", projection_id, digest, uri, generation)
|
|
614
|
+
try:
|
|
615
|
+
clickhouse.insert_commit(expected_commit)
|
|
616
|
+
except Exception:
|
|
617
|
+
# A response may be lost after insertion; the same full gate below
|
|
618
|
+
# determines whether the projection became visible.
|
|
619
|
+
pass
|
|
620
|
+
try:
|
|
621
|
+
_gate_commits(expected_commit, clickhouse.read_commits(projection_id))
|
|
622
|
+
_gate_facts(facts, clickhouse.read_facts(projection_id))
|
|
623
|
+
_archive_gate(gcs, key, generation, archive, digest)
|
|
624
|
+
except Exception:
|
|
625
|
+
return _pending("commit", "commit_or_final_gate_failed", projection_id, digest, uri, generation)
|
|
626
|
+
return SyncResult("committed", projection_id, digest, uri, generation)
|
|
627
|
+
finally:
|
|
628
|
+
assert temporary is not None
|
|
629
|
+
temporary.cleanup()
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def read_committed_projection(projection_id: str, storage_config: StorageConfig, *, archive_store: ArchiveStore | None = None, fact_store: FactStore | None = None) -> SyncResult | None:
|
|
633
|
+
"""Read only a projection whose archived snapshot, facts, and commit agree.
|
|
634
|
+
|
|
635
|
+
The archive is the authoritative snapshot for historical reads. A later
|
|
636
|
+
local regrade does not hide or mutate an earlier committed projection.
|
|
637
|
+
``None`` means absent or invalid; callers must never expose raw fact rows.
|
|
638
|
+
"""
|
|
639
|
+
if not isinstance(storage_config, StorageConfig):
|
|
640
|
+
raise TypeError("storage_config must be StorageConfig")
|
|
641
|
+
temporary: tempfile.TemporaryDirectory[str] | None = None
|
|
642
|
+
try:
|
|
643
|
+
gcs = archive_store if archive_store is not None else GCSArchiveStore(storage_config)
|
|
644
|
+
clickhouse = fact_store if fact_store is not None else ClickHouseFactStore(storage_config)
|
|
645
|
+
commits = clickhouse.read_commits(projection_id)
|
|
646
|
+
if not commits:
|
|
647
|
+
return None
|
|
648
|
+
first = commits[0]
|
|
649
|
+
if first.commit_hash != sha256_bytes(first.payload_json.encode("utf-8")):
|
|
650
|
+
return None
|
|
651
|
+
parsed = _read_object(first.payload_json.encode("utf-8"), "commit")
|
|
652
|
+
run_id, digest, generation, archive_sha = (
|
|
653
|
+
parsed.get("run_id"), parsed.get("bundle_digest"), parsed.get("archive_generation"), parsed.get("archive_sha256")
|
|
654
|
+
)
|
|
655
|
+
if (parsed.get("schema_version") != COMMIT_SCHEMA or parsed.get("projection_id") != projection_id
|
|
656
|
+
or not isinstance(run_id, str) or not _RUN_ID.fullmatch(run_id)
|
|
657
|
+
or not isinstance(digest, str) or not _DIGEST.fullmatch(digest)
|
|
658
|
+
or not isinstance(generation, str) or not _GENERATION.fullmatch(generation)
|
|
659
|
+
or not isinstance(archive_sha, str) or not _DIGEST.fullmatch(archive_sha)
|
|
660
|
+
or _projection_id(run_id, digest) != projection_id):
|
|
661
|
+
return None
|
|
662
|
+
key = _key(storage_config, run_id, digest)
|
|
663
|
+
uri = f"gs://{storage_config.gcs_bucket}/{key}"
|
|
664
|
+
if parsed.get("archive_uri") != uri:
|
|
665
|
+
return None
|
|
666
|
+
archive = gcs.read_generation(key, generation)
|
|
667
|
+
if sha256_bytes(archive) != archive_sha:
|
|
668
|
+
return None
|
|
669
|
+
snapshot = _check_archive(archive, digest)
|
|
670
|
+
if snapshot["run_id"] != run_id:
|
|
671
|
+
return None
|
|
672
|
+
temporary = tempfile.TemporaryDirectory(prefix="sphynx-read-")
|
|
673
|
+
staged = Path(temporary.name) / "runs" / run_id
|
|
674
|
+
staged.mkdir(parents=True)
|
|
675
|
+
with zipfile.ZipFile(io.BytesIO(archive), "r") as zip_file:
|
|
676
|
+
for item in snapshot["files"]:
|
|
677
|
+
relative = _safe_relative(item["path"])
|
|
678
|
+
path = staged / Path(*relative.split("/"))
|
|
679
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
680
|
+
path.write_bytes(zip_file.read(relative))
|
|
681
|
+
if _validate_tree(staged, run_id) != snapshot["files"]:
|
|
682
|
+
return None
|
|
683
|
+
facts = _facts(staged, projection_id)
|
|
684
|
+
expected = _commit(projection_id, run_id, digest, uri, generation, archive_sha, facts)
|
|
685
|
+
_gate_commits(expected, commits)
|
|
686
|
+
_gate_facts(facts, clickhouse.read_facts(projection_id))
|
|
687
|
+
return SyncResult("committed", projection_id, digest, uri, generation)
|
|
688
|
+
except Exception:
|
|
689
|
+
return None
|
|
690
|
+
finally:
|
|
691
|
+
if temporary is not None:
|
|
692
|
+
temporary.cleanup()
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
class GCSArchiveStore:
|
|
696
|
+
"""Lazy google-cloud-storage adapter using keyless or named credentials."""
|
|
697
|
+
|
|
698
|
+
def __init__(self, config: StorageConfig) -> None:
|
|
699
|
+
try:
|
|
700
|
+
from google.cloud import storage # type: ignore[import-not-found]
|
|
701
|
+
|
|
702
|
+
if config.gcs_service_account_json is not None:
|
|
703
|
+
service_account = json.loads(config.gcs_service_account_json)
|
|
704
|
+
if not isinstance(service_account, dict) or service_account.get("client_email") != config.gcs_identity:
|
|
705
|
+
raise ValueError("GCS identity mismatch")
|
|
706
|
+
client = storage.Client.from_service_account_info(service_account)
|
|
707
|
+
else:
|
|
708
|
+
import google.auth # type: ignore[import-not-found]
|
|
709
|
+
|
|
710
|
+
credentials, project = google.auth.default(scopes=["https://www.googleapis.com/auth/devstorage.read_write"])
|
|
711
|
+
observed_email = getattr(credentials, "service_account_email", None)
|
|
712
|
+
# Compute metadata credentials can report "default" until refresh.
|
|
713
|
+
# Resolve that alias before accepting a principal for this sink.
|
|
714
|
+
if observed_email == "default":
|
|
715
|
+
from google.auth.transport.requests import Request # type: ignore[import-not-found]
|
|
716
|
+
|
|
717
|
+
credentials.refresh(Request())
|
|
718
|
+
observed_email = getattr(credentials, "service_account_email", None)
|
|
719
|
+
if observed_email != config.gcs_identity:
|
|
720
|
+
raise ValueError("GCS identity mismatch")
|
|
721
|
+
client = storage.Client(project=project, credentials=credentials)
|
|
722
|
+
self._bucket = client.bucket(config.gcs_bucket)
|
|
723
|
+
except Exception:
|
|
724
|
+
raise SyncTransportError("GCS adapter unavailable or identity invalid") from None
|
|
725
|
+
|
|
726
|
+
def put_create_only(self, key: str, data: bytes) -> str:
|
|
727
|
+
try:
|
|
728
|
+
blob = self._bucket.blob(key)
|
|
729
|
+
blob.upload_from_string(data, content_type="application/zip", if_generation_match=0)
|
|
730
|
+
return str(blob.generation)
|
|
731
|
+
except Exception:
|
|
732
|
+
raise SyncTransportError("GCS create-only upload failed") from None
|
|
733
|
+
|
|
734
|
+
def latest_generation(self, key: str) -> str | None:
|
|
735
|
+
try:
|
|
736
|
+
blob = self._bucket.get_blob(key)
|
|
737
|
+
return str(blob.generation) if blob is not None else None
|
|
738
|
+
except Exception:
|
|
739
|
+
raise SyncTransportError("GCS generation lookup failed") from None
|
|
740
|
+
|
|
741
|
+
def read_generation(self, key: str, generation: str) -> bytes:
|
|
742
|
+
try:
|
|
743
|
+
return self._bucket.blob(key, generation=int(generation)).download_as_bytes()
|
|
744
|
+
except Exception:
|
|
745
|
+
raise SyncTransportError("GCS generation read failed") from None
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
class ClickHouseFactStore:
|
|
749
|
+
"""HTTPS JSONEachRow adapter; sync needs INSERT and SELECT only."""
|
|
750
|
+
|
|
751
|
+
def __init__(self, config: StorageConfig) -> None:
|
|
752
|
+
self._config = config
|
|
753
|
+
token = f"{config.clickhouse_user}:{config.clickhouse_password}".encode("utf-8")
|
|
754
|
+
self._authorization = "Basic " + base64.b64encode(token).decode("ascii")
|
|
755
|
+
self._url = config.clickhouse_url.rstrip("/") + "/"
|
|
756
|
+
self._database = config.clickhouse_database
|
|
757
|
+
|
|
758
|
+
def _post(self, sql: str) -> bytes:
|
|
759
|
+
request = urllib.request.Request(self._url, sql.encode("utf-8"), headers={
|
|
760
|
+
"Authorization": self._authorization, "Content-Type": "text/plain; charset=utf-8",
|
|
761
|
+
}, method="POST")
|
|
762
|
+
try:
|
|
763
|
+
with open_no_redirect(request, timeout=30) as response:
|
|
764
|
+
return response.read()
|
|
765
|
+
except Exception:
|
|
766
|
+
raise SyncTransportError("ClickHouse request failed") from None
|
|
767
|
+
|
|
768
|
+
def _insert(self, table: str, rows: list[dict[str, Any]]) -> None:
|
|
769
|
+
body = "".join(canonical_json(row) + "\n" for row in rows)
|
|
770
|
+
self._post(f"INSERT INTO {self._database}.{table} FORMAT JSONEachRow\n" + body)
|
|
771
|
+
|
|
772
|
+
def _select(self, table: str, projection_id: str) -> list[dict[str, Any]]:
|
|
773
|
+
if not re.fullmatch(r"projection-[0-9a-f]{64}", projection_id):
|
|
774
|
+
raise SyncError("invalid projection ID")
|
|
775
|
+
raw = self._post(f"SELECT * FROM {self._database}.{table} WHERE projection_id = '{projection_id}' FORMAT JSONEachRow")
|
|
776
|
+
try:
|
|
777
|
+
return [json.loads(line) for line in raw.decode("utf-8").splitlines() if line]
|
|
778
|
+
except (UnicodeError, json.JSONDecodeError):
|
|
779
|
+
raise SyncTransportError("ClickHouse response invalid") from None
|
|
780
|
+
|
|
781
|
+
def insert_facts(self, rows: list[FactRow]) -> None:
|
|
782
|
+
self._insert("sphynx_facts_v1", [row.to_dict() for row in rows])
|
|
783
|
+
|
|
784
|
+
def read_facts(self, projection_id: str) -> list[FactRow]:
|
|
785
|
+
try:
|
|
786
|
+
return [FactRow(**row) for row in self._select("sphynx_facts_v1", projection_id)]
|
|
787
|
+
except (TypeError, ValueError):
|
|
788
|
+
raise SyncTransportError("ClickHouse fact row invalid") from None
|
|
789
|
+
|
|
790
|
+
def insert_commit(self, row: CommitRow) -> None:
|
|
791
|
+
self._insert("sphynx_projection_commits_v1", [row.to_dict()])
|
|
792
|
+
|
|
793
|
+
def read_commits(self, projection_id: str) -> list[CommitRow]:
|
|
794
|
+
try:
|
|
795
|
+
return [CommitRow(**row) for row in self._select("sphynx_projection_commits_v1", projection_id)]
|
|
796
|
+
except (TypeError, ValueError):
|
|
797
|
+
raise SyncTransportError("ClickHouse commit row invalid") from None
|