klew 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.
- klew/__init__.py +1 -0
- klew/adapters/__init__.py +0 -0
- klew/adapters/git_miner.py +113 -0
- klew/adapters/hydra_store.py +150 -0
- klew/adapters/memory_store.py +158 -0
- klew/adapters/queries.py +91 -0
- klew/adapters/source_reader.py +270 -0
- klew/adapters/tokens.py +22 -0
- klew/api.py +216 -0
- klew/baselines/__init__.py +0 -0
- klew/baselines/chunks.py +55 -0
- klew/baselines/lexical.py +34 -0
- klew/baselines/vector.py +69 -0
- klew/cli.py +142 -0
- klew/config.py +33 -0
- klew/domain/__init__.py +0 -0
- klew/domain/bm25.py +33 -0
- klew/domain/learning.py +76 -0
- klew/domain/packer.py +182 -0
- klew/domain/seeder.py +126 -0
- klew/domain/walker.py +96 -0
- klew/eval/__init__.py +0 -0
- klew/eval/plots.py +90 -0
- klew/eval/replay.py +285 -0
- klew/eval/stats.py +41 -0
- klew/hydra_local.py +74 -0
- klew/ids.py +27 -0
- klew/mcp_server.py +127 -0
- klew/models.py +71 -0
- klew/ports.py +67 -0
- klew/runtime.py +100 -0
- klew/schema.py +78 -0
- klew/services/__init__.py +0 -0
- klew/services/engine.py +349 -0
- klew/services/index.py +215 -0
- klew/text.py +66 -0
- klew-0.1.0.dist-info/METADATA +290 -0
- klew-0.1.0.dist-info/RECORD +41 -0
- klew-0.1.0.dist-info/WHEEL +4 -0
- klew-0.1.0.dist-info/entry_points.txt +2 -0
- klew-0.1.0.dist-info/licenses/LICENSE +21 -0
klew/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Klew: a self-improving code-context layer for coding agents, on HydraDB."""
|
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Read commit history with the git CLI: replay tasks, ground truth, and co-change counts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from itertools import combinations
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Commit:
|
|
14
|
+
sha: str
|
|
15
|
+
ts: int
|
|
16
|
+
subject: str
|
|
17
|
+
body: str
|
|
18
|
+
changed: list[str] = field(default_factory=list) # modified/renamed-to .py paths
|
|
19
|
+
added: list[str] = field(default_factory=list)
|
|
20
|
+
deleted: list[str] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def task(self) -> str:
|
|
24
|
+
body = "\n".join(ln for ln in self.body.splitlines()
|
|
25
|
+
if not ln.lower().startswith(("co-authored-by", "signed-off-by")))
|
|
26
|
+
return (self.subject + "\n\n" + body).strip()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _git(root: Path, *args: str) -> str:
|
|
30
|
+
return subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True,
|
|
31
|
+
text=True, errors="replace").stdout
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def commits(root: Path, rev_range: str = "HEAD", max_count: int | None = None,
|
|
35
|
+
paths: tuple[str, ...] = ("*.py",)) -> list[Commit]:
|
|
36
|
+
"""Non-merge commits touching `paths`, oldest first."""
|
|
37
|
+
args = ["log", "--no-merges", "--reverse", "-M", "--name-status",
|
|
38
|
+
"--format=%x1e%H%x1f%at%x1f%s%x1f%b%x1f"]
|
|
39
|
+
if max_count:
|
|
40
|
+
args.append(f"--max-count={max_count}")
|
|
41
|
+
out = _git(root, *args, rev_range, "--", *paths)
|
|
42
|
+
result: list[Commit] = []
|
|
43
|
+
for rec in out.split("\x1e"):
|
|
44
|
+
if not rec.strip():
|
|
45
|
+
continue
|
|
46
|
+
head, _, files = rec.rpartition("\x1f")
|
|
47
|
+
sha, ts, subject, body = (head.split("\x1f") + ["", "", "", ""])[:4]
|
|
48
|
+
c = Commit(sha.strip(), int(ts or 0), subject.strip(), body.strip())
|
|
49
|
+
for line in files.strip().splitlines():
|
|
50
|
+
parts = line.split("\t")
|
|
51
|
+
if len(parts) < 2:
|
|
52
|
+
continue
|
|
53
|
+
status, path = parts[0], parts[-1]
|
|
54
|
+
if not path.endswith(".py"):
|
|
55
|
+
continue
|
|
56
|
+
if status.startswith("A"):
|
|
57
|
+
c.added.append(path)
|
|
58
|
+
elif status.startswith("D"):
|
|
59
|
+
c.deleted.append(path)
|
|
60
|
+
else:
|
|
61
|
+
c.changed.append(path)
|
|
62
|
+
result.append(c)
|
|
63
|
+
if max_count and len(result) > max_count:
|
|
64
|
+
result = result[-max_count:]
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def head_sha(root: Path, rev: str = "HEAD") -> str:
|
|
69
|
+
return _git(root, "rev-parse", rev).strip()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def worktree(root: Path, sha: str, dest: Path) -> Path:
|
|
73
|
+
"""Materialize `sha` at `dest` (a detached worktree) for snapshot indexing."""
|
|
74
|
+
dest = dest.resolve()
|
|
75
|
+
if dest.exists():
|
|
76
|
+
_git(dest, "checkout", "-q", "--detach", sha)
|
|
77
|
+
return dest
|
|
78
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
_git(root, "worktree", "prune") # forget worktrees whose directories were deleted
|
|
80
|
+
_git(root, "worktree", "add", "-q", "--detach", str(dest), sha)
|
|
81
|
+
return dest
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def remove_worktree(root: Path, dest: Path) -> None:
|
|
85
|
+
try:
|
|
86
|
+
_git(root, "worktree", "remove", "--force", str(dest.resolve()))
|
|
87
|
+
except subprocess.CalledProcessError:
|
|
88
|
+
_git(root, "worktree", "prune")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class CoChange:
|
|
93
|
+
pair_counts: Counter
|
|
94
|
+
file_counts: Counter
|
|
95
|
+
|
|
96
|
+
def weight(self, a: str, b: str) -> float:
|
|
97
|
+
c = self.pair_counts.get((a, b)) or self.pair_counts.get((b, a)) or 0
|
|
98
|
+
if not c:
|
|
99
|
+
return 0.0
|
|
100
|
+
return c / math.sqrt(self.file_counts[a] * self.file_counts[b])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def co_change(history: list[Commit], max_files: int = 12) -> CoChange:
|
|
104
|
+
"""Count how often files change together. Large sweeping commits are ignored as noise."""
|
|
105
|
+
pairs: Counter = Counter()
|
|
106
|
+
files: Counter = Counter()
|
|
107
|
+
for c in history:
|
|
108
|
+
touched = sorted(set(c.changed + c.added))
|
|
109
|
+
if not touched or len(touched) > max_files:
|
|
110
|
+
continue
|
|
111
|
+
files.update(touched)
|
|
112
|
+
pairs.update(combinations(touched, 2))
|
|
113
|
+
return CoChange(pairs, files)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""GraphStore backed by a HydraDB node over Bolt (Neo4j driver)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Iterable, Sequence
|
|
6
|
+
|
|
7
|
+
from neo4j import GraphDatabase
|
|
8
|
+
|
|
9
|
+
from .. import ids as idmod
|
|
10
|
+
from ..config import HydraConfig
|
|
11
|
+
from ..ports import Neighbor, Row
|
|
12
|
+
from ..schema import EDGE_TYPES, NODE_FIELDS, EdgeType
|
|
13
|
+
from . import queries as Q
|
|
14
|
+
|
|
15
|
+
BATCH = 1000 # HydraDB caps UNWIND batches at 1024 items
|
|
16
|
+
ID_CHUNK = 1000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _chunks(seq: Sequence, n: int):
|
|
20
|
+
for i in range(0, len(seq), n):
|
|
21
|
+
yield seq[i : i + n]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HydraGraphStore:
|
|
25
|
+
def __init__(self, cfg: HydraConfig | None = None):
|
|
26
|
+
cfg = cfg or HydraConfig()
|
|
27
|
+
self._driver = GraphDatabase.driver(cfg.uri, auth=("neo4j", cfg.token))
|
|
28
|
+
self._db = cfg.database
|
|
29
|
+
# Sessions are not thread-safe, the driver is: one session per thread lets the walker
|
|
30
|
+
# run a hop's expansions concurrently.
|
|
31
|
+
self._local = threading.local()
|
|
32
|
+
self._sessions: list = []
|
|
33
|
+
self._count_lock = threading.Lock()
|
|
34
|
+
self.queries = 0
|
|
35
|
+
|
|
36
|
+
# -- plumbing -------------------------------------------------------------------------
|
|
37
|
+
def _run(self, cypher: str, **params) -> list[Row]:
|
|
38
|
+
# HydraDB rejects explicit transactions, so every statement is an auto-commit RUN.
|
|
39
|
+
sess = getattr(self._local, "session", None)
|
|
40
|
+
if sess is None:
|
|
41
|
+
sess = self._local.session = self._driver.session(database=self._db)
|
|
42
|
+
with self._count_lock:
|
|
43
|
+
self._sessions.append(sess)
|
|
44
|
+
with self._count_lock:
|
|
45
|
+
self.queries += 1
|
|
46
|
+
return [r.data() for r in sess.run(cypher, params)]
|
|
47
|
+
|
|
48
|
+
def ping(self) -> None:
|
|
49
|
+
self._driver.verify_connectivity()
|
|
50
|
+
|
|
51
|
+
# -- writes ---------------------------------------------------------------------------
|
|
52
|
+
def upsert_nodes(self, label: str, rows: Sequence[Row]) -> None:
|
|
53
|
+
if not rows:
|
|
54
|
+
return
|
|
55
|
+
fields = tuple(f for f in NODE_FIELDS[label] if f in rows[0])
|
|
56
|
+
cypher = Q.upsert_nodes(label, fields)
|
|
57
|
+
for chunk in _chunks(list(rows), BATCH):
|
|
58
|
+
self._run(cypher, rows=[{"id": r["id"], **{f: r.get(f) for f in fields}} for r in chunk])
|
|
59
|
+
|
|
60
|
+
def upsert_edges(self, etype: EdgeType, rows: Sequence[Row]) -> None:
|
|
61
|
+
if not rows:
|
|
62
|
+
return
|
|
63
|
+
extra = tuple(k for k in rows[0] if k not in ("src", "dst", "eid"))
|
|
64
|
+
cypher = Q.upsert_edges(etype, extra)
|
|
65
|
+
for chunk in _chunks(list(rows), BATCH):
|
|
66
|
+
self._run(cypher, rows=[
|
|
67
|
+
{"src": r["src"], "dst": r["dst"],
|
|
68
|
+
"eid": r.get("eid") or idmod.edge_id(etype.name, r["src"], r["dst"]),
|
|
69
|
+
**{k: r.get(k) for k in extra}}
|
|
70
|
+
for r in chunk
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
def delete_nodes(self, label: str, ids: Iterable[int]) -> None:
|
|
74
|
+
ids = list(dict.fromkeys(ids))
|
|
75
|
+
for chunk in _chunks(ids, ID_CHUNK):
|
|
76
|
+
self._run(Q.delete_nodes(label), ids=chunk)
|
|
77
|
+
|
|
78
|
+
def delete_repo(self, repo: str) -> None:
|
|
79
|
+
for label in NODE_FIELDS:
|
|
80
|
+
self._run(Q.delete_repo(label), repo=repo)
|
|
81
|
+
|
|
82
|
+
# -- reads ----------------------------------------------------------------------------
|
|
83
|
+
def expand(self, etype: EdgeType, direction: str, ids: Iterable[int]) -> list[Neighbor]:
|
|
84
|
+
ids = list(dict.fromkeys(ids))
|
|
85
|
+
out: list[Neighbor] = []
|
|
86
|
+
cypher = Q.expand(etype, direction)
|
|
87
|
+
for chunk in _chunks(ids, ID_CHUNK):
|
|
88
|
+
for r in self._run(cypher, ids=chunk):
|
|
89
|
+
a, b = r["src"], r["dst"]
|
|
90
|
+
# Edge ids are identity, not readable properties; recompute from the endpoints.
|
|
91
|
+
eid = idmod.edge_id(etype.name, a, b) if direction == "out" else idmod.edge_id(etype.name, b, a)
|
|
92
|
+
out.append(Neighbor(a, b, eid, 1.0 if r.get("w") is None else float(r["w"])))
|
|
93
|
+
return sorted(out) # HydraDB row order varies between runs; callers get a stable order
|
|
94
|
+
|
|
95
|
+
def get_nodes(self, label: str, ids: Iterable[int], fields: Sequence[str]) -> list[Row]:
|
|
96
|
+
ids = list(dict.fromkeys(ids))
|
|
97
|
+
out: list[Row] = []
|
|
98
|
+
cypher = Q.get_nodes(label, tuple(fields))
|
|
99
|
+
for chunk in _chunks(ids, ID_CHUNK):
|
|
100
|
+
out.extend(self._run(cypher, ids=chunk))
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
def scan_nodes(self, label: str, repo: str, fields: Sequence[str]) -> list[Row]:
|
|
104
|
+
return self._run(Q.scan_nodes(label, tuple(fields)), repo=repo)
|
|
105
|
+
|
|
106
|
+
def list_nodes(self, label: str, fields: Sequence[str]) -> list[Row]:
|
|
107
|
+
return self._run(Q.list_nodes(label, tuple(fields)))
|
|
108
|
+
|
|
109
|
+
def edge_weights(self, etype: EdgeType, pairs: Iterable[tuple[int, int]]) -> dict[tuple[int, int], Row]:
|
|
110
|
+
pairs = set(pairs)
|
|
111
|
+
srcs = list({s for s, _ in pairs})
|
|
112
|
+
found: dict[tuple[int, int], Row] = {}
|
|
113
|
+
cypher = Q.edge_stats(etype)
|
|
114
|
+
for chunk in _chunks(srcs, ID_CHUNK):
|
|
115
|
+
for r in self._run(cypher, ids=chunk):
|
|
116
|
+
key = (r["src"], r["dst"])
|
|
117
|
+
if key in pairs:
|
|
118
|
+
found[key] = {"w": r.get("w"), "hits": r.get("hits") or 0,
|
|
119
|
+
"misses": r.get("misses") or 0}
|
|
120
|
+
return found
|
|
121
|
+
|
|
122
|
+
def count_edges(self, etype: EdgeType, repo: str) -> int:
|
|
123
|
+
rows = self._run(Q.count_edges(etype), repo=repo)
|
|
124
|
+
return int(rows[0]["c"]) if rows else 0
|
|
125
|
+
|
|
126
|
+
def key_paths(self, repo: str, src_label: str, src_keys, dst_label: str, dst_keys, rel_types,
|
|
127
|
+
max_len: int = 3, limit: int = 5) -> list[list]:
|
|
128
|
+
"""Native algo.MSpaths between nodes named by `key`. Keys repeat across repos, so only paths whose
|
|
129
|
+
nodes all belong to `repo` are kept. Each path alternates node dicts and relationship-type strings."""
|
|
130
|
+
rows = self._run(Q.ms_paths(src_label, list(src_keys), dst_label, list(dst_keys), list(rel_types), max_len))
|
|
131
|
+
out = []
|
|
132
|
+
for r in rows:
|
|
133
|
+
path = r["path"]
|
|
134
|
+
nodes = [x for x in path if isinstance(x, dict)]
|
|
135
|
+
if nodes and all(n.get("repo") == repo for n in nodes):
|
|
136
|
+
out.append(path)
|
|
137
|
+
out.sort(key=len)
|
|
138
|
+
return out[:limit]
|
|
139
|
+
|
|
140
|
+
def shortest_paths(self, etype: EdgeType, src: int, dst: int, max_len: int, k: int) -> list[list[Row]]:
|
|
141
|
+
rows = self._run(Q.shortest_paths(etype), src=src, dst=dst, max_len=max_len, k=k)
|
|
142
|
+
return [[x for x in r["path"] if isinstance(x, dict)] for r in rows]
|
|
143
|
+
|
|
144
|
+
def close(self) -> None:
|
|
145
|
+
for sess in self._sessions:
|
|
146
|
+
sess.close()
|
|
147
|
+
self._driver.close()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
__all__ = ["HydraGraphStore", "EDGE_TYPES"]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""In-memory GraphStore with the same semantics as HydraGraphStore.
|
|
2
|
+
|
|
3
|
+
Used for unit tests, for frontend work before HydraDB is up, and as the reference
|
|
4
|
+
implementation in the store contract test.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from collections.abc import Iterable, Sequence
|
|
10
|
+
|
|
11
|
+
from .. import ids as idmod
|
|
12
|
+
from ..ports import Neighbor, Row
|
|
13
|
+
from ..schema import NODE_FIELDS, EdgeType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MemoryGraphStore:
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self.nodes: dict[str, dict[int, Row]] = defaultdict(dict)
|
|
19
|
+
# etype -> src -> dst -> props
|
|
20
|
+
self.out: dict[str, dict[int, dict[int, Row]]] = defaultdict(lambda: defaultdict(dict))
|
|
21
|
+
self.inn: dict[str, dict[int, dict[int, Row]]] = defaultdict(lambda: defaultdict(dict))
|
|
22
|
+
self.queries = 0
|
|
23
|
+
|
|
24
|
+
def upsert_nodes(self, label: str, rows: Sequence[Row]) -> None:
|
|
25
|
+
self.queries += 1
|
|
26
|
+
for r in rows:
|
|
27
|
+
cur = self.nodes[label].setdefault(r["id"], {"id": r["id"]})
|
|
28
|
+
cur.update({k: v for k, v in r.items() if k in NODE_FIELDS[label]})
|
|
29
|
+
|
|
30
|
+
def upsert_edges(self, etype: EdgeType, rows: Sequence[Row]) -> None:
|
|
31
|
+
self.queries += 1
|
|
32
|
+
for r in rows:
|
|
33
|
+
s, d = r["src"], r["dst"]
|
|
34
|
+
if s not in self.nodes[etype.src] or d not in self.nodes[etype.dst]:
|
|
35
|
+
continue # MATCH would not bind; HydraDB silently writes nothing
|
|
36
|
+
props = self.out[etype.name][s].setdefault(d, {})
|
|
37
|
+
props.update({k: v for k, v in r.items() if k not in ("src", "dst")})
|
|
38
|
+
props.setdefault("eid", idmod.edge_id(etype.name, s, d))
|
|
39
|
+
self.inn[etype.name][d][s] = props
|
|
40
|
+
|
|
41
|
+
def delete_nodes(self, label: str, ids: Iterable[int]) -> None:
|
|
42
|
+
self._drop({i for i in ids if i in self.nodes[label]})
|
|
43
|
+
|
|
44
|
+
def delete_repo(self, repo: str) -> None:
|
|
45
|
+
self._drop({i for lbl in self.nodes.values() for i, r in lbl.items() if r.get("repo") == repo})
|
|
46
|
+
|
|
47
|
+
def _drop(self, doomed: set[int]) -> None:
|
|
48
|
+
self.queries += 1
|
|
49
|
+
for lbl in self.nodes.values():
|
|
50
|
+
for i in list(lbl):
|
|
51
|
+
if i in doomed:
|
|
52
|
+
del lbl[i]
|
|
53
|
+
for idx in (self.out, self.inn):
|
|
54
|
+
for adj in idx.values():
|
|
55
|
+
for a in list(adj):
|
|
56
|
+
if a in doomed:
|
|
57
|
+
del adj[a]
|
|
58
|
+
continue
|
|
59
|
+
for b in list(adj[a]):
|
|
60
|
+
if b in doomed:
|
|
61
|
+
del adj[a][b]
|
|
62
|
+
|
|
63
|
+
def expand(self, etype: EdgeType, direction: str, ids: Iterable[int]) -> list[Neighbor]:
|
|
64
|
+
self.queries += 1
|
|
65
|
+
adj = self.out[etype.name] if direction == "out" else self.inn[etype.name]
|
|
66
|
+
out = []
|
|
67
|
+
for a in dict.fromkeys(ids):
|
|
68
|
+
for b, p in adj.get(a, {}).items():
|
|
69
|
+
w = p.get("w")
|
|
70
|
+
eid = idmod.edge_id(etype.name, a, b) if direction == "out" else idmod.edge_id(etype.name, b, a)
|
|
71
|
+
out.append(Neighbor(a, b, eid, 1.0 if w is None else float(w)))
|
|
72
|
+
return sorted(out)
|
|
73
|
+
|
|
74
|
+
def get_nodes(self, label: str, ids: Iterable[int], fields: Sequence[str]) -> list[Row]:
|
|
75
|
+
self.queries += 1
|
|
76
|
+
tbl = self.nodes[label]
|
|
77
|
+
return [{"id": i, **{f: tbl[i].get(f) for f in fields}} for i in dict.fromkeys(ids) if i in tbl]
|
|
78
|
+
|
|
79
|
+
def scan_nodes(self, label: str, repo: str, fields: Sequence[str]) -> list[Row]:
|
|
80
|
+
self.queries += 1
|
|
81
|
+
return [{"id": i, **{f: r.get(f) for f in fields}}
|
|
82
|
+
for i, r in self.nodes[label].items() if r.get("repo") == repo]
|
|
83
|
+
|
|
84
|
+
def list_nodes(self, label: str, fields: Sequence[str]) -> list[Row]:
|
|
85
|
+
self.queries += 1
|
|
86
|
+
return [{"id": i, **{f: r.get(f) for f in fields}} for i, r in self.nodes[label].items()]
|
|
87
|
+
|
|
88
|
+
def edge_weights(self, etype: EdgeType, pairs: Iterable[tuple[int, int]]) -> dict[tuple[int, int], Row]:
|
|
89
|
+
self.queries += 1
|
|
90
|
+
found = {}
|
|
91
|
+
for s, d in set(pairs):
|
|
92
|
+
p = self.out[etype.name].get(s, {}).get(d)
|
|
93
|
+
if p is not None:
|
|
94
|
+
found[(s, d)] = {"w": p.get("w"), "hits": p.get("hits") or 0,
|
|
95
|
+
"misses": p.get("misses") or 0}
|
|
96
|
+
return found
|
|
97
|
+
|
|
98
|
+
def count_edges(self, etype: EdgeType, repo: str) -> int:
|
|
99
|
+
self.queries += 1
|
|
100
|
+
return sum(len(v) for s, v in self.out[etype.name].items()
|
|
101
|
+
if self.nodes[etype.src].get(s, {}).get("repo") == repo)
|
|
102
|
+
|
|
103
|
+
def key_paths(self, repo: str, src_label: str, src_keys, dst_label: str, dst_keys, rel_types,
|
|
104
|
+
max_len: int = 3, limit: int = 5) -> list[list]:
|
|
105
|
+
"""Reference BFS with the same output shape as HydraDB's algo.MSpaths (both directions)."""
|
|
106
|
+
from collections import deque
|
|
107
|
+
self.queries += 1
|
|
108
|
+
by_key = {}
|
|
109
|
+
for lbl, tbl in self.nodes.items():
|
|
110
|
+
for i, r in tbl.items():
|
|
111
|
+
if r.get("repo") == repo:
|
|
112
|
+
by_key[(lbl, r.get("key"))] = i
|
|
113
|
+
lookup = {i: r for tbl in self.nodes.values() for i, r in tbl.items()}
|
|
114
|
+
targets = {by_key[(dst_label, k)] for k in dst_keys if (dst_label, k) in by_key}
|
|
115
|
+
out = []
|
|
116
|
+
for k in src_keys:
|
|
117
|
+
s = by_key.get((src_label, k))
|
|
118
|
+
if s is None:
|
|
119
|
+
continue
|
|
120
|
+
q = deque([[s]])
|
|
121
|
+
while q and len(out) < limit * 4:
|
|
122
|
+
p = q.popleft()
|
|
123
|
+
if p[-1] in targets and len(p) > 1:
|
|
124
|
+
out.append(p)
|
|
125
|
+
continue
|
|
126
|
+
if (len(p) - 1) // 2 >= max_len:
|
|
127
|
+
continue
|
|
128
|
+
for t in rel_types:
|
|
129
|
+
for nb in list(self.out[t].get(p[-1], {})) + list(self.inn[t].get(p[-1], {})):
|
|
130
|
+
if nb not in p[::2]:
|
|
131
|
+
q.append(p + [t, nb])
|
|
132
|
+
res = [[x if isinstance(x, str) else {k2: v for k2, v in lookup[x].items() if k2 != "id"} for x in p]
|
|
133
|
+
for p in out]
|
|
134
|
+
res.sort(key=len)
|
|
135
|
+
return res[:limit]
|
|
136
|
+
|
|
137
|
+
def shortest_paths(self, etype: EdgeType, src: int, dst: int, max_len: int, k: int) -> list[list[Row]]:
|
|
138
|
+
self.queries += 1
|
|
139
|
+
# BFS over both directions, mirroring relDirection: 'both'
|
|
140
|
+
from collections import deque
|
|
141
|
+
q = deque([[src]])
|
|
142
|
+
paths = []
|
|
143
|
+
while q and len(paths) < k:
|
|
144
|
+
p = q.popleft()
|
|
145
|
+
if p[-1] == dst:
|
|
146
|
+
paths.append(p)
|
|
147
|
+
continue
|
|
148
|
+
if len(p) > max_len:
|
|
149
|
+
continue
|
|
150
|
+
nbrs = set(self.out[etype.name].get(p[-1], {})) | set(self.inn[etype.name].get(p[-1], {}))
|
|
151
|
+
for n in nbrs:
|
|
152
|
+
if n not in p:
|
|
153
|
+
q.append(p + [n])
|
|
154
|
+
lookup = {i: r for lbl in self.nodes.values() for i, r in lbl.items()}
|
|
155
|
+
return [[{k2: v for k2, v in lookup[i].items() if k2 != "id"} for i in p] for p in paths]
|
|
156
|
+
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
pass
|
klew/adapters/queries.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Every Cypher statement Klew sends to HydraDB lives here.
|
|
2
|
+
|
|
3
|
+
HydraDB dialect rules this file respects (verified against v0.4.0, legacy engine):
|
|
4
|
+
- Vertex batch upsert must be `UNWIND $rows AS row MERGE (n {id: row.id}) SET n:Label, ...`.
|
|
5
|
+
- Relationship batch upsert must MATCH both endpoints and MERGE with an edge id: `{id: row.eid}`.
|
|
6
|
+
- Composite (list-of-maps) params are only legal as UNWIND input, so batched *reads* use
|
|
7
|
+
`WHERE a.id IN $ids` with a scalar list instead of UNWIND.
|
|
8
|
+
- Exactly one relationship type per pattern; no type(r)/labels(n); RETURN only binding.prop or
|
|
9
|
+
aggregates; property values are scalars only (lists/maps are stored as JSON strings).
|
|
10
|
+
- Reading r.<prop> works when the far endpoint is unlabeled.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ..schema import EdgeType
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def upsert_nodes(label: str, fields: tuple[str, ...]) -> str:
|
|
18
|
+
sets = ", ".join(f"n.{f} = row.{f}" for f in fields)
|
|
19
|
+
return f"UNWIND $rows AS row MERGE (n {{id: row.id}}) SET n:{label}" + (f", {sets}" if sets else "")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def upsert_edges(e: EdgeType, fields: tuple[str, ...]) -> str:
|
|
23
|
+
sets = ", ".join(f"r.{f} = row.{f}" for f in fields)
|
|
24
|
+
return (
|
|
25
|
+
f"UNWIND $rows AS row MATCH (s:{e.src} {{id: row.src}}), (d:{e.dst} {{id: row.dst}}) "
|
|
26
|
+
f"MERGE (s)-[r:{e.name} {{id: row.eid}}]->(d)" + (f" SET {sets}" if sets else "")
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def expand(e: EdgeType, direction: str) -> str:
|
|
31
|
+
if direction == "out":
|
|
32
|
+
return (f"MATCH (a:{e.src})-[r:{e.name}]->(b) WHERE a.id IN $ids "
|
|
33
|
+
"RETURN a.id AS src, b.id AS dst, r.w AS w")
|
|
34
|
+
return (f"MATCH (a:{e.dst})<-[r:{e.name}]-(b) WHERE a.id IN $ids "
|
|
35
|
+
"RETURN a.id AS src, b.id AS dst, r.w AS w")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def edge_stats(e: EdgeType) -> str:
|
|
39
|
+
return (f"MATCH (a:{e.src})-[r:{e.name}]->(b) WHERE a.id IN $ids "
|
|
40
|
+
"RETURN a.id AS src, b.id AS dst, r.w AS w, r.hits AS hits, r.misses AS misses")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_nodes(label: str, fields: tuple[str, ...]) -> str:
|
|
44
|
+
cols = ", ".join(f"n.{f} AS {f}" for f in fields)
|
|
45
|
+
return f"MATCH (n:{label}) WHERE n.id IN $ids RETURN n.id AS id, {cols}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scan_nodes(label: str, fields: tuple[str, ...]) -> str:
|
|
49
|
+
cols = ", ".join(f"n.{f} AS {f}" for f in fields)
|
|
50
|
+
return f"MATCH (n:{label}) WHERE n.repo = $repo RETURN n.id AS id, {cols}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def list_nodes(label: str, fields: tuple[str, ...]) -> str:
|
|
54
|
+
cols = ", ".join(f"n.{f} AS {f}" for f in fields)
|
|
55
|
+
return f"MATCH (n:{label}) RETURN n.id AS id, {cols}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def count_edges(e: EdgeType) -> str:
|
|
59
|
+
return f"MATCH (a:{e.src})-[r:{e.name}]->(b) WHERE a.repo = $repo RETURN count(*) AS c"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def delete_nodes(label: str) -> str:
|
|
63
|
+
return f"MATCH (n:{label}) WHERE n.id IN $ids DETACH DELETE n"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def delete_repo(label: str) -> str:
|
|
67
|
+
return f"MATCH (n:{label}) WHERE n.repo = $repo DETACH DELETE n"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _lit(values) -> str:
|
|
71
|
+
"""Procedure maps take literals only, so lists are inlined and escaped."""
|
|
72
|
+
return "[" + ", ".join("'" + str(v).replace("\\", "\\\\").replace("'", "\\'") + "'" for v in values) + "]"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ms_paths(src_label: str, src_keys, dst_label: str, dst_keys, rel_types, max_len: int) -> str:
|
|
76
|
+
"""HydraDB's native multi-source path search (GraphBLAS), anchored on the string `key` property."""
|
|
77
|
+
return (
|
|
78
|
+
f"CALL algo.MSpaths({{sourceLabel: '{src_label}', sourceProperty: 'key', sourceValues: {_lit(src_keys)}, "
|
|
79
|
+
f"targetLabel: '{dst_label}', targetProperty: 'key', targetValues: {_lit(dst_keys)}, "
|
|
80
|
+
f"relTypes: {_lit(rel_types)}, relDirection: 'both', maxLen: {int(max_len)}}}) "
|
|
81
|
+
"YIELD path RETURN path"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def shortest_paths(e: EdgeType) -> str:
|
|
86
|
+
# Procedure maps take scalars only; lists are inlined literals.
|
|
87
|
+
return (
|
|
88
|
+
"CALL algo.SPpaths({sourceNode: $src, targetNode: $dst, "
|
|
89
|
+
f"relTypes: ['{e.name}'], relDirection: 'both', maxLen: $max_len, pathCount: $k}}) "
|
|
90
|
+
"YIELD path, pathWeight RETURN path, pathWeight"
|
|
91
|
+
)
|