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,456 @@
1
+ """``run_l3_pretensor`` — agent + Pretensor MCP runner.
2
+
3
+ Same gold question set, same SQL grader, same JSON envelope as the
4
+ baseline (``pretensor.benchmark.l3.runner.run_l3_baseline``); the
5
+ difference is what the agent gets fed.
6
+
7
+ The baseline hands the LLM a frozen DDL dump. This runner hands it a
8
+ live MCP session against a ``pretensor serve`` subprocess, lets the LLM
9
+ discover the tool catalogue, and routes its tool calls through the MCP
10
+ client. Per-question records pick up an extra ``tool_calls`` field so
11
+ each agent decision is auditable from the JSON alone.
12
+
13
+ The runner is otherwise sync: the MCP client is the only async surface
14
+ and lives behind a sync wrapper (``StdioMcpClient``), so the call shape
15
+ matches the baseline runner one-to-one.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import importlib.metadata
22
+ import json
23
+ import os
24
+ import secrets
25
+ import sys
26
+ import time
27
+ from contextlib import contextmanager
28
+ from pathlib import Path
29
+ from typing import Any, Iterator, cast
30
+
31
+ from pretensor.benchmark.fixtures import load_dataset
32
+ from pretensor.benchmark.l3.agent import (
33
+ DEFAULT_MAX_ITERATIONS,
34
+ AgentLlmClient,
35
+ AgentLoopError,
36
+ AgentTool,
37
+ ToolCallTraceEntry,
38
+ ToolInvocationOutcome,
39
+ run_agent_loop,
40
+ )
41
+ from pretensor.benchmark.l3.db import (
42
+ QueryExecutionError,
43
+ execute_query,
44
+ resolve_database_url,
45
+ )
46
+ from pretensor.benchmark.l3.gold import L3GoldEntry, load_l3_gold
47
+ from pretensor.benchmark.l3.llm_client import AnthropicHttpClient, LlmCallError
48
+ from pretensor.benchmark.l3.mcp_client import McpClient, StdioMcpClient
49
+ from pretensor.benchmark.l3.prompt import (
50
+ build_pretensor_system_prompt,
51
+ pretensor_prompt_template_hash,
52
+ prompt_hash,
53
+ strip_sql_fences,
54
+ )
55
+ from pretensor.benchmark.l3.sql_equivalence import gold_is_ordered, rows_equivalent
56
+ from pretensor.benchmark.results import BenchmarkResult, Metric, write_json
57
+ from pretensor.benchmark.runner import Dataset
58
+
59
+ __all__ = ["DEFAULT_TEMPERATURE", "run_l3_pretensor"]
60
+
61
+
62
+ DEFAULT_TEMPERATURE = 0.0
63
+ """LLM temperature; matches the baseline so the comparison is honest."""
64
+
65
+ _DETERMINISTIC_RAN_AT = "1970-01-01T00:00:00Z"
66
+ """Pinned timestamp; mirrors the baseline runner's reproducibility convention."""
67
+
68
+ _now = time.perf_counter
69
+ """Wall-clock source for per-item latency measurement.
70
+
71
+ Module-private so tests can monkeypatch it to a deterministic stub
72
+ (``lambda: 0.0``) — mirrors the pattern in the baseline runner.
73
+ """
74
+
75
+ _AGENT_ERROR_MAX_CHARS = 500
76
+ """Truncation limit for per-item ``error`` strings (matches the baseline)."""
77
+
78
+ _SUCCESS_RATE_METRIC = "nl2sql_success_rate_pretensor"
79
+ _LATENCY_METRIC = "mean_latency_ms_pretensor"
80
+
81
+ _SEMANTIC_SEARCH_TOOL = "semantic_search"
82
+ """The MCP tool that signals the embeddings extra is loaded server-side.
83
+
84
+ Per the L3 contract the runner stays embeddings-agnostic: it inspects
85
+ the catalogue advertised by ``pretensor serve`` and reports whatever
86
+ state it found. If the embeddings extra was installed in the serve
87
+ process, ``semantic_search`` is registered and shows up here.
88
+ """
89
+
90
+
91
+ def run_l3_pretensor(
92
+ dataset: Dataset,
93
+ out: Path | None,
94
+ graph_dir: Path,
95
+ *,
96
+ model: str,
97
+ seed: int | None,
98
+ llm_client: AgentLlmClient | None = None,
99
+ mcp_client: McpClient | None = None,
100
+ ) -> None:
101
+ """Run the L3 pretensor (agent + MCP) variant for ``dataset``.
102
+
103
+ ``llm_client`` and ``mcp_client`` are Python-API-only seams — the
104
+ CLI never exposes them. Tests inject fakes to drive the runner
105
+ without spawning a subprocess or hitting a real LLM provider.
106
+ Production callers leave both ``None`` and the runner builds an
107
+ :class:`AnthropicHttpClient` against ``ANTHROPIC_API_KEY`` plus a
108
+ :class:`StdioMcpClient` against ``pretensor serve`` over ``graph_dir``.
109
+
110
+ The runner refuses to run when ``graph_dir`` does not contain an
111
+ indexed graph — for L3 parity, the baseline and pretensor variants
112
+ must see the same data, and silently re-indexing would defeat the
113
+ comparison. The user is told to run ``pretensor index`` first.
114
+ """
115
+ fixture = load_dataset(dataset)
116
+ _questions_path, questions_bytes, questions = load_l3_gold(fixture)
117
+ dsn = resolve_database_url(dataset)
118
+ _require_indexed_graph_dir(graph_dir)
119
+
120
+ resolved_seed = seed if seed is not None else secrets.randbits(63)
121
+ system_prompt = build_pretensor_system_prompt(dataset.value)
122
+ rendered_hash = prompt_hash(system_prompt)
123
+ template_hash = pretensor_prompt_template_hash()
124
+
125
+ client: AgentLlmClient
126
+ if llm_client is not None:
127
+ client = llm_client
128
+ else:
129
+ # Mirror the baseline runner's friendly-exit pattern: convert the
130
+ # missing-key LlmCallError into a LookupError so the CLI's
131
+ # _handle_input_error path renders a one-line message rather than
132
+ # a Python traceback.
133
+ try:
134
+ client = AnthropicHttpClient()
135
+ except LlmCallError as exc:
136
+ raise LookupError(str(exc)) from exc
137
+
138
+ with _resolve_mcp_client(mcp_client, graph_dir=graph_dir) as mcp:
139
+ tools = mcp.list_tools()
140
+ embeddings_enabled = any(t.name == _SEMANTIC_SEARCH_TOOL for t in tools)
141
+
142
+ notes = _build_notes(tools=tools, embeddings_enabled=embeddings_enabled)
143
+
144
+ per_item: list[dict[str, Any]] = []
145
+ latencies_ms: list[int] = []
146
+ successes = 0
147
+
148
+ for entry in questions:
149
+ record = _evaluate_one(
150
+ entry=entry,
151
+ system_prompt=system_prompt,
152
+ client=client,
153
+ tools=tools,
154
+ mcp=mcp,
155
+ model=model,
156
+ temperature=DEFAULT_TEMPERATURE,
157
+ dsn=dsn,
158
+ )
159
+ per_item.append(record)
160
+ latencies_ms.append(int(record["latency_ms"]))
161
+ if record["pretensor_pass"]:
162
+ successes += 1
163
+
164
+ per_item.sort(key=lambda r: cast(str, r["id"]))
165
+
166
+ metrics: dict[str, Metric] = {
167
+ _SUCCESS_RATE_METRIC: Metric(
168
+ value=(successes / len(questions)) if questions else None,
169
+ direction="higher_is_better",
170
+ ),
171
+ _LATENCY_METRIC: Metric(
172
+ value=(sum(latencies_ms) / len(latencies_ms)) if latencies_ms else None,
173
+ direction="lower_is_better",
174
+ ),
175
+ }
176
+
177
+ fixture_sha = "sha256:" + hashlib.sha256(questions_bytes).hexdigest()
178
+
179
+ extra: dict[str, Any] = {
180
+ "runner": "pretensor",
181
+ "provider": _provider_name(client),
182
+ "model": model,
183
+ "temperature": DEFAULT_TEMPERATURE,
184
+ "seed": resolved_seed,
185
+ "prompt_template_hash": template_hash,
186
+ "prompt_hash": rendered_hash,
187
+ "tool_catalogue": [t.name for t in tools],
188
+ "max_iterations": DEFAULT_MAX_ITERATIONS,
189
+ }
190
+
191
+ result = BenchmarkResult(
192
+ level="l3",
193
+ dataset=dataset.value,
194
+ pretensor_version=_resolve_version(),
195
+ embeddings_enabled=embeddings_enabled,
196
+ ran_at=_DETERMINISTIC_RAN_AT,
197
+ fixture_sha=fixture_sha,
198
+ metrics=metrics,
199
+ per_item=per_item,
200
+ notes=notes,
201
+ extra=extra,
202
+ )
203
+
204
+ if out is None:
205
+ sys.stdout.write(json.dumps(result.to_dict(), sort_keys=True, indent=2) + "\n")
206
+ return
207
+ _atomic_write_json(result, out)
208
+
209
+
210
+ def _evaluate_one(
211
+ *,
212
+ entry: L3GoldEntry,
213
+ system_prompt: str,
214
+ client: AgentLlmClient,
215
+ tools: list[AgentTool],
216
+ mcp: McpClient,
217
+ model: str,
218
+ temperature: float,
219
+ dsn: str,
220
+ ) -> dict[str, Any]:
221
+ """Grade one question end-to-end through the agent + MCP loop.
222
+
223
+ Wraps :func:`_populate_record` in a try/finally so ``latency_ms`` is
224
+ always set to the full per-question wall-clock — including SQL
225
+ execution and equivalence checking, not just the LLM round-trips.
226
+ The aggregated LLM-only timing is recorded separately as
227
+ ``llm_latency_ms`` for cost / regression diagnostics.
228
+ """
229
+ record: dict[str, Any] = {
230
+ "id": entry.id,
231
+ "question": entry.question,
232
+ "gold_sql": entry.expected_sql,
233
+ "agent_sql": "",
234
+ "tool_calls": [],
235
+ "execution_success": False,
236
+ "result_equivalence": False,
237
+ "latency_ms": 0,
238
+ "llm_latency_ms": 0,
239
+ "prompt_tokens": None,
240
+ "completion_tokens": None,
241
+ "iterations": 0,
242
+ "row_count_gold": 0,
243
+ "row_count_agent": 0,
244
+ "pretensor_pass": False,
245
+ "error": None,
246
+ }
247
+ t0 = _now()
248
+ try:
249
+ _populate_record(
250
+ record,
251
+ entry=entry,
252
+ system_prompt=system_prompt,
253
+ client=client,
254
+ tools=tools,
255
+ mcp=mcp,
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: AgentLlmClient,
271
+ tools: list[AgentTool],
272
+ mcp: McpClient,
273
+ model: str,
274
+ temperature: float,
275
+ dsn: str,
276
+ ) -> None:
277
+ """Walk one question through the agent loop → SQL → equivalence pipeline.
278
+
279
+ Mutates ``record`` in place. Errors at any stage short-circuit the
280
+ function and are surfaced in ``record["error"]``; the surrounding
281
+ ``_evaluate_one`` always sets ``latency_ms`` regardless of which
282
+ branch we exited through.
283
+ """
284
+
285
+ def invoke(name: str, arguments: dict[str, Any]) -> ToolInvocationOutcome:
286
+ outcome = mcp.call_tool(name, arguments)
287
+ return ToolInvocationOutcome(content=outcome.content, is_error=outcome.is_error)
288
+
289
+ try:
290
+ loop_result = run_agent_loop(
291
+ client=client,
292
+ system=system_prompt,
293
+ user=entry.question,
294
+ tools=tools,
295
+ invoke_tool=invoke,
296
+ model=model,
297
+ temperature=temperature,
298
+ )
299
+ except LlmCallError as exc:
300
+ record["error"] = _truncate(f"LLM call failed: {exc}")
301
+ return
302
+ except AgentLoopError as exc:
303
+ record["error"] = _truncate(f"agent loop failed: {exc}")
304
+ return
305
+
306
+ record["llm_latency_ms"] = loop_result.total_llm_latency_ms
307
+ record["prompt_tokens"] = loop_result.total_prompt_tokens
308
+ record["completion_tokens"] = loop_result.total_completion_tokens
309
+ record["iterations"] = loop_result.iterations
310
+ record["tool_calls"] = [_trace_to_dict(t) for t in loop_result.trace]
311
+
312
+ agent_sql = strip_sql_fences(loop_result.text).strip()
313
+ record["agent_sql"] = agent_sql
314
+ if not agent_sql:
315
+ record["error"] = "agent returned an empty SQL string"
316
+ return
317
+
318
+ try:
319
+ gold_rows, _ = execute_query(dsn, entry.expected_sql)
320
+ except QueryExecutionError as exc:
321
+ record["error"] = _truncate(f"gold SQL failed to execute: {exc}")
322
+ return
323
+ record["row_count_gold"] = len(gold_rows)
324
+
325
+ try:
326
+ agent_rows, _ = execute_query(dsn, agent_sql, enforce_select_only=True)
327
+ except QueryExecutionError as exc:
328
+ record["error"] = _truncate(f"agent SQL failed to execute: {exc}")
329
+ return
330
+ record["row_count_agent"] = len(agent_rows)
331
+ record["execution_success"] = True
332
+
333
+ equivalence = rows_equivalent(
334
+ gold_rows, agent_rows, ordered=gold_is_ordered(entry.expected_sql)
335
+ )
336
+ record["result_equivalence"] = equivalence.equivalent
337
+ if not equivalence.equivalent:
338
+ record["error"] = _truncate(f"row equivalence failed: {equivalence.reason}")
339
+ record["pretensor_pass"] = (
340
+ record["execution_success"] and record["result_equivalence"]
341
+ )
342
+
343
+
344
+ def _trace_to_dict(entry: ToolCallTraceEntry) -> dict[str, Any]:
345
+ """Render one tool-call trace entry for the JSON envelope.
346
+
347
+ ``is_error`` is always present (matching every other boolean in
348
+ the per-item record). A consumer reading the envelope can do
349
+ ``entry["is_error"]`` without a KeyError on success entries.
350
+ """
351
+ return {
352
+ "tool": entry.tool,
353
+ "args": entry.args,
354
+ "response_size": entry.response_size,
355
+ "is_error": entry.is_error,
356
+ }
357
+
358
+
359
+ def _build_notes(*, tools: list[AgentTool], embeddings_enabled: bool) -> list[str]:
360
+ """Compose the ``notes[]`` block.
361
+
362
+ Always carries the LLM-determinism caveat so an auditor reads the
363
+ same disclaimer the baseline runner emits. Adds catalogue + embeddings
364
+ notes so a reader can infer the run's tool surface from the envelope
365
+ alone (without re-running ``pretensor serve``).
366
+ """
367
+ notes = [
368
+ "L3 is LLM-bound; the --seed value governs harness state only "
369
+ "(the Anthropic / OpenAI APIs do not accept a seed parameter). "
370
+ "Same-seed reruns at temperature 0 may still differ by remaining "
371
+ "LLM nondeterminism — including the order or choice of MCP tool "
372
+ "calls inside the agent loop."
373
+ ]
374
+ notes.append(
375
+ f"MCP tool catalogue ({len(tools)} tools): "
376
+ + ", ".join(sorted(t.name for t in tools))
377
+ )
378
+ if not embeddings_enabled:
379
+ notes.append(
380
+ "embeddings extra not detected on the serve subprocess "
381
+ f"(no '{_SEMANTIC_SEARCH_TOOL}' tool in the catalogue); "
382
+ "the agent ran without semantic search."
383
+ )
384
+ return notes
385
+
386
+
387
+ def _require_indexed_graph_dir(graph_dir: Path) -> None:
388
+ """Refuse to run when ``graph_dir`` clearly has no indexed graph.
389
+
390
+ Looks for a ``graphs/`` subdirectory containing at least one
391
+ ``*.kuzu`` file. The check is deliberately shallow — verifying
392
+ that an *index* is fresh would couple this runner to the graph-dir
393
+ layout details, which the indexing subsystem owns. The message
394
+ points the user at the canonical fix (`pretensor index ...`) so an
395
+ operator can self-serve.
396
+ """
397
+ graphs = graph_dir / "graphs"
398
+ if not graphs.is_dir() or not any(graphs.glob("*.kuzu")):
399
+ raise FileNotFoundError(
400
+ f"L3 pretensor runner requires an indexed graph at {graph_dir}/graphs/. "
401
+ "Run `pretensor index <dsn>` first to populate it."
402
+ )
403
+
404
+
405
+ @contextmanager
406
+ def _resolve_mcp_client(
407
+ injected: McpClient | None, *, graph_dir: Path
408
+ ) -> Iterator[McpClient]:
409
+ """Yield the injected client when given, else stand up a stdio client.
410
+
411
+ The injected branch deliberately does NOT enter a context manager
412
+ on the caller's behalf — tests own the lifecycle of their fakes.
413
+ The stdio branch enters/exits :class:`StdioMcpClient` so the
414
+ subprocess is reaped on every code path through the runner.
415
+
416
+ A :class:`McpClientError` raised by :class:`StdioMcpClient` (broken
417
+ binary, transport handshake failure) propagates unchanged — it is a
418
+ distinct condition from the missing-graph-dir error raised by
419
+ :func:`_require_indexed_graph_dir`. The CLI handles both cleanly
420
+ via its input-error branch; collapsing them here would lose the
421
+ distinction operators need to debug a broken serve install.
422
+ """
423
+ if injected is not None:
424
+ yield injected
425
+ return
426
+ with StdioMcpClient(graph_dir=graph_dir) as stdio_client_:
427
+ yield stdio_client_
428
+
429
+
430
+ def _truncate(s: str) -> str:
431
+ return s if len(s) <= _AGENT_ERROR_MAX_CHARS else s[:_AGENT_ERROR_MAX_CHARS] + "…"
432
+
433
+
434
+ def _atomic_write_json(result: BenchmarkResult, out: Path) -> None:
435
+ """Write JSON to a temp sibling and atomically rename into place."""
436
+ tmp = out.with_suffix(out.suffix + ".tmp")
437
+ write_json(result, tmp)
438
+ os.replace(tmp, out)
439
+
440
+
441
+ def _provider_name(client: AgentLlmClient) -> str:
442
+ """Best-effort provider tag for the envelope.
443
+
444
+ Mirrors the baseline runner's helper so an auditor can compare the
445
+ two envelopes without reaching for provider-specific docs.
446
+ """
447
+ if isinstance(client, AnthropicHttpClient):
448
+ return "anthropic"
449
+ return "custom"
450
+
451
+
452
+ def _resolve_version() -> str:
453
+ try:
454
+ return importlib.metadata.version("pretensor")
455
+ except importlib.metadata.PackageNotFoundError:
456
+ return "0.0.0+unknown"
@@ -0,0 +1,132 @@
1
+ """System-prompt template + hashing for the L3 NL-to-SQL agent.
2
+
3
+ The prompt is locked into source so its hash is reproducible across runs:
4
+ two invocations with the same dataset and DDL produce the same
5
+ ``prompt_hash``. Any drift — a wording tweak, a new DDL revision,
6
+ a different dataset name — flips the hash and is auditable from the
7
+ JSON envelope.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import re
14
+
15
+ __all__ = [
16
+ "MAX_DDL_CHARS",
17
+ "build_pretensor_system_prompt",
18
+ "build_system_prompt",
19
+ "pretensor_prompt_template_hash",
20
+ "prompt_hash",
21
+ "prompt_template_hash",
22
+ "strip_sql_fences",
23
+ ]
24
+
25
+
26
+ _SYSTEM_PROMPT_TEMPLATE = """You are a senior SQL engineer. The schema below describes a {dataset_name} PostgreSQL database. Read the schema carefully, then translate the user's question into a single PostgreSQL SELECT statement that runs against this schema and answers the question.
27
+
28
+ Rules:
29
+ - Return only the SQL query — no markdown, no code fences, no commentary.
30
+ - Emit a single statement; do not include trailing semicolons or explanatory text.
31
+ - Use only objects (tables, columns, schemas) that appear in the schema.
32
+ - Prefer fully-qualified table names (schema.table) when the schema names them that way.
33
+ - Read-only access only: SELECT or WITH ... SELECT statements only. Never DDL or DML.
34
+
35
+ Schema (DDL):
36
+ {ddl_text}
37
+ """
38
+
39
+ MAX_DDL_CHARS = 400_000
40
+ """Soft warning threshold for the DDL passed to the model.
41
+
42
+ Beyond this size the runner emits a ``notes[]`` warning so an auditor can
43
+ tell whether a low success rate might be a context-window artefact rather
44
+ than agent quality. The runner never silently truncates — silent
45
+ truncation would produce a fake baseline.
46
+ """
47
+
48
+
49
+ def build_system_prompt(dataset_name: str, ddl_text: str) -> str:
50
+ """Render the locked template with the given dataset name and DDL.
51
+
52
+ The dataset name is interpolated verbatim — give the agent a hint about
53
+ the schema's domain (e.g. ``pagila`` is a video-rental catalog, ``tpch``
54
+ is a decision-support benchmark) at no cost. The DDL is also verbatim;
55
+ comments inside the DDL carry semantic hints we want preserved.
56
+ """
57
+ return _SYSTEM_PROMPT_TEMPLATE.format(dataset_name=dataset_name, ddl_text=ddl_text)
58
+
59
+
60
+ _PRETENSOR_SYSTEM_PROMPT_TEMPLATE = """You are a senior SQL engineer. The {dataset_name} PostgreSQL database is indexed by Pretensor, and you have access to MCP tools that describe the schema, search for relevant tables, and traverse relationships. Use the tools to gather just enough context, then translate the user's question into a single PostgreSQL SELECT statement.
61
+
62
+ Rules:
63
+ - Return only the SQL query — no markdown, no code fences, no commentary.
64
+ - Emit a single statement; do not include trailing semicolons or explanatory text.
65
+ - Use only objects (tables, columns, schemas) that the tools confirm exist.
66
+ - Prefer fully-qualified table names (schema.table) when the schema names them that way.
67
+ - Read-only access only: SELECT or WITH ... SELECT statements only. Never DDL or DML.
68
+ - Stop calling tools and emit the SQL as soon as you have enough context. Do not call tools for general curiosity.
69
+ """
70
+
71
+
72
+ def build_pretensor_system_prompt(dataset_name: str) -> str:
73
+ """Render the pretensor-runner system prompt for ``dataset_name``.
74
+
75
+ The pretensor prompt is intentionally close to the baseline's: same
76
+ voice, same SQL-emission rules. The only structural change is that
77
+ schema knowledge is delivered through MCP tools rather than inlined
78
+ DDL — so the template tells the model to gather context via tools
79
+ before answering. Keeping the prompts otherwise identical keeps the
80
+ side-by-side comparison about Pretensor's tooling, not about prompt
81
+ engineering.
82
+ """
83
+ return _PRETENSOR_SYSTEM_PROMPT_TEMPLATE.format(dataset_name=dataset_name)
84
+
85
+
86
+ def pretensor_prompt_template_hash() -> str:
87
+ """SHA-256 of the literal pretensor prompt template (no substitutions).
88
+
89
+ Mirrors :func:`prompt_template_hash` so the JSON envelope can record
90
+ which prompt the run used; flips when the wording is edited in source.
91
+ """
92
+ return hashlib.sha256(_PRETENSOR_SYSTEM_PROMPT_TEMPLATE.encode("utf-8")).hexdigest()
93
+
94
+
95
+ def prompt_template_hash() -> str:
96
+ """SHA-256 of the literal template (no substitutions).
97
+
98
+ Stable across runs; flips only when the prompt wording changes in
99
+ source. Pair this with ``prompt_hash`` in the JSON envelope so an
100
+ auditor can tell apart "DDL changed but template didn't" from
101
+ "template changed but DDL didn't".
102
+ """
103
+ return hashlib.sha256(_SYSTEM_PROMPT_TEMPLATE.encode("utf-8")).hexdigest()
104
+
105
+
106
+ def prompt_hash(rendered_prompt: str) -> str:
107
+ """SHA-256 of the rendered prompt (template + dataset + DDL substituted)."""
108
+ return hashlib.sha256(rendered_prompt.encode("utf-8")).hexdigest()
109
+
110
+
111
+ # Match a leading ```sql ... ``` or ``` ... ``` fence. We intentionally
112
+ # only strip the outermost wrapping fence; if the model returns multiple
113
+ # code blocks we want to surface that as an SQL parse failure downstream
114
+ # rather than silently merge them.
115
+ _FENCE_RE = re.compile(
116
+ r"^\s*```(?:sql|postgres|postgresql)?\s*\n(?P<body>.*?)\n```\s*$",
117
+ re.IGNORECASE | re.DOTALL,
118
+ )
119
+
120
+
121
+ def strip_sql_fences(raw: str) -> str:
122
+ """Best-effort cleanup of model output that ignored the no-fence rule.
123
+
124
+ Some models wrap SQL in ```sql ... ``` despite the explicit
125
+ instruction. This strips one leading fence; everything else is
126
+ returned verbatim so downstream parsing still surfaces real garbage
127
+ as a parse failure.
128
+ """
129
+ match = _FENCE_RE.match(raw)
130
+ if match is None:
131
+ return raw
132
+ return match.group("body")