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,487 @@
1
+ """Compile user-authored YAML metrics to validated SQL (OSS).
2
+
3
+ The :class:`MetricSqlCompiler` turns a :class:`Metric` from a
4
+ :class:`SemanticLayer` YAML (``src/pretensor/introspection/models/semantic.py``)
5
+ into an ANSI-quoted SQL string and runs it through the existing
6
+ :class:`pretensor.validation.query_validator.QueryValidator`. See
7
+ ``docs/specs/semantic/spec.md`` for the contract.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from dataclasses import dataclass, field
14
+ from difflib import get_close_matches
15
+
16
+ import sqlglot
17
+ from sqlglot import exp
18
+ from sqlglot.errors import ParseError
19
+
20
+ from pretensor.core.store import KuzuStore
21
+ from pretensor.errors import PretensorError
22
+ from pretensor.introspection.models.semantic import (
23
+ Entity,
24
+ Metric,
25
+ MetricType,
26
+ )
27
+ from pretensor.introspection.models.semantic import (
28
+ SemanticLayer as SemanticLayerModel,
29
+ )
30
+ from pretensor.validation.query_validator import QueryValidator, ValidationResult
31
+
32
+ __all__ = ["CompiledMetric", "MetricCompileError", "MetricSqlCompiler"]
33
+
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ _DID_YOU_MEAN_TOP_K = 3
39
+ _DID_YOU_MEAN_MAX_CANDIDATES = 1000
40
+
41
+
42
+ def _suggest_did_you_mean(
43
+ unresolved: str,
44
+ candidates: list[str],
45
+ *,
46
+ top_k: int = _DID_YOU_MEAN_TOP_K,
47
+ ) -> list[str]:
48
+ """Rank ``candidates`` by similarity to ``unresolved``.
49
+
50
+ Prefers an embedding cosine ranking when the ``[embeddings]`` extra is
51
+ importable AND ``LocalEmbeddingClient.embed`` succeeds. Falls back to
52
+ :func:`difflib.get_close_matches` (Levenshtein-style ratio) so the UX
53
+ remains useful without the extra installed.
54
+
55
+ Returns at most ``top_k`` names. Returns ``[]`` when the candidate set
56
+ is empty or larger than ``_DID_YOU_MEAN_MAX_CANDIDATES`` (suggestions on
57
+ a 50k-column schema add no value and dominate compile-error latency).
58
+
59
+ Pure UX — no caller branches on the suggestion list, so this function
60
+ never raises and always degrades gracefully.
61
+ """
62
+ if not candidates or len(candidates) > _DID_YOU_MEAN_MAX_CANDIDATES:
63
+ return []
64
+ deduped = list(dict.fromkeys(candidates))
65
+
66
+ try:
67
+ from pretensor.intelligence.embeddings import (
68
+ cosine_similarity,
69
+ embeddings_disabled_via_env,
70
+ get_default_embedding_client,
71
+ )
72
+
73
+ # Kill switch: skip the embedding ranking entirely and use the
74
+ # same Levenshtein path as when the extra is absent.
75
+ if not embeddings_disabled_via_env():
76
+ client = get_default_embedding_client()
77
+ all_texts = [unresolved, *deduped]
78
+ vectors = client.embed(all_texts)
79
+ if vectors and len(vectors) == len(all_texts):
80
+ scored = [
81
+ (cosine_similarity(vectors[0], vectors[i + 1]), deduped[i])
82
+ for i in range(len(deduped))
83
+ ]
84
+ scored.sort(key=lambda x: -x[0])
85
+ return [name for _, name in scored[:top_k]]
86
+ except ImportError:
87
+ # ``[embeddings]`` extra not installed — fall through to Levenshtein.
88
+ pass
89
+ except Exception as exc: # noqa: BLE001 — UX hint must never raise
90
+ logger.debug(
91
+ "compile_metric: embedding-based suggestions failed (%s); "
92
+ "falling back to difflib",
93
+ exc,
94
+ )
95
+
96
+ return list(get_close_matches(unresolved, deduped, n=top_k, cutoff=0.5))
97
+
98
+
99
+ _DEFAULT_DIALECT = "postgres"
100
+
101
+
102
+ def _pg_ident(part: str) -> str:
103
+ """ANSI / PostgreSQL double-quote identifier escaping."""
104
+ return '"' + part.replace('"', '""') + '"'
105
+
106
+
107
+ def _split_schema_table(qualified: str) -> tuple[str | None, str]:
108
+ """Split ``schema.table`` into ``(schema, table)`` — ``(None, table)`` when unqualified."""
109
+ if "." in qualified:
110
+ schema, _, table = qualified.partition(".")
111
+ return schema, table
112
+ return None, qualified
113
+
114
+
115
+ @dataclass
116
+ class CompiledMetric:
117
+ """Outcome of compiling a single metric."""
118
+
119
+ metric: str
120
+ entity: str
121
+ sql: str
122
+ dialect: str
123
+ warnings: list[str] = field(default_factory=list)
124
+ validation: ValidationResult = field(
125
+ default_factory=lambda: ValidationResult(valid=True)
126
+ )
127
+
128
+
129
+ class MetricCompileError(PretensorError, ValueError):
130
+ """Raised when a metric cannot be compiled.
131
+
132
+ Carries a structured ``did_you_mean: list[str]`` field populated when
133
+ the failure was caused by an unresolved name (metric, table, column).
134
+ The hint is also embedded in the error string so plain ``str(exc)``
135
+ consumers see it; programmatic callers can read the attribute. Empty
136
+ for structural failures (parse error, ambiguous source table).
137
+ """
138
+
139
+ def __init__(self, message: str, *, did_you_mean: list[str] | None = None) -> None:
140
+ self.did_you_mean: list[str] = list(did_you_mean or [])
141
+ if self.did_you_mean:
142
+ hint = ", ".join(repr(s) for s in self.did_you_mean)
143
+ full = f"{message} (did you mean: {hint}?)"
144
+ else:
145
+ full = message
146
+ super().__init__(full)
147
+
148
+
149
+ class MetricSqlCompiler:
150
+ """Compile YAML-authored :class:`Metric` objects to SQL.
151
+
152
+ The compiler resolves the owning :class:`Entity` source table through the
153
+ same Kuzu lookup the validator uses so callers cannot compile metrics
154
+ against tables that are not in the indexed graph.
155
+ """
156
+
157
+ def __init__(
158
+ self,
159
+ store: KuzuStore,
160
+ *,
161
+ connection_name: str,
162
+ database_key: str,
163
+ dialect: str = _DEFAULT_DIALECT,
164
+ ) -> None:
165
+ self._store = store
166
+ self._connection_name = connection_name
167
+ self._database_key = database_key
168
+ self._dialect = dialect
169
+ self._validator = QueryValidator(
170
+ store,
171
+ connection_name=connection_name,
172
+ database_key=database_key,
173
+ dialect=dialect,
174
+ )
175
+
176
+ def compile(
177
+ self,
178
+ layer: SemanticLayerModel,
179
+ metric_name: str,
180
+ ) -> CompiledMetric:
181
+ """Compile ``metric_name`` from ``layer`` to SQL and validate.
182
+
183
+ Raises:
184
+ MetricCompileError: when the metric is not found, its entity is
185
+ missing, its field is unknown, its expression is unparseable,
186
+ or the owning table cannot be resolved to an indexed table.
187
+ """
188
+ entity, metric = self._find_metric(layer, metric_name)
189
+ warnings: list[str] = []
190
+
191
+ if metric.type is MetricType.DERIVED:
192
+ sql = self._compile_derived(metric, warnings)
193
+ else:
194
+ sql = self._compile_aggregate(entity, metric)
195
+
196
+ validation = self._validator.validate(sql)
197
+ return CompiledMetric(
198
+ metric=metric.name,
199
+ entity=entity.name,
200
+ sql=sql,
201
+ dialect=self._dialect,
202
+ warnings=warnings,
203
+ validation=validation,
204
+ )
205
+
206
+ # ---- internals ---------------------------------------------------------
207
+
208
+ def _find_metric(
209
+ self, layer: SemanticLayerModel, metric_name: str
210
+ ) -> tuple[Entity, Metric]:
211
+ all_metric_names: list[str] = []
212
+ for domain in layer.domains:
213
+ for entity in domain.entities:
214
+ for metric in entity.metrics:
215
+ if metric.name == metric_name:
216
+ return entity, metric
217
+ all_metric_names.append(metric.name)
218
+ suggestions = _suggest_did_you_mean(metric_name, all_metric_names)
219
+ raise MetricCompileError(
220
+ f"metric {metric_name!r} not found in semantic layer "
221
+ f"for connection {layer.connection_name!r}",
222
+ did_you_mean=suggestions,
223
+ )
224
+
225
+ def _resolve_entity_table(self, entity: Entity) -> tuple[str, str]:
226
+ schema, table = _split_schema_table(entity.source_table)
227
+ if schema is not None:
228
+ rows = self._store.query_all_rows(
229
+ """
230
+ MATCH (t:SchemaTable)
231
+ WHERE t.connection_name = $cn AND t.database = $db
232
+ AND t.schema_name = $sn AND t.table_name = $tn
233
+ RETURN t.schema_name, t.table_name
234
+ LIMIT 1
235
+ """,
236
+ {
237
+ "cn": self._connection_name,
238
+ "db": self._database_key,
239
+ "sn": schema,
240
+ "tn": table,
241
+ },
242
+ )
243
+ else:
244
+ rows = self._store.query_all_rows(
245
+ """
246
+ MATCH (t:SchemaTable)
247
+ WHERE t.connection_name = $cn AND t.database = $db
248
+ AND t.table_name = $tbl
249
+ RETURN t.schema_name, t.table_name
250
+ """,
251
+ {
252
+ "cn": self._connection_name,
253
+ "db": self._database_key,
254
+ "tbl": table,
255
+ },
256
+ )
257
+ if len(rows) > 1:
258
+ raise MetricCompileError(
259
+ f"entity {entity.name!r} source table {entity.source_table!r} "
260
+ f"is ambiguous across schemas; qualify it as `schema.table`"
261
+ )
262
+ if not rows:
263
+ suggestions = _suggest_did_you_mean(
264
+ entity.source_table,
265
+ self._known_table_qualnames(),
266
+ )
267
+ raise MetricCompileError(
268
+ f"entity {entity.name!r} source table {entity.source_table!r} "
269
+ f"is not indexed for connection {self._connection_name!r}",
270
+ did_you_mean=suggestions,
271
+ )
272
+ sn, tn = rows[0]
273
+ return str(sn), str(tn)
274
+
275
+ def _known_table_qualnames(self) -> list[str]:
276
+ """Return ``["schema.table", ...]`` for every indexed table.
277
+
278
+ Used as the candidate set for ``did_you_mean`` on unresolved
279
+ ``entity.source_table`` references and ``unknown table`` errors in
280
+ derived-metric SQL. One bulk Kuzu query per compile error is fine
281
+ — error paths are not hot.
282
+ """
283
+ rows = self._store.query_all_rows(
284
+ """
285
+ MATCH (t:SchemaTable)
286
+ WHERE t.connection_name = $cn AND t.database = $db
287
+ RETURN t.schema_name, t.table_name
288
+ ORDER BY t.schema_name, t.table_name
289
+ """,
290
+ {"cn": self._connection_name, "db": self._database_key},
291
+ )
292
+ return [f"{sn}.{tn}" for sn, tn in rows]
293
+
294
+ def _known_columns_on(self, schema_name: str, table_name: str) -> list[str]:
295
+ """Return every column name on ``schema.table`` for did_you_mean ranking.
296
+
297
+ Mirrors :meth:`_known_table_qualnames` but scoped to one table —
298
+ used as the candidate set when an aggregate metric's ``field`` is
299
+ not present on the resolved entity table.
300
+ """
301
+ rows = self._store.query_all_rows(
302
+ """
303
+ MATCH (t:SchemaTable)-[:HAS_COLUMN]->(c:SchemaColumn)
304
+ WHERE t.connection_name = $cn AND t.database = $db
305
+ AND t.schema_name = $sn AND t.table_name = $tn
306
+ RETURN c.column_name
307
+ ORDER BY c.column_name
308
+ """,
309
+ {
310
+ "cn": self._connection_name,
311
+ "db": self._database_key,
312
+ "sn": schema_name,
313
+ "tn": table_name,
314
+ },
315
+ )
316
+ return [str(r[0]) for r in rows]
317
+
318
+ def _column_exists(
319
+ self, schema_name: str, table_name: str, column_name: str
320
+ ) -> bool:
321
+ rows = self._store.query_all_rows(
322
+ """
323
+ MATCH (t:SchemaTable)-[:HAS_COLUMN]->(c:SchemaColumn)
324
+ WHERE t.connection_name = $cn AND t.database = $db
325
+ AND t.schema_name = $sn AND t.table_name = $tn
326
+ AND c.column_name = $col
327
+ RETURN 1 LIMIT 1
328
+ """,
329
+ {
330
+ "cn": self._connection_name,
331
+ "db": self._database_key,
332
+ "sn": schema_name,
333
+ "tn": table_name,
334
+ "col": column_name,
335
+ },
336
+ )
337
+ return bool(rows)
338
+
339
+ def _compile_aggregate(self, entity: Entity, metric: Metric) -> str:
340
+ if metric.field is None:
341
+ raise MetricCompileError(
342
+ f"metric {metric.name!r} of type {metric.type.value!r} requires `field`"
343
+ )
344
+ schema_name, table_name = self._resolve_entity_table(entity)
345
+ if not self._column_exists(schema_name, table_name, metric.field):
346
+ suggestions = _suggest_did_you_mean(
347
+ metric.field,
348
+ self._known_columns_on(schema_name, table_name),
349
+ )
350
+ raise MetricCompileError(
351
+ f"metric {metric.name!r} field {metric.field!r} not found on "
352
+ f"{schema_name}.{table_name} in the indexed graph",
353
+ did_you_mean=suggestions,
354
+ )
355
+
356
+ qschema = _pg_ident(schema_name)
357
+ qtable = _pg_ident(table_name)
358
+ qcol = _pg_ident(metric.field)
359
+ qalias = _pg_ident(metric.name)
360
+
361
+ if metric.type is MetricType.COUNT:
362
+ agg = f"COUNT({qcol})"
363
+ elif metric.type is MetricType.COUNT_DISTINCT:
364
+ agg = f"COUNT(DISTINCT {qcol})"
365
+ elif metric.type is MetricType.SUM:
366
+ agg = f"SUM({qcol})"
367
+ elif metric.type is MetricType.AVERAGE:
368
+ agg = f"AVG({qcol})"
369
+ else: # pragma: no cover — DERIVED routed elsewhere
370
+ raise MetricCompileError(
371
+ f"unsupported aggregate type {metric.type.value!r}"
372
+ )
373
+
374
+ sql = f"SELECT {agg} AS {qalias} FROM {qschema}.{qtable}"
375
+ if metric.filters:
376
+ joined = " AND ".join(f"({f})" for f in metric.filters)
377
+ sql = f"{sql} WHERE {joined}"
378
+ return sql
379
+
380
+ def _compile_derived(self, metric: Metric, warnings: list[str]) -> str:
381
+ if not metric.expression:
382
+ raise MetricCompileError(
383
+ f"metric {metric.name!r} of type 'derived' requires `expression`"
384
+ )
385
+ try:
386
+ parsed = sqlglot.parse_one(metric.expression, dialect=self._dialect)
387
+ except ParseError as exc:
388
+ raise MetricCompileError(
389
+ f"metric {metric.name!r} expression failed to parse: {exc}"
390
+ ) from exc
391
+
392
+ tables: list[tuple[str, str]] = []
393
+ seen: set[tuple[str, str]] = set()
394
+ for table in parsed.find_all(exp.Table):
395
+ name = table.name
396
+ db = table.args.get("db")
397
+ schema = db.name if isinstance(db, exp.Identifier) else None
398
+ qual = f"{schema}.{name}" if schema else name
399
+ resolved = self._resolve_qualified(qual)
400
+ if resolved is None:
401
+ suggestions = _suggest_did_you_mean(qual, self._known_table_qualnames())
402
+ raise MetricCompileError(
403
+ f"metric {metric.name!r} expression references unknown "
404
+ f"table {qual!r}",
405
+ did_you_mean=suggestions,
406
+ )
407
+ if resolved not in seen:
408
+ seen.add(resolved)
409
+ tables.append(resolved)
410
+
411
+ # Warn about table pairs without a graph-backed join edge.
412
+ for i in range(len(tables)):
413
+ for j in range(i + 1, len(tables)):
414
+ a, b = tables[i], tables[j]
415
+ if not self._tables_joinable(a, b):
416
+ warnings.append(
417
+ f"no FK_REFERENCES or INFERRED_JOIN in graph between "
418
+ f"`{a[0]}.{a[1]}` and `{b[0]}.{b[1]}`"
419
+ )
420
+ return metric.expression.strip()
421
+
422
+ def _resolve_qualified(self, qualified: str) -> tuple[str, str] | None:
423
+ schema, table = _split_schema_table(qualified)
424
+ if schema is not None:
425
+ rows = self._store.query_all_rows(
426
+ """
427
+ MATCH (t:SchemaTable)
428
+ WHERE t.connection_name = $cn AND t.database = $db
429
+ AND t.schema_name = $sn AND t.table_name = $tn
430
+ RETURN t.schema_name, t.table_name
431
+ LIMIT 1
432
+ """,
433
+ {
434
+ "cn": self._connection_name,
435
+ "db": self._database_key,
436
+ "sn": schema,
437
+ "tn": table,
438
+ },
439
+ )
440
+ else:
441
+ rows = self._store.query_all_rows(
442
+ """
443
+ MATCH (t:SchemaTable)
444
+ WHERE t.connection_name = $cn AND t.database = $db
445
+ AND t.table_name = $tbl
446
+ RETURN t.schema_name, t.table_name
447
+ """,
448
+ {
449
+ "cn": self._connection_name,
450
+ "db": self._database_key,
451
+ "tbl": table,
452
+ },
453
+ )
454
+ if len(rows) > 1:
455
+ return None
456
+ if not rows:
457
+ return None
458
+ sn, tn = rows[0]
459
+ return (str(sn), str(tn))
460
+
461
+ def _tables_joinable(self, left: tuple[str, str], right: tuple[str, str]) -> bool:
462
+ rows = self._store.query_all_rows(
463
+ """
464
+ MATCH (a:SchemaTable), (b:SchemaTable)
465
+ WHERE a.connection_name = $cn AND a.database = $db
466
+ AND b.connection_name = $cn AND b.database = $db
467
+ AND a.schema_name = $lsa AND a.table_name = $lta
468
+ AND b.schema_name = $rsb AND b.table_name = $rtb
469
+ AND (
470
+ (a)-[:FK_REFERENCES]->(b) OR (b)-[:FK_REFERENCES]->(a)
471
+ OR (a)-[:INFERRED_JOIN]->(b) OR (b)-[:INFERRED_JOIN]->(a)
472
+ )
473
+ RETURN count(*) AS n
474
+ """,
475
+ {
476
+ "cn": self._connection_name,
477
+ "db": self._database_key,
478
+ "lsa": left[0],
479
+ "lta": left[1],
480
+ "rsb": right[0],
481
+ "rtb": right[1],
482
+ },
483
+ )
484
+ if not rows:
485
+ return False
486
+ n = rows[0][0]
487
+ return int(n) > 0 if n is not None else False
@@ -0,0 +1,180 @@
1
+ """``YamlSemanticLayer`` — OSS :class:`SemanticLayer` backed by a YAML file.
2
+
3
+ Loads the Pydantic :class:`pretensor.introspection.models.semantic.SemanticLayer`
4
+ container and answers the :class:`pretensor.semantic.base.SemanticLayer` ABC
5
+ using the loaded definitions plus the indexed Kuzu graph. Query validation is
6
+ delegated to :class:`pretensor.validation.query_validator.QueryValidator`; the
7
+ compile path lives in :mod:`pretensor.semantic.compiler`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from pretensor.core.store import KuzuStore
15
+ from pretensor.introspection.models.semantic import (
16
+ AttributeRole,
17
+ Entity,
18
+ )
19
+ from pretensor.introspection.models.semantic import (
20
+ SemanticLayer as SemanticLayerModel,
21
+ )
22
+ from pretensor.semantic.base import SemanticLayer
23
+ from pretensor.semantic.compiler import MetricSqlCompiler
24
+ from pretensor.validation.query_validator import QueryValidator
25
+
26
+ __all__ = ["YamlSemanticLayer"]
27
+
28
+
29
+ _DEFAULT_DIALECT = "postgres"
30
+
31
+
32
+ class YamlSemanticLayer(SemanticLayer):
33
+ """File-based semantic layer for OSS deployments.
34
+
35
+ Constructed against a loaded :class:`SemanticLayerModel` and an open
36
+ :class:`KuzuStore`. Methods on the ABC return plain ``dict`` / ``list``
37
+ payloads as documented on :class:`SemanticLayer`; no implementation-specific model classes
38
+ leak through.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ layer: SemanticLayerModel,
44
+ *,
45
+ store: KuzuStore,
46
+ database_key: str,
47
+ dialect: str = _DEFAULT_DIALECT,
48
+ ) -> None:
49
+ self._layer = layer
50
+ self._store = store
51
+ self._database_key = database_key
52
+ self._dialect = dialect
53
+ self._validator = QueryValidator(
54
+ store,
55
+ connection_name=layer.connection_name,
56
+ database_key=database_key,
57
+ dialect=dialect,
58
+ )
59
+ self._compiler = MetricSqlCompiler(
60
+ store,
61
+ connection_name=layer.connection_name,
62
+ database_key=database_key,
63
+ dialect=dialect,
64
+ )
65
+
66
+ # ---- accessors --------------------------------------------------------
67
+
68
+ @property
69
+ def layer(self) -> SemanticLayerModel:
70
+ return self._layer
71
+
72
+ @property
73
+ def compiler(self) -> MetricSqlCompiler:
74
+ return self._compiler
75
+
76
+ # ---- SemanticLayer ABC -------------------------------------------------
77
+
78
+ def get_entity(self, entity_id: str) -> dict[str, Any] | None:
79
+ entity = self._find_entity(entity_id)
80
+ if entity is None:
81
+ return None
82
+ return entity.model_dump(mode="json")
83
+
84
+ def get_metric(self, metric_name: str) -> dict[str, Any] | None:
85
+ for domain in self._layer.domains:
86
+ for entity in domain.entities:
87
+ for metric in entity.metrics:
88
+ if metric.name == metric_name:
89
+ payload = metric.model_dump(mode="json")
90
+ payload["entity"] = entity.name
91
+ payload["source_table"] = entity.source_table
92
+ return payload
93
+ return None
94
+
95
+ def get_dimensions(self, entity_id: str) -> list[dict[str, Any]]:
96
+ entity = self._find_entity(entity_id)
97
+ if entity is None:
98
+ return []
99
+ out: list[dict[str, Any]] = []
100
+ for attr in entity.attributes:
101
+ if attr.role in {
102
+ AttributeRole.DIMENSION,
103
+ AttributeRole.TIME_DIMENSION,
104
+ AttributeRole.IDENTIFIER,
105
+ }:
106
+ out.append(attr.model_dump(mode="json"))
107
+ return out
108
+
109
+ def get_rules(self, entity_id: str) -> list[dict[str, Any]]:
110
+ # OSS has no business-rule surface. Extensions override.
111
+ return []
112
+
113
+ def validate_query(self, sql: str, *, connection_name: str) -> dict[str, Any]:
114
+ if connection_name and connection_name != self._layer.connection_name:
115
+ return {
116
+ "valid": False,
117
+ "errors": [
118
+ f"connection {connection_name!r} does not match "
119
+ f"semantic layer connection {self._layer.connection_name!r}"
120
+ ],
121
+ }
122
+ result = self._validator.validate(sql)
123
+ errors: list[str] = []
124
+ errors.extend(result.syntax_errors)
125
+ errors.extend(f"missing_table: {t}" for t in result.missing_tables)
126
+ errors.extend(f"missing_column: {c}" for c in result.missing_columns)
127
+ errors.extend(f"invalid_join: {j.message}" for j in result.invalid_joins)
128
+ return {"valid": result.valid, "errors": errors}
129
+
130
+ def impact_semantic(self, table_node_id: str) -> list[dict[str, Any]]:
131
+ rows = self._store.query_all_rows(
132
+ """
133
+ MATCH (t:SchemaTable {node_id: $nid})
134
+ RETURN t.schema_name, t.table_name
135
+ LIMIT 1
136
+ """,
137
+ {"nid": table_node_id},
138
+ )
139
+ if not rows:
140
+ return []
141
+ sn, tn = rows[0]
142
+ schema_name = str(sn)
143
+ table_name = str(tn)
144
+ qualified_full = f"{schema_name}.{table_name}"
145
+ bare = table_name
146
+ out: list[dict[str, Any]] = []
147
+ for domain in self._layer.domains:
148
+ for entity in domain.entities:
149
+ owns = entity.source_table in (qualified_full, bare)
150
+ for metric in entity.metrics:
151
+ if owns:
152
+ out.append(
153
+ {
154
+ "kind": "metric",
155
+ "metric": metric.name,
156
+ "entity": entity.name,
157
+ "reason": "owns_source_table",
158
+ }
159
+ )
160
+ continue
161
+ expr = metric.expression or ""
162
+ if expr and (qualified_full in expr or f" {bare} " in f" {expr} "):
163
+ out.append(
164
+ {
165
+ "kind": "metric",
166
+ "metric": metric.name,
167
+ "entity": entity.name,
168
+ "reason": "expression_references_table",
169
+ }
170
+ )
171
+ return out
172
+
173
+ # ---- internals --------------------------------------------------------
174
+
175
+ def _find_entity(self, entity_id: str) -> Entity | None:
176
+ for domain in self._layer.domains:
177
+ for entity in domain.entities:
178
+ if entity.name == entity_id:
179
+ return entity
180
+ return None
@@ -0,0 +1,5 @@
1
+ """Agent skill file generation."""
2
+
3
+ from pretensor.skills.generator import SkillGenerator
4
+
5
+ __all__ = ["SkillGenerator"]