pretensor 0.1.0__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 (198) hide show
  1. pretensor/__init__.py +50 -0
  2. pretensor/benchmark/__init__.py +54 -0
  3. pretensor/benchmark/cli.py +294 -0
  4. pretensor/benchmark/fixtures.py +84 -0
  5. pretensor/benchmark/l1/__init__.py +23 -0
  6. pretensor/benchmark/l1/metrics.py +141 -0
  7. pretensor/benchmark/l1/pipeline.py +188 -0
  8. pretensor/benchmark/l1/runner.py +245 -0
  9. pretensor/benchmark/l2/__init__.py +27 -0
  10. pretensor/benchmark/l2/gold.py +236 -0
  11. pretensor/benchmark/l2/metrics.py +146 -0
  12. pretensor/benchmark/l2/pipeline.py +124 -0
  13. pretensor/benchmark/l2/runner.py +530 -0
  14. pretensor/benchmark/l3/__init__.py +73 -0
  15. pretensor/benchmark/l3/agent.py +316 -0
  16. pretensor/benchmark/l3/db.py +188 -0
  17. pretensor/benchmark/l3/gold.py +85 -0
  18. pretensor/benchmark/l3/llm_client.py +395 -0
  19. pretensor/benchmark/l3/mcp_client.py +357 -0
  20. pretensor/benchmark/l3/pretensor_runner.py +456 -0
  21. pretensor/benchmark/l3/prompt.py +132 -0
  22. pretensor/benchmark/l3/runner.py +358 -0
  23. pretensor/benchmark/l3/sql_equivalence.py +176 -0
  24. pretensor/benchmark/release_gate.py +448 -0
  25. pretensor/benchmark/results.py +298 -0
  26. pretensor/benchmark/runner.py +109 -0
  27. pretensor/cli/__init__.py +1 -0
  28. pretensor/cli/commands/_source_runner.py +147 -0
  29. pretensor/cli/commands/analyze.py +201 -0
  30. pretensor/cli/commands/connections/__init__.py +7 -0
  31. pretensor/cli/commands/connections/add_remove.py +126 -0
  32. pretensor/cli/commands/connections/register.py +12 -0
  33. pretensor/cli/commands/export.py +131 -0
  34. pretensor/cli/commands/index.py +559 -0
  35. pretensor/cli/commands/list.py +76 -0
  36. pretensor/cli/commands/quickstart.py +207 -0
  37. pretensor/cli/commands/reindex.py +646 -0
  38. pretensor/cli/commands/semantic.py +190 -0
  39. pretensor/cli/commands/serve.py +144 -0
  40. pretensor/cli/commands/sync_grants.py +149 -0
  41. pretensor/cli/commands/validate.py +176 -0
  42. pretensor/cli/config_file.py +442 -0
  43. pretensor/cli/constants.py +10 -0
  44. pretensor/cli/dbt_enrichment.py +96 -0
  45. pretensor/cli/main.py +109 -0
  46. pretensor/cli/paths.py +43 -0
  47. pretensor/cli/plugin.py +52 -0
  48. pretensor/config.py +226 -0
  49. pretensor/connectors/__init__.py +29 -0
  50. pretensor/connectors/base.py +165 -0
  51. pretensor/connectors/bigquery.py +468 -0
  52. pretensor/connectors/inspect.py +321 -0
  53. pretensor/connectors/lineage_sqlglot.py +97 -0
  54. pretensor/connectors/models.py +130 -0
  55. pretensor/connectors/mysql.py +402 -0
  56. pretensor/connectors/pg_array_parse.py +53 -0
  57. pretensor/connectors/postgres.py +938 -0
  58. pretensor/connectors/registry.py +93 -0
  59. pretensor/connectors/snapshot.py +244 -0
  60. pretensor/connectors/snowflake.py +908 -0
  61. pretensor/core/__init__.py +1 -0
  62. pretensor/core/builder.py +307 -0
  63. pretensor/core/dsn_crypto.py +51 -0
  64. pretensor/core/graph_schema_manager.py +246 -0
  65. pretensor/core/graph_store.py +1226 -0
  66. pretensor/core/ids.py +101 -0
  67. pretensor/core/portable_export.py +276 -0
  68. pretensor/core/query_runner.py +67 -0
  69. pretensor/core/registry.py +209 -0
  70. pretensor/core/schema.py +473 -0
  71. pretensor/core/secure_io.py +93 -0
  72. pretensor/core/store.py +469 -0
  73. pretensor/enrichment/__init__.py +1 -0
  74. pretensor/enrichment/analyze/__init__.py +0 -0
  75. pretensor/enrichment/analyze/classify.py +49 -0
  76. pretensor/enrichment/analyze/extract_python.py +196 -0
  77. pretensor/enrichment/analyze/parse.py +141 -0
  78. pretensor/enrichment/analyze/pipeline.py +195 -0
  79. pretensor/enrichment/analyze/summary.py +38 -0
  80. pretensor/enrichment/analyze/walker.py +98 -0
  81. pretensor/enrichment/analyze/writers.py +214 -0
  82. pretensor/enrichment/dbt/__init__.py +30 -0
  83. pretensor/enrichment/dbt/lineage.py +100 -0
  84. pretensor/enrichment/dbt/manifest.py +300 -0
  85. pretensor/enrichment/dbt/metadata.py +263 -0
  86. pretensor/enrichment/dbt/pipeline.py +77 -0
  87. pretensor/enrichment/dbt/resolution.py +101 -0
  88. pretensor/enrichment/dbt/signals.py +305 -0
  89. pretensor/entities/__init__.py +27 -0
  90. pretensor/entities/builder.py +63 -0
  91. pretensor/entities/classifier.py +383 -0
  92. pretensor/entities/llm_extract.py +66 -0
  93. pretensor/errors.py +35 -0
  94. pretensor/graph_models/__init__.py +17 -0
  95. pretensor/graph_models/base.py +11 -0
  96. pretensor/graph_models/consumer.py +71 -0
  97. pretensor/graph_models/edge.py +35 -0
  98. pretensor/graph_models/entity.py +21 -0
  99. pretensor/graph_models/node.py +79 -0
  100. pretensor/graph_models/relationship.py +33 -0
  101. pretensor/integrations/__init__.py +42 -0
  102. pretensor/integrations/_base.py +138 -0
  103. pretensor/integrations/google_adk.py +49 -0
  104. pretensor/integrations/langchain.py +55 -0
  105. pretensor/integrations/llamaindex.py +53 -0
  106. pretensor/intelligence/__init__.py +33 -0
  107. pretensor/intelligence/cluster_labeler.py +425 -0
  108. pretensor/intelligence/clustering.py +168 -0
  109. pretensor/intelligence/combining.py +32 -0
  110. pretensor/intelligence/discovery.py +114 -0
  111. pretensor/intelligence/embeddings.py +317 -0
  112. pretensor/intelligence/graph_export.py +200 -0
  113. pretensor/intelligence/heuristic.py +544 -0
  114. pretensor/intelligence/join_paths/__init__.py +130 -0
  115. pretensor/intelligence/join_paths/on_demand.py +516 -0
  116. pretensor/intelligence/join_paths/storage.py +70 -0
  117. pretensor/intelligence/llm_infer.py +78 -0
  118. pretensor/intelligence/llm_runtime.py +62 -0
  119. pretensor/intelligence/metric_templates.py +193 -0
  120. pretensor/intelligence/pipeline.py +364 -0
  121. pretensor/intelligence/role_exemplars.py +263 -0
  122. pretensor/intelligence/schema_classification.py +360 -0
  123. pretensor/intelligence/scoring.py +76 -0
  124. pretensor/intelligence/semantic.py +240 -0
  125. pretensor/intelligence/shadow_alias.py +101 -0
  126. pretensor/intelligence/statistical.py +50 -0
  127. pretensor/intelligence/steps.py +191 -0
  128. pretensor/intelligence/steps_embedding.py +168 -0
  129. pretensor/introspection/__init__.py +6 -0
  130. pretensor/introspection/inspector.py +5 -0
  131. pretensor/introspection/models/__init__.py +0 -0
  132. pretensor/introspection/models/base.py +5 -0
  133. pretensor/introspection/models/config.py +237 -0
  134. pretensor/introspection/models/dsn.py +550 -0
  135. pretensor/introspection/models/plan.py +116 -0
  136. pretensor/introspection/models/schema.py +10 -0
  137. pretensor/introspection/models/semantic.py +121 -0
  138. pretensor/introspection/models/validation.py +116 -0
  139. pretensor/introspection/snapshot.py +46 -0
  140. pretensor/mcp/__init__.py +16 -0
  141. pretensor/mcp/config_json.py +24 -0
  142. pretensor/mcp/payload_types.py +274 -0
  143. pretensor/mcp/resources/__init__.py +17 -0
  144. pretensor/mcp/resources/markdown.py +314 -0
  145. pretensor/mcp/server.py +285 -0
  146. pretensor/mcp/service.py +49 -0
  147. pretensor/mcp/service_context.py +142 -0
  148. pretensor/mcp/service_registry.py +294 -0
  149. pretensor/mcp/store_cache.py +43 -0
  150. pretensor/mcp/tool_registry.py +136 -0
  151. pretensor/mcp/tools/__init__.py +1 -0
  152. pretensor/mcp/tools/_rank.py +244 -0
  153. pretensor/mcp/tools/_timed.py +26 -0
  154. pretensor/mcp/tools/compile_metric.py +144 -0
  155. pretensor/mcp/tools/consumers.py +161 -0
  156. pretensor/mcp/tools/context.py +1121 -0
  157. pretensor/mcp/tools/cypher.py +509 -0
  158. pretensor/mcp/tools/detect_changes.py +254 -0
  159. pretensor/mcp/tools/impact.py +271 -0
  160. pretensor/mcp/tools/list.py +131 -0
  161. pretensor/mcp/tools/schema.py +170 -0
  162. pretensor/mcp/tools/search.py +316 -0
  163. pretensor/mcp/tools/semantic_search.py +282 -0
  164. pretensor/mcp/tools/traverse.py +1027 -0
  165. pretensor/mcp/tools/validate_sql.py +150 -0
  166. pretensor/observability.py +203 -0
  167. pretensor/py.typed +0 -0
  168. pretensor/quickstart/README.md +29 -0
  169. pretensor/quickstart/__init__.py +6 -0
  170. pretensor/quickstart/docker-compose.yml +18 -0
  171. pretensor/quickstart/pagila_data.sql +63 -0
  172. pretensor/quickstart/pagila_ddl.sql +92 -0
  173. pretensor/search/__init__.py +6 -0
  174. pretensor/search/base.py +80 -0
  175. pretensor/search/index.py +435 -0
  176. pretensor/semantic/__init__.py +24 -0
  177. pretensor/semantic/base.py +123 -0
  178. pretensor/semantic/compiler.py +487 -0
  179. pretensor/semantic/yaml_layer.py +180 -0
  180. pretensor/skills/__init__.py +5 -0
  181. pretensor/skills/generator.py +235 -0
  182. pretensor/staleness/__init__.py +15 -0
  183. pretensor/staleness/graph_patcher.py +355 -0
  184. pretensor/staleness/impact_analyzer.py +162 -0
  185. pretensor/staleness/snapshot_store.py +38 -0
  186. pretensor/validation/__init__.py +9 -0
  187. pretensor/validation/query_validator.py +436 -0
  188. pretensor/visibility/__init__.py +23 -0
  189. pretensor/visibility/config.py +126 -0
  190. pretensor/visibility/filter.py +143 -0
  191. pretensor/visibility/kuzu_helpers.py +32 -0
  192. pretensor/visibility/runtime.py +36 -0
  193. pretensor/visibility/sync_grants.py +188 -0
  194. pretensor-0.1.0.dist-info/METADATA +251 -0
  195. pretensor-0.1.0.dist-info/RECORD +198 -0
  196. pretensor-0.1.0.dist-info/WHEEL +4 -0
  197. pretensor-0.1.0.dist-info/entry_points.txt +2 -0
  198. pretensor-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,448 @@
