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,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())