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,114 @@
1
+ """Orchestrate heuristic → statistical relationship discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from pretensor.connectors.models import SchemaSnapshot
9
+ from pretensor.core.ids import table_node_id
10
+ from pretensor.core.store import KuzuStore
11
+ from pretensor.graph_models.relationship import RelationshipCandidate
12
+ from pretensor.intelligence.combining import ConfidenceCombiner, MaxScoreCombiner
13
+ from pretensor.intelligence.heuristic import HeuristicScorer
14
+ from pretensor.intelligence.scoring import JoinKey, ScorerRegistry
15
+ from pretensor.intelligence.shadow_alias import get_shadow_alias_node_ids
16
+ from pretensor.intelligence.statistical import apply_statistical_adjustment
17
+
18
+ if TYPE_CHECKING:
19
+ from pretensor.config import GraphConfig, PretensorConfig
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ __all__ = ["RelationshipDiscovery", "explicit_fk_join_keys"]
24
+
25
+
26
+ def explicit_fk_join_keys(snapshot: SchemaSnapshot) -> set[JoinKey]:
27
+ """Set of (source_node_id, target_node_id, source_column, target_column) for declared FKs."""
28
+ conn = snapshot.connection_name
29
+ keys: set[JoinKey] = set()
30
+ for table in snapshot.tables:
31
+ for fk in table.foreign_keys:
32
+ src_id = table_node_id(conn, fk.source_schema, fk.source_table)
33
+ dst_id = table_node_id(conn, fk.target_schema, fk.target_table)
34
+ keys.add((src_id, dst_id, fk.source_column, fk.target_column))
35
+ return keys
36
+
37
+
38
+ def _join_key(c: RelationshipCandidate) -> JoinKey:
39
+ return (
40
+ c.source_node_id,
41
+ c.target_node_id,
42
+ c.source_column,
43
+ c.target_column,
44
+ )
45
+
46
+
47
+ class RelationshipDiscovery:
48
+ """Runs discovery stages and writes ``INFERRED_JOIN`` edges to ``KuzuStore``."""
49
+
50
+ def __init__(
51
+ self,
52
+ store: KuzuStore,
53
+ *,
54
+ scorers: ScorerRegistry | None = None,
55
+ combiner: ConfidenceCombiner | None = None,
56
+ config: PretensorConfig | None = None,
57
+ graph_config: GraphConfig | None = None,
58
+ ) -> None:
59
+ self._store = store
60
+ self._graph_config = graph_config
61
+ if config is not None:
62
+ self._scorers = scorers or config.scorer_registry
63
+ self._combiner = combiner or config.combiner
64
+ if graph_config is None:
65
+ self._graph_config = config.graph
66
+ else:
67
+ self._scorers = scorers or ScorerRegistry([HeuristicScorer()])
68
+ self._combiner = combiner or MaxScoreCombiner()
69
+
70
+ def discover(
71
+ self,
72
+ snapshot: SchemaSnapshot,
73
+ *,
74
+ overlap_scores: dict[JoinKey, float] | None = None,
75
+ ) -> list[RelationshipCandidate]:
76
+ """Return merged inferred candidates (also written to the store)."""
77
+ explicit = explicit_fk_join_keys(snapshot)
78
+
79
+ scored = self._scorers.score_all(snapshot, explicit)
80
+
81
+ merged = self._combiner.combine(scored)
82
+ merged = [c for c in merged if _join_key(c) not in explicit]
83
+
84
+ # Filter out candidates where source or target is a shadow alias.
85
+ shadow_ids = get_shadow_alias_node_ids(
86
+ self._store,
87
+ snapshot.database,
88
+ config=self._graph_config,
89
+ )
90
+ if shadow_ids:
91
+ before = len(merged)
92
+ merged = [
93
+ c
94
+ for c in merged
95
+ if c.source_node_id not in shadow_ids
96
+ and c.target_node_id not in shadow_ids
97
+ ]
98
+ dropped = before - len(merged)
99
+ if dropped:
100
+ logger.debug(
101
+ "Dropped %d INFERRED_JOIN candidates involving shadow aliases",
102
+ dropped,
103
+ )
104
+
105
+ overlap = overlap_scores or {}
106
+ adjusted: list[RelationshipCandidate] = []
107
+ for c in merged:
108
+ adj = apply_statistical_adjustment(c, overlap.get(_join_key(c)))
109
+ adjusted.append(adj)
110
+
111
+ for c in adjusted:
112
+ self._store.upsert_inferred_join(c)
113
+
114
+ return adjusted
@@ -0,0 +1,317 @@
1
+ """Local ONNX embedding clients for cross-database entity resolution (optional ``[embeddings]`` extra)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import math
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Protocol, cast, runtime_checkable
10
+
11
+ __all__ = [
12
+ "EMBEDDING_MODEL_ID",
13
+ "EMBEDDING_MODEL_REVISION",
14
+ "EMBEDDING_DIM",
15
+ "EmbeddingClient",
16
+ "LocalEmbeddingClient",
17
+ "NullEmbeddingClient",
18
+ "cosine_similarity",
19
+ "embeddings_disabled_via_env",
20
+ "embeddings_extra_installed",
21
+ "resolve_embeddings_auto",
22
+ "format_entity_text",
23
+ "get_default_embedding_client",
24
+ "verify_embeddings_extra_installed",
25
+ ]
26
+
27
+ EMBEDDING_MODEL_ID = "Snowflake/snowflake-arctic-embed-xs"
28
+ # Pinned to a specific HuggingFace commit so entity-resolution scores stay reproducible
29
+ # across runs even if the model is updated upstream. Bump explicitly when validating a
30
+ # new revision against the cross-DB benchmark pairs.
31
+ EMBEDDING_MODEL_REVISION = "d8c86521100d3556476a063fc2342036d45c106f"
32
+ EMBEDDING_DIM = 384
33
+
34
+ _EMBEDDINGS_INSTALL_HINT = (
35
+ "Install embedding dependencies with: pip install 'pretensor[embeddings]' "
36
+ "(requires onnxruntime, huggingface_hub, numpy, transformers)."
37
+ )
38
+
39
+ _ENV_DISABLED_FLAG = "PRETENSOR_EMBEDDINGS_DISABLED"
40
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
41
+
42
+
43
+ def embeddings_disabled_via_env() -> bool:
44
+ """Return True iff ``PRETENSOR_EMBEDDINGS_DISABLED`` is set to a truthy value.
45
+
46
+ Honored by every embedding-consuming code path — index-time steps,
47
+ intelligence toggles, and query-time MCP tools — so setting the variable
48
+ forces the null path even when the ``[embeddings]`` extra is installed
49
+ and toggles are on. CI exercises an "extras installed but disabled"
50
+ lane that must produce output byte-identical to the "extras absent"
51
+ lane. Truthy values are ``1``, ``true``, ``yes``, ``on``
52
+ (case-insensitive).
53
+ """
54
+ return os.environ.get(_ENV_DISABLED_FLAG, "").strip().lower() in _TRUTHY
55
+
56
+
57
+ @runtime_checkable
58
+ class EmbeddingClient(Protocol):
59
+ """Structural type for text embedding backends (sync ``embed``; usable with ``asyncio.to_thread``)."""
60
+
61
+ def embed(self, texts: list[str]) -> list[list[float]]:
62
+ """Return one embedding vector per input string (or an empty list when disabled)."""
63
+ ...
64
+
65
+
66
+ class NullEmbeddingClient:
67
+ """No-op client when embeddings are off (e.g. ``--embeddings`` not set). Callers treat ``[]`` as no similarity."""
68
+
69
+ def embed(self, texts: list[str]) -> list[list[float]]:
70
+ return []
71
+
72
+
73
+ def cosine_similarity(a: list[float], b: list[float]) -> float:
74
+ """Cosine similarity in ``[-1, 1]`` (1.0 for identical non-zero vectors, 0.0 when orthogonal or zero norm)."""
75
+ if len(a) != len(b):
76
+ msg = f"Vector length mismatch: {len(a)} vs {len(b)}"
77
+ raise ValueError(msg)
78
+ dot = 0.0
79
+ na = 0.0
80
+ nb = 0.0
81
+ for x, y in zip(a, b, strict=True):
82
+ dot += x * y
83
+ na += x * x
84
+ nb += y * y
85
+ if na == 0.0 or nb == 0.0:
86
+ return 0.0
87
+ return dot / (math.sqrt(na) * math.sqrt(nb))
88
+
89
+
90
+ def verify_embeddings_extra_installed() -> None:
91
+ """Raise ``ImportError`` with an install hint when ``[embeddings]`` is missing.
92
+
93
+ Public guard intended for callers (notably the CLI ``index`` and
94
+ ``reindex`` commands) that want to surface a friendly install hint
95
+ *before* spinning up a pipeline that would otherwise fail mid-run
96
+ when ``LocalEmbeddingClient.embed()`` reaches its lazy import.
97
+ Returns ``None`` on success.
98
+ """
99
+ _require_embeddings_imports()
100
+
101
+
102
+ def embeddings_extra_installed() -> bool:
103
+ """Return True iff the optional ``[embeddings]`` dependencies import cleanly."""
104
+ try:
105
+ _require_embeddings_imports()
106
+ except ImportError:
107
+ return False
108
+ return True
109
+
110
+
111
+ def resolve_embeddings_auto(requested: bool | None) -> bool:
112
+ """Resolve the CLI's tri-state ``--embeddings/--no-embeddings`` flag.
113
+
114
+ ``None`` (neither flag given) means AUTO: embeddings are on whenever
115
+ the ``[embeddings]`` extra is installed and the
116
+ ``PRETENSOR_EMBEDDINGS_DISABLED`` kill switch is not set. Installing
117
+ the extra is the opt-in; the base install never pays the cost. An
118
+ explicit ``True``/``False`` always wins — ``True`` is validated by the
119
+ caller via :func:`verify_embeddings_extra_installed` so a missing
120
+ extra errors with the install hint instead of silently downgrading.
121
+
122
+ The AUTO behavior lives at the CLI boundary on purpose: library-level
123
+ defaults (``EmbeddingsConfig()``) stay all-off so the null-path parity
124
+ contract for direct ``GraphBuilder`` / ``run_intelligence_layer``
125
+ callers is unchanged.
126
+ """
127
+ if requested is not None:
128
+ return requested
129
+ return embeddings_extra_installed() and not embeddings_disabled_via_env()
130
+
131
+
132
+ def _require_embeddings_imports() -> tuple[Any, Any, Callable[..., str], Any]:
133
+ """Import optional deps; raise ImportError with install hint if missing."""
134
+ try:
135
+ np = importlib.import_module("numpy")
136
+ except ImportError as e:
137
+ raise ImportError(_EMBEDDINGS_INSTALL_HINT) from e
138
+ try:
139
+ ort = importlib.import_module("onnxruntime")
140
+ except ImportError as e:
141
+ raise ImportError(_EMBEDDINGS_INSTALL_HINT) from e
142
+ try:
143
+ hub = importlib.import_module("huggingface_hub")
144
+ except ImportError as e:
145
+ raise ImportError(_EMBEDDINGS_INSTALL_HINT) from e
146
+ try:
147
+ transformers = importlib.import_module("transformers")
148
+ except ImportError as e:
149
+ raise ImportError(_EMBEDDINGS_INSTALL_HINT) from e
150
+ snapshot_download = cast(Callable[..., str], hub.snapshot_download)
151
+ AutoTokenizer = transformers.AutoTokenizer
152
+ return np, ort, snapshot_download, AutoTokenizer
153
+
154
+
155
+ class LocalEmbeddingClient:
156
+ """Loads ``Snowflake/snowflake-arctic-embed-xs`` ONNX from the Hugging Face cache (384-dim CLS embeddings).
157
+
158
+ The HuggingFace revision is pinned via ``EMBEDDING_MODEL_REVISION`` so that
159
+ entity-resolution scores are reproducible across runs. Override with the
160
+ ``model_revision`` kwarg only for tests or deliberate model bumps.
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ *,
166
+ model_id: str = EMBEDDING_MODEL_ID,
167
+ model_revision: str = EMBEDDING_MODEL_REVISION,
168
+ onnx_filename: str = "onnx/model.onnx",
169
+ _session_factory: Any | None = None,
170
+ _model_dir: Path | None = None,
171
+ ) -> None:
172
+ self._model_id = model_id
173
+ self._revision = model_revision
174
+ self._onnx_relative = onnx_filename
175
+ self._session_factory = _session_factory
176
+ self._model_dir_override = _model_dir
177
+ self._np: Any | None = None
178
+ self._session: Any | None = None
179
+ self._tokenizer: Any | None = None
180
+ self._input_names: tuple[str, ...] | None = None
181
+ self._output_index_cls: int = 0
182
+
183
+ def _ensure_loaded(self) -> None:
184
+ if (
185
+ self._session is not None
186
+ and self._tokenizer is not None
187
+ and self._np is not None
188
+ ):
189
+ return
190
+ np, ort, snapshot_download, AutoTokenizer = _require_embeddings_imports()
191
+ self._np = np
192
+ model_dir = self._model_dir_override
193
+ if model_dir is None:
194
+ cache_dir = snapshot_download(
195
+ repo_id=self._model_id,
196
+ revision=self._revision,
197
+ local_files_only=False,
198
+ # Only the ONNX graph + tokenizer/config files are read;
199
+ # without the filter the full repo snapshot (PyTorch
200
+ # weights, every quantized ONNX variant) is ~4x larger on
201
+ # the cold first download.
202
+ allow_patterns=[
203
+ self._onnx_relative,
204
+ "*.json",
205
+ "*.txt",
206
+ "1_Pooling/*",
207
+ ],
208
+ )
209
+ model_dir = Path(cache_dir)
210
+ onnx_path = model_dir / self._onnx_relative
211
+ if not onnx_path.is_file():
212
+ msg = f"ONNX model not found at {onnx_path}"
213
+ raise FileNotFoundError(msg)
214
+
215
+ self._tokenizer = AutoTokenizer.from_pretrained(str(model_dir))
216
+ factory = self._session_factory or ort.InferenceSession
217
+ self._session = factory(
218
+ str(onnx_path),
219
+ providers=["CPUExecutionProvider"],
220
+ )
221
+ sess = self._session
222
+ assert sess is not None
223
+ inputs = sess.get_inputs()
224
+ self._input_names = tuple(inp.name for inp in inputs)
225
+ outputs = sess.get_outputs()
226
+ out_names = [o.name for o in outputs]
227
+ if "last_hidden_state" in out_names:
228
+ self._output_index_cls = out_names.index("last_hidden_state")
229
+ else:
230
+ self._output_index_cls = 0
231
+
232
+ def embed(self, texts: list[str]) -> list[list[float]]:
233
+ if not texts:
234
+ return []
235
+ self._ensure_loaded()
236
+ np = self._np
237
+ assert (
238
+ np is not None and self._tokenizer is not None and self._session is not None
239
+ )
240
+ tok = self._tokenizer
241
+ encoded = tok(
242
+ texts,
243
+ padding=True,
244
+ truncation=True,
245
+ max_length=512,
246
+ return_tensors="np",
247
+ )
248
+ feed: dict[str, Any] = {}
249
+ for name in self._input_names or ():
250
+ if name == "input_ids":
251
+ feed[name] = encoded["input_ids"].astype(np.int64)
252
+ elif name == "attention_mask":
253
+ feed[name] = encoded["attention_mask"].astype(np.int64)
254
+ elif name == "token_type_ids" and "token_type_ids" in encoded:
255
+ feed[name] = encoded["token_type_ids"].astype(np.int64)
256
+ if not feed:
257
+ msg = "Could not map tokenizer outputs to ONNX inputs"
258
+ raise RuntimeError(msg)
259
+
260
+ outputs = self._session.run(None, feed)
261
+ last_hidden = np.asarray(outputs[self._output_index_cls], dtype=np.float32)
262
+ if last_hidden.ndim == 3:
263
+ # CLS token at index 0 (per Snowflake / BERT-style pooling).
264
+ pooled = last_hidden[:, 0, :]
265
+ elif last_hidden.ndim == 2:
266
+ # Already-pooled export (e.g. a ``sentence_embedding`` output):
267
+ # shape [batch, dim], nothing to slice.
268
+ pooled = last_hidden
269
+ else:
270
+ msg = (
271
+ "Unexpected ONNX output shape "
272
+ f"{tuple(last_hidden.shape)}; expected [batch, seq, dim] "
273
+ "or [batch, dim]"
274
+ )
275
+ raise RuntimeError(msg)
276
+ norms = np.linalg.norm(pooled, axis=1, keepdims=True)
277
+ norms = np.where(norms == 0, 1.0, norms)
278
+ pooled = pooled / norms
279
+ return [row.astype(float).tolist() for row in pooled]
280
+
281
+
282
+ def format_entity_text(entity_name: str, column_names: list[str]) -> str:
283
+ """Format schema text for embedding: ``\"{entity}: col1, col2\"`` (short, within model length)."""
284
+ cols = ", ".join(column_names)
285
+ return f"{entity_name}: {cols}"
286
+
287
+
288
+ # Process-wide cached default ``LocalEmbeddingClient``. Every consumer
289
+ # (compile_metric, query rerank, traverse tie-break, semantic_search,
290
+ # role-vote, EmbeddingIndexStep) goes through ``get_default_embedding_client``
291
+ # so the multi-MB ONNX session/tokenizer state loads at most once per
292
+ # process. Constructing ``LocalEmbeddingClient()`` is cheap (no I/O until
293
+ # ``embed()`` is called), but the model download + ``InferenceSession``
294
+ # init on first ``embed()`` is dominant on cold runs; sharing one instance
295
+ # keeps that cost amortized across all embedding-driven tools.
296
+ _DEFAULT_CLIENT: LocalEmbeddingClient | None = None
297
+
298
+
299
+ def get_default_embedding_client() -> LocalEmbeddingClient:
300
+ """Return the process-wide cached ``LocalEmbeddingClient``.
301
+
302
+ Constructs one on first call; every subsequent call returns the
303
+ same instance. Tests that need to reset the cache (e.g. to swap a
304
+ stub embedder per test) should call
305
+ :func:`_reset_default_embedding_client_for_tests` in an autouse
306
+ fixture.
307
+ """
308
+ global _DEFAULT_CLIENT
309
+ if _DEFAULT_CLIENT is None:
310
+ _DEFAULT_CLIENT = LocalEmbeddingClient()
311
+ return _DEFAULT_CLIENT
312
+
313
+
314
+ def _reset_default_embedding_client_for_tests() -> None:
315
+ """Test helper: drop the module-level default-client cache."""
316
+ global _DEFAULT_CLIENT
317
+ _DEFAULT_CLIENT = None
@@ -0,0 +1,200 @@
1
+ """Export ``SchemaTable`` nodes and join edges from Kuzu into an igraph graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import igraph as ig
9
+
10
+ from pretensor.core.store import KuzuStore
11
+ from pretensor.intelligence.embeddings import cosine_similarity
12
+ from pretensor.intelligence.shadow_alias import get_shadow_alias_node_ids
13
+
14
+ if TYPE_CHECKING:
15
+ from pretensor.config import GraphConfig
16
+
17
+ __all__ = ["GraphExporter"]
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _FK_WEIGHT = 1.0
22
+
23
+
24
+ class GraphExporter:
25
+ """Build a weighted undirected igraph graph for one logical database."""
26
+
27
+ def __init__(self, store: KuzuStore) -> None:
28
+ self._store = store
29
+
30
+ def to_igraph(
31
+ self,
32
+ database_key: str,
33
+ *,
34
+ config: GraphConfig | None = None,
35
+ cluster_blend: float = 0.0,
36
+ ) -> ig.Graph:
37
+ """Load tables and FK / inferred edges for ``database_key`` into igraph.
38
+
39
+ Vertex attributes: ``node_id`` (Kuzu ``SchemaTable.node_id``), ``name``
40
+ (``schema.table``). Edge attribute: ``weight`` (FK = 1.0, inferred =
41
+ confidence). Undirected; parallel A–B edges are merged with max weight.
42
+
43
+ Shadow aliases (single-source views) are excluded from the graph when
44
+ ``config.collapse_shadow_aliases`` is enabled (the default).
45
+
46
+ Args:
47
+ database_key: Value of ``SchemaTable.database`` (logical DB name).
48
+ config: Graph config; controls shadow-alias collapsing.
49
+ cluster_blend: Weight for blending embedding cosine similarity
50
+ into existing edge weights. When > 0, every edge whose
51
+ endpoints both carry a non-null ``SchemaTable.embedding`` has
52
+ ``cluster_blend * cosine(emb_a, emb_b)`` added to its weight.
53
+ No new edges are introduced from cosine alone. Default 0.0
54
+ preserves null-path byte-identical behavior (Invariant #6).
55
+
56
+ Returns:
57
+ Possibly empty graph (no vertices if no tables match).
58
+ """
59
+ shadow_ids = get_shadow_alias_node_ids(self._store, database_key, config=config)
60
+
61
+ rows = self._store.query_all_rows(
62
+ """
63
+ MATCH (t:SchemaTable)
64
+ WHERE t.database = $db
65
+ RETURN t.node_id, t.schema_name, t.table_name
66
+ ORDER BY t.node_id
67
+ """,
68
+ {"db": database_key},
69
+ )
70
+ if not rows:
71
+ return ig.Graph()
72
+
73
+ # Filter out shadow aliases from the vertex set
74
+ filtered = [
75
+ (str(r[0]), f"{r[1]}.{r[2]}") for r in rows if str(r[0]) not in shadow_ids
76
+ ]
77
+ if not filtered:
78
+ return ig.Graph()
79
+ node_ids = [nid for nid, _ in filtered]
80
+ names = [name for _, name in filtered]
81
+ id_to_idx = {nid: i for i, nid in enumerate(node_ids)}
82
+
83
+ pair_weights: dict[tuple[int, int], tuple[float, str]] = {}
84
+ self._accumulate_edges(
85
+ id_to_idx,
86
+ pair_weights,
87
+ """
88
+ MATCH (a:SchemaTable)-[r:FK_REFERENCES]->(b:SchemaTable)
89
+ WHERE a.database = $db AND b.database = $db
90
+ RETURN a.node_id, b.node_id, r.source_column, r.target_column
91
+ """,
92
+ {"db": database_key},
93
+ edge_kind="fk",
94
+ weight_for_row=lambda _row: _FK_WEIGHT,
95
+ )
96
+ self._accumulate_edges(
97
+ id_to_idx,
98
+ pair_weights,
99
+ """
100
+ MATCH (a:SchemaTable)-[r:INFERRED_JOIN]->(b:SchemaTable)
101
+ WHERE a.database = $db AND b.database = $db
102
+ RETURN a.node_id, b.node_id, r.confidence
103
+ """,
104
+ {"db": database_key},
105
+ edge_kind="inferred",
106
+ weight_for_row=lambda row: float(row[2]) if row[2] is not None else 0.5,
107
+ )
108
+
109
+ g = ig.Graph(n=len(node_ids), directed=False)
110
+ g.vs["node_id"] = node_ids
111
+ g.vs["name"] = names
112
+
113
+ edges: list[tuple[int, int]] = []
114
+ weights: list[float] = []
115
+ for (i, j), (w, _kind) in sorted(pair_weights.items()):
116
+ edges.append((i, j))
117
+ weights.append(w)
118
+ if edges:
119
+ g.add_edges(edges)
120
+ g.es["weight"] = weights
121
+
122
+ if cluster_blend > 0.0 and edges:
123
+ self._apply_embedding_edge_blend(g, database_key, cluster_blend)
124
+
125
+ return g
126
+
127
+ def _apply_embedding_edge_blend(
128
+ self, g: ig.Graph, database_key: str, blend: float
129
+ ) -> None:
130
+ """Add ``blend * cosine(emb_a, emb_b)`` to each edge weight when both
131
+ endpoints have an embedding.
132
+
133
+ Mutates ``g.es["weight"]`` in place. Edges where either endpoint has
134
+ a null embedding are left untouched, so the blend is purely additive
135
+ on the embedded subgraph; clustering on the unembedded subset is
136
+ unchanged.
137
+ """
138
+ rows = self._store.query_all_rows(
139
+ """
140
+ MATCH (t:SchemaTable {database: $db})
141
+ WHERE t.embedding IS NOT NULL
142
+ RETURN t.node_id, t.embedding
143
+ """,
144
+ {"db": database_key},
145
+ )
146
+ if not rows:
147
+ logger.debug(
148
+ "embedding edge blend: no embedded tables for %s; skipping",
149
+ database_key,
150
+ )
151
+ return
152
+
153
+ embeddings: dict[str, list[float]] = {}
154
+ for nid, vec in rows:
155
+ if vec is None:
156
+ continue
157
+ embeddings[str(nid)] = [float(x) for x in vec]
158
+ if not embeddings:
159
+ return
160
+
161
+ node_ids = g.vs["node_id"]
162
+ for edge in g.es:
163
+ u_nid = node_ids[edge.source]
164
+ v_nid = node_ids[edge.target]
165
+ emb_u = embeddings.get(u_nid)
166
+ emb_v = embeddings.get(v_nid)
167
+ if emb_u is None or emb_v is None:
168
+ continue
169
+ try:
170
+ cos = cosine_similarity(emb_u, emb_v)
171
+ except ValueError:
172
+ # Length mismatch: shouldn't happen with a single fixed model
173
+ # revision, but guard against historical data.
174
+ continue
175
+ edge["weight"] = float(edge["weight"]) + blend * cos
176
+
177
+ def _accumulate_edges(
178
+ self,
179
+ id_to_idx: dict[str, int],
180
+ pair_weights: dict[tuple[int, int], tuple[float, str]],
181
+ cypher: str,
182
+ params: dict[str, Any],
183
+ *,
184
+ edge_kind: str,
185
+ weight_for_row: Any,
186
+ ) -> None:
187
+ for row in self._store.query_all_rows(cypher, params):
188
+ a_raw, b_raw = row[0], row[1]
189
+ if a_raw is None or b_raw is None:
190
+ continue
191
+ a, b = str(a_raw), str(b_raw)
192
+ ia = id_to_idx.get(a)
193
+ ib = id_to_idx.get(b)
194
+ if ia is None or ib is None:
195
+ continue
196
+ i, j = (ia, ib) if ia < ib else (ib, ia)
197
+ w = float(weight_for_row(row))
198
+ prev = pair_weights.get((i, j))
199
+ if prev is None or w > prev[0]:
200
+ pair_weights[(i, j)] = (w, edge_kind)