keygraph 0.2.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 (50) hide show
  1. keygraph/__init__.py +8 -0
  2. keygraph/__main__.py +3 -0
  3. keygraph/adapters/__init__.py +33 -0
  4. keygraph/adapters/base.py +57 -0
  5. keygraph/adapters/clickhouse.py +67 -0
  6. keygraph/adapters/dump.py +169 -0
  7. keygraph/adapters/fetch.py +93 -0
  8. keygraph/adapters/live.py +50 -0
  9. keygraph/adapters/metabase.py +78 -0
  10. keygraph/adapters/mongo.py +93 -0
  11. keygraph/adapters/mysql.py +30 -0
  12. keygraph/adapters/postgres.py +68 -0
  13. keygraph/adapters/sqlite.py +24 -0
  14. keygraph/adapters/sqlserver.py +65 -0
  15. keygraph/areas.py +225 -0
  16. keygraph/cli.py +497 -0
  17. keygraph/context.py +387 -0
  18. keygraph/docs_out.py +199 -0
  19. keygraph/explorer.py +128 -0
  20. keygraph/graph.py +163 -0
  21. keygraph/hubs.py +48 -0
  22. keygraph/infer.py +344 -0
  23. keygraph/mcp_server.py +147 -0
  24. keygraph/mirrors.py +109 -0
  25. keygraph/model.py +149 -0
  26. keygraph/notes.py +114 -0
  27. keygraph/overrides.py +112 -0
  28. keygraph/probe.py +119 -0
  29. keygraph/profile.py +262 -0
  30. keygraph/query.py +376 -0
  31. keygraph/report.py +92 -0
  32. keygraph/search.py +175 -0
  33. keygraph/static/DAGRE_LICENSE +19 -0
  34. keygraph/static/OFL-Inter.txt +93 -0
  35. keygraph/static/OFL-JetBrainsMono.txt +93 -0
  36. keygraph/static/dagre-1.1.4.min.js +801 -0
  37. keygraph/static/explorer.html +69 -0
  38. keygraph/static/explorer.js +1076 -0
  39. keygraph/static/favicon.svg +1 -0
  40. keygraph/static/keygraph-logo.svg +1 -0
  41. keygraph/static/keygraph-mark.svg +1 -0
  42. keygraph/static/keygraph.inline.css +843 -0
  43. keygraph/static/kg-diagram.js +267 -0
  44. keygraph/static/kg-theme.js +269 -0
  45. keygraph/summary.py +60 -0
  46. keygraph-0.2.0.dist-info/METADATA +334 -0
  47. keygraph-0.2.0.dist-info/RECORD +50 -0
  48. keygraph-0.2.0.dist-info/WHEEL +4 -0
  49. keygraph-0.2.0.dist-info/entry_points.txt +2 -0
  50. keygraph-0.2.0.dist-info/licenses/LICENSE +21 -0
