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,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
+ )