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,544 @@
1
+ """Deterministic relationship candidates from column and table naming patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+
7
+ from pretensor.connectors.models import Column, SchemaSnapshot, Table
8
+ from pretensor.core.ids import table_node_id
9
+ from pretensor.graph_models.relationship import RelationshipCandidate
10
+ from pretensor.intelligence.scoring import JoinKey, RelationshipScorer
11
+
12
+ __all__ = ["HeuristicScorer", "discover_heuristic_candidates"]
13
+
14
+ _CONFIDENCE_HIGH = 0.85
15
+ _CONFIDENCE_MEDIUM = 0.55
16
+ _CONFIDENCE_LOW = 0.25
17
+
18
+ _SUFFIX_ID = "_id"
19
+ _SUFFIX_FK = "_fk"
20
+
21
+ _GIN_BRIN_INDEX = frozenset({"gin", "brin"})
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Type-family mapping for cross-dialect compatibility checks.
25
+ # Unknown types (not in this map) receive no veto — avoids false negatives
26
+ # for exotic or custom types.
27
+ # ---------------------------------------------------------------------------
28
+ _TYPE_FAMILY: dict[str, str] = {
29
+ # numeric
30
+ "int": "numeric",
31
+ "integer": "numeric",
32
+ "bigint": "numeric",
33
+ "smallint": "numeric",
34
+ "tinyint": "numeric",
35
+ "serial": "numeric",
36
+ "bigserial": "numeric",
37
+ "number": "numeric",
38
+ "numeric": "numeric",
39
+ "decimal": "numeric",
40
+ "float": "numeric",
41
+ "double": "numeric",
42
+ "real": "numeric",
43
+ "money": "numeric",
44
+ # string
45
+ "varchar": "string",
46
+ "text": "string",
47
+ "char": "string",
48
+ "character": "string",
49
+ "string": "string",
50
+ "nvarchar": "string",
51
+ "nchar": "string",
52
+ "ntext": "string",
53
+ "clob": "string",
54
+ "bpchar": "string",
55
+ "character varying": "string",
56
+ # temporal
57
+ "date": "temporal",
58
+ "timestamp": "temporal",
59
+ "datetime": "temporal",
60
+ "timestamptz": "temporal",
61
+ "timestamp without time zone": "temporal",
62
+ "timestamp with time zone": "temporal",
63
+ "time": "temporal",
64
+ "interval": "temporal",
65
+ # boolean
66
+ "bool": "boolean",
67
+ "boolean": "boolean",
68
+ "bit": "boolean",
69
+ # uuid
70
+ "uuid": "uuid",
71
+ "uniqueidentifier": "uuid",
72
+ # binary
73
+ "bytea": "binary",
74
+ "blob": "binary",
75
+ "binary": "binary",
76
+ "varbinary": "binary",
77
+ "image": "binary",
78
+ }
79
+
80
+
81
+ def _type_family(data_type: str) -> str | None:
82
+ """Normalize a dialect-specific type name to a family, or ``None`` if unknown."""
83
+ base = data_type.strip().lower().split("(")[0].strip()
84
+ return _TYPE_FAMILY.get(base)
85
+
86
+
87
+ def types_compatible(src: Column | None, dst: Column | None) -> bool:
88
+ """Reject cross-family inferred joins (e.g. numeric id vs. varchar email).
89
+
90
+ Unknown types pass through — vetoing on missing info would drop valid
91
+ candidates for exotic dialects. Applied to every heuristic kind, not just
92
+ same_name, so ``productid (int) → emailaddress (varchar)``-style fan-outs
93
+ never reach the graph.
94
+
95
+ Public so the embedding-driven scorer in
96
+ :mod:`pretensor.intelligence.semantic` can apply the same gate without
97
+ crossing into a private symbol.
98
+ """
99
+ if src is None or dst is None:
100
+ return True
101
+ sf = _type_family(src.data_type)
102
+ df = _type_family(dst.data_type)
103
+ if sf is None or df is None:
104
+ return True
105
+ return sf == df
106
+ _NULL_PCT_HIGH = 80.0
107
+ _LOW_CARD_MAX = 50
108
+ _LARGE_TABLE_ROWS = 10_000
109
+
110
+ # Column names that are audit/metadata fields rather than join keys. Filtering
111
+ # by *name* (not by how many tables a column appears on) keeps legitimate
112
+ # wide-shared columns — e.g. a ``region_code`` denormalized across many tables
113
+ # — emitting candidates while suppressing the AdventureWorks pathology where
114
+ # ``ModifiedDate`` / ``rowguid`` live on ~60 tables. Comparison is
115
+ # case-insensitive (see lookup site below). The downstream join-path DFS has
116
+ # its own perf safety net via ``_DFS_VISIT_BUDGET`` in
117
+ # ``intelligence/join_paths/on_demand.py``.
118
+ _BORING_SHARED_NAMES = frozenset(
119
+ {
120
+ # Audit / metadata (alphabetical)
121
+ "created_at",
122
+ "deleted_at",
123
+ "id",
124
+ "last_update",
125
+ "lastupdate",
126
+ "modified_at",
127
+ "modifieddate",
128
+ "rowguid",
129
+ "updated_at",
130
+ "version",
131
+ # Generic descriptors — near-zero selectivity as join keys (alphabetical)
132
+ "category",
133
+ "class",
134
+ "code",
135
+ "color",
136
+ "comments",
137
+ "description",
138
+ "due_date",
139
+ "duedate",
140
+ "end_date",
141
+ "enddate",
142
+ "flag",
143
+ "group",
144
+ "label",
145
+ "name",
146
+ "notes",
147
+ "size",
148
+ "start_date",
149
+ "startdate",
150
+ "status",
151
+ "style",
152
+ "title",
153
+ "type",
154
+ # Dimensional attributes — high cardinality but no cross-table join meaning.
155
+ # Dropping these (rather than down-weighting) avoids polluting
156
+ # ``context`` output with e.g. film.rating = category.rating.
157
+ "active",
158
+ "length",
159
+ "rating",
160
+ # Generic numerics — shared column names with no cross-table join
161
+ # semantics. The type-family veto (numeric ↔ numeric) cannot distinguish
162
+ # e.g. ``productreview.rating`` from ``product.rating`` as unrelated
163
+ # concepts, and the per-pair cap (3) only caps, does not eliminate, the
164
+ # noise.
165
+ "amount",
166
+ "count",
167
+ "height",
168
+ "level",
169
+ "order",
170
+ "position",
171
+ "price",
172
+ "qty",
173
+ "quantity",
174
+ "rank",
175
+ "score",
176
+ "total",
177
+ "weight",
178
+ "width",
179
+ }
180
+ )
181
+
182
+
183
+ def _table_key(t: Table) -> tuple[str, str]:
184
+ return (t.schema_name, t.name)
185
+
186
+
187
+ def _column_lookup(
188
+ snapshot: SchemaSnapshot,
189
+ ) -> dict[tuple[str, str], dict[str, Column]]:
190
+ """Map (schema, table_name) -> column_name -> Column."""
191
+ out: dict[tuple[str, str], dict[str, Column]] = {}
192
+ for t in snapshot.tables:
193
+ out[_table_key(t)] = {c.name: c for c in t.columns}
194
+ return out
195
+
196
+
197
+ def _plural_forms(stem: str) -> list[str]:
198
+ """Cheap English plural guesses for table-name matching."""
199
+ s = stem.lower()
200
+ if not s:
201
+ return []
202
+ forms = {s}
203
+ forms.add(s + "s")
204
+ if s.endswith("y") and len(s) > 1:
205
+ forms.add(s[:-1] + "ies")
206
+ if s.endswith("ch") or s.endswith("sh") or s.endswith("s") or s.endswith("x"):
207
+ forms.add(s + "es")
208
+ return list(forms)
209
+
210
+
211
+ def _find_referenced_table(
212
+ snapshot: SchemaSnapshot,
213
+ stem: str,
214
+ exclude: tuple[str, str],
215
+ ) -> Table | None:
216
+ """Pick a single target table whose name matches ``stem`` plural guesses."""
217
+ forms = set(_plural_forms(stem))
218
+ matches = [
219
+ t
220
+ for t in snapshot.tables
221
+ if _table_key(t) != exclude and t.name.lower() in forms
222
+ ]
223
+ if len(matches) == 1:
224
+ return matches[0]
225
+ if len(matches) > 1:
226
+ # Prefer exact stem match, then shortest name (e.g. customer over customers_backup).
227
+ exact = [t for t in matches if t.name.lower() == stem.lower()]
228
+ if len(exact) == 1:
229
+ return exact[0]
230
+ return min(matches, key=lambda t: len(t.name))
231
+ return None
232
+
233
+
234
+ def _prefix_match_tables(
235
+ snapshot: SchemaSnapshot,
236
+ prefix: str,
237
+ exclude: tuple[str, str],
238
+ ) -> Table | None:
239
+ """Tables whose name starts with ``prefix`` (abbreviation heuristic)."""
240
+ p = prefix.lower()
241
+ if len(p) < 2:
242
+ return None
243
+ matches = [
244
+ t
245
+ for t in snapshot.tables
246
+ if _table_key(t) != exclude and t.name.lower().startswith(p)
247
+ ]
248
+ if len(matches) == 1:
249
+ return matches[0]
250
+ if len(matches) > 1:
251
+ return min(matches, key=lambda t: len(t.name))
252
+ return None
253
+
254
+
255
+ def _target_has_id_column(
256
+ columns_by_table: dict[tuple[str, str], dict[str, Column]], t: Table
257
+ ) -> bool:
258
+ cols = columns_by_table.get(_table_key(t), {})
259
+ if "id" in cols:
260
+ return True
261
+ for name, col in cols.items():
262
+ if col.is_primary_key:
263
+ return True
264
+ return False
265
+
266
+
267
+ def _pick_target_column(
268
+ columns_by_table: dict[tuple[str, str], dict[str, Column]], t: Table
269
+ ) -> str | None:
270
+ cols = columns_by_table.get(_table_key(t), {})
271
+ if "id" in cols:
272
+ return "id"
273
+ for name, col in cols.items():
274
+ if col.is_primary_key:
275
+ return name
276
+ return None
277
+
278
+
279
+ def _candidate_id(
280
+ connection_name: str,
281
+ src_schema: str,
282
+ src_table: str,
283
+ src_col: str,
284
+ dst_schema: str,
285
+ dst_table: str,
286
+ dst_col: str,
287
+ kind: str,
288
+ ) -> str:
289
+ return (
290
+ f"{kind}:{connection_name}:{src_schema}.{src_table}.{src_col}"
291
+ f"->{dst_schema}.{dst_table}.{dst_col}"
292
+ )
293
+
294
+
295
+ def _column_at(
296
+ columns_by_table: dict[tuple[str, str], dict[str, Column]],
297
+ table: Table,
298
+ col_name: str,
299
+ ) -> Column | None:
300
+ return columns_by_table.get(_table_key(table), {}).get(col_name)
301
+
302
+
303
+ def _is_low_cardinality_large_table(src_table: Table, src_col: Column | None) -> bool:
304
+ """True when catalog says few distinct values on a large source table."""
305
+ if src_table.row_count is None or src_table.row_count <= _LARGE_TABLE_ROWS:
306
+ return False
307
+ if src_col is None or src_col.column_cardinality is None:
308
+ return False
309
+ c = src_col.column_cardinality
310
+ return c >= 0 and c < _LOW_CARD_MAX
311
+
312
+
313
+ def _apply_catalog_signals(
314
+ *,
315
+ src_table: Table,
316
+ dst_table: Table,
317
+ src_col_name: str,
318
+ dst_col_name: str,
319
+ kind: str,
320
+ base_confidence: float,
321
+ columns_by_table: dict[tuple[str, str], dict[str, Column]],
322
+ ) -> float | None:
323
+ """Adjust confidence using SchemaColumn catalog fields.
324
+
325
+ Order: vetoes (drop candidate), then penalties and boosts, then clamp to [0, 1].
326
+ """
327
+ src_c = _column_at(columns_by_table, src_table, src_col_name)
328
+ dst_c = _column_at(columns_by_table, dst_table, dst_col_name)
329
+
330
+ src_index = (src_c.index_type or "").lower() if src_c else ""
331
+ if src_index in _GIN_BRIN_INDEX and kind == "heuristic_same_name":
332
+ return None
333
+ if (
334
+ _is_low_cardinality_large_table(src_table, src_c)
335
+ and kind == "heuristic_same_name"
336
+ ):
337
+ return None
338
+ if not types_compatible(src_c, dst_c):
339
+ return None
340
+
341
+ conf = base_confidence
342
+ if src_index in _GIN_BRIN_INDEX and kind in ("heuristic_fk", "heuristic_id"):
343
+ conf -= 0.30
344
+ if kind == "heuristic_id" and _is_low_cardinality_large_table(src_table, src_c):
345
+ conf -= 0.20
346
+ if dst_c is not None and dst_c.index_is_unique is True:
347
+ conf += 0.15
348
+ for col in (src_c, dst_c):
349
+ if (
350
+ col is not None
351
+ and col.null_percentage is not None
352
+ and col.null_percentage > _NULL_PCT_HIGH
353
+ ):
354
+ conf -= 0.15
355
+
356
+ return max(0.0, min(1.0, conf))
357
+
358
+
359
+ def discover_heuristic_candidates(
360
+ snapshot: SchemaSnapshot,
361
+ ) -> list[RelationshipCandidate]:
362
+ """Return heuristic join hypotheses for tables in ``snapshot``.
363
+
364
+ Rules: ``*_id`` / ``*_fk`` → referenced table ``.id`` (high),
365
+ abbreviated prefix (medium), same column name across tables (low).
366
+
367
+ Catalog signals adjust confidence when ``Column`` / ``Table`` fields
368
+ from introspection are present (cardinality, index type, uniqueness, null rate,
369
+ row counts).
370
+ """
371
+ conn = snapshot.connection_name
372
+ columns_by_table = _column_lookup(snapshot)
373
+ seen_keys: set[tuple[str, str, str, str, str]] = set()
374
+ out: list[RelationshipCandidate] = []
375
+
376
+ def add(
377
+ src_table: Table,
378
+ src_col: str,
379
+ dst_table: Table,
380
+ dst_col: str,
381
+ confidence: float,
382
+ reasoning: str,
383
+ kind: str,
384
+ ) -> None:
385
+ adjusted = _apply_catalog_signals(
386
+ src_table=src_table,
387
+ dst_table=dst_table,
388
+ src_col_name=src_col,
389
+ dst_col_name=dst_col,
390
+ kind=kind,
391
+ base_confidence=confidence,
392
+ columns_by_table=columns_by_table,
393
+ )
394
+ if adjusted is None:
395
+ return
396
+ confidence = adjusted
397
+ src_id = table_node_id(conn, src_table.schema_name, src_table.name)
398
+ dst_id = table_node_id(conn, dst_table.schema_name, dst_table.name)
399
+ key = (src_id, dst_id, src_col, dst_col, kind)
400
+ if key in seen_keys:
401
+ return
402
+ seen_keys.add(key)
403
+ out.append(
404
+ RelationshipCandidate(
405
+ candidate_id=_candidate_id(
406
+ conn,
407
+ src_table.schema_name,
408
+ src_table.name,
409
+ src_col,
410
+ dst_table.schema_name,
411
+ dst_table.name,
412
+ dst_col,
413
+ kind,
414
+ ),
415
+ source_node_id=src_id,
416
+ target_node_id=dst_id,
417
+ source_column=src_col,
418
+ target_column=dst_col,
419
+ source="heuristic",
420
+ reasoning=reasoning,
421
+ confidence=confidence,
422
+ status="suggested",
423
+ )
424
+ )
425
+
426
+ for table in snapshot.tables:
427
+ exclude_key = _table_key(table)
428
+ cols = columns_by_table.get(exclude_key, {})
429
+ for col_name, col in cols.items():
430
+ lower = col_name.lower()
431
+
432
+ if lower.endswith(_SUFFIX_FK):
433
+ stem = col_name[: -len(_SUFFIX_FK)]
434
+ ref = _find_referenced_table(snapshot, stem, exclude_key)
435
+ if ref is None:
436
+ ref = _prefix_match_tables(snapshot, stem, exclude_key)
437
+ conf = _CONFIDENCE_MEDIUM
438
+ reason = f"column '{col_name}' ends with _fk; matched table prefix '{stem}'"
439
+ else:
440
+ conf = _CONFIDENCE_HIGH
441
+ reason = f"column '{col_name}' ends with _fk → {ref.name}.id"
442
+ if ref and _target_has_id_column(columns_by_table, ref):
443
+ tc = _pick_target_column(columns_by_table, ref)
444
+ if tc:
445
+ add(table, col_name, ref, tc, conf, reason, "heuristic_fk")
446
+
447
+ elif lower.endswith(_SUFFIX_ID) and lower != "id":
448
+ stem = col_name[: -len(_SUFFIX_ID)]
449
+ if not stem:
450
+ continue
451
+ ref = _find_referenced_table(snapshot, stem, exclude_key)
452
+ if ref is None:
453
+ ref = _prefix_match_tables(snapshot, stem, exclude_key)
454
+ conf = _CONFIDENCE_MEDIUM
455
+ reason = (
456
+ f"column '{col_name}' ends with _id; matched table name "
457
+ f"starting with '{stem}'"
458
+ )
459
+ else:
460
+ conf = _CONFIDENCE_HIGH
461
+ reason = f"column '{col_name}' → {ref.name}.id (naming convention)"
462
+ if ref and _target_has_id_column(columns_by_table, ref):
463
+ tc = _pick_target_column(columns_by_table, ref)
464
+ if tc:
465
+ add(table, col_name, ref, tc, conf, reason, "heuristic_id")
466
+
467
+ # Same column name across distinct tables (low confidence).
468
+ # Collect candidates first, then cap per table pair.
469
+ _SAME_NAME_CAP_PER_PAIR = 3
470
+
471
+ by_name: dict[str, list[Table]] = defaultdict(list)
472
+ for t in snapshot.tables:
473
+ for c in t.columns:
474
+ if c.name.lower() in _BORING_SHARED_NAMES:
475
+ continue
476
+ by_name[c.name].append(t)
477
+
478
+ # Each entry: (src, col, dst, col, selectivity, reason)
479
+ raw_same: list[tuple[Table, str, Table, str, float, str]] = []
480
+
481
+ for col_name, tables in by_name.items():
482
+ if len(tables) < 2:
483
+ continue
484
+ uniq_tables: list[Table] = []
485
+ seen_tbl: set[tuple[str, str]] = set()
486
+ for t in tables:
487
+ k = _table_key(t)
488
+ if k not in seen_tbl:
489
+ seen_tbl.add(k)
490
+ uniq_tables.append(t)
491
+ if len(uniq_tables) < 2:
492
+ continue
493
+ selectivity = 1.0 / len(uniq_tables)
494
+ for i, t1 in enumerate(uniq_tables):
495
+ for t2 in uniq_tables[i + 1 :]:
496
+ reason = (
497
+ f"shared column name '{col_name}' on {t1.name} and {t2.name} "
498
+ "(weak join signal)"
499
+ )
500
+ raw_same.append((t1, col_name, t2, col_name, selectivity, reason))
501
+ raw_same.append((t2, col_name, t1, col_name, selectivity, reason))
502
+
503
+ # Group by directed (src, dst) pair and keep top-N by selectivity.
504
+ pair_groups: dict[
505
+ tuple[tuple[str, str], tuple[str, str]],
506
+ list[tuple[Table, str, Table, str, float, str]],
507
+ ] = defaultdict(list)
508
+ for entry in raw_same:
509
+ pk = (_table_key(entry[0]), _table_key(entry[2]))
510
+ pair_groups[pk].append(entry)
511
+
512
+ for entries in pair_groups.values():
513
+ entries.sort(key=lambda e: (-e[4], e[1]))
514
+ for src_t, src_col, dst_t, dst_col, _sel, reason in entries[
515
+ :_SAME_NAME_CAP_PER_PAIR
516
+ ]:
517
+ add(src_t, src_col, dst_t, dst_col, _CONFIDENCE_LOW, reason, "heuristic_same_name")
518
+
519
+ return out
520
+
521
+
522
+ class HeuristicScorer(RelationshipScorer):
523
+ """Default OSS scorer that runs deterministic naming heuristics."""
524
+
525
+ def name(self) -> str:
526
+ return "heuristic"
527
+
528
+ def score(
529
+ self,
530
+ snapshot: SchemaSnapshot,
531
+ explicit_fk_keys: set[JoinKey],
532
+ ) -> list[RelationshipCandidate]:
533
+ """Return heuristic candidates excluding explicit FK edges."""
534
+ raw = discover_heuristic_candidates(snapshot)
535
+ return [c for c in raw if _candidate_join_key(c) not in explicit_fk_keys]
536
+
537
+
538
+ def _candidate_join_key(candidate: RelationshipCandidate) -> JoinKey:
539
+ return (
540
+ candidate.source_node_id,
541
+ candidate.target_node_id,
542
+ candidate.source_column,
543
+ candidate.target_column,
544
+ )
@@ -0,0 +1,130 @@
1
+ """Precompute and look up join paths between physical tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from pretensor.config import GraphConfig
8
+ from pretensor.core.store import KuzuStore
9
+ from pretensor.intelligence.join_paths import on_demand as _od
10
+ from pretensor.intelligence.join_paths import storage as _storage
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ JoinStep = _od.JoinStep
15
+ StoredJoinPath = _od.StoredJoinPath
16
+
17
+
18
+ class JoinPathEngine:
19
+ """BFS/DFS path finding and persistence as ``JoinPath`` nodes."""
20
+
21
+ def __init__(self, store: KuzuStore) -> None:
22
+ self._store = store
23
+
24
+ def precompute(self, database_key: str, config: GraphConfig | None = None) -> int:
25
+ """Store join paths for intra-cluster pairs and short cross-cluster pairs.
26
+
27
+ Args:
28
+ database_key: ``SchemaTable.database`` value.
29
+ config: Depth limits; defaults to :class:`GraphConfig`.
30
+
31
+ Returns:
32
+ Number of ``JoinPath`` rows written.
33
+ """
34
+ cfg = config or GraphConfig()
35
+ intra_max = cfg.join_path_max_depth
36
+ # Cross-cluster pairs previously capped at depth 2 to bound precompute
37
+ # cost on wide schemas. That cap hid the authoritative FK chain for
38
+ # cross-domain pairs whose FK path is ≥3 hops (e.g. pagila
39
+ # ``film → inventory → rental → customer``), letting a 2-hop inferred
40
+ # shortcut win as the stored winner. Match ``intra_max`` so the cost
41
+ # model (which already penalises inferred hops) decides the winner
42
+ # rather than a depth cap. If a future benchmark shows precompute
43
+ # regressions on 1000+ table schemas, introduce a dedicated
44
+ # ``join_path_cross_max_depth`` config knob.
45
+ cross_max = intra_max
46
+
47
+ table_meta = _od.table_meta(self._store, database_key)
48
+ if not table_meta:
49
+ return 0
50
+ adj = _od.build_adjacency(self._store, database_key)
51
+ cluster_of = _od.table_to_cluster(self._store, database_key)
52
+
53
+ written = 0
54
+ ids = list(table_meta.keys())
55
+ for i, a in enumerate(ids):
56
+ for b in ids[i + 1 :]:
57
+ ca = cluster_of.get(a)
58
+ cb = cluster_of.get(b)
59
+ max_d = intra_max if ca is not None and ca == cb else cross_max
60
+ rec = _od.best_path(adj, table_meta, a, b, max_d)
61
+ if rec is None:
62
+ continue
63
+ _storage.persist_path(self._store, database_key, rec)
64
+ written += 1
65
+ rev = _od.reverse_stored_path(rec)
66
+ _storage.persist_path(self._store, database_key, rev)
67
+ written += 1
68
+ logger.info(
69
+ "Join path precompute for %s: %d paths (intra depth=%d, cross depth=%d)",
70
+ database_key,
71
+ written,
72
+ intra_max,
73
+ cross_max,
74
+ )
75
+ return written
76
+
77
+ def load_stored_paths(
78
+ self,
79
+ database_key: str,
80
+ from_table_id: str,
81
+ to_table_id: str,
82
+ ) -> list[StoredJoinPath]:
83
+ """Return precomputed paths for an ordered table pair."""
84
+ return _storage.load_stored_paths(
85
+ self._store, database_key, from_table_id, to_table_id
86
+ )
87
+
88
+ def find_path_on_demand(
89
+ self,
90
+ database_key: str,
91
+ from_table_id: str,
92
+ to_table_id: str,
93
+ max_depth: int,
94
+ ) -> StoredJoinPath | None:
95
+ """Compute a single best path without reading ``JoinPath`` nodes."""
96
+ table_meta = _od.table_meta(self._store, database_key)
97
+ if from_table_id not in table_meta or to_table_id not in table_meta:
98
+ return None
99
+ adj = _od.build_adjacency(self._store, database_key)
100
+ return _od.best_path(adj, table_meta, from_table_id, to_table_id, max_depth)
101
+
102
+ def find_paths_on_demand(
103
+ self,
104
+ database_key: str,
105
+ from_table_id: str,
106
+ to_table_id: str,
107
+ max_depth: int,
108
+ *,
109
+ top_k: int = 3,
110
+ edge_kinds: tuple[_od.EdgeKind, ...] | None = None,
111
+ max_inferred_hops: int = 2,
112
+ ) -> list[StoredJoinPath]:
113
+ """Compute up to ``top_k`` shortest paths without reading ``JoinPath`` nodes."""
114
+ table_meta = _od.table_meta(self._store, database_key)
115
+ if from_table_id not in table_meta or to_table_id not in table_meta:
116
+ return []
117
+ adj = _od.build_adjacency(self._store, database_key)
118
+ return _od.best_paths(
119
+ adj,
120
+ table_meta,
121
+ from_table_id,
122
+ to_table_id,
123
+ max_depth,
124
+ top_k=top_k,
125
+ edge_kinds=edge_kinds,
126
+ max_inferred_hops=max_inferred_hops,
127
+ )
128
+
129
+
130
+ __all__ = ["JoinPathEngine", "JoinStep", "StoredJoinPath"]