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,383 @@
1
+ """Weighted multi-signal table classification for schema intelligence.
2
+
3
+ Scores naming patterns, FK fan-out/fan-in, column composition, row cardinality,
4
+ and optional usage counters. Returns a single :class:`TableClassification` with
5
+ role, confidence in ``[0, 1]``, and human-readable signals.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import Literal
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ __all__ = [
18
+ "TABLE_ROLES",
19
+ "TableClassification",
20
+ "TableClassifier",
21
+ "TableClassifierInput",
22
+ "TableRole",
23
+ "is_entity_extraction_candidate",
24
+ ]
25
+
26
+ TABLE_ROLES = (
27
+ "system",
28
+ "staging",
29
+ "audit",
30
+ "junction",
31
+ "fact",
32
+ "dimension",
33
+ "bridge",
34
+ "aggregate",
35
+ "snapshot_scd",
36
+ "entity_candidate",
37
+ "unknown",
38
+ )
39
+
40
+ TableRole = Literal[
41
+ "system",
42
+ "staging",
43
+ "audit",
44
+ "junction",
45
+ "fact",
46
+ "dimension",
47
+ "bridge",
48
+ "aggregate",
49
+ "snapshot_scd",
50
+ "entity_candidate",
51
+ "unknown",
52
+ ]
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class TableClassification:
57
+ """Result of :meth:`TableClassifier.classify`."""
58
+
59
+ role: TableRole
60
+ confidence: float
61
+ signals: tuple[str, ...] = ()
62
+
63
+
64
+ class TableClassifierInput(BaseModel):
65
+ """Table shape used by :class:`TableClassifier`."""
66
+
67
+ name: str = Field(description="Physical table name (no schema).")
68
+ schema_name: str = Field(default="public", description="Schema qualifier.")
69
+ columns: list[str] = Field(
70
+ default_factory=list, description="Column names in declaration order."
71
+ )
72
+ row_count: int | None = Field(default=None, description="Optional row estimate.")
73
+ fk_out_degree: int = Field(
74
+ default=0, ge=0, description="Count of outgoing FK_REFERENCES from this table."
75
+ )
76
+ fk_in_degree: int = Field(
77
+ default=0, ge=0, description="Count of incoming FK_REFERENCES to this table."
78
+ )
79
+ seq_scan_count: int | None = Field(
80
+ default=None, description="Optional sequential scan count (usage signal)."
81
+ )
82
+ idx_scan_count: int | None = Field(
83
+ default=None, description="Optional index scan count (usage signal)."
84
+ )
85
+ insert_count: int | None = Field(default=None)
86
+ update_count: int | None = Field(default=None)
87
+
88
+
89
+ _SNAKE_WORD = re.compile(r"^[a-z][a-z0-9]*$")
90
+ _ID_COLUMN = re.compile(r"^id$", re.IGNORECASE)
91
+ _SURROGATE_KEY = re.compile(r"^[a-z][a-z0-9_]*_id$", re.IGNORECASE)
92
+ _FK_SUFFIX = re.compile(r"_id$", re.IGNORECASE)
93
+ _DATE_TOKEN = re.compile(
94
+ r"(^|_)(date|time|timestamp|day|month|year)($|_)", re.IGNORECASE
95
+ )
96
+
97
+
98
+ class TableClassifier:
99
+ """Classify a table using weighted heuristics (no graph-wide context)."""
100
+
101
+ def classify(
102
+ self,
103
+ table: TableClassifierInput,
104
+ *,
105
+ embedding_vote: dict[str, float] | None = None,
106
+ role_weight: float = 0.0,
107
+ ) -> TableClassification:
108
+ """Return the highest-scoring role with confidence and contributing signals.
109
+
110
+ Args:
111
+ table: Per-table input (name, columns, structural counts).
112
+ embedding_vote: Optional per-role cosine score from
113
+ :func:`pretensor.intelligence.role_exemplars.embedding_role_vote`.
114
+ When provided AND ``role_weight > 0``, each role's heuristic
115
+ score is incremented by ``role_weight * vote[role]`` before
116
+ the ``max()`` pick. Default ``None`` is byte-identical to
117
+ pre-embedding behavior.
118
+ role_weight: Blend weight for the embedding vote. ``0.0`` (default)
119
+ discards the vote entirely → null path. Heuristic scores
120
+ typically range 0.5–2.5; ``role_weight=0.25`` keeps the
121
+ embedding contribution as a tiebreaker.
122
+ """
123
+ name = table.name.strip()
124
+ lower = name.lower()
125
+ cols = table.columns
126
+ weights = self._score_all(table, name, lower, cols)
127
+ if embedding_vote is not None and role_weight > 0.0:
128
+ for role, vote in embedding_vote.items():
129
+ if role in weights:
130
+ weights[role] += role_weight * float(vote)
131
+ best_role, raw = max(weights.items(), key=lambda kv: kv[1])
132
+ second = sorted(weights.values(), reverse=True)
133
+ runner = second[1] if len(second) > 1 else 0.0
134
+ margin = raw - runner
135
+ confidence = self._confidence_from_score(raw, margin)
136
+ signals = self._signals_for_winner(table, lower, cols, best_role)
137
+ return TableClassification(
138
+ role=best_role, confidence=confidence, signals=tuple(signals)
139
+ )
140
+
141
+ def _confidence_from_score(self, raw: float, margin: float) -> float:
142
+ """Map unbounded score and margin to ``[0, 1]``."""
143
+ base = 1.0 - math.exp(-raw / 1.4)
144
+ boost = min(0.35, margin * 0.45)
145
+ return max(0.0, min(1.0, base + boost))
146
+
147
+ def _score_all(
148
+ self,
149
+ table: TableClassifierInput,
150
+ name: str,
151
+ lower: str,
152
+ cols: list[str],
153
+ ) -> dict[TableRole, float]:
154
+ out: dict[TableRole, float] = {r: 0.0 for r in TABLE_ROLES}
155
+ out["system"] = self._score_system(lower)
156
+ out["staging"] = self._score_staging(lower)
157
+ out["audit"] = self._score_audit(lower)
158
+ j_score, _ = self._junction_signals(name, cols)
159
+ out["junction"] = j_score
160
+ struct = self._score_structure(table, cols)
161
+ for role, v in struct.items():
162
+ out[role] += v
163
+ out["dimension"] += self._score_dimension_name(lower)
164
+ out["fact"] += self._score_fact_name(lower)
165
+ out["aggregate"] += self._score_aggregate_name(lower)
166
+ out["snapshot_scd"] += self._score_snapshot_name(lower)
167
+ out["bridge"] += self._score_bridge_name(lower)
168
+ out["entity_candidate"] += self._score_entity_fallback(cols)
169
+ if max(out.values()) < 0.15:
170
+ out["unknown"] = 0.25
171
+ return out
172
+
173
+ def _score_system(self, lower_name: str) -> float:
174
+ if lower_name in ("migrations", "schema_migrations"):
175
+ return 2.5
176
+ if lower_name.startswith("ar_internal_"):
177
+ return 2.5
178
+ if lower_name.startswith("pg_"):
179
+ return 2.5
180
+ return 0.0
181
+
182
+ def _score_staging(self, lower_name: str) -> float:
183
+ if (
184
+ lower_name.startswith("stg_")
185
+ or lower_name.startswith("raw_")
186
+ or lower_name.startswith("tmp_")
187
+ or lower_name.startswith("staging_")
188
+ ):
189
+ return 2.2
190
+ return 0.0
191
+
192
+ def _score_audit(self, lower_name: str) -> float:
193
+ if lower_name.endswith(("_audit", "_log", "_history", "_changelog")):
194
+ return 2.0
195
+ return 0.0
196
+
197
+ def _junction_signals(
198
+ self, name: str, columns: list[str]
199
+ ) -> tuple[float, list[str]]:
200
+ """Score junction pattern; dim_/fact_ names are excluded."""
201
+ sigs: list[str] = []
202
+ lower = name.lower()
203
+ if lower.startswith("dim_") or lower.startswith("fact_"):
204
+ return 0.0, sigs
205
+ parts = name.split("_")
206
+ if len(parts) != 2:
207
+ return 0.0, sigs
208
+ a, b = parts
209
+ if not _SNAKE_WORD.match(a) or not _SNAKE_WORD.match(b):
210
+ return 0.0, sigs
211
+ if any(_ID_COLUMN.match(c.strip()) for c in columns):
212
+ return 0.0, sigs
213
+ n = len(columns)
214
+ if 2 <= n <= 3:
215
+ sigs.append("two-part snake name with 2–3 columns (junction pattern)")
216
+ return 1.8, sigs
217
+ return 0.0, sigs
218
+
219
+ def _score_structure(
220
+ self, table: TableClassifierInput, cols: list[str]
221
+ ) -> dict[TableRole, float]:
222
+ fk_out = table.fk_out_degree
223
+ fk_in = table.fk_in_degree
224
+ ncols = len(cols)
225
+ fk_cols = sum(1 for c in cols if _FK_SUFFIX.search(c.strip() or ""))
226
+ date_cols = sum(1 for c in cols if _DATE_TOKEN.search(c.strip() or ""))
227
+ row_count = table.row_count
228
+
229
+ scores: dict[TableRole, float] = {
230
+ "fact": 0.0,
231
+ "staging": 0.0,
232
+ "dimension": 0.0,
233
+ "bridge": 0.0,
234
+ "aggregate": 0.0,
235
+ "snapshot_scd": 0.0,
236
+ "entity_candidate": 0.0,
237
+ }
238
+ if fk_out >= 3:
239
+ scores["fact"] += 0.55 + min(0.45, (fk_out - 3) * 0.12)
240
+ elif fk_out == 2:
241
+ scores["fact"] += 0.25
242
+ if fk_in >= 2 and fk_out <= 1:
243
+ scores["dimension"] += 0.4 + min(0.35, (fk_in - 2) * 0.12)
244
+ if fk_out >= 2 and fk_in >= 2:
245
+ scores["bridge"] += 0.85 + min(0.4, (min(fk_out, fk_in) - 2) * 0.1)
246
+ if fk_cols >= 3 and fk_out >= 2:
247
+ scores["fact"] += 0.15
248
+ if ncols >= 2 and fk_cols <= 1 and fk_out <= 1:
249
+ scores["dimension"] += 0.2
250
+ if date_cols >= 2 and fk_out >= 1:
251
+ scores["snapshot_scd"] += 0.5
252
+ if row_count is not None and row_count > 500_000 and fk_out >= 2:
253
+ scores["fact"] += 0.2
254
+ if row_count is not None and row_count < 50_000 and fk_in >= 1 and fk_out <= 1:
255
+ scores["dimension"] += 0.12
256
+ if "total" in " ".join(c.lower() for c in cols) or any(
257
+ c.lower().startswith(("sum_", "avg_", "count_")) for c in cols
258
+ ):
259
+ scores["aggregate"] += 0.55
260
+ scores["entity_candidate"] += 0.08 * min(ncols, 8) / 8.0
261
+ self._apply_usage_scores(table, scores)
262
+ self._apply_seq_idx_ratio(table, scores)
263
+ return scores
264
+
265
+ def _apply_usage_scores(
266
+ self, table: TableClassifierInput, scores: dict[TableRole, float]
267
+ ) -> None:
268
+ seq = table.seq_scan_count
269
+ ins = table.insert_count
270
+ upd = table.update_count
271
+ if seq is None and ins is None and upd is None:
272
+ return
273
+ writes = (ins or 0) + (upd or 0)
274
+ reads = seq or 0
275
+ if writes > 500 and reads > 0 and writes > reads * 0.15:
276
+ scores["fact"] += min(0.35, math.log1p(writes) / 25.0)
277
+
278
+ def _apply_seq_idx_ratio(
279
+ self, table: TableClassifierInput, scores: dict[TableRole, float]
280
+ ) -> None:
281
+ """Boost fact/staging when sequential scans dominate on a large table."""
282
+ seq = table.seq_scan_count
283
+ idx = table.idx_scan_count
284
+ if seq is None and idx is None:
285
+ return
286
+ s = seq or 0
287
+ i = idx or 0
288
+ total = s + i
289
+ if total <= 0:
290
+ return
291
+ rc = table.row_count
292
+ if rc is None or rc <= 10_000:
293
+ return
294
+ if s / total > 0.9:
295
+ scores["fact"] += 0.15
296
+ scores["staging"] += 0.15
297
+
298
+ def _score_dimension_name(self, lower: str) -> float:
299
+ if lower.startswith("dim_") or lower.endswith("_dim") or "_dim_" in lower:
300
+ return 1.1
301
+ return 0.0
302
+
303
+ def _score_fact_name(self, lower: str) -> float:
304
+ if lower.startswith("fact_") or lower.startswith("fct_"):
305
+ return 1.1
306
+ if lower.endswith("_fact") or "_fact_" in lower:
307
+ return 0.85
308
+ return 0.0
309
+
310
+ def _score_aggregate_name(self, lower: str) -> float:
311
+ if lower.startswith(("agg_", "aggregate_", "summary_", "rollup_")):
312
+ return 1.2
313
+ return 0.0
314
+
315
+ def _score_snapshot_name(self, lower: str) -> float:
316
+ if "snapshot" in lower or lower.startswith("snap_") or "_scd" in lower:
317
+ return 1.0
318
+ return 0.0
319
+
320
+ def _score_bridge_name(self, lower: str) -> float:
321
+ if (
322
+ lower.startswith("bridge_")
323
+ or "_bridge_" in lower
324
+ or lower.endswith("_bridge")
325
+ ):
326
+ return 1.0
327
+ return 0.0
328
+
329
+ def _score_entity_fallback(self, cols: list[str]) -> float:
330
+ for c in cols:
331
+ s = c.strip()
332
+ if _ID_COLUMN.match(s) or _SURROGATE_KEY.match(s):
333
+ return 0.35
334
+ return 0.05
335
+
336
+ def _signals_for_winner(
337
+ self,
338
+ table: TableClassifierInput,
339
+ lower: str,
340
+ cols: list[str],
341
+ role: TableRole,
342
+ ) -> list[str]:
343
+ sigs: list[str] = []
344
+ if table.fk_out_degree:
345
+ sigs.append(f"FK out-degree {table.fk_out_degree}")
346
+ if table.fk_in_degree:
347
+ sigs.append(f"FK in-degree {table.fk_in_degree}")
348
+ if table.row_count is not None:
349
+ sigs.append(f"row_count {table.row_count}")
350
+ seq = table.seq_scan_count
351
+ idx = table.idx_scan_count
352
+ rc_sig = table.row_count
353
+ if (
354
+ role in ("fact", "staging")
355
+ and (seq is not None or idx is not None)
356
+ and rc_sig is not None
357
+ and rc_sig > 10_000
358
+ ):
359
+ s, i = seq or 0, idx or 0
360
+ tot = s + i
361
+ if tot > 0 and s / tot > 0.9:
362
+ sigs.append("high seq vs idx scan ratio (large table)")
363
+ j_score, j_sigs = self._junction_signals(table.name.strip(), cols)
364
+ if role == "junction" and j_sigs:
365
+ sigs.extend(j_sigs)
366
+ if role == "dimension" and (lower.startswith("dim_") or "_dim" in lower):
367
+ sigs.append("dimension name prefix/pattern")
368
+ if role == "fact" and (lower.startswith("fact_") or lower.startswith("fct_")):
369
+ sigs.append("fact name prefix")
370
+ if role == "aggregate":
371
+ sigs.append("aggregate naming or measure-like columns")
372
+ if role == "snapshot_scd":
373
+ sigs.append("snapshot / SCD naming or multiple date columns")
374
+ if role == "bridge":
375
+ sigs.append("high FK fan-in and fan-out (bridge pattern)")
376
+ if role == "unknown":
377
+ sigs.append("weak signal mix")
378
+ return sigs[:8] if sigs else ["heuristic winner"]
379
+
380
+
381
+ def is_entity_extraction_candidate(classification: TableClassification) -> bool:
382
+ """Whether a table should be offered to LLM entity grouping."""
383
+ return classification.role in ("entity_candidate", "dimension", "fact")
@@ -0,0 +1,66 @@
1
+ """LLM entity extraction Protocol and null client (extension point)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Protocol
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from pretensor.connectors.models import SchemaSnapshot
11
+ from pretensor.observability import run_timed_async
12
+
13
+ __all__ = [
14
+ "ExtractedEntity",
15
+ "LLMEntityExtractor",
16
+ "LlmEntityClient",
17
+ "NullLlmEntityClient",
18
+ ]
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ExtractedEntity(BaseModel):
24
+ """One business entity grouping physical tables."""
25
+
26
+ name: str
27
+ description: str = ""
28
+ tables: list[str] = Field(
29
+ default_factory=list,
30
+ description="Table refs: schema.name or bare name.",
31
+ )
32
+
33
+
34
+ class LlmEntityClient(Protocol):
35
+ """Async client returning YAML entity extraction (implement per provider)."""
36
+
37
+ async def extract_entities(self, *, prompt: str) -> str:
38
+ """Return YAML body (list of entity dicts)."""
39
+ ...
40
+
41
+
42
+ class NullLlmEntityClient:
43
+ """No-op client: returns an empty YAML list."""
44
+
45
+ async def extract_entities(self, *, prompt: str) -> str:
46
+ async def _empty_yaml() -> str:
47
+ return "[]"
48
+
49
+ return await run_timed_async(
50
+ logger,
51
+ event="llm.extract_entities",
52
+ callback=_empty_yaml,
53
+ provider="null",
54
+ prompt_chars=len(prompt),
55
+ )
56
+
57
+
58
+ class LLMEntityExtractor:
59
+ """Runs entity extraction using a :class:`LlmEntityClient`."""
60
+
61
+ def __init__(self, client: LlmEntityClient | None = None) -> None:
62
+ self._client = client or NullLlmEntityClient()
63
+
64
+ async def extract(self, snapshot: SchemaSnapshot) -> list[ExtractedEntity]:
65
+ """Return extracted entities (empty in OSS; plugins wire a real client)."""
66
+ return []
pretensor/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """Root exception hierarchy for the pretensor package.
2
+
3
+ All internal errors raised by pretensor modules are subclasses of
4
+ :class:`PretensorError`. Callers that want to catch any pretensor-specific
5
+ failure without enumerating every subclass can use a single ``except
6
+ PretensorError`` clause. Subclasses may additionally inherit from a Python
7
+ builtin (e.g. ``ValueError``, ``KeyError``) so that existing ``except
8
+ <Builtin>`` sites keep working without any changes.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __all__ = ["PretensorError", "ConnectorError"]
14
+
15
+
16
+ class PretensorError(Exception):
17
+ """Base class for all exceptions raised by the pretensor library.
18
+
19
+ Catching this class is sufficient to handle any error that originates
20
+ inside pretensor code (as opposed to errors propagated from third-party
21
+ libraries such as SQLAlchemy or Fernet, which are left unwrapped unless
22
+ pretensor re-raises them explicitly).
23
+ """
24
+
25
+
26
+ class ConnectorError(PretensorError):
27
+ """Raised when a database connector operation fails.
28
+
29
+ This is the single shared base for all connector-level errors across
30
+ every supported database backend (PostgreSQL, MySQL, Snowflake, BigQuery,
31
+ etc.). Both ``pretensor.connectors.postgres`` and
32
+ ``pretensor.connectors.mysql`` re-export this class under the name
33
+ ``ConnectorError`` so that code importing from either module continues
34
+ to work unchanged while now sharing the same exception identity.
35
+ """
@@ -0,0 +1,17 @@
1
+ """Graph-layer data models (distinct from pipeline schema models)."""
2
+
3
+ from pretensor.graph_models.consumer import ConsumesEdge, ExternalConsumerNode
4
+ from pretensor.graph_models.edge import GraphEdge, LineageEdge
5
+ from pretensor.graph_models.entity import EntityNode
6
+ from pretensor.graph_models.node import GraphNode
7
+ from pretensor.graph_models.relationship import RelationshipCandidate
8
+
9
+ __all__ = [
10
+ "ConsumesEdge",
11
+ "EntityNode",
12
+ "ExternalConsumerNode",
13
+ "GraphEdge",
14
+ "GraphNode",
15
+ "LineageEdge",
16
+ "RelationshipCandidate",
17
+ ]
@@ -0,0 +1,11 @@
1
+ """Shared base model for graph package Pydantic types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ __all__ = ["GraphModel"]
8
+
9
+
10
+ class GraphModel(BaseModel):
11
+ model_config = ConfigDict(frozen=True)
@@ -0,0 +1,71 @@
1
+ """Graph models for external code consumers of physical tables.
2
+
3
+ ``ExternalConsumerNode`` is a code location (service + repo-relative file + a
4
+ normalized SQL fingerprint) that issues a SQL statement; ``ConsumesEdge`` links
5
+ it to the ``SchemaTable`` it reads or writes. Raw SQL text is never carried on
6
+ either model — only the ``sql_fingerprint`` and resolved endpoints.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pydantic import Field
12
+
13
+ from pretensor.graph_models.base import GraphModel
14
+
15
+ __all__ = ["ExternalConsumerNode", "ConsumesEdge"]
16
+
17
+
18
+ class ExternalConsumerNode(GraphModel):
19
+ """A code location that consumes one or more physical tables via SQL."""
20
+
21
+ node_id: str = Field(
22
+ description="Stable primary key in the Kuzu ExternalConsumer node table."
23
+ )
24
+ connection_name: str = Field(
25
+ description="Pretensor connection whose tables this consumer resolves against."
26
+ )
27
+ service_name: str = Field(
28
+ description="Consumer-service label (defaults to the scanned repo basename)."
29
+ )
30
+ file_path: str = Field(
31
+ description="Repo-relative path of the source file (never absolute)."
32
+ )
33
+ language: str = Field(description="Source language, e.g. 'python'.")
34
+ symbol: str = Field(
35
+ description="Enclosing identifier: assigned variable or called method."
36
+ )
37
+ kind: str = Field(
38
+ description="Extraction context: 'assignment', 'call', 'fstring', or 'sql_file'."
39
+ )
40
+ line_start: int
41
+ line_end: int
42
+ sql_fingerprint: str = Field(
43
+ description="sha256[:16] of the normalized SQL. NOT the raw SQL text."
44
+ )
45
+ confidence: float = 1.0
46
+ dialect_used: str = Field(
47
+ default="",
48
+ description="sqlglot dialect that parsed the statement (empty if none).",
49
+ )
50
+ scan_run_id: str = Field(
51
+ description="Idempotency key for the scan that wrote this node."
52
+ )
53
+
54
+
55
+ class ConsumesEdge(GraphModel):
56
+ """Directed edge from an ExternalConsumer to a table it reads or writes."""
57
+
58
+ edge_id: str = Field(
59
+ description="Stable primary key in the Kuzu CONSUMES rel table."
60
+ )
61
+ source_node_id: str = Field(description="ExternalConsumer node id (FROM).")
62
+ target_node_id: str = Field(description="SchemaTable node id (TO).")
63
+ op: str = Field(description="Access kind: 'read' or 'write'.")
64
+ source: str = Field(
65
+ default="analyze",
66
+ description="Provenance label for the edge (code-scanner = 'analyze').",
67
+ )
68
+ confidence: float = 1.0
69
+ scan_run_id: str = Field(
70
+ description="Idempotency key for the scan that wrote this edge."
71
+ )
@@ -0,0 +1,35 @@
1
+ """Graph edge payloads for FK relationships and table-level lineage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import Field
6
+
7
+ from pretensor.graph_models.base import GraphModel
8
+
9
+ __all__ = ["GraphEdge", "LineageEdge"]
10
+
11
+
12
+ class GraphEdge(GraphModel):
13
+ """A directed foreign-key edge between two table nodes."""
14
+
15
+ edge_id: str = Field(
16
+ description="Stable primary key in the Kuzu FK edge rel table."
17
+ )
18
+ source_node_id: str
19
+ target_node_id: str
20
+ source_column: str
21
+ target_column: str
22
+ constraint_name: str | None = None
23
+
24
+
25
+ class LineageEdge(GraphModel):
26
+ """Directed lineage from a source table to a derived or dependent table."""
27
+
28
+ edge_id: str = Field(description="Stable primary key in the Kuzu LINEAGE rel table.")
29
+ source_node_id: str
30
+ target_node_id: str
31
+ source: str = Field(
32
+ description="Provenance label (e.g. connector + object name), not SQL text."
33
+ )
34
+ lineage_type: str
35
+ confidence: float = 1.0
@@ -0,0 +1,21 @@
1
+ """Business entity node payload for Kuzu."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import Field
6
+
7
+ from pretensor.graph_models.base import GraphModel
8
+
9
+ __all__ = ["EntityNode"]
10
+
11
+
12
+ class EntityNode(GraphModel):
13
+ """A named business entity linked to one or more ``SchemaTable`` nodes."""
14
+
15
+ node_id: str = Field(
16
+ description="Stable primary key in the Kuzu Entity node table."
17
+ )
18
+ connection_name: str
19
+ database: str
20
+ name: str
21
+ description: str = ""