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,240 @@
|
|
|
1
|
+
"""Embedding-based ``RelationshipScorer``.
|
|
2
|
+
|
|
3
|
+
Walks every pair of tables that carry a non-null ``SchemaTable.embedding``
|
|
4
|
+
in the store, computes cosine similarity, and emits ``INFERRED_JOIN``
|
|
5
|
+
candidates with ``source='embedding'`` and ``status='suggested'`` for
|
|
6
|
+
pairs whose cosine ≥ ``threshold`` and whose implied join columns pass
|
|
7
|
+
the heuristic's type-family compatibility check.
|
|
8
|
+
|
|
9
|
+
Determinism contract:
|
|
10
|
+
|
|
11
|
+
* Opt-in via ``EmbeddingsConfig.join_threshold`` — ``None`` skips the
|
|
12
|
+
scorer entirely; the scorer registry behaves byte-identically to the
|
|
13
|
+
pre-embedding pipeline output.
|
|
14
|
+
* Additive only: candidates pass through the same combiner / shadow-alias
|
|
15
|
+
filter / explicit-FK dedup as heuristic candidates. An embedding-only
|
|
16
|
+
proposal that conflicts with an explicit FK is dropped at the
|
|
17
|
+
``discovery.discover`` filter step (line 82); the scorer also skips
|
|
18
|
+
``JoinKey``s already in the explicit-FK set up front.
|
|
19
|
+
* Type-family veto: every emitted candidate has ``types_compatible``
|
|
20
|
+
green for its ``(source_column, target_column)`` pair, reusing the
|
|
21
|
+
heuristic's check.
|
|
22
|
+
|
|
23
|
+
Index-time staging: the scorer reads embeddings from the store. The
|
|
24
|
+
index/reindex paths call ``compute_table_embeddings`` right after schema
|
|
25
|
+
rows are written and *before* relationship discovery, so the scorer sees
|
|
26
|
+
this run's vectors even on the very first index. Callers that drive
|
|
27
|
+
``run_intelligence_layer`` directly without precomputing still get the
|
|
28
|
+
in-pipeline ``embedding_index`` step, in which case discovery (which runs
|
|
29
|
+
earlier) only sees vectors from the previous cycle.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
|
|
36
|
+
from pretensor.connectors.models import Column, SchemaSnapshot, Table
|
|
37
|
+
from pretensor.core.ids import table_node_id
|
|
38
|
+
from pretensor.core.store import KuzuStore
|
|
39
|
+
from pretensor.graph_models.relationship import RelationshipCandidate
|
|
40
|
+
from pretensor.intelligence.embeddings import (
|
|
41
|
+
cosine_similarity,
|
|
42
|
+
embeddings_disabled_via_env,
|
|
43
|
+
)
|
|
44
|
+
from pretensor.intelligence.heuristic import types_compatible
|
|
45
|
+
from pretensor.intelligence.scoring import JoinKey, RelationshipScorer, ScorerRegistry
|
|
46
|
+
|
|
47
|
+
__all__ = ["EmbeddingRelationshipScorer", "extend_with_embedding_scorer"]
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class EmbeddingRelationshipScorer(RelationshipScorer):
|
|
53
|
+
"""Cosine-similarity-driven candidate generator for ``INFERRED_JOIN``."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
store: KuzuStore,
|
|
59
|
+
database_key: str,
|
|
60
|
+
threshold: float,
|
|
61
|
+
) -> None:
|
|
62
|
+
if not 0.0 <= threshold <= 1.0:
|
|
63
|
+
msg = f"threshold must be in [0.0, 1.0], got {threshold}"
|
|
64
|
+
raise ValueError(msg)
|
|
65
|
+
self._store = store
|
|
66
|
+
self._database_key = database_key
|
|
67
|
+
self._threshold = threshold
|
|
68
|
+
|
|
69
|
+
def name(self) -> str:
|
|
70
|
+
return "embedding"
|
|
71
|
+
|
|
72
|
+
def score(
|
|
73
|
+
self,
|
|
74
|
+
snapshot: SchemaSnapshot,
|
|
75
|
+
explicit_fk_keys: set[JoinKey],
|
|
76
|
+
) -> list[RelationshipCandidate]:
|
|
77
|
+
embeddings = self._load_embeddings()
|
|
78
|
+
if not embeddings:
|
|
79
|
+
logger.debug(
|
|
80
|
+
"EmbeddingRelationshipScorer: no embedded tables for %s; skipping",
|
|
81
|
+
self._database_key,
|
|
82
|
+
)
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
table_by_nid = self._index_snapshot_tables(snapshot)
|
|
86
|
+
if not table_by_nid:
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
candidates: list[RelationshipCandidate] = []
|
|
90
|
+
embedded_ids = sorted(nid for nid in embeddings if nid in table_by_nid)
|
|
91
|
+
for i in range(len(embedded_ids)):
|
|
92
|
+
a_id = embedded_ids[i]
|
|
93
|
+
for j in range(i + 1, len(embedded_ids)):
|
|
94
|
+
b_id = embedded_ids[j]
|
|
95
|
+
cos = cosine_similarity(embeddings[a_id], embeddings[b_id])
|
|
96
|
+
if cos < self._threshold:
|
|
97
|
+
continue
|
|
98
|
+
a_tbl = table_by_nid[a_id]
|
|
99
|
+
b_tbl = table_by_nid[b_id]
|
|
100
|
+
|
|
101
|
+
# Both directions: a → b (a is source, b is target) and b → a.
|
|
102
|
+
# The combiner downstream picks the highest-confidence winner
|
|
103
|
+
# per directed JoinKey, so emitting both is safe.
|
|
104
|
+
for src_tbl, dst_tbl, src_id, dst_id in (
|
|
105
|
+
(a_tbl, b_tbl, a_id, b_id),
|
|
106
|
+
(b_tbl, a_tbl, b_id, a_id),
|
|
107
|
+
):
|
|
108
|
+
pair = _pick_join_columns(src_tbl, dst_tbl)
|
|
109
|
+
if pair is None:
|
|
110
|
+
continue
|
|
111
|
+
src_col, dst_col = pair
|
|
112
|
+
join_key: JoinKey = (src_id, dst_id, src_col, dst_col)
|
|
113
|
+
if join_key in explicit_fk_keys:
|
|
114
|
+
continue
|
|
115
|
+
candidate_id = (
|
|
116
|
+
f"embedding::{src_id}::{src_col}::{dst_id}::{dst_col}"
|
|
117
|
+
)
|
|
118
|
+
candidates.append(
|
|
119
|
+
RelationshipCandidate(
|
|
120
|
+
candidate_id=candidate_id,
|
|
121
|
+
source_node_id=src_id,
|
|
122
|
+
target_node_id=dst_id,
|
|
123
|
+
source_column=src_col,
|
|
124
|
+
target_column=dst_col,
|
|
125
|
+
source="embedding",
|
|
126
|
+
confidence=float(cos),
|
|
127
|
+
status="suggested",
|
|
128
|
+
reasoning=(
|
|
129
|
+
f"cosine={cos:.3f} between {src_tbl.schema_name}."
|
|
130
|
+
f"{src_tbl.name} and {dst_tbl.schema_name}.{dst_tbl.name}"
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
logger.debug(
|
|
135
|
+
"EmbeddingRelationshipScorer: emitted %d candidates for %s "
|
|
136
|
+
"(threshold=%.3f, embedded_tables=%d)",
|
|
137
|
+
len(candidates),
|
|
138
|
+
self._database_key,
|
|
139
|
+
self._threshold,
|
|
140
|
+
len(embedded_ids),
|
|
141
|
+
)
|
|
142
|
+
return candidates
|
|
143
|
+
|
|
144
|
+
def _load_embeddings(self) -> dict[str, list[float]]:
|
|
145
|
+
"""One-shot fetch of every embedded ``SchemaTable`` in the database."""
|
|
146
|
+
out: dict[str, list[float]] = {}
|
|
147
|
+
for row in self._store.iter_table_embeddings(database=self._database_key):
|
|
148
|
+
if row.embedding is None:
|
|
149
|
+
continue
|
|
150
|
+
out[row.node_id] = [float(x) for x in row.embedding]
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
def _index_snapshot_tables(self, snapshot: SchemaSnapshot) -> dict[str, Table]:
|
|
154
|
+
"""Map ``SchemaTable.node_id`` → ``Table`` for column lookups."""
|
|
155
|
+
out: dict[str, Table] = {}
|
|
156
|
+
for tbl in snapshot.tables:
|
|
157
|
+
nid = table_node_id(snapshot.connection_name, tbl.schema_name, tbl.name)
|
|
158
|
+
out[nid] = tbl
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _pick_join_columns(src: Table, dst: Table) -> tuple[str, str] | None:
|
|
163
|
+
"""Pick a likely (src_col, dst_col) join pair for an embedding candidate.
|
|
164
|
+
|
|
165
|
+
Strategy:
|
|
166
|
+
1. Prefer the canonical FK shape: ``src.<dst_table>_id`` → ``dst.id``.
|
|
167
|
+
2. Fall back to any type-family-compatible ``(FK-like, PK-like)`` pair
|
|
168
|
+
where FK-like means a column ending in ``_id`` and PK-like means
|
|
169
|
+
``id`` or ``<dst_table>_id``.
|
|
170
|
+
3. If no compatible pair exists, return ``None`` (the candidate is
|
|
171
|
+
dropped).
|
|
172
|
+
|
|
173
|
+
Type compatibility is enforced via the heuristic scorer's
|
|
174
|
+
``types_compatible`` so high-cosine pairs whose join columns disagree
|
|
175
|
+
on type family (e.g. ``customer_id INT`` ↔ ``customer_id VARCHAR``)
|
|
176
|
+
never reach the graph.
|
|
177
|
+
"""
|
|
178
|
+
src_fk_cols: list[Column] = [
|
|
179
|
+
c for c in src.columns if c.name.lower().endswith("_id")
|
|
180
|
+
]
|
|
181
|
+
dst_lower = dst.name.lower()
|
|
182
|
+
dst_pk_cols: list[Column] = [
|
|
183
|
+
c
|
|
184
|
+
for c in dst.columns
|
|
185
|
+
if c.name.lower() == "id" or c.name.lower() == f"{dst_lower}_id"
|
|
186
|
+
]
|
|
187
|
+
if not src_fk_cols or not dst_pk_cols:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
# Preferred shape: src.<dst_table>_id → dst.id.
|
|
191
|
+
pref_src = next(
|
|
192
|
+
(c for c in src_fk_cols if c.name.lower() == f"{dst_lower}_id"), None
|
|
193
|
+
)
|
|
194
|
+
pref_dst = next((c for c in dst_pk_cols if c.name.lower() == "id"), None)
|
|
195
|
+
if (
|
|
196
|
+
pref_src is not None
|
|
197
|
+
and pref_dst is not None
|
|
198
|
+
and types_compatible(pref_src, pref_dst)
|
|
199
|
+
):
|
|
200
|
+
return pref_src.name, pref_dst.name
|
|
201
|
+
|
|
202
|
+
# Fallback: any type-compat pair.
|
|
203
|
+
for sc in src_fk_cols:
|
|
204
|
+
for dc in dst_pk_cols:
|
|
205
|
+
if types_compatible(sc, dc):
|
|
206
|
+
return sc.name, dc.name
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def extend_with_embedding_scorer(
|
|
211
|
+
base: ScorerRegistry,
|
|
212
|
+
*,
|
|
213
|
+
store: KuzuStore,
|
|
214
|
+
database_key: str,
|
|
215
|
+
threshold: float | None,
|
|
216
|
+
) -> ScorerRegistry:
|
|
217
|
+
"""Return a new ``ScorerRegistry`` containing ``base``'s scorers in order
|
|
218
|
+
plus an ``EmbeddingRelationshipScorer`` when ``threshold is not None``.
|
|
219
|
+
|
|
220
|
+
When ``threshold is None``, returns ``base`` unchanged (null-path
|
|
221
|
+
invariant). ``PRETENSOR_EMBEDDINGS_DISABLED=1`` does the same even
|
|
222
|
+
when a threshold is set, so the kill switch forces the null path here
|
|
223
|
+
too. Otherwise builds a fresh registry, copies every base scorer in
|
|
224
|
+
registration order, and appends the embedding scorer last so the
|
|
225
|
+
combiner sees heuristic / statistical candidates before the embedding
|
|
226
|
+
pass.
|
|
227
|
+
"""
|
|
228
|
+
if threshold is None or embeddings_disabled_via_env():
|
|
229
|
+
return base
|
|
230
|
+
out = ScorerRegistry()
|
|
231
|
+
for scorer in base:
|
|
232
|
+
out.register(scorer)
|
|
233
|
+
out.register(
|
|
234
|
+
EmbeddingRelationshipScorer(
|
|
235
|
+
store=store,
|
|
236
|
+
database_key=database_key,
|
|
237
|
+
threshold=threshold,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
return out
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Shadow-alias detection for single-source views.
|
|
2
|
+
|
|
3
|
+
A *shadow alias* is a view (``table_type = 'view'``) whose sole data source
|
|
4
|
+
is one base table — signaled by having exactly one incoming ``LINEAGE`` edge
|
|
5
|
+
(or at most ``lineage_in_max_for_alias`` edges, configurable via
|
|
6
|
+
:class:`~pretensor.config.GraphConfig`).
|
|
7
|
+
|
|
8
|
+
These views duplicate information already present on the base table and cause
|
|
9
|
+
noise in clustering, INFERRED_JOIN generation, impact analysis, and traversal.
|
|
10
|
+
The helpers here let each consumer detect and suppress them.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from pretensor.config import GraphConfig
|
|
20
|
+
from pretensor.core.store import KuzuStore
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"get_shadow_alias_node_ids",
|
|
24
|
+
"is_shadow_alias",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_shadow_alias_node_ids(
|
|
31
|
+
store: KuzuStore,
|
|
32
|
+
database_key: str,
|
|
33
|
+
*,
|
|
34
|
+
config: GraphConfig | None = None,
|
|
35
|
+
) -> frozenset[str]:
|
|
36
|
+
"""Return ``node_id`` values for all shadow aliases in *database_key*.
|
|
37
|
+
|
|
38
|
+
A table is a shadow alias when:
|
|
39
|
+
|
|
40
|
+
1. ``table_type = 'view'``
|
|
41
|
+
2. ``count(incoming LINEAGE edges) <= lineage_in_max_for_alias`` (default 1)
|
|
42
|
+
3. It has at least one incoming LINEAGE edge (pure orphan views are not aliases)
|
|
43
|
+
|
|
44
|
+
The result is a :class:`frozenset` suitable for fast ``in`` checks by
|
|
45
|
+
downstream consumers.
|
|
46
|
+
"""
|
|
47
|
+
if config is not None and not config.collapse_shadow_aliases:
|
|
48
|
+
return frozenset()
|
|
49
|
+
|
|
50
|
+
max_lineage_in = 1 if config is None else config.lineage_in_max_for_alias
|
|
51
|
+
|
|
52
|
+
rows = store.query_all_rows(
|
|
53
|
+
"""
|
|
54
|
+
MATCH (src:SchemaTable)-[:LINEAGE]->(v:SchemaTable {database: $db})
|
|
55
|
+
WHERE v.table_type = 'view'
|
|
56
|
+
WITH v.node_id AS vid, count(DISTINCT src.node_id) AS lin
|
|
57
|
+
WHERE lin >= 1 AND lin <= $max_lin
|
|
58
|
+
RETURN vid
|
|
59
|
+
""",
|
|
60
|
+
{"db": database_key, "max_lin": max_lineage_in},
|
|
61
|
+
)
|
|
62
|
+
ids = frozenset(str(r[0]) for r in rows)
|
|
63
|
+
if ids:
|
|
64
|
+
logger.debug(
|
|
65
|
+
"Detected %d shadow alias(es) in %s (lineage_in_max=%d)",
|
|
66
|
+
len(ids),
|
|
67
|
+
database_key,
|
|
68
|
+
max_lineage_in,
|
|
69
|
+
)
|
|
70
|
+
return ids
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_shadow_alias(
|
|
74
|
+
store: KuzuStore,
|
|
75
|
+
node_id: str,
|
|
76
|
+
*,
|
|
77
|
+
config: GraphConfig | None = None,
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""Check whether a single table node is a shadow alias.
|
|
80
|
+
|
|
81
|
+
Prefer :func:`get_shadow_alias_node_ids` when checking many tables at once
|
|
82
|
+
(one query vs. N).
|
|
83
|
+
"""
|
|
84
|
+
if config is not None and not config.collapse_shadow_aliases:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
max_lineage_in = 1 if config is None else config.lineage_in_max_for_alias
|
|
88
|
+
|
|
89
|
+
rows = store.query_all_rows(
|
|
90
|
+
"""
|
|
91
|
+
MATCH (t:SchemaTable {node_id: $nid})
|
|
92
|
+
WHERE t.table_type = 'view'
|
|
93
|
+
WITH t
|
|
94
|
+
MATCH (src:SchemaTable)-[:LINEAGE]->(t)
|
|
95
|
+
WITH count(DISTINCT src.node_id) AS lin
|
|
96
|
+
WHERE lin >= 1 AND lin <= $max_lin
|
|
97
|
+
RETURN lin
|
|
98
|
+
""",
|
|
99
|
+
{"nid": node_id, "max_lin": max_lineage_in},
|
|
100
|
+
)
|
|
101
|
+
return len(rows) > 0
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Adjust relationship confidence using sample overlap scores (0.0–1.0)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pretensor.graph_models.relationship import (
|
|
6
|
+
RelationshipCandidate,
|
|
7
|
+
RelationshipSource,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = ["apply_statistical_adjustment"]
|
|
11
|
+
|
|
12
|
+
# Weight overlap more than prior confidence when data supports the join.
|
|
13
|
+
_OVERLAP_WEIGHT = 0.65
|
|
14
|
+
_PRIOR_WEIGHT = 0.35
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def apply_statistical_adjustment(
|
|
18
|
+
candidate: RelationshipCandidate,
|
|
19
|
+
overlap_score: float | None,
|
|
20
|
+
) -> RelationshipCandidate:
|
|
21
|
+
"""Blend ``overlap_score`` into ``confidence`` and mark ``source`` as statistical.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
candidate: Incoming hypothesis (heuristic or LLM).
|
|
25
|
+
overlap_score: Jaccard-like overlap in ``[0, 1]``, or ``None`` to skip.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Updated candidate; unchanged if ``overlap_score`` is ``None``.
|
|
29
|
+
"""
|
|
30
|
+
if overlap_score is None:
|
|
31
|
+
return candidate
|
|
32
|
+
o = max(0.0, min(1.0, float(overlap_score)))
|
|
33
|
+
blended = _PRIOR_WEIGHT * candidate.confidence + _OVERLAP_WEIGHT * o
|
|
34
|
+
blended = max(0.0, min(1.0, blended))
|
|
35
|
+
prior = candidate.source
|
|
36
|
+
note = f"statistical overlap={o:.3f} (prior source={prior})"
|
|
37
|
+
reasoning = f"{note}. {candidate.reasoning}".strip()
|
|
38
|
+
return candidate.model_copy(
|
|
39
|
+
update={
|
|
40
|
+
"confidence": blended,
|
|
41
|
+
"source": _statistical_source(candidate.source),
|
|
42
|
+
"reasoning": reasoning,
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _statistical_source(prior: RelationshipSource) -> RelationshipSource:
|
|
48
|
+
"""Preserve lineage in reasoning; edge ``source`` is ``statistical`` per contract."""
|
|
49
|
+
_ = prior
|
|
50
|
+
return "statistical"
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""PipelineStep Protocol and PipelineRunner for the intelligence pipeline.
|
|
2
|
+
|
|
3
|
+
The runner resolves step execution order from declared dependencies and executes
|
|
4
|
+
each step in sequence. Plugins can register additional steps (e.g. ``llm_refine``,
|
|
5
|
+
``feedback_score``, ``semantic_propose``) by appending them before calling
|
|
6
|
+
:meth:`PipelineRunner.run`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CyclicDependencyError",
|
|
16
|
+
"PipelineContext",
|
|
17
|
+
"PipelineRunner",
|
|
18
|
+
"PipelineStep",
|
|
19
|
+
]
|
|
20
|
+
# The underscore-prefixed ``_CTX_*`` constants below are intentionally
|
|
21
|
+
# omitted from ``__all__`` — they are private-by-convention names shared
|
|
22
|
+
# across step modules in this package. Importing them by full path is
|
|
23
|
+
# fine for in-package callers; they should not be re-exported as part of
|
|
24
|
+
# the public API surface.
|
|
25
|
+
|
|
26
|
+
from pretensor.errors import PretensorError
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Canonical ``PipelineContext`` keys — single source of truth.
|
|
33
|
+
# Both :mod:`pretensor.intelligence.pipeline` (the orchestration layer) and
|
|
34
|
+
# every step module that needs to read or write artifacts on the context
|
|
35
|
+
# import from here. Keeping them in one place prevents the silent no-op
|
|
36
|
+
# class of bug where a string diverges between writer and reader.
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
_CTX_STORE = "store"
|
|
39
|
+
_CTX_DATABASE_KEY = "database_key"
|
|
40
|
+
_CTX_CONFIG = "config"
|
|
41
|
+
_CTX_GRAPH = "graph"
|
|
42
|
+
_CTX_ROLE_BY_TABLE = "role_by_table"
|
|
43
|
+
_CTX_CLUSTERS = "clusters"
|
|
44
|
+
_CTX_PATTERNS = "patterns"
|
|
45
|
+
_CTX_EMBEDDINGS_CONFIG = "embeddings_config"
|
|
46
|
+
_CTX_EMBEDDINGS_PRECOMPUTED = "embeddings_precomputed"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CyclicDependencyError(PretensorError, ValueError):
|
|
50
|
+
"""Raised when the pipeline step dependency graph contains a cycle."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PipelineContext:
|
|
54
|
+
"""Shared mutable state threaded through every pipeline step.
|
|
55
|
+
|
|
56
|
+
Steps read and write named artifacts via :meth:`get` and :meth:`set` so
|
|
57
|
+
that later steps can consume outputs produced by earlier ones without
|
|
58
|
+
coupling them directly.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, **initial: Any) -> None:
|
|
62
|
+
self._data: dict[str, Any] = dict(initial)
|
|
63
|
+
|
|
64
|
+
def set(self, key: str, value: Any) -> None: # noqa: A003
|
|
65
|
+
"""Store *value* under *key*."""
|
|
66
|
+
self._data[key] = value
|
|
67
|
+
|
|
68
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
69
|
+
"""Return the value stored under *key*, or *default*."""
|
|
70
|
+
return self._data.get(key, default)
|
|
71
|
+
|
|
72
|
+
def __contains__(self, key: object) -> bool:
|
|
73
|
+
return key in self._data
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@runtime_checkable
|
|
77
|
+
class PipelineStep(Protocol):
|
|
78
|
+
"""Structural interface every intelligence pipeline step must satisfy.
|
|
79
|
+
|
|
80
|
+
Implementing classes do **not** need to inherit from this class — any
|
|
81
|
+
object with the right attributes and an ``execute`` coroutine qualifies.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
name: Stable, unique identifier for the step (e.g. ``"classify"``).
|
|
85
|
+
dependencies: Names of steps that must complete before this one runs.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
name: str
|
|
89
|
+
dependencies: list[str]
|
|
90
|
+
|
|
91
|
+
async def execute(self, ctx: PipelineContext) -> None:
|
|
92
|
+
"""Run this step, reading inputs from and writing outputs to *ctx*.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
ctx: Shared pipeline context populated by upstream steps.
|
|
96
|
+
"""
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PipelineRunner:
|
|
101
|
+
"""Topological executor for a set of :class:`PipelineStep` objects.
|
|
102
|
+
|
|
103
|
+
Steps are registered in any order; the runner resolves execution order
|
|
104
|
+
from ``step.dependencies`` before the first :meth:`run` call.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
steps: Initial list of steps. Additional steps can be appended via
|
|
108
|
+
:meth:`register` before calling :meth:`run`.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, steps: list[PipelineStep] | None = None) -> None:
|
|
112
|
+
self._steps: list[PipelineStep] = list(steps or [])
|
|
113
|
+
|
|
114
|
+
def register(self, step: PipelineStep) -> None:
|
|
115
|
+
"""Append *step* to the pipeline.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
step: A :class:`PipelineStep` implementation to add.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
ValueError: If a step with the same name is already registered.
|
|
122
|
+
"""
|
|
123
|
+
existing = {s.name for s in self._steps}
|
|
124
|
+
if step.name in existing:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"A step named {step.name!r} is already registered in this pipeline."
|
|
127
|
+
)
|
|
128
|
+
self._steps.append(step)
|
|
129
|
+
|
|
130
|
+
def _ordered(self) -> list[PipelineStep]:
|
|
131
|
+
"""Return steps in topological (dependency-resolved) order.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ValueError: If a step lists an unknown dependency name.
|
|
135
|
+
CyclicDependencyError: If the dependency graph contains a cycle.
|
|
136
|
+
"""
|
|
137
|
+
by_name = {s.name: s for s in self._steps}
|
|
138
|
+
for step in self._steps:
|
|
139
|
+
for dep in step.dependencies:
|
|
140
|
+
if dep not in by_name:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"Step {step.name!r} declares unknown dependency {dep!r}."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Kahn's algorithm for topological sort
|
|
146
|
+
in_degree: dict[str, int] = {name: 0 for name in by_name}
|
|
147
|
+
dependents: dict[str, list[str]] = {name: [] for name in by_name}
|
|
148
|
+
for step in self._steps:
|
|
149
|
+
for dep in step.dependencies:
|
|
150
|
+
dependents[dep].append(step.name)
|
|
151
|
+
in_degree[step.name] += 1
|
|
152
|
+
|
|
153
|
+
queue = [name for name, deg in in_degree.items() if deg == 0]
|
|
154
|
+
ordered: list[PipelineStep] = []
|
|
155
|
+
|
|
156
|
+
while queue:
|
|
157
|
+
name = queue.pop(0)
|
|
158
|
+
ordered.append(by_name[name])
|
|
159
|
+
for dependent in dependents[name]:
|
|
160
|
+
in_degree[dependent] -= 1
|
|
161
|
+
if in_degree[dependent] == 0:
|
|
162
|
+
queue.append(dependent)
|
|
163
|
+
|
|
164
|
+
if len(ordered) != len(self._steps):
|
|
165
|
+
cycle_nodes = [n for n, d in in_degree.items() if d > 0]
|
|
166
|
+
raise CyclicDependencyError(
|
|
167
|
+
f"Pipeline contains a dependency cycle involving: {cycle_nodes}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return ordered
|
|
171
|
+
|
|
172
|
+
async def run(self, ctx: PipelineContext) -> None:
|
|
173
|
+
"""Execute all registered steps in dependency order.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
ctx: Shared pipeline context passed to every step.
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
ValueError: If any step has an unknown dependency.
|
|
180
|
+
CyclicDependencyError: If the dependency graph contains a cycle.
|
|
181
|
+
"""
|
|
182
|
+
ordered = self._ordered()
|
|
183
|
+
logger.info(
|
|
184
|
+
"PipelineRunner: executing %d step(s): %s",
|
|
185
|
+
len(ordered),
|
|
186
|
+
[s.name for s in ordered],
|
|
187
|
+
)
|
|
188
|
+
for step in ordered:
|
|
189
|
+
logger.debug("PipelineRunner: starting step %r", step.name)
|
|
190
|
+
await step.execute(ctx)
|
|
191
|
+
logger.debug("PipelineRunner: finished step %r", step.name)
|