sable-sphynx 0.7.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. sable_sphynx-0.7.0.dev0.dist-info/METADATA +152 -0
  2. sable_sphynx-0.7.0.dev0.dist-info/RECORD +174 -0
  3. sable_sphynx-0.7.0.dev0.dist-info/WHEEL +5 -0
  4. sable_sphynx-0.7.0.dev0.dist-info/top_level.txt +1 -0
  5. sphynx/__init__.py +469 -0
  6. sphynx/__init__.pyi +376 -0
  7. sphynx/_analytical_client.py +77 -0
  8. sphynx/_analytical_values.py +252 -0
  9. sphynx/_audit_client.py +173 -0
  10. sphynx/_comparison_adapters.py +113 -0
  11. sphynx/_componentized_full_rag.py +34 -0
  12. sphynx/_composed_pipeline.py +109 -0
  13. sphynx/_full_rag_grading.py +218 -0
  14. sphynx/_full_rag_trace.py +221 -0
  15. sphynx/_local_eval.py +268 -0
  16. sphynx/_pipeline_core.py +243 -0
  17. sphynx/_pr_client.py +237 -0
  18. sphynx/_rag_diagnostics_calibration.py +285 -0
  19. sphynx/_rag_diagnostics_catalog.py +39 -0
  20. sphynx/_rag_diagnostics_common.py +26 -0
  21. sphynx/_rag_diagnostics_contracts.py +186 -0
  22. sphynx/_rag_diagnostics_engine.py +243 -0
  23. sphynx/_rag_diagnostics_importers.py +242 -0
  24. sphynx/_rag_diagnostics_ranking.py +81 -0
  25. sphynx/_rag_diagnostics_semantic.py +325 -0
  26. sphynx/_rag_ranking.py +149 -0
  27. sphynx/_speech_benchmark_scoring.py +140 -0
  28. sphynx/_speech_benchmark_sources.py +275 -0
  29. sphynx/_speech_complete_scoring.py +218 -0
  30. sphynx/_speech_complete_source.py +118 -0
  31. sphynx/_speech_kido.py +294 -0
  32. sphynx/_speech_scoring.py +77 -0
  33. sphynx/_track_capture.py +213 -0
  34. sphynx/analytical_views.py +515 -0
  35. sphynx/assessment_contracts.py +378 -0
  36. sphynx/assessment_selection.py +57 -0
  37. sphynx/audit_access.py +245 -0
  38. sphynx/audit_contracts.py +204 -0
  39. sphynx/audit_requests.py +180 -0
  40. sphynx/authoring.py +188 -0
  41. sphynx/benchmarks.py +568 -0
  42. sphynx/candidates.py +62 -0
  43. sphynx/catalog.py +272 -0
  44. sphynx/chunking.py +372 -0
  45. sphynx/clickhouse_reader.py +169 -0
  46. sphynx/client.py +486 -0
  47. sphynx/comparison_core.py +149 -0
  48. sphynx/comparison_views.py +223 -0
  49. sphynx/components.py +1661 -0
  50. sphynx/contracts.py +514 -0
  51. sphynx/customer_rerank.py +299 -0
  52. sphynx/customer_retrieval.py +146 -0
  53. sphynx/data/benchmark-releases.v1.json +164 -0
  54. sphynx/data/public-benchmark-release.v1.json +354 -0
  55. sphynx/data/rag-diagnostics-synthetic.v1.json +6982 -0
  56. sphynx/data/rag-paper-references.v1.json +176 -0
  57. sphynx/data/rag-paper-schemas.v1.json +1069 -0
  58. sphynx/data/rag-rerank-v1.json +507 -0
  59. sphynx/dataset_catalog_data.py +1214 -0
  60. sphynx/dataset_readiness.py +138 -0
  61. sphynx/dataset_v3.py +999 -0
  62. sphynx/demo.py +130 -0
  63. sphynx/demo_candidates/__init__.py +1 -0
  64. sphynx/demo_candidates/dense.py +11 -0
  65. sphynx/demo_candidates/lexical.py +21 -0
  66. sphynx/diagnostics.py +207 -0
  67. sphynx/documentation.py +737 -0
  68. sphynx/documentation_candidate.py +939 -0
  69. sphynx/evals.py +538 -0
  70. sphynx/evaluation_protocols.py +124 -0
  71. sphynx/evaluator_bindings.py +370 -0
  72. sphynx/evidence.py +352 -0
  73. sphynx/execution_capabilities.py +109 -0
  74. sphynx/fast_search.py +285 -0
  75. sphynx/full_rag.py +341 -0
  76. sphynx/hosted/__init__.py +18 -0
  77. sphynx/hosted/benchmark_intake.py +218 -0
  78. sphynx/hosted/contracts.py +361 -0
  79. sphynx/hosted/model_admission.py +130 -0
  80. sphynx/hosted/model_artifacts.py +298 -0
  81. sphynx/hosted/model_contracts.py +63 -0
  82. sphynx/hosted/model_gcs.py +181 -0
  83. sphynx/hosted/model_history.py +84 -0
  84. sphynx/hosted/model_session.py +233 -0
  85. sphynx/hosted/public_suite.py +74 -0
  86. sphynx/hosted/registry.py +97 -0
  87. sphynx/hosted/revisions.py +126 -0
  88. sphynx/hosted/sandbox.py +105 -0
  89. sphynx/hosted/submission.py +287 -0
  90. sphynx/hosted_workflow.py +451 -0
  91. sphynx/http_transport.py +86 -0
  92. sphynx/integrated_rag.py +352 -0
  93. sphynx/integrated_workflow.py +552 -0
  94. sphynx/integration_contracts.py +474 -0
  95. sphynx/invocation.py +791 -0
  96. sphynx/invocation_candidates.py +392 -0
  97. sphynx/invocation_catalog_data.py +40 -0
  98. sphynx/learned_catalog_data.py +30 -0
  99. sphynx/learned_rerank.py +267 -0
  100. sphynx/legacy_full_rag.py +67 -0
  101. sphynx/lifecycle.py +885 -0
  102. sphynx/lifecycle_workflow.py +490 -0
  103. sphynx/metric_views.py +474 -0
  104. sphynx/metrics.py +282 -0
  105. sphynx/model_adapters.py +438 -0
  106. sphynx/model_catalog_data.py +30 -0
  107. sphynx/official_benchmarks.py +385 -0
  108. sphynx/packs.py +258 -0
  109. sphynx/pipeline.py +313 -0
  110. sphynx/pipeline_workflow.py +551 -0
  111. sphynx/pr_evaluations.py +560 -0
  112. sphynx/progress.py +89 -0
  113. sphynx/public_bundle.py +173 -0
  114. sphynx/public_eval.py +62 -0
  115. sphynx/public_rerank.py +355 -0
  116. sphynx/py.typed +0 -0
  117. sphynx/pytest_plugin.py +62 -0
  118. sphynx/question_robustness.py +437 -0
  119. sphynx/rag.py +329 -0
  120. sphynx/rag_diagnostics.py +309 -0
  121. sphynx/rag_papers/__init__.py +29 -0
  122. sphynx/rag_papers/__main__.py +45 -0
  123. sphynx/rag_papers/candidates.py +19 -0
  124. sphynx/rag_papers/contracts.py +282 -0
  125. sphynx/rag_papers/datasets.py +291 -0
  126. sphynx/rag_papers/experiments.py +116 -0
  127. sphynx/rag_papers/judges.py +308 -0
  128. sphynx/rag_papers/registry.py +33 -0
  129. sphynx/rag_papers/runner.py +176 -0
  130. sphynx/rag_papers/sable.py +135 -0
  131. sphynx/rag_papers/schemas.py +12 -0
  132. sphynx/rag_papers/scoring.py +249 -0
  133. sphynx/rag_papers/semantic.py +288 -0
  134. sphynx/rag_papers/trec.py +80 -0
  135. sphynx/recorded_scores.py +352 -0
  136. sphynx/records.py +124 -0
  137. sphynx/reflex_traces.py +193 -0
  138. sphynx/report_rendering.py +71 -0
  139. sphynx/rerank_family_contracts.py +150 -0
  140. sphynx/rerank_workflow.py +424 -0
  141. sphynx/research_candidate.py +190 -0
  142. sphynx/results.py +852 -0
  143. sphynx/retrieval.py +750 -0
  144. sphynx/retrieval_candidates.py +295 -0
  145. sphynx/retrieval_catalog_data.py +19 -0
  146. sphynx/retrieval_recordings.py +125 -0
  147. sphynx/reviewer_audit.py +266 -0
  148. sphynx/routing_policies.py +375 -0
  149. sphynx/runner.py +544 -0
  150. sphynx/sable_rerank.py +489 -0
  151. sphynx/score_reliability.py +393 -0
  152. sphynx/speech_benchmarks.py +204 -0
  153. sphynx/speech_campaigns.py +378 -0
  154. sphynx/speech_complete.py +236 -0
  155. sphynx/speech_contracts.py +225 -0
  156. sphynx/speech_evidence.py +429 -0
  157. sphynx/speech_listening.py +115 -0
  158. sphynx/speech_multilingual.py +71 -0
  159. sphynx/speech_tts.py +479 -0
  160. sphynx/sql/001_sync_v1.sql +30 -0
  161. sphynx/stage_contracts.py +199 -0
  162. sphynx/statistical_catalog_data.py +53 -0
  163. sphynx/statistical_rerank.py +232 -0
  164. sphynx/sync.py +797 -0
  165. sphynx/workbench.py +378 -0
  166. sphynx/workbench_catalog_data.py +13 -0
  167. sphynx/workbench_tracks.py +78 -0
  168. sphynx/workflow_async.py +197 -0
  169. sphynx/workflow_contracts.py +594 -0
  170. sphynx/workflow_decisions.py +211 -0
  171. sphynx/workflow_defaults.py +16 -0
  172. sphynx/workflow_extensions.py +212 -0
  173. sphynx/workflow_registry.py +513 -0
  174. sphynx/workflows.py +792 -0
sphynx/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