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,435 @@
1
+ """SQLite FTS5 keyword search index over SchemaTable and Entity nodes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sqlite3
8
+ from pathlib import Path
9
+
10
+ from pretensor.core.registry import GraphRegistry, RegistryEntry
11
+ from pretensor.core.store import KuzuStore
12
+ from pretensor.search.base import BaseSearchIndex, SearchResult
13
+
14
+ __all__ = ["KeywordSearchIndex", "SearchIndex", "SearchResult"]
15
+
16
+ _INDEX_SCHEMA_VERSION = "2"
17
+ _IDENTIFIER_TOKEN_SPLIT_RE = re.compile(r"[^a-zA-Z0-9]+")
18
+
19
+
20
+ def _graph_path_for_entry(entry: RegistryEntry) -> Path:
21
+ return Path(entry.unified_graph_path or entry.graph_path)
22
+
23
+
24
+ def _camel_to_space(value: str) -> str:
25
+ return re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", value)
26
+
27
+
28
+ def _dedupe_non_empty(values: list[str]) -> list[str]:
29
+ seen: set[str] = set()
30
+ out: list[str] = []
31
+ for raw in values:
32
+ text = str(raw).strip()
33
+ if not text:
34
+ continue
35
+ key = text.lower()
36
+ if key in seen:
37
+ continue
38
+ seen.add(key)
39
+ out.append(text)
40
+ return out
41
+
42
+
43
+ def _identifier_tokens(value: str) -> list[str]:
44
+ pieces = [value, _camel_to_space(value)]
45
+ out: list[str] = []
46
+ for piece in pieces:
47
+ for token in _IDENTIFIER_TOKEN_SPLIT_RE.split(piece.lower()):
48
+ if token:
49
+ out.append(token)
50
+ return _dedupe_non_empty(out)
51
+
52
+
53
+ def _identifier_terms(schema_name: str, table_name: str) -> str:
54
+ fq_name = f"{schema_name}.{table_name}"
55
+ ordered = _dedupe_non_empty(
56
+ [
57
+ fq_name,
58
+ schema_name,
59
+ table_name,
60
+ *_identifier_tokens(schema_name),
61
+ *_identifier_tokens(table_name),
62
+ ]
63
+ )
64
+ return " ".join(ordered)
65
+
66
+
67
+ def _trim_for_tie_breaker(raw: str, *, max_tokens: int = 16) -> str:
68
+ tokens = raw.split()
69
+ if not tokens:
70
+ return ""
71
+ return " ".join(tokens[:max_tokens])
72
+
73
+
74
+ def _sanitize_fts_query(raw: str) -> str:
75
+ """Build a safe FTS5 MATCH expression from free text (token OR token)."""
76
+ cleaned = "".join(c if c.isalnum() or c.isspace() else " " for c in raw)
77
+ tokens = [t for t in cleaned.lower().split() if t]
78
+ if not tokens:
79
+ return '""'
80
+ return " OR ".join(tokens)
81
+
82
+
83
+ def _open_sqlite(path: Path) -> sqlite3.Connection:
84
+ path.parent.mkdir(parents=True, exist_ok=True)
85
+ conn = sqlite3.connect(str(path))
86
+ conn.row_factory = sqlite3.Row
87
+ return conn
88
+
89
+
90
+ class KeywordSearchIndex(BaseSearchIndex):
91
+ """BM25-ranked keyword search backed by SQLite FTS5."""
92
+
93
+ def __init__(self, index_path: Path) -> None:
94
+ self._path = index_path
95
+
96
+ @property
97
+ def path(self) -> Path:
98
+ return self._path
99
+
100
+ @staticmethod
101
+ def default_path(graph_dir: Path) -> Path:
102
+ """Default FTS index location under the graph state directory."""
103
+ return graph_dir / "search_metadata.fts.sqlite"
104
+
105
+ @classmethod
106
+ def needs_rebuild(cls, registry: GraphRegistry, index_path: Path) -> bool:
107
+ """True when the index is missing or older than any registered Kuzu file."""
108
+ if not index_path.exists():
109
+ return True
110
+ if not cls._has_current_schema(index_path):
111
+ return True
112
+ entries = registry.list_entries()
113
+ if not entries:
114
+ return False
115
+ index_mtime = index_path.stat().st_mtime
116
+ for entry in entries:
117
+ gp = _graph_path_for_entry(entry)
118
+ if gp.exists() and gp.stat().st_mtime > index_mtime:
119
+ return True
120
+ return False
121
+
122
+ @classmethod
123
+ def _has_current_schema(cls, index_path: Path) -> bool:
124
+ conn: sqlite3.Connection | None = None
125
+ try:
126
+ conn = sqlite3.connect(str(index_path))
127
+ row = conn.execute(
128
+ "SELECT value FROM meta WHERE key = ?",
129
+ ("schema_version",),
130
+ ).fetchone()
131
+ return bool(row) and str(row[0]) == _INDEX_SCHEMA_VERSION
132
+ except sqlite3.Error:
133
+ return False
134
+ finally:
135
+ if conn is not None:
136
+ conn.close()
137
+
138
+ @classmethod
139
+ def build(cls, registry: GraphRegistry, index_path: Path) -> KeywordSearchIndex:
140
+ """Rebuild the FTS index from all graphs in the registry."""
141
+ index = cls(index_path)
142
+ conn = _open_sqlite(index_path)
143
+ try:
144
+ conn.executescript(
145
+ """
146
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
147
+ DROP TABLE IF EXISTS graph_fts;
148
+ CREATE VIRTUAL TABLE graph_fts USING fts5(
149
+ node_type UNINDEXED,
150
+ name,
151
+ description,
152
+ table_text,
153
+ cluster_context,
154
+ database_name UNINDEXED,
155
+ connection_name UNINDEXED,
156
+ tokenize = 'porter unicode61'
157
+ );
158
+ """
159
+ )
160
+ for entry in registry.list_entries():
161
+ gp = _graph_path_for_entry(entry)
162
+ if not gp.exists():
163
+ continue
164
+ store = KuzuStore(gp)
165
+ try:
166
+ index._ingest_connection(store, entry, conn)
167
+ finally:
168
+ store.close()
169
+ conn.execute(
170
+ "INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)",
171
+ ("schema_version", _INDEX_SCHEMA_VERSION),
172
+ )
173
+ conn.commit()
174
+ finally:
175
+ conn.close()
176
+ return index
177
+
178
+ @classmethod
179
+ def load_or_build(
180
+ cls, registry: GraphRegistry, index_path: Path
181
+ ) -> KeywordSearchIndex:
182
+ """Return a search index, rebuilding automatically when graphs are newer."""
183
+ if cls.needs_rebuild(registry, index_path):
184
+ return cls.build(registry, index_path)
185
+ return cls(index_path)
186
+
187
+ def _ingest_connection(
188
+ self,
189
+ store: KuzuStore,
190
+ entry: RegistryEntry,
191
+ conn: sqlite3.Connection,
192
+ ) -> None:
193
+ table_signals_rows = store.query_all_rows(
194
+ """
195
+ MATCH (t:SchemaTable {connection_name: $cn})-[:HAS_COLUMN]->(c:SchemaColumn)
196
+ RETURN t.schema_name, t.table_name, c.column_name, c.most_common_values_json
197
+ """,
198
+ {"cn": entry.connection_name},
199
+ )
200
+ column_names_by_table: dict[tuple[str, str], list[str]] = {}
201
+ mcv_by_table: dict[tuple[str, str], list[str]] = {}
202
+ for sn, tn, column_name, mcv_json in table_signals_rows:
203
+ key = (str(sn), str(tn))
204
+ column_text = str(column_name).strip()
205
+ if column_text:
206
+ column_names_by_table.setdefault(key, []).append(column_text)
207
+ if not mcv_json:
208
+ continue
209
+ try:
210
+ parsed = json.loads(str(mcv_json))
211
+ except json.JSONDecodeError:
212
+ continue
213
+ if not isinstance(parsed, list):
214
+ continue
215
+ for v in parsed:
216
+ s = str(v).strip()
217
+ if s:
218
+ mcv_by_table.setdefault(key, []).append(s)
219
+
220
+ entity_text_by_table: dict[tuple[str, str], list[str]] = {}
221
+ entity_rows = store.query_all_rows(
222
+ """
223
+ MATCH (e:Entity {connection_name: $cn})-[:REPRESENTS]->(t:SchemaTable {connection_name: $cn})
224
+ RETURN t.schema_name, t.table_name, e.name, e.description
225
+ """,
226
+ {"cn": entry.connection_name},
227
+ )
228
+ for sn, tn, entity_name, entity_desc in entity_rows:
229
+ key = (str(sn), str(tn))
230
+ if entity_name:
231
+ entity_text_by_table.setdefault(key, []).append(str(entity_name))
232
+ if entity_desc and str(entity_desc).strip():
233
+ entity_text_by_table.setdefault(key, []).append(str(entity_desc).strip())
234
+
235
+ cluster_text_by_table: dict[tuple[str, str], list[str]] = {}
236
+ cluster_rows = store.query_all_rows(
237
+ """
238
+ MATCH (t:SchemaTable {connection_name: $cn})-[:IN_CLUSTER]->(c:Cluster)
239
+ RETURN t.schema_name, t.table_name, c.label, c.description
240
+ """,
241
+ {"cn": entry.connection_name},
242
+ )
243
+ for sn, tn, cluster_label, cluster_desc in cluster_rows:
244
+ key = (str(sn), str(tn))
245
+ if cluster_label:
246
+ cluster_text_by_table.setdefault(key, []).append(str(cluster_label))
247
+ if cluster_desc and str(cluster_desc).strip():
248
+ trimmed = _trim_for_tie_breaker(str(cluster_desc).strip())
249
+ if trimmed:
250
+ cluster_text_by_table.setdefault(key, []).append(trimmed)
251
+
252
+ rows = store.query_all_rows(
253
+ """
254
+ MATCH (t:SchemaTable {connection_name: $cn})
255
+ RETURN t.schema_name, t.table_name, t.comment, t.description,
256
+ t.entity_type, t.database,
257
+ COALESCE(t.tags, CAST([] AS STRING[])) AS tags
258
+ """,
259
+ {"cn": entry.connection_name},
260
+ )
261
+ for (
262
+ schema_name,
263
+ table_name,
264
+ comment,
265
+ desc_col,
266
+ entity_type,
267
+ database,
268
+ tags_arr,
269
+ ) in rows:
270
+ key = (str(schema_name), str(table_name))
271
+ parts: list[str] = []
272
+ if comment:
273
+ parts.append(str(comment))
274
+ if desc_col and str(desc_col).strip():
275
+ parts.append(f"description: {str(desc_col).strip()}")
276
+ if isinstance(tags_arr, (list, tuple)) and tags_arr:
277
+ parts.append(
278
+ "tags: " + " ".join(str(x) for x in tags_arr if str(x).strip())
279
+ )
280
+ if entity_type:
281
+ parts.append(f"entity_type: {entity_type}")
282
+ mcv_extra = _dedupe_non_empty(mcv_by_table.get(key, []))
283
+ if mcv_extra:
284
+ parts.append("common_values: " + " ".join(mcv_extra))
285
+ desc = " ".join(parts)
286
+
287
+ table_text_parts: list[str] = []
288
+ table_text_parts.append(
289
+ "identifier: "
290
+ + _identifier_terms(str(schema_name), str(table_name))
291
+ )
292
+ column_terms = _dedupe_non_empty(column_names_by_table.get(key, []))
293
+ if column_terms:
294
+ column_tokens: list[str] = []
295
+ for col in column_terms:
296
+ column_tokens.extend(_identifier_tokens(col))
297
+ table_text_parts.append(
298
+ "columns: "
299
+ + " ".join(_dedupe_non_empty([*column_terms, *column_tokens]))
300
+ )
301
+ entity_terms = _dedupe_non_empty(entity_text_by_table.get(key, []))
302
+ if entity_terms:
303
+ table_text_parts.append("entity: " + " ".join(entity_terms))
304
+ table_text = " ".join(table_text_parts)
305
+
306
+ cluster_terms = _dedupe_non_empty(cluster_text_by_table.get(key, []))
307
+ cluster_context = ""
308
+ if cluster_terms:
309
+ cluster_context = "cluster: " + " ".join(cluster_terms)
310
+
311
+ fq = f"{schema_name}.{table_name}"
312
+ conn.execute(
313
+ """
314
+ INSERT INTO graph_fts(
315
+ node_type, name, description, table_text, cluster_context,
316
+ database_name, connection_name
317
+ )
318
+ VALUES (?, ?, ?, ?, ?, ?, ?)
319
+ """,
320
+ (
321
+ "SchemaTable",
322
+ fq,
323
+ desc,
324
+ table_text,
325
+ cluster_context,
326
+ str(database or entry.database),
327
+ entry.connection_name,
328
+ ),
329
+ )
330
+
331
+ erows = store.query_all_rows(
332
+ """
333
+ MATCH (e:Entity {connection_name: $cn})
334
+ RETURN e.name, e.description, e.database
335
+ """,
336
+ {"cn": entry.connection_name},
337
+ )
338
+ for name, description, database in erows:
339
+ conn.execute(
340
+ """
341
+ INSERT INTO graph_fts(
342
+ node_type, name, description, table_text, cluster_context,
343
+ database_name, connection_name
344
+ )
345
+ VALUES (?, ?, ?, ?, ?, ?, ?)
346
+ """,
347
+ (
348
+ "Entity",
349
+ str(name),
350
+ str(description or ""),
351
+ "",
352
+ "",
353
+ str(database or entry.database),
354
+ entry.connection_name,
355
+ ),
356
+ )
357
+
358
+ def search(
359
+ self,
360
+ q: str,
361
+ *,
362
+ db: str | None = None,
363
+ limit: int = 10,
364
+ ) -> list[SearchResult]:
365
+ """Run BM25 search; optional ``db`` filters by connection or logical database name."""
366
+ if not self._path.exists():
367
+ return []
368
+ conn = sqlite3.connect(str(self._path))
369
+ try:
370
+ match_expr = _sanitize_fts_query(q)
371
+ sql = """
372
+ SELECT node_type, name, description, database_name, connection_name,
373
+ bm25(graph_fts, 0.0, 1.0, 1.0, 1.4, 0.2, 0.0, 0.0) AS score
374
+ FROM graph_fts
375
+ WHERE graph_fts MATCH ?
376
+ """
377
+ params: list[object] = [match_expr]
378
+ if db is not None:
379
+ sql += " AND (connection_name = ? OR database_name = ?)"
380
+ params.extend([db, db])
381
+ # bm25() is lower for better matches in SQLite FTS5
382
+ sql += " ORDER BY score ASC LIMIT ?"
383
+ params.append(limit)
384
+ cur = conn.execute(sql, params)
385
+ out: list[SearchResult] = []
386
+ for row in cur.fetchall():
387
+ out.append(
388
+ SearchResult(
389
+ node_type=str(row[0]),
390
+ name=str(row[1]),
391
+ database_name=str(row[3]),
392
+ connection_name=str(row[4]),
393
+ description=str(row[2] or ""),
394
+ score=float(row[5]),
395
+ )
396
+ )
397
+ return out
398
+ finally:
399
+ conn.close()
400
+
401
+ def similar(
402
+ self,
403
+ name: str,
404
+ *,
405
+ db: str | None = None,
406
+ limit: int = 10,
407
+ ) -> list[SearchResult]:
408
+ """Return nodes related to *name* via keyword proximity in the FTS index.
409
+
410
+ Uses the node's own name as the search query so that nodes sharing
411
+ significant tokens (schema prefix, entity type, common words) rank
412
+ highly. Results matching *name* exactly are excluded.
413
+
414
+ Args:
415
+ name: Fully-qualified node name, e.g. ``schema.table``.
416
+ db: Optional connection or logical database name filter.
417
+ limit: Maximum number of results to return.
418
+
419
+ Returns:
420
+ Related nodes ranked by BM25 proximity.
421
+ """
422
+ candidates = self.search(name, db=db, limit=limit + 1)
423
+ return [r for r in candidates if r.name != name][:limit]
424
+
425
+ def index_graph(self, registry: GraphRegistry) -> None:
426
+ """Rebuild the FTS index from all graphs in *registry*.
427
+
428
+ Args:
429
+ registry: Loaded registry describing available graph files.
430
+ """
431
+ self.build(registry, self._path)
432
+
433
+
434
+ # Backward-compatible alias — existing imports of ``SearchIndex`` keep working.
435
+ SearchIndex = KeywordSearchIndex
@@ -0,0 +1,24 @@
1
+ """Semantic layer extension point for Pretensor.
2
+
3
+ OSS ships ``NullSemanticLayer`` (all methods return None/empty).
4
+ Downstream implementations override ``SemanticLayer`` to populate the
5
+ reserved Kuzu node types (``Metric``, ``Dimension``, ``BusinessRule``)
6
+ and their associated edge types.
7
+ """
8
+
9
+ from pretensor.semantic.base import NullSemanticLayer, SemanticLayer
10
+ from pretensor.semantic.compiler import (
11
+ CompiledMetric,
12
+ MetricCompileError,
13
+ MetricSqlCompiler,
14
+ )
15
+ from pretensor.semantic.yaml_layer import YamlSemanticLayer
16
+
17
+ __all__ = [
18
+ "CompiledMetric",
19
+ "MetricCompileError",
20
+ "MetricSqlCompiler",
21
+ "NullSemanticLayer",
22
+ "SemanticLayer",
23
+ "YamlSemanticLayer",
24
+ ]
@@ -0,0 +1,123 @@
1
+ """SemanticLayer interface and NullSemanticLayer stub.
2
+
3
+ The ``SemanticLayer`` ABC defines the extension point for semantic-layer
4
+ implementations. ``NullSemanticLayer`` is the default no-op — all methods
5
+ return ``None`` or empty collections so graph operations and MCP tool
6
+ responses compile and run without any semantic-layer implementation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from typing import Any
13
+
14
+ __all__ = ["SemanticLayer", "NullSemanticLayer"]
15
+
16
+
17
+ class SemanticLayer(ABC):
18
+ """Abstract interface for the Pretensor semantic layer.
19
+
20
+ Downstream implementations populate this with business-level knowledge
21
+ (metrics, dimensions, business rules). OSS callers always receive
22
+ ``NullSemanticLayer``, which returns ``None`` or empty collections
23
+ for every method.
24
+
25
+ All methods are intentionally narrow: they accept identifiers (strings
26
+ or simple primitives) and return plain Python types so callers never
27
+ depend on implementation-specific model classes.
28
+ """
29
+
30
+ @abstractmethod
31
+ def get_entity(self, entity_id: str) -> dict[str, Any] | None:
32
+ """Return business entity metadata by ID.
33
+
34
+ Args:
35
+ entity_id: Canonical entity identifier (e.g. ``"customers"``).
36
+
37
+ Returns:
38
+ Mapping of entity attributes, or ``None`` if not found.
39
+ """
40
+
41
+ @abstractmethod
42
+ def get_metric(self, metric_name: str) -> dict[str, Any] | None:
43
+ """Return a named metric definition.
44
+
45
+ Args:
46
+ metric_name: Unique metric name (e.g. ``"monthly_revenue"``).
47
+
48
+ Returns:
49
+ Mapping with metric metadata and SQL template, or ``None``.
50
+ """
51
+
52
+ @abstractmethod
53
+ def get_dimensions(self, entity_id: str) -> list[dict[str, Any]]:
54
+ """Return dimension definitions associated with an entity.
55
+
56
+ Args:
57
+ entity_id: Canonical entity identifier.
58
+
59
+ Returns:
60
+ List of dimension attribute mappings; empty list if none exist.
61
+ """
62
+
63
+ @abstractmethod
64
+ def get_rules(self, entity_id: str) -> list[dict[str, Any]]:
65
+ """Return business rules that apply to an entity.
66
+
67
+ Args:
68
+ entity_id: Canonical entity identifier.
69
+
70
+ Returns:
71
+ List of business rule mappings; empty list if none exist.
72
+ """
73
+
74
+ @abstractmethod
75
+ def validate_query(self, sql: str, *, connection_name: str) -> dict[str, Any]:
76
+ """Validate a SQL query against the semantic layer.
77
+
78
+ Args:
79
+ sql: SQL statement to validate.
80
+ connection_name: Target connection for dialect and schema context.
81
+
82
+ Returns:
83
+ Mapping with at minimum ``{"valid": bool, "errors": list[str]}``.
84
+ """
85
+
86
+ @abstractmethod
87
+ def impact_semantic(self, table_node_id: str) -> list[dict[str, Any]]:
88
+ """Return semantic objects impacted by changes to a table.
89
+
90
+ Args:
91
+ table_node_id: Kuzu node ID of the affected ``SchemaTable``.
92
+
93
+ Returns:
94
+ List of impacted semantic object descriptors (metrics, rules,
95
+ dimensions); empty list if none exist.
96
+ """
97
+
98
+
99
+ class NullSemanticLayer(SemanticLayer):
100
+ """No-op semantic layer for OSS deployments.
101
+
102
+ Every method returns ``None`` or an empty collection. This lets the
103
+ graph core and MCP tools reference a ``SemanticLayer`` without
104
+ requiring external credentials or implementations.
105
+ """
106
+
107
+ def get_entity(self, entity_id: str) -> dict[str, Any] | None:
108
+ return None
109
+
110
+ def get_metric(self, metric_name: str) -> dict[str, Any] | None:
111
+ return None
112
+
113
+ def get_dimensions(self, entity_id: str) -> list[dict[str, Any]]:
114
+ return []
115
+
116
+ def get_rules(self, entity_id: str) -> list[dict[str, Any]]:
117
+ return []
118
+
119
+ def validate_query(self, sql: str, *, connection_name: str) -> dict[str, Any]:
120
+ return {"valid": True, "errors": []}
121
+
122
+ def impact_semantic(self, table_node_id: str) -> list[dict[str, Any]]:
123
+ return []