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,162 @@
1
+ """Downstream graph impact for a batch of :class:`SchemaChange` rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+
8
+ from pretensor.connectors.snapshot import ChangeTarget, SchemaChange
9
+ from pretensor.core.ids import table_node_id
10
+ from pretensor.core.store import KuzuStore
11
+
12
+ __all__ = ["ImpactAnalyzer", "ImpactReport"]
13
+
14
+
15
+ @dataclass
16
+ class ImpactReport:
17
+ """Artifacts that may be stale or invalid after schema changes."""
18
+
19
+ stale_join_path_ids: list[str] = field(default_factory=list)
20
+ stale_join_path_labels: list[str] = field(default_factory=list)
21
+ affected_cluster_labels: list[str] = field(default_factory=list)
22
+ broken_metrics: list[str] = field(default_factory=list)
23
+ summary: str = ""
24
+
25
+ def to_impact_dict(self) -> dict[str, object]:
26
+ """Shape used by MCP ``detect_changes``."""
27
+ return {
28
+ "stale_join_paths": len(self.stale_join_path_ids),
29
+ "affected_clusters": list(dict.fromkeys(self.affected_cluster_labels)),
30
+ "broken_metrics": list(self.broken_metrics),
31
+ }
32
+
33
+
34
+ class ImpactAnalyzer:
35
+ """Find join paths and clusters affected by schema drift (read-only)."""
36
+
37
+ def __init__(self, store: KuzuStore) -> None:
38
+ self._store = store
39
+
40
+ def analyze(
41
+ self,
42
+ changes: list[SchemaChange],
43
+ *,
44
+ connection_name: str,
45
+ database_key: str,
46
+ ) -> ImpactReport:
47
+ """Return join paths and clusters touching changed tables/columns."""
48
+ report = ImpactReport()
49
+ if not changes:
50
+ report.summary = "No schema changes."
51
+ return report
52
+
53
+ affected_tables: set[tuple[str, str]] = set()
54
+ affected_columns: set[tuple[str, str, str]] = set()
55
+ for ch in changes:
56
+ affected_tables.add((ch.schema_name, ch.table_name))
57
+ if ch.target == ChangeTarget.COLUMN and ch.column_name:
58
+ affected_columns.add((ch.schema_name, ch.table_name, ch.column_name))
59
+
60
+ table_ids = {table_node_id(connection_name, s, t) for s, t in affected_tables}
61
+
62
+ path_rows = self._store.query_all_rows(
63
+ """
64
+ MATCH (p:JoinPath)
65
+ WHERE p.database_key = $db
66
+ RETURN p.node_id, p.semantic_label, p.from_table_id, p.to_table_id,
67
+ p.steps_json, p.stale
68
+ """,
69
+ {"db": database_key},
70
+ )
71
+ seen_paths: set[str] = set()
72
+ for row in path_rows:
73
+ pid, label, from_id, to_id, steps_json, _stale = row
74
+ pid_s = str(pid)
75
+ if self._path_is_affected(
76
+ pid_s,
77
+ str(from_id or ""),
78
+ str(to_id or ""),
79
+ str(steps_json or ""),
80
+ table_ids,
81
+ affected_tables,
82
+ affected_columns,
83
+ ):
84
+ if pid_s not in seen_paths:
85
+ seen_paths.add(pid_s)
86
+ report.stale_join_path_ids.append(pid_s)
87
+ report.stale_join_path_labels.append(str(label or pid_s))
88
+
89
+ cluster_labels: set[str] = set()
90
+ for schema_name, table_name in affected_tables:
91
+ rows = self._store.query_all_rows(
92
+ """
93
+ MATCH (t:SchemaTable)-[:IN_CLUSTER]->(c:Cluster)
94
+ WHERE t.database = $db AND t.schema_name = $sn AND t.table_name = $tn
95
+ RETURN c.label
96
+ """,
97
+ {"db": database_key, "sn": schema_name, "tn": table_name},
98
+ )
99
+ for (lab,) in rows:
100
+ if lab:
101
+ cluster_labels.add(str(lab))
102
+ report.affected_cluster_labels = sorted(cluster_labels)
103
+
104
+ parts: list[str] = []
105
+ if report.stale_join_path_ids:
106
+ parts.append(
107
+ f"{len(report.stale_join_path_ids)} precomputed join path(s) may be "
108
+ "invalid or stale; verify before using in SQL."
109
+ )
110
+ if report.affected_cluster_labels:
111
+ parts.append(
112
+ f"{len(report.affected_cluster_labels)} cluster(s) may need "
113
+ "re-labeling after membership changes."
114
+ )
115
+ if not parts:
116
+ report.summary = (
117
+ "Schema changes are additive or non-breaking for indexed join paths "
118
+ "and clusters."
119
+ )
120
+ else:
121
+ report.summary = " ".join(parts)
122
+ report.summary += (
123
+ " Run `pretensor reindex <dsn>` to refresh the graph; use "
124
+ "`pretensor index` for a full rebuild if needed."
125
+ )
126
+ return report
127
+
128
+ def _path_is_affected(
129
+ self,
130
+ _path_id: str,
131
+ from_id: str,
132
+ to_id: str,
133
+ steps_json: str,
134
+ table_ids: set[str],
135
+ affected_tables: set[tuple[str, str]],
136
+ affected_columns: set[tuple[str, str, str]],
137
+ ) -> bool:
138
+ if from_id in table_ids or to_id in table_ids:
139
+ return True
140
+ try:
141
+ data = json.loads(steps_json)
142
+ except json.JSONDecodeError:
143
+ return False
144
+ if not isinstance(data, list):
145
+ return False
146
+ for step in data:
147
+ if not isinstance(step, dict):
148
+ continue
149
+ fs = str(step.get("from_schema", ""))
150
+ ft = str(step.get("from_table", ""))
151
+ ts_ = str(step.get("to_schema", ""))
152
+ tt = str(step.get("to_table", ""))
153
+ fc = str(step.get("from_column", ""))
154
+ tc = str(step.get("to_column", ""))
155
+ if (fs, ft) in affected_tables or (ts_, tt) in affected_tables:
156
+ return True
157
+ for sn, tn, cn in affected_columns:
158
+ if (fs, ft) == (sn, tn) and (fc == cn or tc == cn):
159
+ return True
160
+ if (ts_, tt) == (sn, tn) and (tc == cn or fc == cn):
161
+ return True
162
+ return False
@@ -0,0 +1,38 @@
1
+ """Persist schema snapshots next to the graph registry for drift detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pretensor.connectors.models import SchemaSnapshot
8
+
9
+ __all__ = ["SnapshotStore"]
10
+
11
+
12
+ class SnapshotStore:
13
+ """Read/write ``SchemaSnapshot`` YAML under ``.pretensor/snapshots/``."""
14
+
15
+ def __init__(self, state_dir: Path) -> None:
16
+ self._dir = state_dir / "snapshots"
17
+
18
+ @property
19
+ def snapshots_dir(self) -> Path:
20
+ return self._dir
21
+
22
+ def path_for(self, connection_name: str) -> Path:
23
+ safe = connection_name.replace("/", "_")
24
+ return self._dir / f"{safe}.yaml"
25
+
26
+ def load(self, connection_name: str) -> SchemaSnapshot | None:
27
+ """Return the last snapshot or None if missing."""
28
+ path = self.path_for(connection_name)
29
+ if not path.exists():
30
+ return None
31
+ return SchemaSnapshot.from_yaml(path.read_text(encoding="utf-8"))
32
+
33
+ def save(self, connection_name: str, snapshot: SchemaSnapshot) -> Path:
34
+ """Write snapshot YAML; creates parent directories."""
35
+ self._dir.mkdir(parents=True, exist_ok=True)
36
+ path = self.path_for(connection_name)
37
+ path.write_text(snapshot.to_yaml(), encoding="utf-8")
38
+ return path
@@ -0,0 +1,9 @@
1
+ """SQL semantic validation against the Kuzu schema graph."""
2
+
3
+ from pretensor.validation.query_validator import (
4
+ JoinWarning,
5
+ QueryValidator,
6
+ ValidationResult,
7
+ )
8
+
9
+ __all__ = ["JoinWarning", "QueryValidator", "ValidationResult"]
@@ -0,0 +1,436 @@
1
+ """Parse SQL with sqlglot and validate table/column/join references against Kuzu."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from difflib import get_close_matches
7
+ from typing import Any
8
+
9
+ import sqlglot
10
+ from sqlglot import exp
11
+ from sqlglot.errors import ParseError
12
+
13
+ from pretensor.core.store import KuzuStore
14
+
15
+ __all__ = [
16
+ "JoinWarning",
17
+ "QueryValidator",
18
+ "ValidationResult",
19
+ ]
20
+
21
+ _DEFAULT_DIALECT = "postgres"
22
+ _FUZZY_CUTOFF = 0.5
23
+
24
+
25
+ @dataclass
26
+ class JoinWarning:
27
+ """A join whose endpoints are not linked by FK or inferred join in the graph."""
28
+
29
+ message: str
30
+ left_table: str
31
+ right_table: str
32
+
33
+
34
+ @dataclass
35
+ class ValidationResult:
36
+ """Outcome of semantic validation for a single SQL string."""
37
+
38
+ valid: bool
39
+ syntax_errors: list[str] = field(default_factory=list)
40
+ missing_tables: list[str] = field(default_factory=list)
41
+ missing_columns: list[str] = field(default_factory=list)
42
+ invalid_joins: list[JoinWarning] = field(default_factory=list)
43
+ suggestions: list[str] = field(default_factory=list)
44
+
45
+
46
+ class QueryValidator:
47
+ """Validate SQL against ``SchemaTable`` / ``SchemaColumn`` / join edges in Kuzu."""
48
+
49
+ def __init__(
50
+ self,
51
+ store: KuzuStore,
52
+ *,
53
+ connection_name: str,
54
+ database_key: str,
55
+ dialect: str = _DEFAULT_DIALECT,
56
+ ) -> None:
57
+ """Args:
58
+ store: Open Kuzu store for one indexed connection.
59
+ connection_name: ``SchemaTable.connection_name`` filter (registry connection).
60
+ database_key: ``SchemaTable.database`` filter (logical DB; same as MCP ``db`` key).
61
+ dialect: sqlglot dialect for parsing (default PostgreSQL).
62
+ """
63
+ self._store = store
64
+ self._connection_name = connection_name
65
+ self._database_key = database_key
66
+ self._dialect = dialect
67
+
68
+ def validate(self, sql: str) -> ValidationResult:
69
+ """Parse SQL, then check tables, columns, and join relationships in the graph."""
70
+ try:
71
+ parsed = sqlglot.parse_one(sql, dialect=self._dialect)
72
+ except ParseError as exc:
73
+ return ValidationResult(valid=False, syntax_errors=[str(exc)])
74
+
75
+ syntax_errors: list[str] = []
76
+ missing_tables: list[str] = []
77
+ missing_columns: list[str] = []
78
+ invalid_joins: list[JoinWarning] = []
79
+ suggestions: list[str] = []
80
+
81
+ alias_map = _build_alias_map(parsed)
82
+ tables_in_query = _tables_referenced_in_query(parsed)
83
+
84
+ physical_by_qual: dict[str, tuple[str, str]] = {}
85
+ for qual in sorted(tables_in_query):
86
+ phys = self._resolve_physical_table(qual)
87
+ if phys is None:
88
+ missing_tables.append(qual)
89
+ suggestions.extend(self._suggest_tables(qual))
90
+ else:
91
+ physical_by_qual[qual] = phys
92
+
93
+ if missing_tables:
94
+ return ValidationResult(
95
+ valid=False,
96
+ syntax_errors=syntax_errors,
97
+ missing_tables=missing_tables,
98
+ missing_columns=missing_columns,
99
+ invalid_joins=invalid_joins,
100
+ suggestions=suggestions,
101
+ )
102
+
103
+ for column_ref in _iter_column_refs(parsed):
104
+ table_qual = _column_table_qualifier(
105
+ column_ref, alias_map, physical_by_qual
106
+ )
107
+ if table_qual is None:
108
+ continue
109
+ if table_qual not in physical_by_qual:
110
+ continue
111
+ schema_name, table_name = physical_by_qual[table_qual]
112
+ col_name = column_ref.name
113
+ if not self._column_exists(schema_name, table_name, col_name):
114
+ key = f"{schema_name}.{table_name}.{col_name}"
115
+ missing_columns.append(key)
116
+ hint = self._suggest_column(schema_name, table_name, col_name)
117
+ if hint:
118
+ suggestions.append(
119
+ f"column `{schema_name}.{table_name}.{col_name}` not found in graph; "
120
+ f"did you mean `{hint}`?"
121
+ )
122
+
123
+ seen_bad_pairs: set[tuple[str, str]] = set()
124
+ for join in _iter_joins(parsed):
125
+ on_expr = join.args.get("on")
126
+ if on_expr is None:
127
+ continue
128
+ physical_tables: set[tuple[str, str]] = set()
129
+ for col in on_expr.find_all(exp.Column):
130
+ tq = _column_table_qualifier(col, alias_map, physical_by_qual)
131
+ if tq is None or tq not in physical_by_qual:
132
+ continue
133
+ physical_tables.add(physical_by_qual[tq])
134
+ if len(physical_tables) < 2:
135
+ continue
136
+ pairs = list(physical_tables)
137
+ for i in range(len(pairs)):
138
+ for j in range(i + 1, len(pairs)):
139
+ a, b = pairs[i], pairs[j]
140
+ if self._tables_joinable(a, b):
141
+ continue
142
+ left_qual = f"{a[0]}.{a[1]}"
143
+ right_qual = f"{b[0]}.{b[1]}"
144
+ ordered = sorted((left_qual, right_qual))
145
+ pair_key: tuple[str, str] = (ordered[0], ordered[1])
146
+ if pair_key in seen_bad_pairs:
147
+ continue
148
+ seen_bad_pairs.add(pair_key)
149
+ left_s, left_t = a
150
+ right_s, right_t = b
151
+ invalid_joins.append(
152
+ JoinWarning(
153
+ message=(
154
+ f"no FK_REFERENCES or INFERRED_JOIN in graph between "
155
+ f"`{left_s}.{left_t}` and `{right_s}.{right_t}`"
156
+ ),
157
+ left_table=f"{left_s}.{left_t}",
158
+ right_table=f"{right_s}.{right_t}",
159
+ )
160
+ )
161
+ alt = self._join_suggestion(a, b)
162
+ if alt:
163
+ suggestions.append(alt)
164
+
165
+ has_semantic_issue = bool(missing_columns or invalid_joins)
166
+ return ValidationResult(
167
+ valid=not has_semantic_issue,
168
+ syntax_errors=syntax_errors,
169
+ missing_tables=missing_tables,
170
+ missing_columns=missing_columns,
171
+ invalid_joins=invalid_joins,
172
+ suggestions=suggestions,
173
+ )
174
+
175
+ def _resolve_physical_table(self, qualified: str) -> tuple[str, str] | None:
176
+ """Map ``schema.table`` or bare ``table`` to ``(schema_name, table_name)`` in Kuzu."""
177
+ if "." in qualified:
178
+ schema_name, _, table_name = qualified.partition(".")
179
+ rows = self._store.query_all_rows(
180
+ """
181
+ MATCH (t:SchemaTable)
182
+ WHERE t.connection_name = $cn AND t.database = $db
183
+ AND t.schema_name = $sn AND t.table_name = $tn
184
+ RETURN t.schema_name, t.table_name
185
+ LIMIT 1
186
+ """,
187
+ {
188
+ "cn": self._connection_name,
189
+ "db": self._database_key,
190
+ "sn": schema_name,
191
+ "tn": table_name,
192
+ },
193
+ )
194
+ else:
195
+ rows = self._store.query_all_rows(
196
+ """
197
+ MATCH (t:SchemaTable)
198
+ WHERE t.connection_name = $cn AND t.database = $db
199
+ AND t.table_name = $tbl
200
+ RETURN t.schema_name, t.table_name
201
+ """,
202
+ {
203
+ "cn": self._connection_name,
204
+ "db": self._database_key,
205
+ "tbl": qualified,
206
+ },
207
+ )
208
+ if len(rows) > 1:
209
+ return None
210
+ if not rows:
211
+ return None
212
+ sn, tn = rows[0]
213
+ return (str(sn), str(tn))
214
+
215
+ def _column_exists(
216
+ self, schema_name: str, table_name: str, column_name: str
217
+ ) -> bool:
218
+ rows = self._store.query_all_rows(
219
+ """
220
+ MATCH (t:SchemaTable)-[:HAS_COLUMN]->(c:SchemaColumn)
221
+ WHERE t.connection_name = $cn AND t.database = $db
222
+ AND t.schema_name = $sn AND t.table_name = $tn
223
+ AND c.column_name = $col
224
+ RETURN 1 LIMIT 1
225
+ """,
226
+ {
227
+ "cn": self._connection_name,
228
+ "db": self._database_key,
229
+ "sn": schema_name,
230
+ "tn": table_name,
231
+ "col": column_name,
232
+ },
233
+ )
234
+ return bool(rows)
235
+
236
+ def _tables_joinable(
237
+ self,
238
+ left: tuple[str, str],
239
+ right: tuple[str, str],
240
+ ) -> bool:
241
+ rows = self._store.query_all_rows(
242
+ """
243
+ MATCH (a:SchemaTable), (b:SchemaTable)
244
+ WHERE a.connection_name = $cn AND a.database = $db
245
+ AND b.connection_name = $cn AND b.database = $db
246
+ AND a.schema_name = $lsa AND a.table_name = $lta
247
+ AND b.schema_name = $rsb AND b.table_name = $rtb
248
+ AND (
249
+ (a)-[:FK_REFERENCES]->(b) OR (b)-[:FK_REFERENCES]->(a)
250
+ OR (a)-[:INFERRED_JOIN]->(b) OR (b)-[:INFERRED_JOIN]->(a)
251
+ )
252
+ RETURN count(*) AS n
253
+ """,
254
+ {
255
+ "cn": self._connection_name,
256
+ "db": self._database_key,
257
+ "lsa": left[0],
258
+ "lta": left[1],
259
+ "rsb": right[0],
260
+ "rtb": right[1],
261
+ },
262
+ )
263
+ if not rows:
264
+ return False
265
+ n = rows[0][0]
266
+ return int(n) > 0 if n is not None else False
267
+
268
+ def _suggest_tables(self, qualified: str) -> list[str]:
269
+ if "." in qualified:
270
+ _, _, tbl = qualified.partition(".")
271
+ else:
272
+ tbl = qualified
273
+ rows = self._store.query_all_rows(
274
+ """
275
+ MATCH (t:SchemaTable)
276
+ WHERE t.connection_name = $cn AND t.database = $db
277
+ RETURN t.schema_name, t.table_name
278
+ """,
279
+ {"cn": self._connection_name, "db": self._database_key},
280
+ )
281
+ names = [f"{sn}.{tn}" for sn, tn in rows]
282
+ matches = get_close_matches(
283
+ tbl, [r[1] for r in rows], n=3, cutoff=_FUZZY_CUTOFF
284
+ )
285
+ out: list[str] = []
286
+ for m in matches:
287
+ for sn, tn in rows:
288
+ if str(tn) == m:
289
+ out.append(
290
+ f"table `{qualified}` not in graph; did you mean `{sn}.{tn}`?"
291
+ )
292
+ break
293
+ if not out and names:
294
+ out.append(
295
+ f"table `{qualified}` not in graph; known tables include "
296
+ f"{', '.join(names[:5])}{'…' if len(names) > 5 else ''}"
297
+ )
298
+ return out
299
+
300
+ def _suggest_column(
301
+ self, schema_name: str, table_name: str, column_name: str
302
+ ) -> str | None:
303
+ rows = self._store.query_all_rows(
304
+ """
305
+ MATCH (t:SchemaTable)-[:HAS_COLUMN]->(c:SchemaColumn)
306
+ WHERE t.connection_name = $cn AND t.database = $db
307
+ AND t.schema_name = $sn AND t.table_name = $tn
308
+ RETURN c.column_name
309
+ """,
310
+ {
311
+ "cn": self._connection_name,
312
+ "db": self._database_key,
313
+ "sn": schema_name,
314
+ "tn": table_name,
315
+ },
316
+ )
317
+ names = [str(r[0]) for r in rows]
318
+ matches = get_close_matches(column_name, names, n=1, cutoff=_FUZZY_CUTOFF)
319
+ if not matches:
320
+ return None
321
+ return f"{schema_name}.{table_name}.{matches[0]}"
322
+
323
+ def _join_suggestion(
324
+ self, left: tuple[str, str], right: tuple[str, str]
325
+ ) -> str | None:
326
+ """If a single-hop path exists via an intermediate table, mention it."""
327
+ rows = self._store.query_all_rows(
328
+ """
329
+ MATCH (a:SchemaTable), (b:SchemaTable), (mid:SchemaTable)
330
+ WHERE a.connection_name = $cn AND a.database = $db
331
+ AND b.connection_name = $cn AND b.database = $db
332
+ AND mid.connection_name = $cn AND mid.database = $db
333
+ AND a.schema_name = $lsa AND a.table_name = $lta
334
+ AND b.schema_name = $rsb AND b.table_name = $rtb
335
+ AND a <> mid AND b <> mid AND a <> b
336
+ AND (
337
+ ((a)-[:FK_REFERENCES|INFERRED_JOIN]-(mid)) AND
338
+ ((mid)-[:FK_REFERENCES|INFERRED_JOIN]-(b))
339
+ )
340
+ RETURN mid.schema_name, mid.table_name
341
+ LIMIT 1
342
+ """,
343
+ {
344
+ "cn": self._connection_name,
345
+ "db": self._database_key,
346
+ "lsa": left[0],
347
+ "lta": left[1],
348
+ "rsb": right[0],
349
+ "rtb": right[1],
350
+ },
351
+ )
352
+ if not rows:
353
+ return None
354
+ ms, mt = rows[0]
355
+ return (
356
+ f"no direct graph edge between `{left[0]}.{left[1]}` and `{right[0]}.{right[1]}`; "
357
+ f"try joining through `{ms}.{mt}` if that matches your model"
358
+ )
359
+
360
+
361
+ def _build_alias_map(root: Any) -> dict[str, str]:
362
+ """Map SQL alias → qualified ``schema.table`` or ``table`` string."""
363
+ mapping: dict[str, str] = {}
364
+ for table in root.find_all(exp.Table):
365
+ qual = _table_qualifier(table)
366
+ alias = _table_alias(table)
367
+ if alias:
368
+ mapping[alias.lower()] = qual
369
+ mapping[qual.lower()] = qual
370
+ return mapping
371
+
372
+
373
+ def _table_qualifier(table: exp.Table) -> str:
374
+ table_name = table.name
375
+ db = table.args.get("db")
376
+ schema_name = db.name if isinstance(db, exp.Identifier) else None
377
+ if schema_name:
378
+ return f"{schema_name}.{table_name}"
379
+ return table_name
380
+
381
+
382
+ def _table_alias(table: exp.Table) -> str | None:
383
+ alias = table.args.get("alias")
384
+ if alias is None:
385
+ return None
386
+ if isinstance(alias, exp.TableAlias):
387
+ ident = alias.this
388
+ if isinstance(ident, exp.Identifier):
389
+ return ident.name
390
+ return None
391
+
392
+
393
+ def _tables_referenced_in_query(parsed: Any) -> set[str]:
394
+ seen: set[str] = set()
395
+ for table in parsed.find_all(exp.Table):
396
+ seen.add(_table_qualifier(table))
397
+ return seen
398
+
399
+
400
+ def _iter_joins(parsed: Any) -> list[exp.Join]:
401
+ joins: list[exp.Join] = []
402
+ select = parsed.find(exp.Select)
403
+ if not select:
404
+ return joins
405
+ for j in select.args.get("joins") or []:
406
+ if isinstance(j, exp.Join):
407
+ joins.append(j)
408
+ return joins
409
+
410
+
411
+ def _iter_column_refs(parsed: Any) -> list[exp.Column]:
412
+ return [c for c in parsed.find_all(exp.Column) if isinstance(c, exp.Column)]
413
+
414
+
415
+ def _column_table_qualifier(
416
+ column: exp.Column,
417
+ alias_map: dict[str, str],
418
+ physical_by_qual: dict[str, tuple[str, str]],
419
+ ) -> str | None:
420
+ table_ref = column.table
421
+ if table_ref is None:
422
+ return None
423
+ if isinstance(table_ref, exp.Identifier):
424
+ key = table_ref.name.lower()
425
+ elif isinstance(table_ref, str):
426
+ key = table_ref.lower()
427
+ else:
428
+ return None
429
+ if key not in alias_map:
430
+ return None
431
+ raw = alias_map[key]
432
+ # Normalize to a key present in physical_by_qual (case-insensitive match).
433
+ for q in physical_by_qual:
434
+ if q.lower() == raw.lower():
435
+ return q
436
+ return raw
@@ -0,0 +1,23 @@
1
+ """Graph visibility: YAML config, filtering, and MCP integration helpers."""
2
+
3
+ from pretensor.visibility.config import (
4
+ VisibilityConfig,
5
+ VisibilityProfileRule,
6
+ default_visibility_path,
7
+ load_visibility_config,
8
+ merge_profile_into_base,
9
+ )
10
+ from pretensor.visibility.filter import VisibilityFilter
11
+ from pretensor.visibility.runtime import (
12
+ load_visibility_filter_for_graph_dir,
13
+ )
14
+
15
+ __all__ = [
16
+ "VisibilityConfig",
17
+ "VisibilityFilter",
18
+ "VisibilityProfileRule",
19
+ "default_visibility_path",
20
+ "load_visibility_config",
21
+ "load_visibility_filter_for_graph_dir",
22
+ "merge_profile_into_base",
23
+ ]