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