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,170 @@
1
+ """MCP ``schema`` tool: introspect node labels and edge types in the Kuzu graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pretensor.core.schema import CATALOG_EDGE_TYPES, CATALOG_NODE_LABELS
10
+ from pretensor.core.store import KuzuStore
11
+ from pretensor.mcp.tool_registry import McpTool
12
+
13
+ from ..service_registry import (
14
+ graph_path_for_entry,
15
+ load_registry,
16
+ open_store_for_path,
17
+ release_store,
18
+ resolve_registry_entry,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ __all__ = ["create_tool", "schema_payload"]
24
+
25
+
26
+ def create_tool(graph_dir: Path) -> McpTool:
27
+ from ._timed import timed_tool
28
+
29
+ async def _handle(args: dict) -> dict:
30
+ db_t = str(args.get("database", "")).strip()
31
+ if not db_t:
32
+ return {"error": "Missing `database`"}
33
+ label_raw = args.get("label")
34
+ label_s = str(label_raw).strip() if label_raw is not None else None
35
+ if label_s == "":
36
+ label_s = None
37
+ with timed_tool("schema", graph_dir, database=db_t, label=label_s):
38
+ return schema_payload(graph_dir, database=db_t, label=label_s)
39
+
40
+ return McpTool(
41
+ name="schema",
42
+ description=(
43
+ "Discover node labels, edge types, and properties in the graph. "
44
+ "Call before writing Cypher."
45
+ ),
46
+ input_schema={
47
+ "type": "object",
48
+ "properties": {
49
+ "database": {
50
+ "type": "string",
51
+ "description": "Connection name or logical database",
52
+ },
53
+ "label": {
54
+ "type": ["string", "null"],
55
+ "description": "Optional node label or edge type to filter",
56
+ },
57
+ },
58
+ "required": ["database"],
59
+ "additionalProperties": False,
60
+ },
61
+ handler=_handle,
62
+ )
63
+
64
+
65
+ _NODE_LABEL_DESC = {label: desc for label, desc in CATALOG_NODE_LABELS}
66
+ _EDGE_INFO = {name: (src, dst, desc) for name, src, dst, desc in CATALOG_EDGE_TYPES}
67
+
68
+
69
+ def _list_catalog_tables(store: KuzuStore) -> list[tuple[str, str]]:
70
+ """Return ``[(name, type), ...]`` from ``CALL show_tables()``.
71
+
72
+ ``type`` is ``'NODE'`` or ``'REL'`` per Kuzu.
73
+ """
74
+ raw = store.execute("CALL show_tables() RETURN name, type")
75
+ if isinstance(raw, list):
76
+ return []
77
+ rows = raw.rows_as_dict()
78
+ out: list[tuple[str, str]] = []
79
+ while rows.has_next():
80
+ row: Any = rows.get_next()
81
+ if isinstance(row, dict):
82
+ out.append((str(row.get("name", "")), str(row.get("type", "")).upper()))
83
+ return out
84
+
85
+
86
+ def _table_properties(store: KuzuStore, label: str) -> list[dict[str, str]]:
87
+ """Return ``[{"name": ..., "type": ...}, ...]`` for one node/rel table."""
88
+ try:
89
+ raw = store.execute(f"CALL table_info('{label}') RETURN name, type")
90
+ except Exception:
91
+ return []
92
+ if isinstance(raw, list):
93
+ return []
94
+ rows = raw.rows_as_dict()
95
+ out: list[dict[str, str]] = []
96
+ while rows.has_next():
97
+ row: Any = rows.get_next()
98
+ if isinstance(row, dict):
99
+ out.append(
100
+ {
101
+ "name": str(row.get("name", "")),
102
+ "type": str(row.get("type", "")),
103
+ }
104
+ )
105
+ return out
106
+
107
+
108
+ def schema_payload(
109
+ graph_dir: Path,
110
+ *,
111
+ database: str,
112
+ label: str | None = None,
113
+ ) -> dict[str, Any]:
114
+ """Return the node and edge catalog for the indexed graph of ``database``.
115
+
116
+ Args:
117
+ graph_dir: Graph workspace directory (registry + Kuzu files).
118
+ database: Connection name or logical database name.
119
+ label: If set, return only the matching node label or edge type.
120
+
121
+ Returns:
122
+ ``{"nodes": [...], "edges": [...]}`` on success, or ``{"error": "..."}``.
123
+ """
124
+ db = (database or "").strip()
125
+ if not db:
126
+ return {"error": "Missing `database`"}
127
+
128
+ reg = load_registry(graph_dir)
129
+ entry = resolve_registry_entry(reg, db)
130
+ if entry is None:
131
+ return {"error": f"Unknown database: {db!r}"}
132
+
133
+ graph_path = graph_path_for_entry(entry)
134
+ if not graph_path.exists():
135
+ return {"error": f"Graph file not found for database: {db!r}"}
136
+
137
+ store = open_store_for_path(graph_path)
138
+ try:
139
+ catalog = _list_catalog_tables(store)
140
+ nodes: list[dict[str, Any]] = []
141
+ edges: list[dict[str, Any]] = []
142
+ for name, kind in catalog:
143
+ if label and name != label:
144
+ continue
145
+ properties = _table_properties(store, name)
146
+ if kind == "NODE":
147
+ nodes.append(
148
+ {
149
+ "label": name,
150
+ "description": _NODE_LABEL_DESC.get(name, ""),
151
+ "properties": properties,
152
+ }
153
+ )
154
+ elif kind == "REL":
155
+ src, dst, desc = _EDGE_INFO.get(name, ("", "", ""))
156
+ edges.append(
157
+ {
158
+ "type": name,
159
+ "from": src,
160
+ "to": dst,
161
+ "description": desc,
162
+ "properties": properties,
163
+ }
164
+ )
165
+ finally:
166
+ release_store(store)
167
+
168
+ if label and not nodes and not edges:
169
+ return {"error": f"Unknown label: {label!r}"}
170
+ return {"nodes": nodes, "edges": edges}
@@ -0,0 +1,316 @@
1
+ """MCP ``query`` tool.
2
+
3
+ BM25 search over table and entity metadata (SQLite FTS5). When the
4
+ ``[embeddings]`` extra is installed and at least one indexed table carries a
5
+ vector, the results are fused with a cosine top-K pass via Reciprocal Rank
6
+ Fusion (RRF) and the envelope gains a ``"rerank": "rrf"`` marker. When no
7
+ vectors are present or the embedding client fails at runtime, the envelope is
8
+ byte-identical to the pre-fusion response (no ``rerank`` key).
9
+
10
+ Determinism contract: fusion must never override the BM25 path when
11
+ embeddings are absent.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from pathlib import Path
18
+ from typing import Any, cast
19
+
20
+ from pretensor.intelligence.embeddings import (
21
+ EmbeddingClient,
22
+ embeddings_disabled_via_env,
23
+ get_default_embedding_client,
24
+ )
25
+ from pretensor.mcp.tool_registry import McpTool
26
+ from pretensor.observability import log_timed_operation
27
+ from pretensor.search.base import BaseSearchIndex
28
+ from pretensor.search.index import KeywordSearchIndex
29
+ from pretensor.visibility.filter import VisibilityFilter
30
+
31
+ from ..payload_types import QueryHit, snippet
32
+ from ..service_context import (
33
+ get_effective_search_index_cls,
34
+ get_effective_visibility_filter,
35
+ )
36
+ from ..service_registry import load_registry, resolve_registry_entry
37
+ from ._rank import (
38
+ CosineHit,
39
+ any_entry_has_vectors,
40
+ cosine_rank_tables,
41
+ fusion_key,
42
+ rrf_fuse,
43
+ )
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ def _hit_visible(hit: QueryHit, vf: VisibilityFilter | None) -> bool:
49
+ if vf is None or hit.get("node_type") != "SchemaTable":
50
+ return True
51
+ name = str(hit.get("name", "")).strip()
52
+ if "." not in name:
53
+ return vf.is_table_visible(str(hit.get("connection_name", "")), "", name)
54
+ sn, _, tn = name.partition(".")
55
+ return vf.is_table_visible(str(hit.get("connection_name", "")), sn, tn)
56
+
57
+
58
+ def _load_search_index(
59
+ graph_dir: Path,
60
+ *,
61
+ search_index_cls: type[BaseSearchIndex],
62
+ ) -> BaseSearchIndex:
63
+ """Load/build a search index using class-level hooks when available."""
64
+ reg = load_registry(graph_dir)
65
+ search_index_cls_any = search_index_cls
66
+ default_path = getattr(search_index_cls_any, "default_path", None)
67
+ index_path = (
68
+ Path(cast(Path, default_path(graph_dir)))
69
+ if callable(default_path)
70
+ else KeywordSearchIndex.default_path(graph_dir)
71
+ )
72
+ load_or_build = getattr(search_index_cls_any, "load_or_build", None)
73
+ if callable(load_or_build):
74
+ built = load_or_build(reg, index_path)
75
+ if not isinstance(built, BaseSearchIndex):
76
+ raise TypeError(
77
+ f"{search_index_cls.__name__}.load_or_build must return BaseSearchIndex"
78
+ )
79
+ return built
80
+ index = cast(Any, search_index_cls_any)(index_path)
81
+ if not isinstance(index, BaseSearchIndex):
82
+ raise TypeError(
83
+ f"{search_index_cls.__name__} constructor must return BaseSearchIndex"
84
+ )
85
+ index.index_graph(reg)
86
+ return index
87
+
88
+
89
+ def _split_name(name: str) -> tuple[str, str]:
90
+ """Split ``"schema.table"`` into ``(schema, table)``; tolerate a missing dot."""
91
+ if "." in name:
92
+ schema_name, _, table_name = name.partition(".")
93
+ return schema_name, table_name
94
+ return "", name
95
+
96
+
97
+ def _bm25_key(hit: QueryHit) -> str:
98
+ schema_name, table_name = _split_name(str(hit.get("name", "")))
99
+ return fusion_key(
100
+ connection_name=str(hit.get("connection_name", "")),
101
+ database=str(hit.get("database_name", "")),
102
+ schema_name=schema_name,
103
+ table_name=table_name,
104
+ )
105
+
106
+
107
+ def _hit_from_cosine(hit: CosineHit, *, score: float) -> QueryHit:
108
+ item: QueryHit = {
109
+ "node_type": "SchemaTable",
110
+ "name": (
111
+ f"{hit.schema_name}.{hit.table_name}" if hit.schema_name else hit.table_name
112
+ ),
113
+ "database_name": hit.database,
114
+ "connection_name": hit.connection_name,
115
+ "description": hit.description,
116
+ "snippet": snippet(hit.description),
117
+ "score": score,
118
+ }
119
+ return item
120
+
121
+
122
+ def query_payload(
123
+ graph_dir: Path,
124
+ *,
125
+ q: str,
126
+ db: str | None = None,
127
+ limit: int = 10,
128
+ search_index_cls: type[BaseSearchIndex] | None = None,
129
+ visibility_filter: VisibilityFilter | None = None,
130
+ embedding_client: EmbeddingClient | None = None,
131
+ ) -> dict[str, Any]:
132
+ """BM25 search over indexed metadata; hybrid RRF rerank when embeddings available."""
133
+ with log_timed_operation(
134
+ logger,
135
+ event="mcp.query_payload",
136
+ query=q,
137
+ db=db,
138
+ limit=limit,
139
+ graph_dir=str(graph_dir),
140
+ ):
141
+ vf = visibility_filter or get_effective_visibility_filter()
142
+ index_cls = get_effective_search_index_cls(search_index_cls)
143
+ idx = _load_search_index(graph_dir, search_index_cls=index_cls)
144
+ raw = idx.search(q, db=db, limit=limit * 4)
145
+
146
+ # BM25 pool: retain up to limit*4 visible SchemaTable hits so fusion
147
+ # has a reasonable pool to pull from; null path truncates to ``limit``.
148
+ bm25_pool: list[QueryHit] = []
149
+ for row in raw:
150
+ if row.node_type != "SchemaTable":
151
+ continue
152
+ item: QueryHit = {
153
+ "node_type": row.node_type,
154
+ "name": row.name,
155
+ "database_name": row.database_name,
156
+ "connection_name": row.connection_name,
157
+ "description": row.description,
158
+ "snippet": snippet(row.description),
159
+ "score": row.score,
160
+ }
161
+ if not _hit_visible(item, vf):
162
+ continue
163
+ bm25_pool.append(item)
164
+
165
+ fused = _maybe_fuse(
166
+ graph_dir,
167
+ q=q,
168
+ db=db,
169
+ limit=limit,
170
+ bm25_pool=bm25_pool,
171
+ visibility_filter=vf,
172
+ embedding_client=embedding_client,
173
+ )
174
+ if fused is not None:
175
+ return fused
176
+
177
+ # Null path: BM25-only envelope, byte-identical to pre-fusion behavior.
178
+ return {"query": q, "db": db, "results": bm25_pool[:limit]}
179
+
180
+
181
+ def _maybe_fuse(
182
+ graph_dir: Path,
183
+ *,
184
+ q: str,
185
+ db: str | None,
186
+ limit: int,
187
+ bm25_pool: list[QueryHit],
188
+ visibility_filter: VisibilityFilter | None,
189
+ embedding_client: EmbeddingClient | None,
190
+ ) -> dict[str, Any] | None:
191
+ """Attempt the hybrid rerank; return ``None`` when the null path applies."""
192
+ # Kill switch wins over everything, including an injected client, so
193
+ # PRETENSOR_EMBEDDINGS_DISABLED=1 forces the BM25-only envelope even
194
+ # when the extra is installed and vectors exist.
195
+ if embeddings_disabled_via_env():
196
+ return None
197
+
198
+ reg = load_registry(graph_dir)
199
+ all_entries = reg.list_entries()
200
+ if not all_entries:
201
+ return None
202
+ if db is None:
203
+ entries = all_entries
204
+ else:
205
+ resolved = resolve_registry_entry(reg, db)
206
+ if resolved is None:
207
+ # Unknown db: BM25 already returns empty for this case; fall back
208
+ # to its behavior instead of marking the envelope as reranked.
209
+ return None
210
+ entries = [resolved]
211
+
212
+ # Probe for stored vectors BEFORE embedding the query: when no table
213
+ # carries a vector the fused result is discarded anyway, and on a cold
214
+ # process the first embed() call would trigger the ONNX model download
215
+ # + session init for nothing.
216
+ if not any_entry_has_vectors(entries):
217
+ return None
218
+
219
+ # ``LocalEmbeddingClient.__init__`` does no I/O — the heavy lifting
220
+ # happens lazily inside ``embed()``. So a fresh-or-cached construction
221
+ # here cannot raise ImportError; the cached default keeps the ONNX
222
+ # session warm across query calls instead of re-initializing per
223
+ # request.
224
+ client = embedding_client or get_default_embedding_client()
225
+
226
+ try:
227
+ vectors = client.embed([q])
228
+ except ImportError as exc:
229
+ logger.warning("query: [embeddings] extra not installed: %s", exc)
230
+ return None
231
+ except Exception as exc: # noqa: BLE001 — tool must never raise
232
+ logger.warning("query: embedding client failed (%s); BM25 only", exc)
233
+ return None
234
+
235
+ if not vectors or not vectors[0]:
236
+ return None
237
+ qvec = vectors[0]
238
+
239
+ cosine_hits, any_candidate_had_vector = cosine_rank_tables(
240
+ entries,
241
+ qvec,
242
+ database=db,
243
+ cluster=None,
244
+ visibility_filter=visibility_filter,
245
+ k=max(20, limit * 4),
246
+ )
247
+ if not any_candidate_had_vector:
248
+ # No tables carry vectors → preserve null-path parity.
249
+ return None
250
+
251
+ bm25_by_key: dict[str, QueryHit] = {}
252
+ bm25_keys: list[str] = []
253
+ for hit in bm25_pool:
254
+ key = _bm25_key(hit)
255
+ if key in bm25_by_key:
256
+ # Duplicate FQN in the BM25 pool (shouldn't happen, but be safe).
257
+ continue
258
+ bm25_by_key[key] = hit
259
+ bm25_keys.append(key)
260
+ cosine_by_key: dict[str, CosineHit] = {h.key: h for h in cosine_hits}
261
+ cosine_keys = [h.key for h in cosine_hits]
262
+
263
+ fused_results: list[QueryHit] = []
264
+ for key, score in rrf_fuse(bm25_keys=bm25_keys, cosine_keys=cosine_keys):
265
+ if key in bm25_by_key:
266
+ # Reuse BM25 hit (keeps its snippet); overwrite score with RRF.
267
+ hit = dict(bm25_by_key[key])
268
+ hit["score"] = score
269
+ fused_results.append(cast(QueryHit, hit))
270
+ elif key in cosine_by_key:
271
+ fused_results.append(_hit_from_cosine(cosine_by_key[key], score=score))
272
+ if len(fused_results) >= limit:
273
+ break
274
+
275
+ return {"query": q, "db": db, "results": fused_results, "rerank": "rrf"}
276
+
277
+
278
+ __all__ = ["create_tool", "query_payload"]
279
+
280
+
281
+ def create_tool(graph_dir: Path) -> McpTool:
282
+ from ._timed import timed_tool
283
+
284
+ async def _handle(args: dict) -> dict:
285
+ q = str(args.get("q", "")).strip()
286
+ if not q:
287
+ return {"error": "Missing or empty `q`"}
288
+ limit = int(args.get("limit", 10))
289
+ db = args.get("db")
290
+ db_s = str(db) if db is not None else None
291
+ with timed_tool("query", graph_dir, db=db_s, limit=limit):
292
+ return query_payload(graph_dir, q=q, db=db_s, limit=limit)
293
+
294
+ return McpTool(
295
+ name="query",
296
+ description="BM25 keyword search over table and entity metadata (FTS5).",
297
+ input_schema={
298
+ "type": "object",
299
+ "properties": {
300
+ "q": {"type": "string", "description": "Search query"},
301
+ "db": {
302
+ "type": ["string", "null"],
303
+ "description": "Filter by connection_name or logical database name",
304
+ },
305
+ "limit": {
306
+ "type": "integer",
307
+ "default": 10,
308
+ "minimum": 1,
309
+ "maximum": 50,
310
+ },
311
+ },
312
+ "required": ["q"],
313
+ "additionalProperties": False,
314
+ },
315
+ handler=_handle,
316
+ )