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.
- pretensor/__init__.py +50 -0
- pretensor/benchmark/__init__.py +54 -0
- pretensor/benchmark/cli.py +294 -0
- pretensor/benchmark/fixtures.py +84 -0
- pretensor/benchmark/l1/__init__.py +23 -0
- pretensor/benchmark/l1/metrics.py +141 -0
- pretensor/benchmark/l1/pipeline.py +188 -0
- pretensor/benchmark/l1/runner.py +245 -0
- pretensor/benchmark/l2/__init__.py +27 -0
- pretensor/benchmark/l2/gold.py +236 -0
- pretensor/benchmark/l2/metrics.py +146 -0
- pretensor/benchmark/l2/pipeline.py +124 -0
- pretensor/benchmark/l2/runner.py +530 -0
- pretensor/benchmark/l3/__init__.py +73 -0
- pretensor/benchmark/l3/agent.py +316 -0
- pretensor/benchmark/l3/db.py +188 -0
- pretensor/benchmark/l3/gold.py +85 -0
- pretensor/benchmark/l3/llm_client.py +395 -0
- pretensor/benchmark/l3/mcp_client.py +357 -0
- pretensor/benchmark/l3/pretensor_runner.py +456 -0
- pretensor/benchmark/l3/prompt.py +132 -0
- pretensor/benchmark/l3/runner.py +358 -0
- pretensor/benchmark/l3/sql_equivalence.py +176 -0
- pretensor/benchmark/release_gate.py +448 -0
- pretensor/benchmark/results.py +298 -0
- pretensor/benchmark/runner.py +109 -0
- pretensor/cli/__init__.py +1 -0
- pretensor/cli/commands/_source_runner.py +147 -0
- pretensor/cli/commands/analyze.py +201 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +126 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +131 -0
- pretensor/cli/commands/index.py +559 -0
- pretensor/cli/commands/list.py +76 -0
- pretensor/cli/commands/quickstart.py +207 -0
- pretensor/cli/commands/reindex.py +646 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +144 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +176 -0
- pretensor/cli/config_file.py +442 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +109 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +226 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +468 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +97 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/mysql.py +402 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +938 -0
- pretensor/connectors/registry.py +93 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +908 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +307 -0
- pretensor/core/dsn_crypto.py +51 -0
- pretensor/core/graph_schema_manager.py +246 -0
- pretensor/core/graph_store.py +1226 -0
- pretensor/core/ids.py +101 -0
- pretensor/core/portable_export.py +276 -0
- pretensor/core/query_runner.py +67 -0
- pretensor/core/registry.py +209 -0
- pretensor/core/schema.py +473 -0
- pretensor/core/secure_io.py +93 -0
- pretensor/core/store.py +469 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/analyze/__init__.py +0 -0
- pretensor/enrichment/analyze/classify.py +49 -0
- pretensor/enrichment/analyze/extract_python.py +196 -0
- pretensor/enrichment/analyze/parse.py +141 -0
- pretensor/enrichment/analyze/pipeline.py +195 -0
- pretensor/enrichment/analyze/summary.py +38 -0
- pretensor/enrichment/analyze/walker.py +98 -0
- pretensor/enrichment/analyze/writers.py +214 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +300 -0
- pretensor/enrichment/dbt/metadata.py +263 -0
- pretensor/enrichment/dbt/pipeline.py +77 -0
- pretensor/enrichment/dbt/resolution.py +101 -0
- pretensor/enrichment/dbt/signals.py +305 -0
- pretensor/entities/__init__.py +27 -0
- pretensor/entities/builder.py +63 -0
- pretensor/entities/classifier.py +383 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/errors.py +35 -0
- pretensor/graph_models/__init__.py +17 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/consumer.py +71 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +79 -0
- pretensor/graph_models/relationship.py +33 -0
- pretensor/integrations/__init__.py +42 -0
- pretensor/integrations/_base.py +138 -0
- pretensor/integrations/google_adk.py +49 -0
- pretensor/integrations/langchain.py +55 -0
- pretensor/integrations/llamaindex.py +53 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +425 -0
- pretensor/intelligence/clustering.py +168 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +317 -0
- pretensor/intelligence/graph_export.py +200 -0
- pretensor/intelligence/heuristic.py +544 -0
- pretensor/intelligence/join_paths/__init__.py +130 -0
- pretensor/intelligence/join_paths/on_demand.py +516 -0
- pretensor/intelligence/join_paths/storage.py +70 -0
- pretensor/intelligence/llm_infer.py +78 -0
- pretensor/intelligence/llm_runtime.py +62 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +364 -0
- pretensor/intelligence/role_exemplars.py +263 -0
- pretensor/intelligence/schema_classification.py +360 -0
- pretensor/intelligence/scoring.py +76 -0
- pretensor/intelligence/semantic.py +240 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +191 -0
- pretensor/intelligence/steps_embedding.py +168 -0
- pretensor/introspection/__init__.py +6 -0
- pretensor/introspection/inspector.py +5 -0
- pretensor/introspection/models/__init__.py +0 -0
- pretensor/introspection/models/base.py +5 -0
- pretensor/introspection/models/config.py +237 -0
- pretensor/introspection/models/dsn.py +550 -0
- pretensor/introspection/models/plan.py +116 -0
- pretensor/introspection/models/schema.py +10 -0
- pretensor/introspection/models/semantic.py +121 -0
- pretensor/introspection/models/validation.py +116 -0
- pretensor/introspection/snapshot.py +46 -0
- pretensor/mcp/__init__.py +16 -0
- pretensor/mcp/config_json.py +24 -0
- pretensor/mcp/payload_types.py +274 -0
- pretensor/mcp/resources/__init__.py +17 -0
- pretensor/mcp/resources/markdown.py +314 -0
- pretensor/mcp/server.py +285 -0
- pretensor/mcp/service.py +49 -0
- pretensor/mcp/service_context.py +142 -0
- pretensor/mcp/service_registry.py +294 -0
- pretensor/mcp/store_cache.py +43 -0
- pretensor/mcp/tool_registry.py +136 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/_rank.py +244 -0
- pretensor/mcp/tools/_timed.py +26 -0
- pretensor/mcp/tools/compile_metric.py +144 -0
- pretensor/mcp/tools/consumers.py +161 -0
- pretensor/mcp/tools/context.py +1121 -0
- pretensor/mcp/tools/cypher.py +509 -0
- pretensor/mcp/tools/detect_changes.py +254 -0
- pretensor/mcp/tools/impact.py +271 -0
- pretensor/mcp/tools/list.py +131 -0
- pretensor/mcp/tools/schema.py +170 -0
- pretensor/mcp/tools/search.py +316 -0
- pretensor/mcp/tools/semantic_search.py +282 -0
- pretensor/mcp/tools/traverse.py +1027 -0
- pretensor/mcp/tools/validate_sql.py +150 -0
- pretensor/observability.py +203 -0
- pretensor/py.typed +0 -0
- pretensor/quickstart/README.md +29 -0
- pretensor/quickstart/__init__.py +6 -0
- pretensor/quickstart/docker-compose.yml +18 -0
- pretensor/quickstart/pagila_data.sql +63 -0
- pretensor/quickstart/pagila_ddl.sql +92 -0
- pretensor/search/__init__.py +6 -0
- pretensor/search/base.py +80 -0
- pretensor/search/index.py +435 -0
- pretensor/semantic/__init__.py +24 -0
- pretensor/semantic/base.py +123 -0
- pretensor/semantic/compiler.py +487 -0
- pretensor/semantic/yaml_layer.py +180 -0
- pretensor/skills/__init__.py +5 -0
- pretensor/skills/generator.py +235 -0
- pretensor/staleness/__init__.py +15 -0
- pretensor/staleness/graph_patcher.py +355 -0
- pretensor/staleness/impact_analyzer.py +162 -0
- pretensor/staleness/snapshot_store.py +38 -0
- pretensor/validation/__init__.py +9 -0
- pretensor/validation/query_validator.py +436 -0
- pretensor/visibility/__init__.py +23 -0
- pretensor/visibility/config.py +126 -0
- pretensor/visibility/filter.py +143 -0
- pretensor/visibility/kuzu_helpers.py +32 -0
- pretensor/visibility/runtime.py +36 -0
- pretensor/visibility/sync_grants.py +188 -0
- pretensor-0.1.0.dist-info/METADATA +251 -0
- pretensor-0.1.0.dist-info/RECORD +198 -0
- pretensor-0.1.0.dist-info/WHEEL +4 -0
- pretensor-0.1.0.dist-info/entry_points.txt +2 -0
- 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())
|