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