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,358 @@
|
|
|
1
|
+
"""``run_l3_baseline`` — the agent + raw schema control runner.
|
|
2
|
+
|
|
3
|
+
Walks the gold question set for a dataset, asks the configured LLM to
|
|
4
|
+
translate each question into PostgreSQL, executes both the gold and the
|
|
5
|
+
agent SQL against the live database, and grades equivalence row-by-row.
|
|
6
|
+
Emits a :class:`BenchmarkResult` envelope whose ``extra`` block captures
|
|
7
|
+
the full agent configuration so a future re-run can be reproduced
|
|
8
|
+
byte-for-byte modulo LLM nondeterminism.
|
|
9
|
+
|
|
10
|
+
Atomic JSON write: the runner writes to ``<out>.tmp`` first and renames,
|
|
11
|
+
so a process kill mid-run never leaves a half-written file behind.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import importlib.metadata
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, cast
|
|
25
|
+
|
|
26
|
+
from pretensor.benchmark.fixtures import load_dataset
|
|
27
|
+
from pretensor.benchmark.l3.db import (
|
|
28
|
+
QueryExecutionError,
|
|
29
|
+
execute_query,
|
|
30
|
+
resolve_database_url,
|
|
31
|
+
)
|
|
32
|
+
from pretensor.benchmark.l3.gold import L3GoldEntry, load_l3_gold
|
|
33
|
+
from pretensor.benchmark.l3.llm_client import (
|
|
34
|
+
AnthropicHttpClient,
|
|
35
|
+
LlmCallError,
|
|
36
|
+
LlmClient,
|
|
37
|
+
OpenAIHttpClient,
|
|
38
|
+
)
|
|
39
|
+
from pretensor.benchmark.l3.prompt import (
|
|
40
|
+
MAX_DDL_CHARS,
|
|
41
|
+
build_system_prompt,
|
|
42
|
+
prompt_hash,
|
|
43
|
+
prompt_template_hash,
|
|
44
|
+
strip_sql_fences,
|
|
45
|
+
)
|
|
46
|
+
from pretensor.benchmark.l3.sql_equivalence import gold_is_ordered, rows_equivalent
|
|
47
|
+
from pretensor.benchmark.results import BenchmarkResult, Metric, write_json
|
|
48
|
+
from pretensor.benchmark.runner import Dataset
|
|
49
|
+
|
|
50
|
+
__all__ = ["DEFAULT_TEMPERATURE", "run_l3_baseline"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DEFAULT_TEMPERATURE = 0.0
|
|
54
|
+
"""LLM temperature for the baseline runner.
|
|
55
|
+
|
|
56
|
+
Temp=0 minimises variance; the spec accepts that some LLM
|
|
57
|
+
nondeterminism remains and tracks it via the seed envelope field
|
|
58
|
+
plus a documented ``notes[]`` entry.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
_DETERMINISTIC_RAN_AT = "1970-01-01T00:00:00Z"
|
|
62
|
+
"""Pinned timestamp so identical inputs produce byte-identical envelopes.
|
|
63
|
+
|
|
64
|
+
Mirrors the ``_DETERMINISTIC_RAN_AT`` convention used in the L1 / L2
|
|
65
|
+
runners; real wall-clock would defeat reproducibility comparisons.
|
|
66
|
+
Module-private — callers have no reason to depend on this value.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
_now = time.perf_counter
|
|
70
|
+
"""Wall-clock source for per-item latency measurement.
|
|
71
|
+
|
|
72
|
+
Module-private so tests can monkeypatch it to a deterministic stub
|
|
73
|
+
(``lambda: 0.0``) without touching production behaviour — mirrors the
|
|
74
|
+
``_DETERMINISTIC_RAN_AT`` pattern used for the run timestamp.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
_AGENT_ERROR_MAX_CHARS = 500
|
|
78
|
+
"""Truncation limit for per-item ``error`` strings.
|
|
79
|
+
|
|
80
|
+
Database errors and LLM error responses can be huge; the auditor only
|
|
81
|
+
needs the leading edge to diagnose.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
_SUCCESS_RATE_METRIC = "nl2sql_success_rate_baseline"
|
|
85
|
+
_LATENCY_METRIC = "mean_latency_ms_baseline"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_l3_baseline(
|
|
89
|
+
dataset: Dataset,
|
|
90
|
+
out: Path | None,
|
|
91
|
+
*,
|
|
92
|
+
model: str,
|
|
93
|
+
seed: int | None,
|
|
94
|
+
llm_client: LlmClient | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Run the L3 baseline (agent + raw schema, no Pretensor) for ``dataset``.
|
|
97
|
+
|
|
98
|
+
``llm_client`` is a Python-API-only seam — the CLI never exposes it.
|
|
99
|
+
Tests pass a fake client to avoid hitting a real provider; production
|
|
100
|
+
callers leave it ``None`` and the runner builds an
|
|
101
|
+
:class:`AnthropicHttpClient` against ``ANTHROPIC_API_KEY``.
|
|
102
|
+
"""
|
|
103
|
+
fixture = load_dataset(dataset)
|
|
104
|
+
if fixture.ddl_sql_path is None:
|
|
105
|
+
raise FileNotFoundError(
|
|
106
|
+
f"Dataset {dataset.value!r} has no DDL bundle "
|
|
107
|
+
f"(scripts/data/{dataset.value}/schema.sql); cannot build the L3 prompt."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
ddl_text = fixture.ddl_sql_path.read_text(encoding="utf-8")
|
|
111
|
+
# ``load_l3_gold`` raises FileNotFoundError when ``questions_path`` is
|
|
112
|
+
# absent and returns the resolved path + raw bytes explicitly. Reusing
|
|
113
|
+
# the bytes for the fixture_sha avoids a second file read; relying on
|
|
114
|
+
# the path lets us drop a narrowing ``assert`` (which would be
|
|
115
|
+
# stripped under ``python -O``).
|
|
116
|
+
_questions_path, questions_bytes, questions = load_l3_gold(fixture)
|
|
117
|
+
dsn = resolve_database_url(dataset)
|
|
118
|
+
|
|
119
|
+
resolved_seed = seed if seed is not None else secrets.randbits(63)
|
|
120
|
+
|
|
121
|
+
system_prompt = build_system_prompt(dataset.value, ddl_text)
|
|
122
|
+
rendered_hash = prompt_hash(system_prompt)
|
|
123
|
+
template_hash = prompt_template_hash()
|
|
124
|
+
|
|
125
|
+
client: LlmClient
|
|
126
|
+
if llm_client is not None:
|
|
127
|
+
client = llm_client
|
|
128
|
+
else:
|
|
129
|
+
# ``AnthropicHttpClient()`` raises ``LlmCallError`` when
|
|
130
|
+
# ``ANTHROPIC_API_KEY`` is unset. Re-raise as ``LookupError`` so
|
|
131
|
+
# the CLI's missing-env-var handler treats this the same as
|
|
132
|
+
# missing ``PAGILA_DATABASE_URL`` and prints a friendly exit-1
|
|
133
|
+
# message rather than a Python traceback.
|
|
134
|
+
try:
|
|
135
|
+
client = AnthropicHttpClient()
|
|
136
|
+
except LlmCallError as exc:
|
|
137
|
+
raise LookupError(str(exc)) from exc
|
|
138
|
+
|
|
139
|
+
notes: list[str] = [
|
|
140
|
+
"L3 is LLM-bound; the --seed value governs harness state only "
|
|
141
|
+
"(the Anthropic / OpenAI APIs do not accept a seed parameter). "
|
|
142
|
+
"Same-seed reruns at temperature 0 may still differ by remaining "
|
|
143
|
+
"LLM nondeterminism."
|
|
144
|
+
]
|
|
145
|
+
if len(ddl_text) > MAX_DDL_CHARS:
|
|
146
|
+
notes.append(
|
|
147
|
+
f"DDL for {dataset.value} is {len(ddl_text)} characters "
|
|
148
|
+
f"(> soft limit {MAX_DDL_CHARS}); some models may truncate the "
|
|
149
|
+
"schema at their context-window edge. The runner did NOT "
|
|
150
|
+
"truncate the DDL — agent failures may reflect this rather "
|
|
151
|
+
"than agent quality."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
per_item: list[dict[str, Any]] = []
|
|
155
|
+
latencies_ms: list[int] = []
|
|
156
|
+
successes = 0
|
|
157
|
+
|
|
158
|
+
for entry in questions:
|
|
159
|
+
record = _evaluate_one(
|
|
160
|
+
entry=entry,
|
|
161
|
+
system_prompt=system_prompt,
|
|
162
|
+
client=client,
|
|
163
|
+
model=model,
|
|
164
|
+
temperature=DEFAULT_TEMPERATURE,
|
|
165
|
+
dsn=dsn,
|
|
166
|
+
)
|
|
167
|
+
per_item.append(record)
|
|
168
|
+
latencies_ms.append(int(record["latency_ms"]))
|
|
169
|
+
if record["baseline_pass"]:
|
|
170
|
+
successes += 1
|
|
171
|
+
|
|
172
|
+
per_item.sort(key=lambda r: cast(str, r["id"]))
|
|
173
|
+
|
|
174
|
+
metrics: dict[str, Metric] = {
|
|
175
|
+
_SUCCESS_RATE_METRIC: Metric(
|
|
176
|
+
value=(successes / len(questions)) if questions else None,
|
|
177
|
+
direction="higher_is_better",
|
|
178
|
+
),
|
|
179
|
+
_LATENCY_METRIC: Metric(
|
|
180
|
+
value=(sum(latencies_ms) / len(latencies_ms)) if latencies_ms else None,
|
|
181
|
+
direction="lower_is_better",
|
|
182
|
+
),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
fixture_sha = "sha256:" + hashlib.sha256(questions_bytes).hexdigest()
|
|
186
|
+
|
|
187
|
+
extra: dict[str, Any] = {
|
|
188
|
+
"runner": "baseline",
|
|
189
|
+
"provider": _provider_name(client),
|
|
190
|
+
"model": model,
|
|
191
|
+
"temperature": DEFAULT_TEMPERATURE,
|
|
192
|
+
"seed": resolved_seed,
|
|
193
|
+
"prompt_template_hash": template_hash,
|
|
194
|
+
"prompt_hash": rendered_hash,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
result = BenchmarkResult(
|
|
198
|
+
level="l3",
|
|
199
|
+
dataset=dataset.value,
|
|
200
|
+
pretensor_version=_resolve_version(),
|
|
201
|
+
embeddings_enabled=False,
|
|
202
|
+
ran_at=_DETERMINISTIC_RAN_AT,
|
|
203
|
+
fixture_sha=fixture_sha,
|
|
204
|
+
metrics=metrics,
|
|
205
|
+
per_item=per_item,
|
|
206
|
+
notes=notes,
|
|
207
|
+
extra=extra,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if out is None:
|
|
211
|
+
sys.stdout.write(json.dumps(result.to_dict(), sort_keys=True, indent=2) + "\n")
|
|
212
|
+
return
|
|
213
|
+
_atomic_write_json(result, out)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _evaluate_one(
|
|
217
|
+
*,
|
|
218
|
+
entry: L3GoldEntry,
|
|
219
|
+
system_prompt: str,
|
|
220
|
+
client: LlmClient,
|
|
221
|
+
model: str,
|
|
222
|
+
temperature: float,
|
|
223
|
+
dsn: str,
|
|
224
|
+
) -> dict[str, Any]:
|
|
225
|
+
"""Grade one question end-to-end. Returns the per-item record.
|
|
226
|
+
|
|
227
|
+
Wraps :func:`_populate_record` in a try/finally so ``latency_ms`` is
|
|
228
|
+
always set to the full per-question wall-clock — including SQL
|
|
229
|
+
execution and equivalence checking, not just the LLM call. The
|
|
230
|
+
LLM-only timing the provider client measured is recorded separately
|
|
231
|
+
as ``llm_latency_ms`` for cost / regression diagnostics.
|
|
232
|
+
"""
|
|
233
|
+
record: dict[str, Any] = {
|
|
234
|
+
"id": entry.id,
|
|
235
|
+
"question": entry.question,
|
|
236
|
+
"gold_sql": entry.expected_sql,
|
|
237
|
+
"agent_sql": "",
|
|
238
|
+
"execution_success": False,
|
|
239
|
+
"result_equivalence": False,
|
|
240
|
+
"latency_ms": 0,
|
|
241
|
+
"llm_latency_ms": 0,
|
|
242
|
+
"prompt_tokens": None,
|
|
243
|
+
"completion_tokens": None,
|
|
244
|
+
"row_count_gold": 0,
|
|
245
|
+
"row_count_agent": 0,
|
|
246
|
+
"baseline_pass": False,
|
|
247
|
+
"error": None,
|
|
248
|
+
}
|
|
249
|
+
t0 = _now()
|
|
250
|
+
try:
|
|
251
|
+
_populate_record(
|
|
252
|
+
record,
|
|
253
|
+
entry=entry,
|
|
254
|
+
system_prompt=system_prompt,
|
|
255
|
+
client=client,
|
|
256
|
+
model=model,
|
|
257
|
+
temperature=temperature,
|
|
258
|
+
dsn=dsn,
|
|
259
|
+
)
|
|
260
|
+
finally:
|
|
261
|
+
record["latency_ms"] = int((_now() - t0) * 1000)
|
|
262
|
+
return record
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _populate_record(
|
|
266
|
+
record: dict[str, Any],
|
|
267
|
+
*,
|
|
268
|
+
entry: L3GoldEntry,
|
|
269
|
+
system_prompt: str,
|
|
270
|
+
client: LlmClient,
|
|
271
|
+
model: str,
|
|
272
|
+
temperature: float,
|
|
273
|
+
dsn: str,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Walk one question through the LLM → SQL → equivalence pipeline.
|
|
276
|
+
|
|
277
|
+
Mutates ``record`` in place and returns ``None``. Errors at any
|
|
278
|
+
stage short-circuit the function and are surfaced in
|
|
279
|
+
``record["error"]``; the surrounding ``_evaluate_one`` always sets
|
|
280
|
+
``latency_ms`` regardless of which branch we exited through.
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
response = client.complete(
|
|
284
|
+
system=system_prompt,
|
|
285
|
+
user=entry.question,
|
|
286
|
+
model=model,
|
|
287
|
+
temperature=temperature,
|
|
288
|
+
)
|
|
289
|
+
except LlmCallError as exc:
|
|
290
|
+
record["error"] = _truncate(f"LLM call failed: {exc}")
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
record["llm_latency_ms"] = response.latency_ms
|
|
294
|
+
record["prompt_tokens"] = response.prompt_tokens
|
|
295
|
+
record["completion_tokens"] = response.completion_tokens
|
|
296
|
+
|
|
297
|
+
agent_sql = strip_sql_fences(response.text).strip()
|
|
298
|
+
record["agent_sql"] = agent_sql
|
|
299
|
+
if not agent_sql:
|
|
300
|
+
record["error"] = "agent returned an empty SQL string"
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
gold_rows, _ = execute_query(dsn, entry.expected_sql)
|
|
305
|
+
except QueryExecutionError as exc:
|
|
306
|
+
record["error"] = _truncate(f"gold SQL failed to execute: {exc}")
|
|
307
|
+
return
|
|
308
|
+
record["row_count_gold"] = len(gold_rows)
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
agent_rows, _ = execute_query(dsn, agent_sql, enforce_select_only=True)
|
|
312
|
+
except QueryExecutionError as exc:
|
|
313
|
+
record["error"] = _truncate(f"agent SQL failed to execute: {exc}")
|
|
314
|
+
return
|
|
315
|
+
record["row_count_agent"] = len(agent_rows)
|
|
316
|
+
record["execution_success"] = True
|
|
317
|
+
|
|
318
|
+
equivalence = rows_equivalent(
|
|
319
|
+
gold_rows, agent_rows, ordered=gold_is_ordered(entry.expected_sql)
|
|
320
|
+
)
|
|
321
|
+
record["result_equivalence"] = equivalence.equivalent
|
|
322
|
+
if not equivalence.equivalent:
|
|
323
|
+
record["error"] = _truncate(f"row equivalence failed: {equivalence.reason}")
|
|
324
|
+
record["baseline_pass"] = (
|
|
325
|
+
record["execution_success"] and record["result_equivalence"]
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _truncate(s: str) -> str:
|
|
330
|
+
return s if len(s) <= _AGENT_ERROR_MAX_CHARS else s[:_AGENT_ERROR_MAX_CHARS] + "…"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _atomic_write_json(result: BenchmarkResult, out: Path) -> None:
|
|
334
|
+
"""Write JSON to a temp sibling and atomically rename into place."""
|
|
335
|
+
tmp = out.with_suffix(out.suffix + ".tmp")
|
|
336
|
+
write_json(result, tmp)
|
|
337
|
+
os.replace(tmp, out)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _provider_name(client: LlmClient) -> str:
|
|
341
|
+
"""Best-effort provider tag for the envelope.
|
|
342
|
+
|
|
343
|
+
Looks at the client's class to keep tests free of an extra
|
|
344
|
+
"provider" parameter on every fake. Falls back to ``"custom"``
|
|
345
|
+
when neither built-in client matches.
|
|
346
|
+
"""
|
|
347
|
+
if isinstance(client, AnthropicHttpClient):
|
|
348
|
+
return "anthropic"
|
|
349
|
+
if isinstance(client, OpenAIHttpClient):
|
|
350
|
+
return "openai"
|
|
351
|
+
return "custom"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _resolve_version() -> str:
|
|
355
|
+
try:
|
|
356
|
+
return importlib.metadata.version("pretensor")
|
|
357
|
+
except importlib.metadata.PackageNotFoundError:
|
|
358
|
+
return "0.0.0+unknown"
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Row-set equivalence for L3 NL-to-SQL grading.
|
|
2
|
+
|
|
3
|
+
The L3 baseline (and forthcoming pretensor) runner asks an LLM to translate
|
|
4
|
+
a natural-language question into SQL, then compares the result rows to those
|
|
5
|
+
returned by the gold SQL. "Equivalence" must tolerate cosmetic differences
|
|
6
|
+
the spec deems irrelevant while still catching real correctness failures.
|
|
7
|
+
|
|
8
|
+
Rules implemented here:
|
|
9
|
+
|
|
10
|
+
* **Ordered vs multiset** — when the gold query has a top-level ``ORDER BY``,
|
|
11
|
+
rows are compared as an ordered list; otherwise as a sorted multiset. The
|
|
12
|
+
detection ignores ``ORDER BY`` nested inside subqueries / CTEs / window
|
|
13
|
+
frames.
|
|
14
|
+
* **Column comparison is positional** — a SELECT list with renamed columns
|
|
15
|
+
is still equivalent. The gold author chose the column order; the agent
|
|
16
|
+
matches positions, not names.
|
|
17
|
+
* **Value normalisation is deterministic** — ``Decimal('1.00')`` and
|
|
18
|
+
``Decimal('1')`` compare equal; dates / datetimes use ISO 8601; ``bytes``
|
|
19
|
+
use hex; ``None`` is its own bucket. Floats are coerced through
|
|
20
|
+
``Decimal`` so ``1`` and ``1.0`` collapse together.
|
|
21
|
+
|
|
22
|
+
The module deliberately exposes only pure functions — no DB access, no I/O.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import date, datetime, time
|
|
29
|
+
from decimal import Decimal
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import sqlglot
|
|
33
|
+
from sqlglot import exp
|
|
34
|
+
from sqlglot.errors import SqlglotError
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"EquivalenceResult",
|
|
38
|
+
"gold_is_ordered",
|
|
39
|
+
"normalize_row",
|
|
40
|
+
"normalize_value",
|
|
41
|
+
"rows_equivalent",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class EquivalenceResult:
|
|
47
|
+
"""Outcome of comparing gold vs agent rows.
|
|
48
|
+
|
|
49
|
+
``equivalent`` is the boolean grade the runner records. ``reason`` is a
|
|
50
|
+
short string suitable for the per-item ``error`` field when the rows
|
|
51
|
+
don't match (``None`` when equivalent). ``row_count_gold`` /
|
|
52
|
+
``row_count_agent`` make mismatches diagnosable from the JSON envelope
|
|
53
|
+
alone — the most common failure mode is "agent returned 999 rows; gold
|
|
54
|
+
returned 1000" and inspecting that without the JSON diff is painful.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
equivalent: bool
|
|
58
|
+
reason: str | None
|
|
59
|
+
row_count_gold: int
|
|
60
|
+
row_count_agent: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def gold_is_ordered(gold_sql: str) -> bool:
|
|
64
|
+
"""Return ``True`` iff the gold query has a *top-level* ``ORDER BY``.
|
|
65
|
+
|
|
66
|
+
Subquery / CTE / window-frame ``ORDER BY`` clauses are ignored — they
|
|
67
|
+
don't affect the ordering of the outermost result set. Returns ``False``
|
|
68
|
+
on parse failure so the comparison falls back to multiset semantics
|
|
69
|
+
(the safer, more permissive choice).
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
parsed = sqlglot.parse_one(gold_sql, dialect="postgres")
|
|
73
|
+
except SqlglotError:
|
|
74
|
+
return False
|
|
75
|
+
if parsed is None:
|
|
76
|
+
return False
|
|
77
|
+
# parsed.args["order"] is the top-level Order node; nested ORDER BYs
|
|
78
|
+
# live inside the relevant Subquery/Window/CTE child trees and don't
|
|
79
|
+
# appear here.
|
|
80
|
+
order_node = parsed.args.get("order")
|
|
81
|
+
return isinstance(order_node, exp.Order)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def normalize_value(value: Any) -> str:
|
|
85
|
+
"""Coerce a single cell to a deterministic string for comparison.
|
|
86
|
+
|
|
87
|
+
Numerics route through ``Decimal`` so ``1``, ``1.0``, and
|
|
88
|
+
``Decimal('1.00')`` all collapse to the same canonical form.
|
|
89
|
+
Temporal values use ISO 8601. Bytes use lowercase hex. ``None`` gets
|
|
90
|
+
a sentinel so nullness is distinguishable from string ``"None"``.
|
|
91
|
+
"""
|
|
92
|
+
if value is None:
|
|
93
|
+
return "\x00NULL\x00"
|
|
94
|
+
if isinstance(value, bool):
|
|
95
|
+
# Must precede int branch — bool is a subclass of int in Python.
|
|
96
|
+
return "true" if value else "false"
|
|
97
|
+
if isinstance(value, (int, float, Decimal)):
|
|
98
|
+
# ``Decimal`` normalises trailing zeros (1.00 → 1); float passes
|
|
99
|
+
# through ``str`` first to avoid Decimal(0.1) inflating to a long
|
|
100
|
+
# repeating expansion.
|
|
101
|
+
as_decimal = Decimal(str(value)) if isinstance(value, float) else Decimal(value)
|
|
102
|
+
return format(as_decimal.normalize(), "f")
|
|
103
|
+
if isinstance(value, datetime):
|
|
104
|
+
return value.isoformat()
|
|
105
|
+
if isinstance(value, date):
|
|
106
|
+
return value.isoformat()
|
|
107
|
+
if isinstance(value, time):
|
|
108
|
+
return value.isoformat()
|
|
109
|
+
if isinstance(value, (bytes, bytearray)):
|
|
110
|
+
return bytes(value).hex()
|
|
111
|
+
return str(value)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def normalize_row(row: tuple[Any, ...] | list[Any]) -> tuple[str, ...]:
|
|
115
|
+
"""Apply ``normalize_value`` element-wise; preserves positional order."""
|
|
116
|
+
return tuple(normalize_value(v) for v in row)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def rows_equivalent(
|
|
120
|
+
gold_rows: list[tuple[Any, ...]] | list[list[Any]],
|
|
121
|
+
agent_rows: list[tuple[Any, ...]] | list[list[Any]],
|
|
122
|
+
*,
|
|
123
|
+
ordered: bool,
|
|
124
|
+
) -> EquivalenceResult:
|
|
125
|
+
"""Decide whether two row lists are equivalent under the chosen rule.
|
|
126
|
+
|
|
127
|
+
Column count must match (we never compare a 3-column gold against a
|
|
128
|
+
2-column agent — that's a structural correctness failure). When
|
|
129
|
+
``ordered=True`` the rows are compared in order; when ``False`` they
|
|
130
|
+
are compared as multisets via sorted normalised tuples.
|
|
131
|
+
"""
|
|
132
|
+
n_gold = len(gold_rows)
|
|
133
|
+
n_agent = len(agent_rows)
|
|
134
|
+
if n_gold != n_agent:
|
|
135
|
+
return EquivalenceResult(
|
|
136
|
+
equivalent=False,
|
|
137
|
+
reason=f"row count mismatch: gold={n_gold}, agent={n_agent}",
|
|
138
|
+
row_count_gold=n_gold,
|
|
139
|
+
row_count_agent=n_agent,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if n_gold == 0:
|
|
143
|
+
return EquivalenceResult(
|
|
144
|
+
equivalent=True,
|
|
145
|
+
reason=None,
|
|
146
|
+
row_count_gold=0,
|
|
147
|
+
row_count_agent=0,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
gold_norm = [normalize_row(r) for r in gold_rows]
|
|
151
|
+
agent_norm = [normalize_row(r) for r in agent_rows]
|
|
152
|
+
|
|
153
|
+
gold_widths = {len(r) for r in gold_norm}
|
|
154
|
+
agent_widths = {len(r) for r in agent_norm}
|
|
155
|
+
if gold_widths != agent_widths or len(gold_widths) != 1:
|
|
156
|
+
return EquivalenceResult(
|
|
157
|
+
equivalent=False,
|
|
158
|
+
reason=(
|
|
159
|
+
f"column count mismatch: gold={sorted(gold_widths)}, "
|
|
160
|
+
f"agent={sorted(agent_widths)}"
|
|
161
|
+
),
|
|
162
|
+
row_count_gold=n_gold,
|
|
163
|
+
row_count_agent=n_agent,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if ordered:
|
|
167
|
+
match = gold_norm == agent_norm
|
|
168
|
+
else:
|
|
169
|
+
match = sorted(gold_norm) == sorted(agent_norm)
|
|
170
|
+
|
|
171
|
+
return EquivalenceResult(
|
|
172
|
+
equivalent=match,
|
|
173
|
+
reason=None if match else "row contents differ",
|
|
174
|
+
row_count_gold=n_gold,
|
|
175
|
+
row_count_agent=n_agent,
|
|
176
|
+
)
|