keygraph/model.py ADDED
@@ -0,0 +1,149 @@
1
+ """Normalised schema model shared by every adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class Column:
11
+ name: str
12
+ type: str = ""
13
+ nullable: bool = True
14
+ position: int = 0
15
+ key: str = "" # PRI / UNI / MUL as reported by MySQL COLUMNS, or ""
16
+ default: str | None = None
17
+ extra: str = "" # auto_increment, on update ..., VIRTUAL GENERATED
18
+ charset: str = ""
19
+ collation: str = ""
20
+ comment: str = ""
21
+ values: list[str] = field(default_factory=list) # enum / set members
22
+ generated: str = "" # generation expression
23
+
24
+
25
+ @dataclass
26
+ class Index:
27
+ name: str
28
+ columns: list[str]
29
+ unique: bool = False
30
+ primary: bool = False
31
+ approximate: bool = False # derived from COLUMN_KEY only; composition unknown
32
+
33
+
34
+ @dataclass
35
+ class ForeignKey:
36
+ name: str
37
+ columns: list[str]
38
+ ref_schema: str
39
+ ref_table: str
40
+ ref_columns: list[str]
41
+
42
+
43
+ @dataclass
44
+ class Table:
45
+ schema: str
46
+ name: str
47
+ columns: list[Column] = field(default_factory=list)
48
+ indexes: list[Index] = field(default_factory=list)
49
+ foreign_keys: list[ForeignKey] = field(default_factory=list)
50
+ info: dict = field(default_factory=dict) # engine, rows_estimate, data_bytes, partitioning, comment, checks, sorting_key ...
51
+
52
+ @property
53
+ def id(self) -> str:
54
+ return f"{self.schema}.{self.name}"
55
+
56
+ @property
57
+ def primary_key(self) -> list[str]:
58
+ for ix in self.indexes:
59
+ if ix.primary:
60
+ return list(ix.columns)
61
+ return []
62
+
63
+ @property
64
+ def unique_keys(self) -> list[list[str]]:
65
+ return [list(ix.columns) for ix in self.indexes if ix.unique and not ix.primary]
66
+
67
+ def column(self, name: str) -> Column | None:
68
+ for c in self.columns:
69
+ if c.name == name:
70
+ return c
71
+ return None
72
+
73
+
74
+ @dataclass
75
+ class Schema:
76
+ tables: dict[str, Table] = field(default_factory=dict)
77
+
78
+ def add(self, table: Table) -> None:
79
+ self.tables[table.id] = table
80
+
81
+ def get_or_create(self, schema: str, name: str) -> Table:
82
+ tid = f"{schema}.{name}"
83
+ if tid not in self.tables:
84
+ self.tables[tid] = Table(schema=schema, name=name)
85
+ return self.tables[tid]
86
+
87
+
88
+ _INT = {"tinyint", "smallint", "mediumint", "int", "integer", "bigint", "serial", "bigserial",
89
+ "smallserial", "int2", "int4", "int8", "int1", "int16", "int32", "int64", "uint8",
90
+ "uint16", "uint32", "uint64", "number"}
91
+ _DEC = {"decimal", "numeric", "money", "dec", "fixed"}
92
+ _FLT = {"float", "double", "real", "float4", "float8", "float32", "float64"}
93
+ _STR = {"char", "varchar", "text", "tinytext", "mediumtext", "longtext", "character", "nvarchar",
94
+ "nchar", "string", "citext", "enum", "enum8", "enum16", "set", "uuid", "ntext", "fixedstring", "varchar2"}
95
+ _DATE = {"date", "datetime", "timestamp", "timestamptz", "time", "year", "datetime2", "date32"}
96
+ _BIN = {"blob", "tinyblob", "mediumblob", "longblob", "binary", "varbinary", "bytea"}
97
+ _BOOL = {"bool", "boolean", "bit"}
98
+
99
+
100
+ def type_family(type_str: str) -> str:
101
+ """Coarse type family: int, decimal, float, string, datetime, json, binary, bool or unknown.
102
+
103
+ Signedness and width are ignored on purpose: a signed key and an unsigned key still join.
104
+ """
105
+ t = (type_str or "").lower().strip()
106
+ if not t:
107
+ return "unknown"
108
+ while re.match(r"^(nullable|lowcardinality)\(", t):
109
+ t = re.sub(r"^(nullable|lowcardinality)\(", "", t).rstrip(")")
110
+ first = re.split(r"[\s(]", t, maxsplit=1)[0]
111
+ if first == "double" or first == "character" and "varying" in t:
112
+ return "float" if first == "double" else "string"
113
+ if first in _INT or first.startswith("uint") or (first.startswith("int") and first[3:].isdigit()):
114
+ return "int"
115
+ if first in _DEC:
116
+ return "decimal"
117
+ if first in _FLT:
118
+ return "float"
119
+ if first in _STR:
120
+ return "string"
121
+ if first in _DATE or first.startswith("timestamp"):
122
+ return "datetime"
123
+ if first in {"json", "jsonb"}:
124
+ return "json"
125
+ if first in _BIN:
126
+ return "binary"
127
+ if first in _BOOL:
128
+ return "bool"
129
+ return "unknown"
130
+
131
+
132
+ def type_length(type_str: str) -> int | None:
133
+ m = re.search(r"\((\d+)\)", type_str or "")
134
+ return int(m.group(1)) if m else None
135
+
136
+
137
+ def parse_enum_members(type_str: str) -> list[str]:
138
+ """Members of MySQL enum('a','b') / set(...), ClickHouse Enum8('a' = 1, 'b' = 2) (possibly wrapped in
139
+ Nullable/LowCardinality). Returns [] for other types. ClickHouse members keep their numeric code as 'a=1'."""
140
+ t = (type_str or "").strip()
141
+ m = re.search(r"\b(enum|set|enum8|enum16)\s*\((.*)\)", t, re.I | re.S)
142
+ if not m:
143
+ return []
144
+ body = m.group(2)
145
+ out = []
146
+ for lit in re.finditer(r"'((?:[^'\\]|\\.|'')*)'(?:\s*=\s*(-?\d+))?", body):
147
+ name = lit.group(1).replace("''", "'").replace("\\'", "'")
148
+ out.append(f"{name}={lit.group(2)}" if lit.group(2) is not None and m.group(1).lower().startswith("enum") and m.group(1).lower() != "enum" else name)
149
+ return out
keygraph/notes.py ADDED
@@ -0,0 +1,114 @@
1
+ """Generic importer: one markdown note per table -> table notes and column semantics.
2
+
3
+ Recognised (case-insensitive) heading words: grain|meaning -> grain; gotcha|caveat|pitfall|warning -> pitfalls;
4
+ column(s) -> bullets/rows like "- `col` - meaning" or "| col | meaning |"; code|status|enum -> "- `value` = label"
5
+ (optionally under a "### column" sub-heading); a verified section is read but does not mark the note verified (only a person or a checked run should). A "**Summary:**" line sets the
6
+ summary. Front matter keys: table (or the file stem), and the key named by schema_key for the schema.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+
15
+ def parse_frontmatter(text: str) -> tuple[dict, str]:
16
+ m = re.match(r"^---\n(.*?)\n---\n", text, re.S)
17
+ if not m:
18
+ return {}, text
19
+ meta = {}
20
+ for line in m.group(1).splitlines():
21
+ if ":" in line:
22
+ k, v = line.split(":", 1)
23
+ meta[k.strip()] = v.strip().strip("[]")
24
+ return meta, text[m.end():]
25
+
26
+
27
+ def _sections(body: str) -> list[tuple[int, str, list[str]]]:
28
+ out: list[tuple[int, str, list[str]]] = []
29
+ for line in body.splitlines():
30
+ h = re.match(r"^(#{1,4})\s+(.*)$", line)
31
+ if h:
32
+ out.append((len(h.group(1)), h.group(2).strip(), []))
33
+ elif out:
34
+ out[-1][2].append(line)
35
+ return out
36
+
37
+
38
+ _BUL = re.compile(r"^\s*[-*]\s+`?([A-Za-z0-9_.\-]+)`?\s*(?:[:=—–-]+|\s)\s*(.+)$")
39
+
40
+
41
+ def _text(lines: list[str]) -> str:
42
+ return re.sub(r"\s+", " ", " ".join(l.strip() for l in lines if l.strip() and not l.strip().startswith("_None"))).strip()
43
+
44
+
45
+ def parse_note(path: str | Path, schema_key: str = "schema", default_schema: str = "main") -> dict | None:
46
+ p = Path(path)
47
+ meta, body = parse_frontmatter(p.read_text(encoding="utf-8"))
48
+ table = meta.get("table") or p.stem
49
+ schema = meta.get(schema_key) or default_schema
50
+ if meta.get("kind") not in (None, "table"):
51
+ return None
52
+ note: dict = {"table": f"{schema}.{table}", "columns": [], "source": str(p), "verified": False}
53
+ sm = re.search(r"\*\*Summary:\*\*\s*(.+)", body)
54
+ if sm:
55
+ note["summary"] = sm.group(1).strip()
56
+ pit: list[str] = []
57
+ cur_code_col = None
58
+ for level, title, lines in _sections(body):
59
+ tl = title.lower()
60
+ if re.search(r"grain|meaning", tl) and "column" not in tl:
61
+ note["grain"] = _text(lines)
62
+ elif re.search(r"gotcha|caveat|pitfall|warning", tl):
63
+ pit.extend(re.sub(r"^\s*[-*]\s+", "", l).strip() for l in lines if l.strip())
64
+ elif re.search(r"verified", tl):
65
+ note["verified_facts"] = _text(lines) # kept out of the verified flag: a note is a claim, not a check
66
+ elif re.search(r"column", tl):
67
+ for l in lines:
68
+ m = _BUL.match(l)
69
+ r = re.match(r"^\|\s*`?([A-Za-z0-9_]+)`?\s*\|\s*([^|]+)\|", l)
70
+ if (m or r) and not (r and set(r.group(1)) <= {"-"}):
71
+ c, mean = (m or r).group(1), (m or r).group(2).strip()
72
+ note["columns"].append({"column": c, "meaning": mean})
73
+ elif re.search(r"code|status|enum", tl):
74
+ cur_code_col = None
75
+ for l in lines:
76
+ m = _BUL.match(l)
77
+ if m:
78
+ cur_code_col = cur_code_col or re.sub(r"[^\w]", "", title.split()[-1])
79
+ note.setdefault("_codes", {}).setdefault(cur_code_col, {})[m.group(1)] = m.group(2).strip()
80
+ elif level >= 3 and note.get("_codes") is not None:
81
+ pass
82
+ if pit:
83
+ note["pitfalls"] = " | ".join(pit)
84
+ note["codes"] = note.pop("_codes", {})
85
+ return note
86
+
87
+
88
+ def notes_to_context(root: str | Path, schema_key: str = "schema", default_schema: str = "main",
89
+ skip_names: tuple[str, ...] = ("memory.md", "readme.md")) -> dict:
90
+ """Convert a folder of per-table markdown notes into a context structure (tables + columns).
91
+
92
+ Entries carry source = the note path; they are always unverified.
93
+ Column meanings and codes are always unverified: a note bullet is a claim, not a check.
94
+ """
95
+ out: dict = {"tables": [], "columns": []}
96
+ for f in sorted(Path(root).rglob("*.md")):
97
+ if f.name.lower() in skip_names:
98
+ continue
99
+ n = parse_note(f, schema_key, default_schema)
100
+ if not n:
101
+ continue
102
+ cols, codes = n.pop("columns"), n.pop("codes", {})
103
+ n.pop("verified_facts", None)
104
+ out["tables"].append({k: v for k, v in n.items() if v not in ("", None)})
105
+ seen = set()
106
+ for c in cols:
107
+ out["columns"].append({"table": n["table"], "column": c["column"], "meaning": c["meaning"],
108
+ **({"codes": codes[c["column"]]} if c["column"] in codes else {}),
109
+ "source": str(f), "verified": False})
110
+ seen.add(c["column"])
111
+ for col, cd in codes.items():
112
+ if col not in seen:
113
+ out["columns"].append({"table": n["table"], "column": col, "codes": cd, "source": str(f), "verified": False})
114
+ return out
keygraph/overrides.py ADDED
@@ -0,0 +1,112 @@
1
+ """User overrides: confirm / reject / add edges, table aliases, role aliases, prefixes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass
11
+ class Overrides:
12
+ aliases: dict[str, str] = field(default_factory=dict)
13
+ role_aliases: dict[str, list[str]] = field(default_factory=dict)
14
+ prefixes: list[str] = field(default_factory=list)
15
+ confirm: list[dict] = field(default_factory=list)
16
+ reject: list[dict] = field(default_factory=list)
17
+ add: list[dict] = field(default_factory=list)
18
+
19
+ @classmethod
20
+ def load(cls, path: str | Path) -> "Overrides":
21
+ p = Path(path)
22
+ text = p.read_text(encoding="utf-8")
23
+ if p.suffix.lower() in (".yaml", ".yml"):
24
+ try:
25
+ import yaml
26
+ except ImportError as exc: # pragma: no cover
27
+ raise SystemExit("YAML needs PyYAML: pip install 'keygraph[yaml]' (or use a .json file)") from exc
28
+ raw = yaml.safe_load(text) or {}
29
+ else:
30
+ raw = json.loads(text)
31
+ ra = {k: ([v] if isinstance(v, str) else list(v)) for k, v in (raw.get("role_aliases") or {}).items()}
32
+ return cls(aliases=dict(raw.get("aliases") or {}), role_aliases=ra, prefixes=list(raw.get("prefixes") or []),
33
+ confirm=list(raw.get("confirm") or []), reject=list(raw.get("reject") or []),
34
+ add=list(raw.get("add") or []))
35
+
36
+ def configure(self, opts) -> None:
37
+ opts.aliases.update(self.aliases)
38
+ opts.role_aliases.update(self.role_aliases)
39
+ opts.prefixes.extend(p for p in self.prefixes if p not in opts.prefixes)
40
+
41
+
42
+ def parse_ref(ref: str) -> tuple[str, list[str]]:
43
+ """'shop.orders.customer_id' or 'orders.a,b' -> (table ref, columns)."""
44
+ head, cols = ref.rsplit(".", 1)
45
+ return head, [c.strip() for c in cols.split(",") if c.strip()]
46
+
47
+
48
+ def _resolve(nodes: dict, head: str) -> str | None:
49
+ if head in nodes:
50
+ return head
51
+ hits = [n for n in nodes if n.split(".", 1)[1] == head]
52
+ return hits[0] if len(hits) == 1 else None
53
+
54
+
55
+ def apply_overrides(data: dict, ov: Overrides) -> None:
56
+ nodes = {n["id"]: n for n in data["nodes"]}
57
+ applied = {"confirmed": 0, "rejected": 0, "added": 0}
58
+ notes = data["warnings"]
59
+
60
+ def locate(item):
61
+ fh, fc = parse_ref(item["from"])
62
+ ft = _resolve(nodes, fh)
63
+ if item.get("to"):
64
+ th, tc = parse_ref(item["to"])
65
+ tt = _resolve(nodes, th)
66
+ else:
67
+ tt, tc = None, []
68
+ return ft, fc, tt, tc
69
+
70
+ for item in ov.reject:
71
+ ft, fc, tt, tc = locate(item)
72
+ before = len(data["edges"])
73
+ data["edges"] = [e for e in data["edges"] if not (
74
+ e["kind"] == "inferred" and e["from"] == ft and e["from_columns"] == fc
75
+ and (tt is None or (e["to"] == tt and (not tc or e["to_columns"] == tc))))]
76
+ applied["rejected"] += before - len(data["edges"])
77
+ if ft is None:
78
+ notes.append(f"override reject: unknown table in {item['from']}")
79
+ for kind, items, conf in (("confirmed", ov.confirm, 0.98), ("added", ov.add, 0.9)):
80
+ for item in items:
81
+ ft, fc, tt, tc = locate(item)
82
+ if not (ft and tt):
83
+ notes.append(f"override {kind}: unknown table in {item}")
84
+ continue
85
+ have = {c["name"] for c in nodes[ft]["columns"]}, {c["name"] for c in nodes[tt]["columns"]}
86
+ if not (set(fc) <= have[0] and set(tc) <= have[1]) or not tc:
87
+ notes.append(f"override {kind}: unknown column in {item}")
88
+ continue
89
+ note = item.get("note", "")
90
+ match = [e for e in data["edges"] if e["from"] == ft and e["from_columns"] == fc and e["kind"] == "inferred"]
91
+ hit = [e for e in match if e["to"] == tt and e["to_columns"] == tc]
92
+ if kind == "confirmed" and hit:
93
+ data["edges"] = [e for e in data["edges"] if e not in match or e is hit[0]]
94
+ e = hit[0]
95
+ e["status"], e["confidence"], e["ambiguous"] = "confirmed", conf, False
96
+ e["evidence"].append("confirmed by user override" + (f": {note}" if note else ""))
97
+ else:
98
+ if kind == "confirmed":
99
+ data["edges"] = [e for e in data["edges"] if e not in match]
100
+ data["edges"] = [e for e in data["edges"] if not (
101
+ e["from"] == ft and e["from_columns"] == fc and e["to"] == tt and e["to_columns"] == tc)]
102
+ fk = nodes[ft]
103
+ from .infer import cardinality
104
+ from .model import Table, Index
105
+ t = Table(ft.split(".")[0], fk["table"], [], [Index(i["name"], i["columns"], i["unique"], i["primary"]) for i in fk["indexes"]])
106
+ data["edges"].append({
107
+ "kind": "inferred", "status": kind, "from": ft, "from_columns": fc, "to": tt, "to_columns": tc,
108
+ "confidence": conf, "cardinality": cardinality(t, fc), "ambiguous": False,
109
+ "evidence": [f"{'confirmed' if kind == 'confirmed' else 'added'} by user override" + (f": {note}" if note else "")],
110
+ })
111
+ applied[kind] += 1
112
+ data["overrides"] = applied
keygraph/probe.py ADDED
@@ -0,0 +1,119 @@
1
+ """Read-only value-overlap probe for inferred edges. Refuses anything that is not a single SELECT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from decimal import Decimal
7
+ from typing import Callable
8
+
9
+ from .graph import Graph
10
+
11
+ _BAD = re.compile(r"\b(insert|update|delete|replace|drop|alter|create|truncate|grant|revoke|rename|call|handler|load|"
12
+ r"lock|unlock|merge|exec|execute|set|use|copy|vacuum|attach|pragma)\b|\binto\s+(outfile|dumpfile)\b|"
13
+ r"\bfor\s+update\b", re.I)
14
+
15
+
16
+ class UnsafeSQL(ValueError):
17
+ pass
18
+
19
+
20
+ def assert_select(sql: str) -> str:
21
+ s = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S)
22
+ s = re.sub(r"--[^\n]*", " ", s).strip()
23
+ if s.endswith(";"):
24
+ s = s[:-1].strip()
25
+ if not s:
26
+ raise UnsafeSQL("empty statement")
27
+ if ";" in s:
28
+ raise UnsafeSQL("only one statement is allowed")
29
+ if not re.match(r"select\b", s, re.I):
30
+ raise UnsafeSQL("only SELECT statements are allowed")
31
+ stripped = re.sub(r"'(?:[^']|'')*'", "''", s)
32
+ m = _BAD.search(stripped)
33
+ if m:
34
+ raise UnsafeSQL(f"forbidden keyword in query: {m.group(0)}")
35
+ return s
36
+
37
+
38
+ class SelectOnlyRunner:
39
+ def __init__(self, execute: Callable[[str], list]):
40
+ self._execute = execute
41
+
42
+ def run(self, sql: str) -> list:
43
+ return list(self._execute(assert_select(sql)))
44
+
45
+ @classmethod
46
+ def from_url(cls, url: str) -> "SelectOnlyRunner":
47
+ from sqlalchemy import create_engine, text
48
+ eng = create_engine(url)
49
+
50
+ def ex(sql):
51
+ with eng.connect() as c:
52
+ return [tuple(r) for r in c.execute(text(sql))]
53
+ return cls(ex)
54
+
55
+ @classmethod
56
+ def from_dbapi(cls, conn) -> "SelectOnlyRunner":
57
+ def ex(sql):
58
+ cur = conn.cursor()
59
+ try:
60
+ cur.execute(sql)
61
+ return cur.fetchall()
62
+ finally:
63
+ cur.close()
64
+ return cls(ex)
65
+
66
+
67
+ def _lit(v) -> str | None:
68
+ if isinstance(v, bool):
69
+ return None
70
+ if isinstance(v, int):
71
+ return str(v)
72
+ if isinstance(v, Decimal) and v == v.to_integral_value():
73
+ return str(int(v))
74
+ if isinstance(v, str) and "\\" not in v and "\x00" not in v:
75
+ return "'" + v.replace("'", "''") + "'"
76
+ return None
77
+
78
+
79
+ def probe_graph(graph: Graph, runner: SelectOnlyRunner, sample: int = 500, quote: str = '"',
80
+ max_edges: int = 200, min_conf: float = 0.0, statuses=("inferred",), profiles: dict | None = None) -> dict:
81
+ q = lambda name: f"{quote}{name}{quote}"
82
+ qt = lambda tid: ".".join(q(p) for p in tid.split(".", 1)) if not tid.startswith("main.") else q(tid.split(".", 1)[1])
83
+ res = {"probed": 0, "skipped": 0, "errors": []}
84
+ for e in graph.edges:
85
+ if e["status"] not in statuses or e["confidence"] < min_conf or res["probed"] >= max_edges:
86
+ continue
87
+ if len(e["from_columns"]) != 1:
88
+ res["skipped"] += 1
89
+ continue
90
+ fc, tc = e["from_columns"][0], e["to_columns"][0]
91
+ pf, pt = (profiles or {}).get(f"{e['from']}.{fc}", {}).get("numeric"), (profiles or {}).get(f"{e['to']}.{tc}", {}).get("numeric")
92
+ if pf and pt and (pf["min"] > pt["max"] or pf["max"] < pt["min"]):
93
+ e["evidence"].append(f"profile: value ranges do not overlap ({pf['min']:g}..{pf['max']:g} vs {pt['min']:g}..{pt['max']:g})")
94
+ e["confidence"] = round(e["confidence"] * 0.3, 2)
95
+ e["status"] = "probed"
96
+ res["probed"] += 1
97
+ continue
98
+ try:
99
+ vals = [r[0] for r in runner.run(
100
+ f"SELECT {q(fc)} FROM {qt(e['from'])} WHERE {q(fc)} IS NOT NULL LIMIT {int(sample)}")]
101
+ lits = sorted({l for l in map(_lit, vals) if l is not None})
102
+ if not lits:
103
+ res["skipped"] += 1
104
+ continue
105
+ found = runner.run(f"SELECT {q(tc)} FROM {qt(e['to'])} WHERE {q(tc)} IN ({','.join(lits)}) LIMIT {len(lits)}")
106
+ except Exception as exc: # noqa: BLE001
107
+ res["errors"].append(f"{e['id']}: {exc}")
108
+ continue
109
+ rate = len({r[0] for r in found}) / len(lits)
110
+ e["match_rate"] = round(rate, 3)
111
+ e["evidence"].append(f"probe: {rate:.0%} of {len(lits)} sampled distinct values of {e['from']}.{fc} exist in {e['to']}.{tc}")
112
+ c = e["confidence"]
113
+ c = min(0.99, c + 0.25) if rate >= 0.95 else min(0.99, c + 0.10) if rate >= 0.8 else c if rate >= 0.5 else \
114
+ min(c, 0.1) if rate == 0 else c * 0.5
115
+ e["confidence"] = round(c, 2)
116
+ e["status"] = "probed"
117
+ res["probed"] += 1
118
+ graph.refresh_stats()
119
+ return res