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,425 @@
|
|
|
1
|
+
"""Heuristic labels for domain clusters and persistence to Kuzu."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from pretensor.core.store import KuzuStore
|
|
13
|
+
from pretensor.intelligence.clustering import Cluster
|
|
14
|
+
from pretensor.intelligence.embeddings import cosine_similarity
|
|
15
|
+
from pretensor.search.index import _identifier_tokens
|
|
16
|
+
|
|
17
|
+
# Shared with MCP context resolution when a table has multiple IN_CLUSTER edges.
|
|
18
|
+
HEURISTIC_CLUSTER_DESCRIPTION = (
|
|
19
|
+
"Grouped by graph connectivity (heuristic label; no LLM)."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ClusterLabeler",
|
|
24
|
+
"HEURISTIC_CLUSTER_DESCRIPTION",
|
|
25
|
+
"LabeledCluster",
|
|
26
|
+
"LlmClusterLabelingClient",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class LabeledCluster:
|
|
34
|
+
"""Cluster with persisted metadata."""
|
|
35
|
+
|
|
36
|
+
cluster_id: str
|
|
37
|
+
table_ids: list[str]
|
|
38
|
+
label: str
|
|
39
|
+
description: str
|
|
40
|
+
cohesion_score: float
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@runtime_checkable
|
|
44
|
+
class LlmClusterLabelingClient(Protocol):
|
|
45
|
+
"""Minimal async client for batched JSON labels."""
|
|
46
|
+
|
|
47
|
+
async def label_clusters_json(self, user_prompt: str) -> str:
|
|
48
|
+
"""Return JSON array of objects with keys label, description (per cluster batch)."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ClusterLabeler:
|
|
53
|
+
"""Assign domain labels and write ``Cluster`` + ``IN_CLUSTER`` to Kuzu."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
store: KuzuStore,
|
|
58
|
+
llm_client: LlmClusterLabelingClient | None = None,
|
|
59
|
+
embedding_resolver: Callable[[str], list[float] | None] | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._store = store
|
|
62
|
+
self._llm = llm_client
|
|
63
|
+
self._embedding_resolver = embedding_resolver
|
|
64
|
+
|
|
65
|
+
async def label_and_persist(
|
|
66
|
+
self,
|
|
67
|
+
clusters: list[Cluster],
|
|
68
|
+
database_key: str,
|
|
69
|
+
*,
|
|
70
|
+
cluster_schema_patterns: Mapping[int, str] | None = None,
|
|
71
|
+
) -> list[LabeledCluster]:
|
|
72
|
+
"""Label clusters via heuristic and upsert into Kuzu."""
|
|
73
|
+
out: list[LabeledCluster] = []
|
|
74
|
+
batch_size = 5
|
|
75
|
+
# Pre-pass: compute one centroid per cluster so the per-cluster
|
|
76
|
+
# ``_heuristic_label`` call can use centroid distance as a tiebreaker
|
|
77
|
+
# when role-weighted degree and row count tie. Only fires when an
|
|
78
|
+
# ``embedding_resolver`` was provided (default: no centroid tiebreaker
|
|
79
|
+
# → identical to the pre-PR labeler).
|
|
80
|
+
centroids: dict[int, list[float] | None] = {}
|
|
81
|
+
if self._embedding_resolver is not None:
|
|
82
|
+
for idx, cluster in enumerate(clusters):
|
|
83
|
+
centroids[idx] = _compute_cluster_centroid(
|
|
84
|
+
cluster, self._embedding_resolver
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for batch_start in range(0, len(clusters), batch_size):
|
|
88
|
+
batch = clusters[batch_start : batch_start + batch_size]
|
|
89
|
+
labels_descs = [
|
|
90
|
+
_heuristic_label(
|
|
91
|
+
self._store,
|
|
92
|
+
c,
|
|
93
|
+
centroid=centroids.get(batch_start + i),
|
|
94
|
+
embedding_resolver=self._embedding_resolver,
|
|
95
|
+
)
|
|
96
|
+
for i, c in enumerate(batch)
|
|
97
|
+
]
|
|
98
|
+
logger.info(
|
|
99
|
+
"Cluster labeling (heuristic): %d clusters in this batch",
|
|
100
|
+
len(batch),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
for offset, cluster in enumerate(batch):
|
|
104
|
+
idx = batch_start + offset
|
|
105
|
+
cluster_id = f"{database_key}::cluster::{idx}"
|
|
106
|
+
label, desc = (
|
|
107
|
+
labels_descs[offset]
|
|
108
|
+
if offset < len(labels_descs)
|
|
109
|
+
else (
|
|
110
|
+
f"Cluster {idx + 1}",
|
|
111
|
+
"Heuristic domain grouping from schema graph connectivity.",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
schema_pattern = "unknown"
|
|
115
|
+
if cluster_schema_patterns is not None:
|
|
116
|
+
schema_pattern = cluster_schema_patterns.get(idx, "unknown")
|
|
117
|
+
self._store.upsert_cluster(
|
|
118
|
+
node_id=cluster_id,
|
|
119
|
+
database_key=database_key,
|
|
120
|
+
label=label,
|
|
121
|
+
description=desc,
|
|
122
|
+
cohesion_score=float(cluster.cohesion_score),
|
|
123
|
+
table_count=len(cluster.table_ids),
|
|
124
|
+
schema_pattern=schema_pattern,
|
|
125
|
+
)
|
|
126
|
+
for tid in cluster.table_ids:
|
|
127
|
+
self._store.upsert_in_cluster(tid, cluster_id)
|
|
128
|
+
cluster.label = label
|
|
129
|
+
out.append(
|
|
130
|
+
LabeledCluster(
|
|
131
|
+
cluster_id=cluster_id,
|
|
132
|
+
table_ids=list(cluster.table_ids),
|
|
133
|
+
label=label,
|
|
134
|
+
description=desc,
|
|
135
|
+
cohesion_score=cluster.cohesion_score,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _cluster_context(store: KuzuStore, cluster: Cluster) -> str:
|
|
142
|
+
lines: list[str] = []
|
|
143
|
+
for tid in cluster.table_ids:
|
|
144
|
+
rows = store.query_all_rows(
|
|
145
|
+
"""
|
|
146
|
+
MATCH (t:SchemaTable {node_id: $id})
|
|
147
|
+
RETURN t.schema_name, t.table_name, t.comment
|
|
148
|
+
""",
|
|
149
|
+
{"id": tid},
|
|
150
|
+
)
|
|
151
|
+
if not rows:
|
|
152
|
+
lines.append(f"- {tid}: (missing node)")
|
|
153
|
+
continue
|
|
154
|
+
sn, tn, comment = rows[0]
|
|
155
|
+
qual = f"{sn}.{tn}"
|
|
156
|
+
desc = str(comment or "").strip() or "(no description)"
|
|
157
|
+
lines.append(f"- {qual}: {desc}")
|
|
158
|
+
return "\n".join(lines)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_NOUN_STOPWORDS: frozenset[str] = frozenset(
|
|
162
|
+
{
|
|
163
|
+
"tbl",
|
|
164
|
+
"table",
|
|
165
|
+
"tables",
|
|
166
|
+
"fk",
|
|
167
|
+
"id",
|
|
168
|
+
"ids",
|
|
169
|
+
"ref",
|
|
170
|
+
"refs",
|
|
171
|
+
"rel",
|
|
172
|
+
"rels",
|
|
173
|
+
"map",
|
|
174
|
+
"maps",
|
|
175
|
+
"link",
|
|
176
|
+
"links",
|
|
177
|
+
"detail",
|
|
178
|
+
"details",
|
|
179
|
+
"log",
|
|
180
|
+
"logs",
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Suffix-based deny: catches hub/audit/categorisation tokens that consistently
|
|
185
|
+
# beat the real semantic head on weighted votes (e.g. ``Businessentity`` eating
|
|
186
|
+
# ``Person`` on the AW person cluster, ``Orderhistory`` eating ``Order``).
|
|
187
|
+
_NOUN_SUFFIX_DENY: tuple[str, ...] = ("entity", "history", "type")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _cluster_table_rows(
|
|
191
|
+
store: KuzuStore, table_ids: list[str]
|
|
192
|
+
) -> list[tuple[str, str, str, list[str], list[str], int, str | None]]:
|
|
193
|
+
"""Return (node_id, schema_name, table_name, out_neighbors, in_neighbors, row_count, role) per table."""
|
|
194
|
+
out: list[tuple[str, str, str, list[str], list[str], int, str | None]] = []
|
|
195
|
+
for tid in table_ids:
|
|
196
|
+
rows = store.query_all_rows(
|
|
197
|
+
"""
|
|
198
|
+
MATCH (t:SchemaTable {node_id: $id})
|
|
199
|
+
OPTIONAL MATCH (t)-[:FK_REFERENCES]->(o:SchemaTable)
|
|
200
|
+
OPTIONAL MATCH (i:SchemaTable)-[:FK_REFERENCES]->(t)
|
|
201
|
+
RETURN t.schema_name, t.table_name, t.row_count, t.role,
|
|
202
|
+
collect(DISTINCT o.node_id), collect(DISTINCT i.node_id)
|
|
203
|
+
""",
|
|
204
|
+
{"id": tid},
|
|
205
|
+
)
|
|
206
|
+
if not rows:
|
|
207
|
+
continue
|
|
208
|
+
sn, tn, rc, role, outs, ins = rows[0]
|
|
209
|
+
role_str = str(role) if role else None
|
|
210
|
+
out.append(
|
|
211
|
+
(
|
|
212
|
+
str(tid),
|
|
213
|
+
str(sn or ""),
|
|
214
|
+
str(tn or ""),
|
|
215
|
+
[str(x) for x in (outs or []) if x],
|
|
216
|
+
[str(x) for x in (ins or []) if x],
|
|
217
|
+
int(rc) if rc is not None else 0,
|
|
218
|
+
role_str,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
_ROLE_WEIGHT: dict[str, float] = {
|
|
225
|
+
"dimension": 1.5,
|
|
226
|
+
"fact": 1.25,
|
|
227
|
+
"bridge": 0.4,
|
|
228
|
+
"link": 0.4,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _role_weight(role: str | None) -> float:
|
|
233
|
+
"""Multiplier for a table's contribution to the cluster-head vote.
|
|
234
|
+
|
|
235
|
+
Dimensions and facts carry the semantic head noun; bridge/link tables
|
|
236
|
+
(e.g. ``person.businessentity``) connect many neighbours but their names
|
|
237
|
+
are plumbing, so they should not dominate the label. Unknown roles get
|
|
238
|
+
neutral weight 1.0 for back-compat with older graphs.
|
|
239
|
+
"""
|
|
240
|
+
if role is None:
|
|
241
|
+
return 1.0
|
|
242
|
+
return _ROLE_WEIGHT.get(role.lower(), 1.0)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _strip_suffix(table_name: str) -> str:
|
|
246
|
+
"""Strip trailing _fk / _id (case-insensitive) while preserving original casing."""
|
|
247
|
+
for suffix in ("_fk", "_id"):
|
|
248
|
+
if table_name.lower().endswith(suffix):
|
|
249
|
+
return table_name[: -len(suffix)]
|
|
250
|
+
return table_name
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _frequent_noun(
|
|
254
|
+
table_entries: list[tuple[str, str, float]], schema_stopwords: set[str]
|
|
255
|
+
) -> str | None:
|
|
256
|
+
"""Pick the highest-weighted domain-noun stem across table names.
|
|
257
|
+
|
|
258
|
+
Each entry is ``(schema, table_name, weight)``. Weight reflects per-table
|
|
259
|
+
importance (in-cluster FK degree scaled by log row_count, multiplied by a
|
|
260
|
+
role weight — dimensions/facts dominate, bridges/links contribute less).
|
|
261
|
+
Ties on weight are broken by raw occurrence count, then alphabetically —
|
|
262
|
+
but alphabetical tie-break is a last resort, not the primary signal, so
|
|
263
|
+
heads like ``Addresstype`` only win when no more central noun stands out.
|
|
264
|
+
"""
|
|
265
|
+
weighted: dict[str, float] = {}
|
|
266
|
+
occurrences: Counter[str] = Counter()
|
|
267
|
+
tiebreak: dict[str, str] = {}
|
|
268
|
+
for _schema, table_name, weight in table_entries:
|
|
269
|
+
seen: set[str] = set()
|
|
270
|
+
stripped = _strip_suffix(table_name)
|
|
271
|
+
for token in _identifier_tokens(stripped):
|
|
272
|
+
if not token or token.isdigit() or len(token) < 2:
|
|
273
|
+
continue
|
|
274
|
+
if token in _NOUN_STOPWORDS or token in schema_stopwords:
|
|
275
|
+
continue
|
|
276
|
+
stem = token[:-1] if len(token) > 3 and token.endswith("s") else token
|
|
277
|
+
if stem in seen:
|
|
278
|
+
continue
|
|
279
|
+
if any(stem.endswith(s) for s in _NOUN_SUFFIX_DENY):
|
|
280
|
+
continue
|
|
281
|
+
seen.add(stem)
|
|
282
|
+
weighted[stem] = weighted.get(stem, 0.0) + weight
|
|
283
|
+
occurrences[stem] += 1
|
|
284
|
+
# Preserve a canonical casing for title-casing: prefer longer original.
|
|
285
|
+
prior = tiebreak.get(stem)
|
|
286
|
+
if prior is None or len(token) > len(prior):
|
|
287
|
+
tiebreak[stem] = token
|
|
288
|
+
if not weighted:
|
|
289
|
+
return None
|
|
290
|
+
best = max(weighted.values())
|
|
291
|
+
candidates = [s for s, w in weighted.items() if w == best]
|
|
292
|
+
# Secondary: occurrence count; tertiary: alphabetical.
|
|
293
|
+
candidates.sort(key=lambda s: (-occurrences[s], s))
|
|
294
|
+
return tiebreak.get(candidates[0], candidates[0])
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _compute_cluster_centroid(
|
|
298
|
+
cluster: Cluster,
|
|
299
|
+
embedding_resolver: Callable[[str], list[float] | None],
|
|
300
|
+
) -> list[float] | None:
|
|
301
|
+
"""Mean-pool the embeddings for tables in ``cluster`` that have one.
|
|
302
|
+
|
|
303
|
+
Returns ``None`` when no table in the cluster carries an embedding (the
|
|
304
|
+
labeler then falls back to the pre-embedding tiebreaker chain). Tables
|
|
305
|
+
with ``None`` embeddings are simply skipped — they neither pull nor push
|
|
306
|
+
the centroid.
|
|
307
|
+
"""
|
|
308
|
+
vecs: list[list[float]] = []
|
|
309
|
+
for tid in cluster.table_ids:
|
|
310
|
+
v = embedding_resolver(tid)
|
|
311
|
+
if v is None:
|
|
312
|
+
continue
|
|
313
|
+
vecs.append([float(x) for x in v])
|
|
314
|
+
if not vecs:
|
|
315
|
+
return None
|
|
316
|
+
dim = len(vecs[0])
|
|
317
|
+
centroid = [0.0] * dim
|
|
318
|
+
# Track the count of vectors that actually contribute to the sum so
|
|
319
|
+
# the divisor matches the numerator. Dividing by ``len(vecs)`` would
|
|
320
|
+
# dilute the mean when any vector was skipped for dim mismatch
|
|
321
|
+
# (matches the pattern in ``role_exemplars.compute_role_centroids``).
|
|
322
|
+
count = 0
|
|
323
|
+
for v in vecs:
|
|
324
|
+
if len(v) != dim:
|
|
325
|
+
# Mismatched dim shouldn't happen with a single fixed model
|
|
326
|
+
# revision; skip the offender rather than pollute the mean.
|
|
327
|
+
continue
|
|
328
|
+
for i in range(dim):
|
|
329
|
+
centroid[i] += v[i]
|
|
330
|
+
count += 1
|
|
331
|
+
if count == 0:
|
|
332
|
+
return None
|
|
333
|
+
return [x / count for x in centroid]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _centroid_distance(
|
|
337
|
+
embedding: list[float] | None, centroid: list[float] | None
|
|
338
|
+
) -> float:
|
|
339
|
+
"""``1 - cosine(emb, centroid)``; ``inf`` if either side is missing.
|
|
340
|
+
|
|
341
|
+
``inf`` ensures unembedded tables sort *after* embedded ones in the
|
|
342
|
+
tiebreaker, but only matters when prior keys (``-effective_degree``,
|
|
343
|
+
``-row_count``) tie — heuristic signals always dominate.
|
|
344
|
+
"""
|
|
345
|
+
if embedding is None or centroid is None:
|
|
346
|
+
return float("inf")
|
|
347
|
+
try:
|
|
348
|
+
return 1.0 - cosine_similarity(embedding, centroid)
|
|
349
|
+
except ValueError:
|
|
350
|
+
return float("inf")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _heuristic_label(
|
|
354
|
+
store: KuzuStore,
|
|
355
|
+
cluster: Cluster,
|
|
356
|
+
*,
|
|
357
|
+
centroid: list[float] | None = None,
|
|
358
|
+
embedding_resolver: Callable[[str], list[float] | None] | None = None,
|
|
359
|
+
) -> tuple[str, str]:
|
|
360
|
+
entries = _cluster_table_rows(store, list(cluster.table_ids))
|
|
361
|
+
if not entries:
|
|
362
|
+
return "Schema domain", HEURISTIC_CLUSTER_DESCRIPTION
|
|
363
|
+
|
|
364
|
+
table_count = len(entries)
|
|
365
|
+
cluster_set = {nid for nid, _, _, _, _, _, _ in entries}
|
|
366
|
+
# Anchor: highest *role-weighted* in-cluster FK degree. Bridges/links
|
|
367
|
+
# contribute at 0.4× so a 2-degree dimension (``person``) beats a
|
|
368
|
+
# 3-degree bridge (``businessentity``) on the AW person cluster. When
|
|
369
|
+
# role-weighted degree and row count tie AND a cluster centroid is
|
|
370
|
+
# available, prefer the table closest to centroid.
|
|
371
|
+
# Heuristic signals stay primary — centroid distance only resolves ties.
|
|
372
|
+
best: tuple[float, int, float, str, str, str] | None = None
|
|
373
|
+
# key shape: (-effective_degree, -row_count, centroid_distance,
|
|
374
|
+
# schema, table, node_id)
|
|
375
|
+
for nid, sn, tn, outs, ins, rc, role in entries:
|
|
376
|
+
in_degree = sum(1 for o in outs if o in cluster_set)
|
|
377
|
+
in_degree += sum(1 for i in ins if i in cluster_set)
|
|
378
|
+
eff = in_degree * _role_weight(role)
|
|
379
|
+
if embedding_resolver is not None and centroid is not None:
|
|
380
|
+
dist = _centroid_distance(embedding_resolver(nid), centroid)
|
|
381
|
+
else:
|
|
382
|
+
dist = float("inf")
|
|
383
|
+
key = (-eff, -rc, dist, sn.lower(), tn.lower(), nid)
|
|
384
|
+
if best is None or key < best:
|
|
385
|
+
best = key
|
|
386
|
+
assert best is not None
|
|
387
|
+
anchor_nid = best[5]
|
|
388
|
+
rep_schema = next(
|
|
389
|
+
sn for nid, sn, _tn, _o, _i, _rc, _role in entries if nid == anchor_nid
|
|
390
|
+
)
|
|
391
|
+
anchor_table = next(
|
|
392
|
+
tn for nid, _sn, tn, _o, _i, _rc, _role in entries if nid == anchor_nid
|
|
393
|
+
)
|
|
394
|
+
if not rep_schema or not anchor_table:
|
|
395
|
+
return "Schema domain", HEURISTIC_CLUSTER_DESCRIPTION
|
|
396
|
+
|
|
397
|
+
# Schema names are stopworded so a common prefix (``analytics``, ``hr``)
|
|
398
|
+
# doesn't eat the vote — but when a schema happens to share a name with
|
|
399
|
+
# a table in the cluster (e.g. AW's ``person.person``), the noun *is*
|
|
400
|
+
# the domain head and must survive. Skip those.
|
|
401
|
+
table_names_lower = {
|
|
402
|
+
tn.lower() for _nid, _sn, tn, _o, _i, _rc, _role in entries if tn
|
|
403
|
+
}
|
|
404
|
+
schema_stopwords = {
|
|
405
|
+
sn.lower()
|
|
406
|
+
for _nid, sn, _tn, _o, _i, _rc, _role in entries
|
|
407
|
+
if sn and sn.lower() not in table_names_lower
|
|
408
|
+
}
|
|
409
|
+
# Weight each table by (in-cluster FK degree + 1) * log10(row_count + 10),
|
|
410
|
+
# then multiply by a role weight. Dimensions/facts dominate the vote;
|
|
411
|
+
# bridges/links contribute at 0.4× so their plumbing names (e.g.
|
|
412
|
+
# ``businessentity``) don't eat the real head noun. The +1 / +10 floors
|
|
413
|
+
# keep empty or leaf tables in the vote with non-zero weight.
|
|
414
|
+
weighted_entries: list[tuple[str, str, float]] = []
|
|
415
|
+
for _nid, sn, tn, outs, ins, rc, role in entries:
|
|
416
|
+
deg = sum(1 for o in outs if o in cluster_set) + sum(
|
|
417
|
+
1 for i in ins if i in cluster_set
|
|
418
|
+
)
|
|
419
|
+
weight = (deg + 1) * math.log10(max(rc, 0) + 10) * _role_weight(role)
|
|
420
|
+
weighted_entries.append((sn, tn, weight))
|
|
421
|
+
noun = _frequent_noun(weighted_entries, schema_stopwords)
|
|
422
|
+
head = noun.replace("_", " ").title() if noun else anchor_table.replace("_", " ")
|
|
423
|
+
suffix = f" cluster ({table_count} tables)" if table_count > 1 else ""
|
|
424
|
+
label = f"{rep_schema}.{anchor_table} · {head}{suffix}"
|
|
425
|
+
return label, HEURISTIC_CLUSTER_DESCRIPTION
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Leiden (or Louvain) community detection on the table graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import random
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import igraph as ig
|
|
11
|
+
|
|
12
|
+
from pretensor.config import GraphConfig
|
|
13
|
+
|
|
14
|
+
__all__ = ["Cluster", "ClusteringEngine"]
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_LEIDEN_SEED = 42
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Cluster:
|
|
23
|
+
"""One detected community of physical tables."""
|
|
24
|
+
|
|
25
|
+
table_ids: list[str]
|
|
26
|
+
cohesion_score: float
|
|
27
|
+
label: str | None = None
|
|
28
|
+
singleton: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ClusteringEngine:
|
|
33
|
+
"""Partition a weighted undirected graph into table clusters."""
|
|
34
|
+
|
|
35
|
+
config: GraphConfig = field(default_factory=GraphConfig)
|
|
36
|
+
|
|
37
|
+
def cluster(self, g: ig.Graph) -> list[Cluster]:
|
|
38
|
+
"""Run community detection and post-process tiny clusters.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
One :class:`Cluster` per community, after merging size-<
|
|
42
|
+
``min_cluster_size_merge`` groups into neighbors.
|
|
43
|
+
"""
|
|
44
|
+
if g.vcount() == 0:
|
|
45
|
+
return []
|
|
46
|
+
if g.ecount() == 0:
|
|
47
|
+
return [
|
|
48
|
+
Cluster(
|
|
49
|
+
table_ids=[g.vs[i]["node_id"]],
|
|
50
|
+
cohesion_score=0.0,
|
|
51
|
+
singleton=True,
|
|
52
|
+
)
|
|
53
|
+
for i in range(g.vcount())
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
membership = self._partition(g)
|
|
57
|
+
groups: dict[int, list[int]] = {}
|
|
58
|
+
for vi, part in enumerate(membership):
|
|
59
|
+
groups.setdefault(int(part), []).append(vi)
|
|
60
|
+
|
|
61
|
+
merged = self._merge_small_clusters(g, groups)
|
|
62
|
+
clusters: list[Cluster] = []
|
|
63
|
+
for _pid, members in sorted(merged.items(), key=lambda x: min(x[1])):
|
|
64
|
+
table_ids = [g.vs[i]["node_id"] for i in sorted(members)]
|
|
65
|
+
coh = _cohesion_score(g, members)
|
|
66
|
+
clusters.append(
|
|
67
|
+
Cluster(
|
|
68
|
+
table_ids=table_ids,
|
|
69
|
+
cohesion_score=coh,
|
|
70
|
+
singleton=len(members) == 1 and g.degree(members[0]) == 0,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
return clusters
|
|
74
|
+
|
|
75
|
+
def _partition(self, g: ig.Graph) -> list[int]:
|
|
76
|
+
resolution = (
|
|
77
|
+
self.config.clustering_resolution_override
|
|
78
|
+
if self.config.clustering_resolution_override is not None
|
|
79
|
+
else _default_resolution(g.vcount())
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
import leidenalg # type: ignore[import-untyped]
|
|
83
|
+
|
|
84
|
+
partition = leidenalg.find_partition(
|
|
85
|
+
g,
|
|
86
|
+
leidenalg.RBConfigurationVertexPartition,
|
|
87
|
+
weights="weight",
|
|
88
|
+
resolution_parameter=resolution,
|
|
89
|
+
seed=_LEIDEN_SEED,
|
|
90
|
+
)
|
|
91
|
+
return list(partition.membership)
|
|
92
|
+
except ImportError:
|
|
93
|
+
logger.warning(
|
|
94
|
+
"leidenalg not installed — falling back to igraph Louvain "
|
|
95
|
+
"(no resolution tuning). Install the clustering extra for "
|
|
96
|
+
"better community detection: pip install 'pretensor[clustering]'"
|
|
97
|
+
)
|
|
98
|
+
# Seed igraph's RNG so the Louvain fallback partitions
|
|
99
|
+
# deterministically. Without this, BM25 / cluster-aware
|
|
100
|
+
# callers (including the benchmark harness) see different
|
|
101
|
+
# cluster labels across re-runs.
|
|
102
|
+
ig.set_random_number_generator(random.Random(_LEIDEN_SEED))
|
|
103
|
+
vc: Any = g.community_multilevel(weights="weight")
|
|
104
|
+
return list(vc.membership)
|
|
105
|
+
|
|
106
|
+
def _merge_small_clusters(
|
|
107
|
+
self, g: ig.Graph, groups: dict[int, list[int]]
|
|
108
|
+
) -> dict[int, list[int]]:
|
|
109
|
+
min_sz = self.config.min_cluster_size_merge
|
|
110
|
+
if min_sz <= 1:
|
|
111
|
+
return groups
|
|
112
|
+
|
|
113
|
+
def cluster_weight_to(from_members: list[int], to_pid: int) -> float:
|
|
114
|
+
to_members = groups[to_pid]
|
|
115
|
+
total = 0.0
|
|
116
|
+
for i in from_members:
|
|
117
|
+
for j in to_members:
|
|
118
|
+
eid = g.get_eid(i, j, directed=False, error=False)
|
|
119
|
+
if eid >= 0:
|
|
120
|
+
total += float(g.es[eid]["weight"])
|
|
121
|
+
return total
|
|
122
|
+
|
|
123
|
+
changed = True
|
|
124
|
+
while changed:
|
|
125
|
+
changed = False
|
|
126
|
+
small_pids = [pid for pid, mem in groups.items() if len(mem) < min_sz]
|
|
127
|
+
if not small_pids:
|
|
128
|
+
break
|
|
129
|
+
pid = small_pids[0]
|
|
130
|
+
members = groups[pid]
|
|
131
|
+
if len(groups) <= 1:
|
|
132
|
+
break
|
|
133
|
+
candidates: list[tuple[float, int, int]] = []
|
|
134
|
+
for other_pid, other_mem in groups.items():
|
|
135
|
+
if other_pid == pid:
|
|
136
|
+
continue
|
|
137
|
+
w = cluster_weight_to(members, other_pid)
|
|
138
|
+
candidates.append((w, len(other_mem), other_pid))
|
|
139
|
+
if not candidates:
|
|
140
|
+
break
|
|
141
|
+
candidates.sort(key=lambda t: (-t[0], -t[1], t[2]))
|
|
142
|
+
best_pid = candidates[0][2]
|
|
143
|
+
groups[best_pid].extend(members)
|
|
144
|
+
del groups[pid]
|
|
145
|
+
changed = True
|
|
146
|
+
return groups
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _default_resolution(table_count: int) -> float:
|
|
150
|
+
"""Clustering heuristic: target medium-sized communities."""
|
|
151
|
+
return 0.5 + (table_count / 200.0)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _cohesion_score(g: ig.Graph, members: list[int]) -> float:
|
|
155
|
+
"""Ratio of realized internal weight to fully-connected upper bound."""
|
|
156
|
+
k = len(members)
|
|
157
|
+
if k < 2:
|
|
158
|
+
return 0.0
|
|
159
|
+
member_set = set(members)
|
|
160
|
+
internal = 0.0
|
|
161
|
+
for i in members:
|
|
162
|
+
for j in g.neighbors(i):
|
|
163
|
+
if j in member_set and i < j:
|
|
164
|
+
eid = g.get_eid(i, j, directed=False, error=False)
|
|
165
|
+
if eid >= 0:
|
|
166
|
+
internal += float(g.es[eid]["weight"])
|
|
167
|
+
max_edges = k * (k - 1) / 2.0
|
|
168
|
+
return internal / max_edges if max_edges else 0.0
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Confidence combiner contracts for relationship discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from pretensor.graph_models.relationship import RelationshipCandidate
|
|
8
|
+
from pretensor.intelligence.scoring import JoinKey
|
|
9
|
+
|
|
10
|
+
__all__ = ["ConfidenceCombiner", "MaxScoreCombiner"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfidenceCombiner(ABC):
|
|
14
|
+
"""Contract for merging relationship candidate groups into a single deduplicated list."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def combine(self, *groups: list[RelationshipCandidate]) -> list[RelationshipCandidate]:
|
|
18
|
+
"""Merge one or more candidate groups into a single deduplicated list."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MaxScoreCombiner(ConfidenceCombiner):
|
|
22
|
+
"""Keeps the highest-confidence candidate per directed column pair."""
|
|
23
|
+
|
|
24
|
+
def combine(self, *groups: list[RelationshipCandidate]) -> list[RelationshipCandidate]:
|
|
25
|
+
"""Return one candidate per JoinKey, keeping the one with the highest confidence."""
|
|
26
|
+
best: dict[JoinKey, RelationshipCandidate] = {}
|
|
27
|
+
for group in groups:
|
|
28
|
+
for c in group:
|
|
29
|
+
k: JoinKey = (c.source_node_id, c.target_node_id, c.source_column, c.target_column)
|
|
30
|
+
if k not in best or c.confidence > best[k].confidence:
|
|
31
|
+
best[k] = c
|
|
32
|
+
return list(best.values())
|