1
+ """Release gate for ``.github/workflows/release.yml``.
2
+
3
+ A tag cut may publish to PyPI only after this script exits 0. The gate
4
+ runs L1 and L2 against every release-gating dataset, compares the
5
+ resulting JSON against the previous tag's stored results under
6
+ ``tests/benchmark/results/<previous-tag>/``, and refuses to publish on
7
+ any regression. L3 is informational and is never evaluated here.
8
+
9
+ See ``docs/specs/benchmark/spec.md`` §Release-gate rules and
10
+ ``docs/contracts/architecture.md`` Invariant #13 for the contract.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Literal, Sequence
23
+
24
+ from pretensor.benchmark.results import (
25
+ ComparisonError,
26
+ compare,
27
+ read_json,
28
+ )
29
+ from pretensor.benchmark.runner import Dataset
30
+
31
+ __all__ = ["main"]
32
+
33
+
34
+ _DEFAULT_RESULTS_BASE = Path("tests/benchmark/results")
35
+ """Root directory for ``<tag>/`` subdirectories, relative to the repo root."""
36
+
37
+ _RELEASE_DATASETS: tuple[Dataset, ...] = (
38
+ Dataset.PAGILA,
39
+ Dataset.TPCH,
40
+ Dataset.ADVENTUREWORKS,
41
+ )
42
+ """Datasets the gate evaluates by default — see spec §Release-gate rules.
43
+
44
+ Other fixtures (``analytics_dwh``, ``adversarial``, ``saas_multitenant``)
45
+ exist in the repo but are exercises for L1/L2 internals; the spec calls
46
+ out Pagila, TPC-H, and AdventureWorks as the three release-gating
47
+ datasets that ship with documented external provenance.
48
+ """
49
+
50
+ _GATING_LEVELS: tuple[str, ...] = ("l1", "l2")
51
+ """Levels the gate evaluates. L3 is informational and never enters here."""
52
+
53
+ _DEFAULT_TOLERANCE = 1e-6
54
+ """Single global tolerance reused from ``compare()``.
55
+
56
+ When a per-metric tolerance table arrives (derived from the null-client
57
+ determinism leg), introduce the lookup at the call site in
58
+ :func:`_evaluate_pair` then. Until then, this single threshold suffices
59
+ and the gate avoids unused indirection layers.
60
+ """
61
+
62
+ _DEFAULT_GRAPH_DIR = Path(".pretensor")
63
+ """Where ``run_l1`` / ``run_l2`` look for an indexed graph."""
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Tag + path resolution (module-level seams so tests can monkeypatch)
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def _resolve_previous_tag() -> str | None:
72
+ """Return the most recent tag before HEAD, or ``None`` if no prior tag exists.
73
+
74
+ Uses ``git describe --abbrev=0 --tags HEAD^`` per the issue body. Any
75
+ git failure (no parent commit, no prior tag, not a git repo) collapses
76
+ to ``None`` so callers can branch cleanly on first-tag behaviour.
77
+ """
78
+ try:
79
+ result = subprocess.run(
80
+ ["git", "describe", "--abbrev=0", "--tags", "HEAD^"],
81
+ capture_output=True,
82
+ text=True,
83
+ check=False,
84
+ )
85
+ except FileNotFoundError:
86
+ return None
87
+ if result.returncode != 0:
88
+ return None
89
+ tag = result.stdout.strip()
90
+ return tag or None
91
+
92
+
93
+ def _resolve_candidate_tag() -> str:
94
+ """Return the tag for the candidate release.
95
+
96
+ GitHub Actions sets ``GITHUB_REF_NAME`` to the tag on a ``push: tags``
97
+ trigger — preferred because it's authoritative for the running
98
+ workflow. Local invocation falls back to
99
+ ``git describe --tags --exact-match HEAD`` so an operator can dry-run
100
+ the gate at a tag they've already cut.
101
+ """
102
+ env_tag = os.environ.get("GITHUB_REF_NAME", "").strip()
103
+ if env_tag:
104
+ return env_tag
105
+ result = subprocess.run(
106
+ ["git", "describe", "--tags", "--exact-match", "HEAD"],
107
+ capture_output=True,
108
+ text=True,
109
+ check=False,
110
+ )
111
+ if result.returncode != 0:
112
+ raise SystemExit(
113
+ "Unable to determine candidate tag: GITHUB_REF_NAME unset and "
114
+ "HEAD does not point at a tag. Pass --current-dir to run "
115
+ "the gate against a pre-staged result directory."
116
+ )
117
+ return result.stdout.strip()
118
+
119
+
120
+ def _resolve_results_dir(tag: str, results_base: Path) -> Path:
121
+ """Map a tag to its per-tag results directory under ``results_base``."""
122
+ return results_base / tag
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Benchmark invocation seam
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ def _run_benchmark(
131
+ level: str,
132
+ dataset: Dataset,
133
+ out: Path,
134
+ *,
135
+ graph_dir: Path,
136
+ embeddings: bool,
137
+ ) -> None:
138
+ """Run one ``(level, dataset)`` benchmark, writing JSON to ``out``.
139
+
140
+ Imports ``run_l1`` / ``run_l2`` lazily so a test that monkeypatches
141
+ this seam never pays the heavy dependency-graph cost (Kuzu, igraph,
142
+ sqlglot, MCP SDK).
143
+ """
144
+ if level == "l1":
145
+ from pretensor.benchmark.runner import run_l1
146
+
147
+ run_l1(dataset, out, graph_dir, embeddings=embeddings)
148
+ return
149
+ if level == "l2":
150
+ from pretensor.benchmark.runner import run_l2
151
+
152
+ run_l2(dataset, out, graph_dir, embeddings=embeddings)
153
+ return
154
+ raise ValueError(f"unsupported gating level: {level!r}")
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Per-pair comparison
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ _PairStatus = Literal["pass", "regression", "skipped", "error"]
163
+
164
+
165
+ @dataclass(frozen=True, slots=True)
166
+ class _PairOutcome:
167
+ """One ``(dataset, level)`` evaluation's verdict."""
168
+
169
+ dataset: str
170
+ level: str
171
+ status: _PairStatus
172
+ detail: str = ""
173
+
174
+ @property
175
+ def label(self) -> str:
176
+ return f"{self.dataset}/{self.level}"
177
+
178
+
179
+ def _evaluate_pair(
180
+ *,
181
+ dataset: Dataset,
182
+ level: str,
183
+ baseline_dir: Path,
184
+ current_dir: Path,
185
+ run_benchmarks: bool,
186
+ graph_dir: Path,
187
+ embeddings: bool,
188
+ ) -> _PairOutcome:
189
+ """Run (optionally) and compare one ``(dataset, level)`` pair."""
190
+ fname = f"{dataset.value}-{level}.json"
191
+ baseline_path = baseline_dir / fname
192
+ current_path = current_dir / fname
193
+
194
+ if run_benchmarks:
195
+ try:
196
+ current_dir.mkdir(parents=True, exist_ok=True)
197
+ _run_benchmark(
198
+ level,
199
+ dataset,
200
+ current_path,
201
+ graph_dir=graph_dir,
202
+ embeddings=embeddings,
203
+ )
204
+ except Exception as exc: # noqa: BLE001 — surface ANY runner fault
205
+ return _PairOutcome(
206
+ dataset=dataset.value,
207
+ level=level,
208
+ status="error",
209
+ detail=f"failed to produce current result: {exc}",
210
+ )
211
+
212
+ if not baseline_path.is_file():
213
+ # New dataset added between tags — not a regression.
214
+ return _PairOutcome(
215
+ dataset=dataset.value,
216
+ level=level,
217
+ status="skipped",
218
+ detail=f"no baseline at {baseline_path}",
219
+ )
220
+ if not current_path.is_file():
221
+ return _PairOutcome(
222
+ dataset=dataset.value,
223
+ level=level,
224
+ status="error",
225
+ detail=f"no current result at {current_path}",
226
+ )
227
+
228
+ try:
229
+ baseline_result = read_json(baseline_path)
230
+ current_result = read_json(current_path)
231
+ except (json.JSONDecodeError, KeyError, OSError) as exc:
232
+ return _PairOutcome(
233
+ dataset=dataset.value,
234
+ level=level,
235
+ status="error",
236
+ detail=f"failed to read result JSON: {exc}",
237
+ )
238
+
239
+ try:
240
+ report = compare(
241
+ baseline_result,
242
+ current_result,
243
+ tolerance=_DEFAULT_TOLERANCE,
244
+ )
245
+ except ComparisonError as exc:
246
+ return _PairOutcome(
247
+ dataset=dataset.value,
248
+ level=level,
249
+ status="error",
250
+ detail=str(exc),
251
+ )
252
+
253
+ if report.has_regression:
254
+ return _PairOutcome(
255
+ dataset=dataset.value,
256
+ level=level,
257
+ status="regression",
258
+ detail=report.format_diff(),
259
+ )
260
+ return _PairOutcome(dataset=dataset.value, level=level, status="pass")
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # CLI
265
+ # ---------------------------------------------------------------------------
266
+
267
+
268
+ def _parse_datasets(value: str) -> tuple[Dataset, ...]:
269
+ """Argparse ``type=`` for ``--datasets``.
270
+
271
+ Empty / whitespace-only string → release-gating defaults. Unknown
272
+ dataset names raise :class:`argparse.ArgumentTypeError`, which
273
+ argparse converts into a uniform ``error: argument --datasets: …``
274
+ message (with the standard usage line) — same surface as every
275
+ other validation failure in this CLI.
276
+ """
277
+ if not value or not value.strip():
278
+ return _RELEASE_DATASETS
279
+ out: list[Dataset] = []
280
+ for raw in value.split(","):
281
+ raw = raw.strip()
282
+ if not raw:
283
+ continue
284
+ try:
285
+ out.append(Dataset(raw))
286
+ except ValueError as exc:
287
+ raise argparse.ArgumentTypeError(f"unknown dataset {raw!r}: {exc}") from exc
288
+ return tuple(out) or _RELEASE_DATASETS
289
+
290
+
291
+ def _build_parser() -> argparse.ArgumentParser:
292
+ parser = argparse.ArgumentParser(
293
+ prog="pretensor.benchmark.release_gate",
294
+ description=(
295
+ "Block a tag publication on L1/L2 regression vs the previous tag's "
296
+ "stored benchmark results."
297
+ ),
298
+ )
299
+ parser.add_argument(
300
+ "--results-base",
301
+ type=Path,
302
+ default=_DEFAULT_RESULTS_BASE,
303
+ help="Root directory containing per-tag result subdirectories. "
304
+ "Default: tests/benchmark/results.",
305
+ )
306
+ parser.add_argument(
307
+ "--baseline-dir",
308
+ type=Path,
309
+ default=None,
310
+ help="Override the baseline directory (skips git previous-tag resolution).",
311
+ )
312
+ parser.add_argument(
313
+ "--current-dir",
314
+ type=Path,
315
+ default=None,
316
+ help="Override the candidate-tag directory (skips running benchmarks).",
317
+ )
318
+ parser.add_argument(
319
+ "--datasets",
320
+ type=_parse_datasets,
321
+ default=_RELEASE_DATASETS,
322
+ help=("Comma-separated dataset list (default: pagila,tpch,adventureworks)."),
323
+ )
324
+ parser.add_argument(
325
+ "--graph-dir",
326
+ type=Path,
327
+ default=_DEFAULT_GRAPH_DIR,
328
+ help=(
329
+ "Graph directory passed through to run_l1 / run_l2. Default: .pretensor."
330
+ ),
331
+ )
332
+ parser.add_argument(
333
+ "--embeddings",
334
+ action=argparse.BooleanOptionalAction,
335
+ default=False,
336
+ help=(
337
+ "Run the embeddings-on leg of L1/L2 (requires the 'embeddings' "
338
+ "extra). Default: --no-embeddings."
339
+ ),
340
+ )
341
+ return parser
342
+
343
+
344
+ def main(argv: Sequence[str] | None = None) -> int:
345
+ parser = _build_parser()
346
+ args = parser.parse_args(argv)
347
+
348
+ datasets: tuple[Dataset, ...] = args.datasets
349
+ results_base: Path = args.results_base
350
+
351
+ # Resolve baseline directory.
352
+ baseline_dir: Path
353
+ if args.baseline_dir is not None:
354
+ baseline_dir = args.baseline_dir
355
+ else:
356
+ previous_tag = _resolve_previous_tag()
357
+ if previous_tag is None:
358
+ print(
359
+ "release-gate: no previous tag found — no baseline to compare. "
360
+ "First-tag path; treating as pass.",
361
+ file=sys.stderr,
362
+ )
363
+ return 0
364
+ baseline_dir = _resolve_results_dir(previous_tag, results_base)
365
+ # A previous tag exists but its archive directory is entirely
366
+ # absent — most likely the post-publish archive PR for that tag
367
+ # never landed. Treating this as "skipped, all clear" would
368
+ # masquerade as a first-tag run and silently let regressions
369
+ # through; refuse instead and force the operator to investigate.
370
+ if not baseline_dir.is_dir():
371
+ print(
372
+ f"release-gate: previous tag {previous_tag!r} resolved but "
373
+ f"its baseline directory {baseline_dir} is absent. The "
374
+ "post-publish archive job for that tag likely failed or "
375
+ "the archive PR was never merged. Refusing to proceed.",
376
+ file=sys.stderr,
377
+ )
378
+ return 1
379
+
380
+ # Resolve candidate directory + decide whether to run benchmarks.
381
+ current_dir: Path
382
+ run_benchmarks: bool
383
+ if args.current_dir is not None:
384
+ current_dir = args.current_dir
385
+ run_benchmarks = False
386
+ else:
387
+ candidate_tag = _resolve_candidate_tag()
388
+ current_dir = _resolve_results_dir(candidate_tag, results_base)
389
+ run_benchmarks = True
390
+
391
+ outcomes: list[_PairOutcome] = []
392
+ for dataset in datasets:
393
+ for level in _GATING_LEVELS:
394
+ outcomes.append(
395
+ _evaluate_pair(
396
+ dataset=dataset,
397
+ level=level,
398
+ baseline_dir=baseline_dir,
399
+ current_dir=current_dir,
400
+ run_benchmarks=run_benchmarks,
401
+ graph_dir=args.graph_dir,
402
+ embeddings=args.embeddings,
403
+ )
404
+ )
405
+
406
+ return _emit_summary(outcomes)
407
+
408
+
409
+ def _emit_summary(outcomes: Sequence[_PairOutcome]) -> int:
410
+ """Print the verdict to stderr; return 0 on no regression, 1 otherwise."""
411
+ regressions = [o for o in outcomes if o.status == "regression"]
412
+ errors = [o for o in outcomes if o.status == "error"]
413
+ skipped = [o for o in outcomes if o.status == "skipped"]
414
+ passed = [o for o in outcomes if o.status == "pass"]
415
+
416
+ lines: list[str] = []
417
+ if regressions or errors:
418
+ lines.append("RELEASE GATE: REGRESSION DETECTED")
419
+ else:
420
+ lines.append("release gate: all comparisons within tolerance")
421
+
422
+ if regressions:
423
+ lines.append("")
424
+ lines.append("Regressions:")
425
+ for o in regressions:
426
+ lines.append(f" {o.label}:")
427
+ for diff_line in o.detail.splitlines():
428
+ lines.append(f" {diff_line}")
429
+ if errors:
430
+ lines.append("")
431
+ lines.append("Errors:")
432
+ for o in errors:
433
+ lines.append(f" {o.label}: {o.detail}")
434
+ if skipped:
435
+ lines.append("")
436
+ lines.append("Skipped (no baseline — new dataset since previous tag):")
437
+ for o in skipped:
438
+ lines.append(f" {o.label}: {o.detail}")
439
+ if passed and not (regressions or errors):
440
+ lines.append("")
441
+ lines.append(f"Passed: {len(passed)} comparison(s)")
442
+
443
+ print("\n".join(lines), file=sys.stderr)
444
+ return 0 if not (regressions or errors) else 1
445
+
446
+
447
+ if __name__ == "__main__": # pragma: no cover
448
+ sys.exit(main())