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.
- pretensor/__init__.py +50 -0
- pretensor/benchmark/__init__.py +54 -0
- pretensor/benchmark/cli.py +294 -0
- pretensor/benchmark/fixtures.py +84 -0
- pretensor/benchmark/l1/__init__.py +23 -0
- pretensor/benchmark/l1/metrics.py +141 -0
- pretensor/benchmark/l1/pipeline.py +188 -0
- pretensor/benchmark/l1/runner.py +245 -0
- pretensor/benchmark/l2/__init__.py +27 -0
- pretensor/benchmark/l2/gold.py +236 -0
- pretensor/benchmark/l2/metrics.py +146 -0
- pretensor/benchmark/l2/pipeline.py +124 -0
- pretensor/benchmark/l2/runner.py +530 -0
- pretensor/benchmark/l3/__init__.py +73 -0
- pretensor/benchmark/l3/agent.py +316 -0
- pretensor/benchmark/l3/db.py +188 -0
- pretensor/benchmark/l3/gold.py +85 -0
- pretensor/benchmark/l3/llm_client.py +395 -0
- pretensor/benchmark/l3/mcp_client.py +357 -0
- pretensor/benchmark/l3/pretensor_runner.py +456 -0
- pretensor/benchmark/l3/prompt.py +132 -0
- pretensor/benchmark/l3/runner.py +358 -0
- pretensor/benchmark/l3/sql_equivalence.py +176 -0
- pretensor/benchmark/release_gate.py +448 -0
- pretensor/benchmark/results.py +298 -0
- pretensor/benchmark/runner.py +109 -0
- pretensor/cli/__init__.py +1 -0
- pretensor/cli/commands/_source_runner.py +147 -0
- pretensor/cli/commands/analyze.py +201 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +126 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +131 -0
- pretensor/cli/commands/index.py +559 -0
- pretensor/cli/commands/list.py +76 -0
- pretensor/cli/commands/quickstart.py +207 -0
- pretensor/cli/commands/reindex.py +646 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +144 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +176 -0
- pretensor/cli/config_file.py +442 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +109 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +226 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +468 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +97 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/mysql.py +402 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +938 -0
- pretensor/connectors/registry.py +93 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +908 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +307 -0
- pretensor/core/dsn_crypto.py +51 -0
- pretensor/core/graph_schema_manager.py +246 -0
- pretensor/core/graph_store.py +1226 -0
- pretensor/core/ids.py +101 -0
- pretensor/core/portable_export.py +276 -0
- pretensor/core/query_runner.py +67 -0
- pretensor/core/registry.py +209 -0
- pretensor/core/schema.py +473 -0
- pretensor/core/secure_io.py +93 -0
- pretensor/core/store.py +469 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/analyze/__init__.py +0 -0
- pretensor/enrichment/analyze/classify.py +49 -0
- pretensor/enrichment/analyze/extract_python.py +196 -0
- pretensor/enrichment/analyze/parse.py +141 -0
- pretensor/enrichment/analyze/pipeline.py +195 -0
- pretensor/enrichment/analyze/summary.py +38 -0
- pretensor/enrichment/analyze/walker.py +98 -0
- pretensor/enrichment/analyze/writers.py +214 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +300 -0
- pretensor/enrichment/dbt/metadata.py +263 -0
- pretensor/enrichment/dbt/pipeline.py +77 -0
- pretensor/enrichment/dbt/resolution.py +101 -0
- pretensor/enrichment/dbt/signals.py +305 -0
- pretensor/entities/__init__.py +27 -0
- pretensor/entities/builder.py +63 -0
- pretensor/entities/classifier.py +383 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/errors.py +35 -0
- pretensor/graph_models/__init__.py +17 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/consumer.py +71 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +79 -0
- pretensor/graph_models/relationship.py +33 -0
- pretensor/integrations/__init__.py +42 -0
- pretensor/integrations/_base.py +138 -0
- pretensor/integrations/google_adk.py +49 -0
- pretensor/integrations/langchain.py +55 -0
- pretensor/integrations/llamaindex.py +53 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +425 -0
- pretensor/intelligence/clustering.py +168 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +317 -0
- pretensor/intelligence/graph_export.py +200 -0
- pretensor/intelligence/heuristic.py +544 -0
- pretensor/intelligence/join_paths/__init__.py +130 -0
- pretensor/intelligence/join_paths/on_demand.py +516 -0
- pretensor/intelligence/join_paths/storage.py +70 -0
- pretensor/intelligence/llm_infer.py +78 -0
- pretensor/intelligence/llm_runtime.py +62 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +364 -0
- pretensor/intelligence/role_exemplars.py +263 -0
- pretensor/intelligence/schema_classification.py +360 -0
- pretensor/intelligence/scoring.py +76 -0
- pretensor/intelligence/semantic.py +240 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +191 -0
- pretensor/intelligence/steps_embedding.py +168 -0
- pretensor/introspection/__init__.py +6 -0
- pretensor/introspection/inspector.py +5 -0
- pretensor/introspection/models/__init__.py +0 -0
- pretensor/introspection/models/base.py +5 -0
- pretensor/introspection/models/config.py +237 -0
- pretensor/introspection/models/dsn.py +550 -0
- pretensor/introspection/models/plan.py +116 -0
- pretensor/introspection/models/schema.py +10 -0
- pretensor/introspection/models/semantic.py +121 -0
- pretensor/introspection/models/validation.py +116 -0
- pretensor/introspection/snapshot.py +46 -0
- pretensor/mcp/__init__.py +16 -0
- pretensor/mcp/config_json.py +24 -0
- pretensor/mcp/payload_types.py +274 -0
- pretensor/mcp/resources/__init__.py +17 -0
- pretensor/mcp/resources/markdown.py +314 -0
- pretensor/mcp/server.py +285 -0
- pretensor/mcp/service.py +49 -0
- pretensor/mcp/service_context.py +142 -0
- pretensor/mcp/service_registry.py +294 -0
- pretensor/mcp/store_cache.py +43 -0
- pretensor/mcp/tool_registry.py +136 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/_rank.py +244 -0
- pretensor/mcp/tools/_timed.py +26 -0
- pretensor/mcp/tools/compile_metric.py +144 -0
- pretensor/mcp/tools/consumers.py +161 -0
- pretensor/mcp/tools/context.py +1121 -0
- pretensor/mcp/tools/cypher.py +509 -0
- pretensor/mcp/tools/detect_changes.py +254 -0
- pretensor/mcp/tools/impact.py +271 -0
- pretensor/mcp/tools/list.py +131 -0
- pretensor/mcp/tools/schema.py +170 -0
- pretensor/mcp/tools/search.py +316 -0
- pretensor/mcp/tools/semantic_search.py +282 -0
- pretensor/mcp/tools/traverse.py +1027 -0
- pretensor/mcp/tools/validate_sql.py +150 -0
- pretensor/observability.py +203 -0
- pretensor/py.typed +0 -0
- pretensor/quickstart/README.md +29 -0
- pretensor/quickstart/__init__.py +6 -0
- pretensor/quickstart/docker-compose.yml +18 -0
- pretensor/quickstart/pagila_data.sql +63 -0
- pretensor/quickstart/pagila_ddl.sql +92 -0
- pretensor/search/__init__.py +6 -0
- pretensor/search/base.py +80 -0
- pretensor/search/index.py +435 -0
- pretensor/semantic/__init__.py +24 -0
- pretensor/semantic/base.py +123 -0
- pretensor/semantic/compiler.py +487 -0
- pretensor/semantic/yaml_layer.py +180 -0
- pretensor/skills/__init__.py +5 -0
- pretensor/skills/generator.py +235 -0
- pretensor/staleness/__init__.py +15 -0
- pretensor/staleness/graph_patcher.py +355 -0
- pretensor/staleness/impact_analyzer.py +162 -0
- pretensor/staleness/snapshot_store.py +38 -0
- pretensor/validation/__init__.py +9 -0
- pretensor/validation/query_validator.py +436 -0
- pretensor/visibility/__init__.py +23 -0
- pretensor/visibility/config.py +126 -0
- pretensor/visibility/filter.py +143 -0
- pretensor/visibility/kuzu_helpers.py +32 -0
- pretensor/visibility/runtime.py +36 -0
- pretensor/visibility/sync_grants.py +188 -0
- pretensor-0.1.0.dist-info/METADATA +251 -0
- pretensor-0.1.0.dist-info/RECORD +198 -0
- pretensor-0.1.0.dist-info/WHEEL +4 -0
- pretensor-0.1.0.dist-info/entry_points.txt +2 -0
- 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"]
|