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,1027 @@
1
+ """MCP ``traverse`` tool: join paths between tables in an indexed database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ import json
7
+ import logging
8
+ from collections.abc import Mapping, Sequence
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from pretensor.core.registry import RegistryEntry
13
+ from pretensor.core.store import KuzuStore
14
+ from pretensor.intelligence.embeddings import (
15
+ EmbeddingClient,
16
+ cosine_similarity,
17
+ embeddings_disabled_via_env,
18
+ get_default_embedding_client,
19
+ )
20
+ from pretensor.intelligence.join_paths import JoinPathEngine, StoredJoinPath
21
+ from pretensor.intelligence.shadow_alias import get_shadow_alias_node_ids
22
+ from pretensor.mcp.tool_registry import McpTool
23
+ from pretensor.visibility.filter import VisibilityFilter
24
+ from pretensor.visibility.kuzu_helpers import visible_schema_table_node_ids
25
+
26
+ from ..payload_types import TraversePathPayload, TraverseStepPayload
27
+ from ..service_context import get_effective_visibility_filter
28
+ from ..service_registry import (
29
+ graph_path_for_entry,
30
+ load_registry,
31
+ open_store_for_entry,
32
+ release_store,
33
+ resolve_registry_entry,
34
+ )
35
+ from .context import qualified_from_node_id, resolve_table_node_id
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def sql_hint_from_steps(steps: list[TraverseStepPayload]) -> str:
41
+ """Build SQL JOIN hints, grouping composite FK columns into one JOIN clause."""
42
+ # Group consecutive steps that share the same tables and constraint_name
43
+ # into a single JOIN with multiple ON conditions.
44
+ groups: list[list[TraverseStepPayload]] = []
45
+ for s in steps:
46
+ cn = s.get("constraint_name")
47
+ if (
48
+ cn
49
+ and groups
50
+ and groups[-1][0].get("constraint_name") == cn
51
+ and groups[-1][0].get("from_table") == s.get("from_table")
52
+ and groups[-1][0].get("to_table") == s.get("to_table")
53
+ ):
54
+ groups[-1].append(s)
55
+ else:
56
+ groups.append([s])
57
+
58
+ lines: list[str] = []
59
+ for group in groups:
60
+ first = group[0]
61
+ left = first.get("from_table", "")
62
+ right = first.get("to_table", "")
63
+ left_short = left.split(".")[-1]
64
+ right_short = right.split(".")[-1]
65
+ conditions = [
66
+ f"{left_short}.{g.get('from_column', '')} = {right_short}.{g.get('to_column', '')}"
67
+ for g in group
68
+ ]
69
+ lines.append(f"JOIN {right_short} ON {' AND '.join(conditions)}")
70
+ return "\n".join(lines)
71
+
72
+
73
+ def stored_path_to_payload(path: StoredJoinPath) -> TraversePathPayload:
74
+ steps_out: list[TraverseStepPayload] = []
75
+ for s in path.steps:
76
+ steps_out.append(
77
+ {
78
+ "from_table": f"{s.from_schema}.{s.from_table}",
79
+ "to_table": f"{s.to_schema}.{s.to_table}",
80
+ "from_column": s.from_column,
81
+ "to_column": s.to_column,
82
+ "edge_type": "fk" if s.edge_type == "fk" else "inferred",
83
+ }
84
+ )
85
+ out: TraversePathPayload = {
86
+ "confidence": path.confidence,
87
+ "ambiguous": path.ambiguous,
88
+ "semantic_label": path.semantic_label,
89
+ "steps": steps_out,
90
+ "sql_hint": sql_hint_from_steps(steps_out),
91
+ }
92
+ if path.stale:
93
+ out["stale"] = True
94
+ out["stale_warning"] = (
95
+ "This join path may be outdated due to recent schema changes. "
96
+ "Verify the path before using it in SQL, or run "
97
+ "`pretensor reindex <dsn> --recompute-intelligence`."
98
+ )
99
+ return out
100
+
101
+
102
+ def _qualified_visible(
103
+ vf: VisibilityFilter | None,
104
+ connection_name: str,
105
+ qualified: str,
106
+ ) -> bool:
107
+ if vf is None:
108
+ return True
109
+ q = qualified.strip()
110
+ if "." not in q:
111
+ return vf.is_table_visible(connection_name, "", q)
112
+ sn, _, tn = q.partition(".")
113
+ return vf.is_table_visible(connection_name, sn, tn)
114
+
115
+
116
+ def traverse_steps_respect_visibility(
117
+ steps: Sequence[Mapping[str, Any]],
118
+ *,
119
+ visibility_filter: VisibilityFilter | None,
120
+ default_connection: str,
121
+ ) -> bool:
122
+ """True when every hop endpoint is visible (cross-db steps may set from_db/to_db).
123
+
124
+ FK steps (``edge_type == "fk"``) are exempt: FK declarations are DDL-level
125
+ join structure, not row data, so surfacing a path through a hidden FK
126
+ waypoint does not leak data. Only inferred edges remain gated.
127
+ """
128
+ if visibility_filter is None:
129
+ return True
130
+ for s in steps:
131
+ if str(s.get("edge_type", "")).strip() == "fk":
132
+ continue
133
+ ft = str(s.get("from_table", "")).strip()
134
+ tt = str(s.get("to_table", "")).strip()
135
+ fdb = str(s.get("from_db", "") or default_connection).strip()
136
+ tdb = str(s.get("to_db", "") or default_connection).strip()
137
+ if not _qualified_visible(visibility_filter, fdb, ft):
138
+ return False
139
+ if not _qualified_visible(visibility_filter, tdb, tt):
140
+ return False
141
+ return True
142
+
143
+
144
+ def dijkstra_join_path(
145
+ store: KuzuStore,
146
+ *,
147
+ start_id: str,
148
+ end_id: str,
149
+ connection_name: str,
150
+ max_depth: int,
151
+ allowed_table_node_ids: set[str] | None = None,
152
+ shadow_alias_ids: frozenset[str] | None = None,
153
+ edge_kinds: tuple[str, ...] | None = None,
154
+ max_inferred_hops: int = 2,
155
+ ) -> tuple[list[tuple[str, str, str, str, str, str | None]], float] | None:
156
+ """Return the lowest-cost edge list under :func:`edge_cost`.
157
+
158
+ See :func:`pretensor.intelligence.join_paths.on_demand.edge_cost` for the
159
+ canonical cost formula (base hop + inferred penalty + low-confidence
160
+ penalty). Returns ``(edges, min_confidence)`` or *None*.
161
+
162
+ Invariant: FK edges are never gated by ``allowed_table_node_ids``; only
163
+ inferred edges are. Rationale: FK declarations are DDL-level join
164
+ structure, not row-level data exposure, so pre-pruning them would drop
165
+ authoritative join chains whenever a waypoint table is hidden. Do not
166
+ re-introduce a gate on FK edges here — :func:`traverse_payload` relies
167
+ on this invariant for the fk-only probe that promotes a FK chain over a
168
+ stored inferred winner when the precompute pass wrote a short inferred
169
+ shortcut (e.g. pagila ``film → customer`` across cluster boundaries).
170
+ """
171
+ from pretensor.intelligence.join_paths.on_demand import AdjEdge, edge_cost
172
+
173
+ fk_rows = store.query_all_rows(
174
+ """
175
+ MATCH (a:SchemaTable)-[r:FK_REFERENCES]->(b:SchemaTable)
176
+ WHERE a.connection_name = $cn AND b.connection_name = $cn
177
+ RETURN a.node_id, b.node_id, r.source_column, r.target_column, r.constraint_name
178
+ """,
179
+ {"cn": connection_name},
180
+ )
181
+ inf_rows = store.query_all_rows(
182
+ """
183
+ MATCH (a:SchemaTable)-[r:INFERRED_JOIN]->(b:SchemaTable)
184
+ WHERE a.connection_name = $cn AND b.connection_name = $cn
185
+ RETURN a.node_id, b.node_id, r.source_column, r.target_column, r.confidence
186
+ """,
187
+ {"cn": connection_name},
188
+ )
189
+ _shadows = shadow_alias_ids or frozenset()
190
+ # Adjacency mirrors on_demand.build_adjacency: both FK and inferred edges
191
+ # are added **bidirectionally**. Join paths are symmetric (A joins B by
192
+ # the FK iff B joins A by it), so pagila ``film → customer`` needs to
193
+ # walk the inventory FK in reverse — only the directional adjacency
194
+ # would strand the start node when it is the FK parent. Constraint
195
+ # names are preserved on both directions for SQL hint rendering.
196
+ adj: dict[str, list[tuple[AdjEdge, str | None]]] = {}
197
+ for a, b, sc, tc, cn in fk_rows:
198
+ sa, sb = str(a), str(b)
199
+ if sa in _shadows or sb in _shadows:
200
+ continue
201
+ cname = str(cn) if cn is not None else None
202
+ s_col = str(sc)
203
+ t_col = str(tc)
204
+ adj.setdefault(sa, []).append((AdjEdge(sb, s_col, t_col, "fk", 1.0), cname))
205
+ adj.setdefault(sb, []).append((AdjEdge(sa, t_col, s_col, "fk", 1.0), cname))
206
+ for a, b, sc, tc, conf in inf_rows:
207
+ sa, sb = str(a), str(b)
208
+ if sa in _shadows or sb in _shadows:
209
+ continue
210
+ w = float(conf) if conf is not None else 0.5
211
+ s_col = str(sc)
212
+ t_col = str(tc)
213
+ adj.setdefault(sa, []).append((AdjEdge(sb, s_col, t_col, "inferred", w), None))
214
+ adj.setdefault(sb, []).append((AdjEdge(sa, t_col, s_col, "inferred", w), None))
215
+
216
+ counter = 0
217
+ heap: list[
218
+ tuple[
219
+ float,
220
+ int,
221
+ str,
222
+ list[tuple[str, str, str, str, str, str | None]],
223
+ float,
224
+ int,
225
+ ]
226
+ ] = []
227
+ heapq.heappush(heap, (0.0, counter, start_id, [], 1.0, 0))
228
+ visited: set[str] = set()
229
+ while heap:
230
+ cost, _, cur, path_edges, min_conf, inf_hops = heapq.heappop(heap)
231
+ if cur in visited:
232
+ continue
233
+ visited.add(cur)
234
+ if cur == end_id:
235
+ return path_edges, min_conf
236
+ if len(path_edges) >= max_depth:
237
+ continue
238
+ for ae, cname in adj.get(cur, []):
239
+ if edge_kinds is not None and ae.kind not in edge_kinds:
240
+ continue
241
+ if (
242
+ allowed_table_node_ids is not None
243
+ and ae.kind != "fk"
244
+ and ae.to_id not in allowed_table_node_ids
245
+ ):
246
+ continue
247
+ if ae.to_id in visited:
248
+ continue
249
+ new_inf = inf_hops + (1 if ae.kind == "inferred" else 0)
250
+ if new_inf > max_inferred_hops:
251
+ continue
252
+ edge = (cur, ae.to_id, ae.source_column, ae.target_column, ae.kind, cname)
253
+ counter += 1
254
+ heapq.heappush(
255
+ heap,
256
+ (
257
+ cost + edge_cost(ae),
258
+ counter,
259
+ ae.to_id,
260
+ path_edges + [edge],
261
+ min(min_conf, ae.confidence),
262
+ new_inf,
263
+ ),
264
+ )
265
+ return None
266
+
267
+
268
+ def step_dict_from_edge(
269
+ store: KuzuStore,
270
+ edge: tuple[str, str, str, str, str, str | None],
271
+ connection_label: str,
272
+ ) -> TraverseStepPayload:
273
+ fn, tn, scol, tcol, kind, cname = edge
274
+ qf = qualified_from_node_id(store, fn)
275
+ qt = qualified_from_node_id(store, tn)
276
+ sc = str(scol)
277
+ tc = str(tcol)
278
+ step: TraverseStepPayload = {
279
+ "from_table": qf,
280
+ "to_table": qt,
281
+ "from_column": sc,
282
+ "to_column": tc,
283
+ "from_db": connection_label,
284
+ "to_db": connection_label,
285
+ "via": f"{sc} = {tc}",
286
+ "edge_type": "fk" if kind == "fk" else "inferred",
287
+ }
288
+ if cname is not None:
289
+ step["constraint_name"] = cname
290
+ return step
291
+
292
+
293
+ # Extension surface: indexing never populates SAME_ENTITY, so this always
294
+ # returns an error in standard deployments. Kept as an integration point for
295
+ # deployments that supply entity-resolution results via external pipelines.
296
+ def traverse_cross_database(
297
+ store: KuzuStore,
298
+ *,
299
+ from_table: str,
300
+ to_table: str,
301
+ from_database_key: str,
302
+ to_database_key: str,
303
+ from_connection: str,
304
+ to_connection: str,
305
+ max_depth: int,
306
+ visibility_filter: VisibilityFilter | None = None,
307
+ ) -> dict[str, Any]:
308
+ """Path via confirmed ``SAME_ENTITY`` between two connections in one Kuzu file."""
309
+ from_id, err = resolve_table_node_id(
310
+ store,
311
+ from_table,
312
+ from_database_key,
313
+ visibility_filter=visibility_filter,
314
+ )
315
+ if err is not None:
316
+ try:
317
+ return json.loads(err)
318
+ except json.JSONDecodeError:
319
+ return {"error": err}
320
+ to_id, err2 = resolve_table_node_id(
321
+ store,
322
+ to_table,
323
+ to_database_key,
324
+ visibility_filter=visibility_filter,
325
+ )
326
+ if err2 is not None:
327
+ try:
328
+ return json.loads(err2)
329
+ except json.JSONDecodeError:
330
+ return {"error": err2}
331
+ if from_id is None or to_id is None:
332
+ return {"error": "Could not resolve one or both table references."}
333
+
334
+ bridge_rows = store.query_all_rows(
335
+ """
336
+ MATCH (e1:Entity)-[r:SAME_ENTITY]->(e2:Entity)
337
+ WHERE r.status = $st
338
+ MATCH (e1)-[:REPRESENTS]->(ta:SchemaTable)
339
+ MATCH (e2)-[:REPRESENTS]->(tb:SchemaTable)
340
+ WHERE ta.connection_name = $fca AND tb.connection_name = $tca
341
+ RETURN ta.node_id, tb.node_id, r.join_columns, r.score
342
+ """,
343
+ {"st": "confirmed", "fca": from_connection, "tca": to_connection},
344
+ )
345
+ bridge_rows += store.query_all_rows(
346
+ """
347
+ MATCH (e1:Entity)-[r:SAME_ENTITY]->(e2:Entity)
348
+ WHERE r.status = $st
349
+ MATCH (e1)-[:REPRESENTS]->(ta:SchemaTable)
350
+ MATCH (e2)-[:REPRESENTS]->(tb:SchemaTable)
351
+ WHERE ta.connection_name = $tca AND tb.connection_name = $fca
352
+ RETURN tb.node_id, ta.node_id, r.join_columns, r.score
353
+ """,
354
+ {"st": "confirmed", "fca": from_connection, "tca": to_connection},
355
+ )
356
+ allowed_from = visible_schema_table_node_ids(
357
+ store, from_connection, visibility_filter
358
+ )
359
+ allowed_to = visible_schema_table_node_ids(store, to_connection, visibility_filter)
360
+ shadow_from = get_shadow_alias_node_ids(store, from_database_key)
361
+ shadow_to = get_shadow_alias_node_ids(store, to_database_key)
362
+
363
+ best: tuple[float, list[TraverseStepPayload]] | None = None
364
+ cross_note = (
365
+ "Cross-database paths require application-side joins or a federation layer; "
366
+ "SQL cannot span these connections in a single query."
367
+ )
368
+ for ta_id, tb_id, jcols, sc in bridge_rows:
369
+ ta_s, tb_s = str(ta_id), str(tb_id)
370
+ left = dijkstra_join_path(
371
+ store,
372
+ start_id=from_id,
373
+ end_id=ta_s,
374
+ connection_name=from_connection,
375
+ max_depth=max_depth,
376
+ allowed_table_node_ids=allowed_from,
377
+ shadow_alias_ids=shadow_from,
378
+ )
379
+ right = dijkstra_join_path(
380
+ store,
381
+ start_id=tb_s,
382
+ end_id=to_id,
383
+ connection_name=to_connection,
384
+ max_depth=max_depth,
385
+ allowed_table_node_ids=allowed_to,
386
+ shadow_alias_ids=shadow_to,
387
+ )
388
+ if left is None or right is None:
389
+ continue
390
+ left_edges, left_conf = left
391
+ right_edges, right_conf = right
392
+ bridge_conf = float(sc) if sc is not None else 1.0
393
+ conf = min(left_conf, right_conf, bridge_conf)
394
+
395
+ steps_out: list[TraverseStepPayload] = [
396
+ step_dict_from_edge(store, e, from_connection) for e in left_edges
397
+ ]
398
+ bridge_step: TraverseStepPayload = {
399
+ "from_table": qualified_from_node_id(store, ta_s),
400
+ "to_table": qualified_from_node_id(store, tb_s),
401
+ "from_column": "",
402
+ "to_column": "",
403
+ "from_db": from_connection,
404
+ "to_db": to_connection,
405
+ "via": "SAME_ENTITY (confirmed)",
406
+ "edge_type": "entity_link",
407
+ "join_columns": str(jcols) if jcols is not None else None,
408
+ }
409
+ steps_out.append(bridge_step)
410
+ steps_out.extend(
411
+ step_dict_from_edge(store, e, to_connection) for e in right_edges
412
+ )
413
+ if best is None or conf > best[0]:
414
+ best = (conf, steps_out)
415
+
416
+ if best is None:
417
+ return {
418
+ "error": (
419
+ "No cross-database path found via confirmed SAME_ENTITY links. "
420
+ "Entity links are populated by external tooling, not by indexing."
421
+ ),
422
+ "from": from_table,
423
+ "to": to_table,
424
+ "from_database": from_connection,
425
+ "to_database": to_connection,
426
+ "cross_database": True,
427
+ }
428
+ conf, steps_out = best
429
+ if visibility_filter is not None and not traverse_steps_respect_visibility(
430
+ steps_out,
431
+ visibility_filter=visibility_filter,
432
+ default_connection=from_connection,
433
+ ):
434
+ return {
435
+ "error": (
436
+ "No cross-database path found that respects visibility rules "
437
+ "(a required hop crosses a hidden table)."
438
+ ),
439
+ "from": from_table,
440
+ "to": to_table,
441
+ "from_database": from_connection,
442
+ "to_database": to_connection,
443
+ "cross_database": True,
444
+ }
445
+
446
+ return {
447
+ "from": from_table,
448
+ "to": to_table,
449
+ "from_database": from_connection,
450
+ "to_database": to_connection,
451
+ "cross_database": True,
452
+ "cross_db_note": cross_note,
453
+ "paths": [
454
+ {
455
+ "steps": steps_out,
456
+ "confidence": conf,
457
+ "requires_application_join": True,
458
+ "semantic_label": "cross_database",
459
+ "ambiguous": False,
460
+ "sql_hint": "",
461
+ }
462
+ ],
463
+ "used_precomputed": False,
464
+ "warning": None,
465
+ }
466
+
467
+
468
+ def _intermediate_keys(path: TraversePathPayload) -> list[tuple[str, str]]:
469
+ """Return ``(schema, table)`` pairs for every node a path passes *through*.
470
+
471
+ The ``from`` endpoint is ``steps[0].from_table``; the ``to`` endpoint is
472
+ ``steps[-1].to_table``. Intermediates are the ``to_table`` of every step
473
+ except the last (equivalently, the ``from_table`` of every step except
474
+ the first). Table names in step payloads are already ``"schema.table"``.
475
+ """
476
+ steps = path.get("steps") or []
477
+ if len(steps) <= 1:
478
+ return []
479
+ keys: list[tuple[str, str]] = []
480
+ for step in steps[:-1]:
481
+ qual = str(step.get("to_table", ""))
482
+ if "." in qual:
483
+ sn, tn = qual.split(".", 1)
484
+ else:
485
+ sn, tn = "", qual
486
+ keys.append((sn, tn))
487
+ return keys
488
+
489
+
490
+ def _embedding_tie_break(
491
+ store: KuzuStore,
492
+ *,
493
+ db_key: str,
494
+ from_id: str,
495
+ to_id: str,
496
+ paths: list[TraversePathPayload],
497
+ embedding_client: EmbeddingClient | None = None,
498
+ ) -> tuple[list[TraversePathPayload], bool]:
499
+ """Reorder equal-cost ``paths`` by embedding similarity.
500
+
501
+ Scoring: ``cosine(embed(from.description ⊕ to.description),
502
+ embed(⊕ intermediate.description))`` per path. Embeds all texts in one
503
+ batch call — endpoints at index 0, each path's concatenated intermediate
504
+ text at indices 1..N.
505
+
506
+ Returns ``(paths, False)`` unchanged (pre-PR behavior) when any of:
507
+ * ``PRETENSOR_EMBEDDINGS_DISABLED`` is set (kill switch);
508
+ * fewer than two paths (no tie to break);
509
+ * either endpoint row is missing its ``SchemaTable.embedding``;
510
+ * every path has zero intermediates (direct ``from → to`` hops);
511
+ * the embedding client is missing, unavailable, or raises.
512
+
513
+ Returns ``(reranked, True)`` with paths sorted by descending score.
514
+ Python's ``sorted`` is stable, so paths with identical scores retain
515
+ their incoming order — all top-tied paths are emitted together.
516
+ """
517
+ if len(paths) <= 1:
518
+ return paths, False
519
+
520
+ # Kill switch wins over everything, including an injected client.
521
+ if embeddings_disabled_via_env():
522
+ return paths, False
523
+
524
+ # Two-stage check: first confirm both endpoints carry an embedding
525
+ # (cheap boolean), then fetch only the descriptions used by the
526
+ # tie-break input. Avoids reading the 384-float vector for both
527
+ # endpoints when we only need a null-check on them — that data is
528
+ # never consulted; the tie-break embeds the descriptions, not the
529
+ # endpoints' own vectors.
530
+ endpoint_rows = store.query_all_rows(
531
+ """
532
+ MATCH (t:SchemaTable)
533
+ WHERE t.database = $db AND t.node_id IN [$fid, $tid]
534
+ RETURN t.node_id, t.description, t.embedding IS NOT NULL
535
+ """,
536
+ {"db": db_key, "fid": from_id, "tid": to_id},
537
+ )
538
+ by_nid: dict[str, tuple[str, bool]] = {}
539
+ for row in endpoint_rows:
540
+ nid = str(row[0])
541
+ desc = str(row[1] or "")
542
+ has_vec = bool(row[2])
543
+ by_nid[nid] = (desc, has_vec)
544
+ from_entry = by_nid.get(from_id)
545
+ to_entry = by_nid.get(to_id)
546
+ if from_entry is None or to_entry is None:
547
+ return paths, False
548
+ if not from_entry[1] or not to_entry[1]:
549
+ return paths, False
550
+ from_desc = from_entry[0]
551
+ to_desc = to_entry[0]
552
+
553
+ path_intermediates = [_intermediate_keys(p) for p in paths]
554
+ if not any(path_intermediates):
555
+ return paths, False
556
+
557
+ wanted_schemas: set[str] = set()
558
+ wanted_tables: set[str] = set()
559
+ for keys in path_intermediates:
560
+ for sn, tn in keys:
561
+ wanted_schemas.add(sn)
562
+ wanted_tables.add(tn)
563
+ desc_rows = store.query_all_rows(
564
+ """
565
+ MATCH (t:SchemaTable)
566
+ WHERE t.database = $db
567
+ AND t.schema_name IN $schemas
568
+ AND t.table_name IN $tables
569
+ RETURN t.schema_name, t.table_name, t.description
570
+ """,
571
+ {
572
+ "db": db_key,
573
+ "schemas": list(wanted_schemas),
574
+ "tables": list(wanted_tables),
575
+ },
576
+ )
577
+ desc_by_key: dict[tuple[str, str], str] = {}
578
+ for row in desc_rows:
579
+ desc_by_key[(str(row[0] or ""), str(row[1] or ""))] = str(row[2] or "")
580
+
581
+ # ``LocalEmbeddingClient.__init__`` is I/O-free; a fresh-or-cached
582
+ # construction here cannot raise. Errors from the actual embed()
583
+ # call are caught below, where they route to skipping the tie-break
584
+ # without raising up to the MCP caller.
585
+ client = embedding_client or get_default_embedding_client()
586
+
587
+ texts: list[str] = [f"{from_desc} {to_desc}"]
588
+ for keys in path_intermediates:
589
+ texts.append(" ".join(desc_by_key.get(k, "") for k in keys))
590
+
591
+ try:
592
+ vectors = client.embed(texts)
593
+ except ImportError as exc:
594
+ logger.warning("traverse: [embeddings] extra not installed: %s", exc)
595
+ return paths, False
596
+ except Exception as exc: # noqa: BLE001 — tool must never raise
597
+ logger.warning(
598
+ "traverse: embedding client failed (%s); skipping tie-break", exc
599
+ )
600
+ return paths, False
601
+
602
+ if not vectors or len(vectors) != len(texts):
603
+ return paths, False
604
+ if any(not v for v in vectors):
605
+ return paths, False
606
+
607
+ anchor = vectors[0]
608
+ scored: list[tuple[int, float, TraversePathPayload]] = []
609
+ for idx, path in enumerate(paths):
610
+ try:
611
+ score = cosine_similarity(anchor, vectors[idx + 1])
612
+ except ValueError as exc:
613
+ logger.warning("traverse: tie-break dim mismatch (%s); skipping", exc)
614
+ return paths, False
615
+ scored.append((idx, score, path))
616
+
617
+ # Python's sort is stable — paths with identical scores retain
618
+ # input order, preserving the incoming order across all top-tied paths.
619
+ scored.sort(key=lambda item: item[1], reverse=True)
620
+ reranked = [item[2] for item in scored]
621
+ return reranked, True
622
+
623
+
624
+ def traverse_payload(
625
+ graph_dir: Path,
626
+ *,
627
+ from_table: str,
628
+ to_table: str,
629
+ database: str,
630
+ to_database: str | None = None,
631
+ max_depth: int = 4,
632
+ top_k: int = 3,
633
+ edge_types: tuple[str, ...] | None = None,
634
+ max_inferred_hops: int = 2,
635
+ visibility_filter: VisibilityFilter | None = None,
636
+ embedding_client: EmbeddingClient | None = None,
637
+ ) -> dict[str, Any]:
638
+ """Join paths between two tables (precomputed or on-demand Yen's K-shortest)."""
639
+ edge_kinds: tuple[Any, ...] | None = None
640
+ if edge_types:
641
+ valid = {"fk", "inferred"}
642
+ cleaned = tuple(t for t in edge_types if t in valid)
643
+ if cleaned:
644
+ edge_kinds = cleaned
645
+ reg = load_registry(graph_dir)
646
+ entry = resolve_registry_entry(reg, database)
647
+ if entry is None:
648
+ return {
649
+ "error": "Unknown database connection or name; pass `database` "
650
+ "(connection name or logical database)."
651
+ }
652
+ gp = graph_path_for_entry(entry)
653
+ if not gp.exists():
654
+ return {"error": f"Graph file missing: {gp}"}
655
+
656
+ db_key = str(entry.database)
657
+ vf = visibility_filter or get_effective_visibility_filter()
658
+ store = open_store_for_entry(entry)
659
+ try:
660
+ allowed = visible_schema_table_node_ids(store, entry.connection_name, vf)
661
+ to_entry_target: RegistryEntry | None = None
662
+ if to_database is not None and str(to_database).strip():
663
+ to_entry_target = resolve_registry_entry(reg, str(to_database).strip())
664
+ if to_entry_target is None:
665
+ return {
666
+ "error": f"Unknown to_database connection {to_database!r}.",
667
+ }
668
+
669
+ if to_entry_target is None:
670
+ entries = reg.list_entries()
671
+ if len(entries) > 1:
672
+ for cand in entries:
673
+ if cand.connection_name == entry.connection_name:
674
+ continue
675
+ if graph_path_for_entry(cand) != gp:
676
+ continue
677
+ res = traverse_cross_database(
678
+ store,
679
+ from_table=from_table,
680
+ to_table=to_table,
681
+ from_database_key=db_key,
682
+ to_database_key=str(cand.database),
683
+ from_connection=entry.connection_name,
684
+ to_connection=cand.connection_name,
685
+ max_depth=max_depth,
686
+ visibility_filter=vf,
687
+ )
688
+ if "error" not in res:
689
+ return res
690
+
691
+ elif (
692
+ str(to_entry_target.database) != db_key
693
+ or to_entry_target.connection_name != entry.connection_name
694
+ ):
695
+ return traverse_cross_database(
696
+ store,
697
+ from_table=from_table,
698
+ to_table=to_table,
699
+ from_database_key=db_key,
700
+ to_database_key=str(to_entry_target.database),
701
+ from_connection=entry.connection_name,
702
+ to_connection=to_entry_target.connection_name,
703
+ max_depth=max_depth,
704
+ visibility_filter=vf,
705
+ )
706
+
707
+ from_id, err = resolve_table_node_id(
708
+ store, from_table, db_key, visibility_filter=vf
709
+ )
710
+ if err is not None:
711
+ try:
712
+ return json.loads(err)
713
+ except json.JSONDecodeError:
714
+ return {"error": err}
715
+ to_id, err2 = resolve_table_node_id(
716
+ store, to_table, db_key, visibility_filter=vf
717
+ )
718
+ if err2 is not None:
719
+ try:
720
+ return json.loads(err2)
721
+ except json.JSONDecodeError:
722
+ return {"error": err2}
723
+ if from_id is None or to_id is None:
724
+ return {"error": "Could not resolve one or both table references."}
725
+
726
+ engine = JoinPathEngine(store)
727
+ stored = engine.load_stored_paths(db_key, from_id, to_id)
728
+ used_fallback = False
729
+ tie_break_ran = False
730
+ paths_payload: list[TraversePathPayload] = []
731
+ visible_stored: list[StoredJoinPath] = []
732
+ for p in stored:
733
+ pay = stored_path_to_payload(p)
734
+ steps = pay.get("steps") or []
735
+ if traverse_steps_respect_visibility(
736
+ steps,
737
+ visibility_filter=vf,
738
+ default_connection=entry.connection_name,
739
+ ):
740
+ paths_payload.append(pay)
741
+ visible_stored.append(p)
742
+
743
+ shadow_ids = get_shadow_alias_node_ids(store, db_key)
744
+
745
+ # Safety net for stale indexes written before the precompute cross-cluster
746
+ # depth was lifted. If every stored path for this pair goes through an
747
+ # inferred edge, probe for a pure-FK chain within ``max_depth``; when one
748
+ # exists, promote it to the top slot so the authoritative DDL-declared
749
+ # join beats a short inferred shortcut. Fresh indexes (post this PR)
750
+ # already persist the FK chain, so the probe is a no-op.
751
+ if paths_payload and all(
752
+ any(
753
+ str(s.get("edge_type", "")) == "inferred"
754
+ for s in (p.get("steps") or [])
755
+ )
756
+ for p in paths_payload
757
+ ):
758
+ fk_probe = dijkstra_join_path(
759
+ store,
760
+ start_id=from_id,
761
+ end_id=to_id,
762
+ connection_name=entry.connection_name,
763
+ max_depth=max_depth,
764
+ allowed_table_node_ids=allowed,
765
+ shadow_alias_ids=shadow_ids,
766
+ edge_kinds=("fk",),
767
+ max_inferred_hops=0,
768
+ )
769
+ if fk_probe is not None:
770
+ fk_edges, fk_min_conf = fk_probe
771
+ fk_steps_out: list[TraverseStepPayload] = [
772
+ step_dict_from_edge(store, e, entry.connection_name)
773
+ for e in fk_edges
774
+ ]
775
+ if traverse_steps_respect_visibility(
776
+ fk_steps_out,
777
+ visibility_filter=vf,
778
+ default_connection=entry.connection_name,
779
+ ):
780
+ fk_payload: TraversePathPayload = {
781
+ "confidence": fk_min_conf,
782
+ "ambiguous": False,
783
+ "semantic_label": "fk_chain_promoted",
784
+ "steps": fk_steps_out,
785
+ "sql_hint": sql_hint_from_steps(fk_steps_out),
786
+ }
787
+ paths_payload = [fk_payload, *paths_payload]
788
+ used_fallback = True
789
+ logger.info(
790
+ "traverse: fk_chain_promoted over stored inferred path "
791
+ "for %s -> %s (%s); reindex to refresh precomputed winners",
792
+ from_table,
793
+ to_table,
794
+ database,
795
+ )
796
+
797
+ # Precompute only persists the single winning path per pair; when it is
798
+ # flagged ambiguous we re-enumerate tied peers so callers see every
799
+ # candidate they may need to pick between.
800
+ if paths_payload and any(p.ambiguous for p in visible_stored):
801
+ tied = engine.find_paths_on_demand(
802
+ db_key,
803
+ from_id,
804
+ to_id,
805
+ max_depth,
806
+ top_k=top_k,
807
+ edge_kinds=edge_kinds,
808
+ max_inferred_hops=max_inferred_hops,
809
+ )
810
+ tied_payloads: list[TraversePathPayload] = []
811
+ for tp in tied:
812
+ tpay = stored_path_to_payload(tp)
813
+ tsteps = tpay.get("steps") or []
814
+ if traverse_steps_respect_visibility(
815
+ tsteps,
816
+ visibility_filter=vf,
817
+ default_connection=entry.connection_name,
818
+ ):
819
+ tied_payloads.append(tpay)
820
+ if len(tied_payloads) > 1:
821
+ paths_payload, tie_break_ran = _embedding_tie_break(
822
+ store,
823
+ db_key=db_key,
824
+ from_id=from_id,
825
+ to_id=to_id,
826
+ paths=tied_payloads,
827
+ embedding_client=embedding_client,
828
+ )
829
+
830
+ if not paths_payload:
831
+ bfs = dijkstra_join_path(
832
+ store,
833
+ start_id=from_id,
834
+ end_id=to_id,
835
+ connection_name=entry.connection_name,
836
+ max_depth=max_depth,
837
+ allowed_table_node_ids=allowed,
838
+ shadow_alias_ids=shadow_ids,
839
+ edge_kinds=edge_kinds,
840
+ max_inferred_hops=max_inferred_hops,
841
+ )
842
+ if bfs is not None:
843
+ edges, min_conf = bfs
844
+ steps_out: list[TraverseStepPayload] = [
845
+ step_dict_from_edge(store, e, entry.connection_name) for e in edges
846
+ ]
847
+ fallback_path: TraversePathPayload = {
848
+ "confidence": min_conf,
849
+ "ambiguous": False,
850
+ "semantic_label": "visibility_filtered",
851
+ "steps": steps_out,
852
+ "sql_hint": sql_hint_from_steps(steps_out),
853
+ }
854
+ paths_payload = [fallback_path]
855
+ used_fallback = True
856
+ logger.warning(
857
+ "traverse: visibility-filtered on-demand path for %s -> %s (%s)",
858
+ from_table,
859
+ to_table,
860
+ database,
861
+ )
862
+
863
+ if not paths_payload:
864
+ on_demand_paths = engine.find_paths_on_demand(
865
+ db_key,
866
+ from_id,
867
+ to_id,
868
+ max_depth,
869
+ top_k=top_k,
870
+ edge_kinds=edge_kinds,
871
+ max_inferred_hops=max_inferred_hops,
872
+ )
873
+ collected: list[TraversePathPayload] = []
874
+ for od in on_demand_paths:
875
+ pay2 = stored_path_to_payload(od)
876
+ steps2 = pay2.get("steps") or []
877
+ if traverse_steps_respect_visibility(
878
+ steps2,
879
+ visibility_filter=vf,
880
+ default_connection=entry.connection_name,
881
+ ):
882
+ collected.append(pay2)
883
+ if collected:
884
+ paths_payload = collected
885
+ used_fallback = True
886
+ logger.warning(
887
+ "traverse: on-demand path for %s -> %s (%s)",
888
+ from_table,
889
+ to_table,
890
+ database,
891
+ )
892
+
893
+ if not paths_payload:
894
+ return {
895
+ "error": (
896
+ f"No join path found between {from_table!r} and {to_table!r} "
897
+ f"within {max_depth} hops that respects visibility rules. "
898
+ "Try increasing max_depth or adjust visibility.yml."
899
+ ),
900
+ "from": from_table,
901
+ "to": to_table,
902
+ "database": database,
903
+ }
904
+ # Only include ``tie_break`` when a tie-break actually ran. Always
905
+ # emitting ``"tie_break": None`` would change the response shape
906
+ # for every caller of ``traverse`` — backward-incompatible. Existing
907
+ # tests on the null path assert the key is absent; this preserves
908
+ # that contract.
909
+ out: dict[str, Any] = {
910
+ "from": from_table,
911
+ "to": to_table,
912
+ "database": database,
913
+ "paths": paths_payload,
914
+ "used_precomputed": not used_fallback,
915
+ "warning": (
916
+ "Path computed on demand; re-run index to refresh precomputed join paths."
917
+ if used_fallback
918
+ else None
919
+ ),
920
+ }
921
+ if tie_break_ran:
922
+ out["tie_break"] = "embedding"
923
+ return out
924
+ finally:
925
+ release_store(store)
926
+
927
+
928
+ __all__ = ["create_tool", "traverse_payload"]
929
+
930
+
931
+ def create_tool(graph_dir: Path) -> McpTool:
932
+ from ._timed import timed_tool
933
+
934
+ async def _handle(args: dict) -> dict:
935
+ from_t = str(args.get("from_table", "")).strip()
936
+ to_t = str(args.get("to_table", "")).strip()
937
+ db_t = str(args.get("database", "")).strip()
938
+ if not from_t or not to_t:
939
+ return {"error": "Missing `from_table` or `to_table`"}
940
+ if not db_t:
941
+ return {"error": "Missing `database`"}
942
+ to_db = args.get("to_database")
943
+ to_db_s = str(to_db).strip() if to_db is not None else None
944
+ if to_db_s == "":
945
+ to_db_s = None
946
+ max_depth = int(args.get("max_depth", 4))
947
+ top_k = max(1, min(10, int(args.get("top_k", 3))))
948
+ max_inf = max(0, min(8, int(args.get("max_inferred_hops", 2))))
949
+ raw_kinds = args.get("edge_types")
950
+ edge_types: tuple[str, ...] | None = None
951
+ if isinstance(raw_kinds, list):
952
+ cleaned = tuple(str(k).strip() for k in raw_kinds if isinstance(k, str))
953
+ if cleaned:
954
+ edge_types = cleaned
955
+ with timed_tool(
956
+ "traverse",
957
+ graph_dir,
958
+ database=db_t,
959
+ to_database=to_db_s,
960
+ max_depth=max_depth,
961
+ top_k=top_k,
962
+ ):
963
+ return traverse_payload(
964
+ graph_dir,
965
+ from_table=from_t,
966
+ to_table=to_t,
967
+ database=db_t,
968
+ to_database=to_db_s,
969
+ max_depth=max_depth,
970
+ top_k=top_k,
971
+ edge_types=edge_types,
972
+ max_inferred_hops=max_inf,
973
+ )
974
+
975
+ return McpTool(
976
+ name="traverse",
977
+ description=(
978
+ "Find a join path between two physical tables (precomputed or on-demand), "
979
+ "with SQL JOIN hints."
980
+ ),
981
+ input_schema={
982
+ "type": "object",
983
+ "properties": {
984
+ "from_table": {
985
+ "type": "string",
986
+ "description": "Source table (name or schema.table)",
987
+ },
988
+ "to_table": {
989
+ "type": "string",
990
+ "description": "Target table (name or schema.table)",
991
+ },
992
+ "database": {
993
+ "type": "string",
994
+ "description": "Connection name or logical database (required if multiple graphs)",
995
+ },
996
+ "max_depth": {
997
+ "type": "integer",
998
+ "default": 4,
999
+ "minimum": 1,
1000
+ "maximum": 8,
1001
+ "description": "Max hops",
1002
+ },
1003
+ "top_k": {
1004
+ "type": "integer",
1005
+ "default": 3,
1006
+ "minimum": 1,
1007
+ "maximum": 10,
1008
+ "description": "Yen's K — return up to this many ranked alternative paths",
1009
+ },
1010
+ "edge_types": {
1011
+ "type": "array",
1012
+ "items": {"type": "string", "enum": ["fk", "inferred"]},
1013
+ "description": "Restrict adjacency to these edge kinds (default: both)",
1014
+ },
1015
+ "max_inferred_hops": {
1016
+ "type": "integer",
1017
+ "default": 2,
1018
+ "minimum": 0,
1019
+ "maximum": 8,
1020
+ "description": "Cap on inferred-join hops per path (FK hops uncapped beyond max_depth)",
1021
+ },
1022
+ },
1023
+ "required": ["from_table", "to_table", "database"],
1024
+ "additionalProperties": False,
1025
+ },
1026
+ handler=_handle,
1027
+ )