research-diff 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.
- research_diff/__init__.py +2 -0
- research_diff/__main__.py +5 -0
- research_diff/agent.py +141 -0
- research_diff/cli.py +153 -0
- research_diff/compare.py +76 -0
- research_diff/config.py +61 -0
- research_diff/diff.py +106 -0
- research_diff/extract.py +327 -0
- research_diff/graph.py +94 -0
- research_diff/mcp_server.py +112 -0
- research_diff/readme.py +38 -0
- research_diff/suggest.py +154 -0
- research_diff/trackers.py +103 -0
- research_diff/watch.py +69 -0
- research_diff-0.1.0.dist-info/METADATA +111 -0
- research_diff-0.1.0.dist-info/RECORD +19 -0
- research_diff-0.1.0.dist-info/WHEEL +4 -0
- research_diff-0.1.0.dist-info/entry_points.txt +3 -0
- research_diff-0.1.0.dist-info/licenses/LICENSE +21 -0
research_diff/extract.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""A research project's state as one JSON-able dict, read from markers in its own files.
|
|
2
|
+
|
|
3
|
+
THE MARKERS. One grammar, wherever it is written:
|
|
4
|
+
|
|
5
|
+
H2: A larger batch helps on small datasets <- a definition: an id (Q, H, E, R or C and a number) and a title
|
|
6
|
+
answers: Q1 <- links, on the lines right after it
|
|
7
|
+
status: supported
|
|
8
|
+
|
|
9
|
+
In Markdown the definition may be a heading or a list item. In LaTeX it goes in a comment (`% C1: …`), and the claim's
|
|
10
|
+
paragraph gives its sources (`\\cite{…}`). In a script, notebook or config it is a comment: `# research-diff: E5 tests
|
|
11
|
+
H2`. A YAML/JSON/TOML config may instead carry a `research:` block: `{id: E5, tests: [H2], results: out/e5.json}`;
|
|
12
|
+
the results file (JSON or CSV) becomes the result `E5.results` with its numbers as metrics.
|
|
13
|
+
|
|
14
|
+
The map is built from markers only, so the same files give the same map. `research-diff suggest` asks an LLM to
|
|
15
|
+
propose markers for what is untagged; it never writes them.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import csv
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import tomllib
|
|
24
|
+
from collections import defaultdict
|
|
25
|
+
from pathlib import PurePosixPath
|
|
26
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
from system_diff.config import segment_rx
|
|
29
|
+
from system_diff.extract import glob_rx
|
|
30
|
+
|
|
31
|
+
RELS = {"answers": "answers", "tests": "tests", "of": "of", "supports": "supports", "weakens": "weakens",
|
|
32
|
+
"relies on": "relies on", "relies": "relies on", "depends on": "relies on", "based on": "relies on"}
|
|
33
|
+
ATTRS = ("status", "metrics", "results", "sources", "source", "expects")
|
|
34
|
+
KEYS = "|".join(sorted(list(RELS) + list(ATTRS), key=len, reverse=True))
|
|
35
|
+
ATTR_RX = re.compile(rf"^\s*(?:[-*+]\s+)?\**({KEYS})\**\s*:\s*(.+?)\s*$", re.I)
|
|
36
|
+
COMMENT_RX = re.compile(r"(?:#|//|%)\s*research-diff:\s*(.+)$", re.M)
|
|
37
|
+
CITE_RX = re.compile(r"\\(?:cite[pt]?|citeauthor|citeyear)\*?(?:\[[^\]]*\])*\{([^}]+)\}")
|
|
38
|
+
NUM_RX = re.compile(r"^-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _def_rx(kinds: Dict[str, str]) -> re.Pattern:
|
|
42
|
+
letters = "".join(sorted(kinds))
|
|
43
|
+
return re.compile(rf"^\s*(?:#+\s*|[-*+]\s+|>\s*)*\**([{letters}]\d+[a-z]?)\**\s*[:.)\u2014-]\s*(.+?)\s*$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ids(text: str, kinds: Dict[str, str]) -> List[str]:
|
|
47
|
+
letters = "".join(sorted(kinds))
|
|
48
|
+
return re.findall(rf"\b([{letters}]\d+[a-z]?(?:\.results)?)\b", text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _metrics(text: str) -> Dict[str, float]:
|
|
52
|
+
out = {}
|
|
53
|
+
for part in re.split(r"[,;]", text):
|
|
54
|
+
if "=" in part or ":" in part:
|
|
55
|
+
k, v = re.split(r"[=:]", part, maxsplit=1)
|
|
56
|
+
v = v.strip().rstrip("%")
|
|
57
|
+
if NUM_RX.match(v):
|
|
58
|
+
out[k.strip()] = float(v)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Model:
|
|
63
|
+
"""Nodes, links and warnings as they are read; first definition names a node, later ones add to it."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, kinds: Dict[str, str]):
|
|
66
|
+
self.kinds = kinds
|
|
67
|
+
self.nodes: Dict[str, dict] = {}
|
|
68
|
+
self.links: set = set()
|
|
69
|
+
|
|
70
|
+
def node(self, nid: str, title: str = "", where: str = "") -> dict:
|
|
71
|
+
kind = "result" if "." in nid else self.kinds.get(nid[0], "note") # E1.results, E1.mlflow.ab12cd34
|
|
72
|
+
n = self.nodes.setdefault(nid, {"kind": kind, "title": "", "status": "", "files": [], "metrics": {},
|
|
73
|
+
"sources": []})
|
|
74
|
+
if title and not n["title"]:
|
|
75
|
+
n["title"] = title
|
|
76
|
+
if where and where not in n["files"]:
|
|
77
|
+
n["files"].append(where)
|
|
78
|
+
return n
|
|
79
|
+
|
|
80
|
+
def attr(self, nid: str, key: str, value: str, where: str) -> None:
|
|
81
|
+
key = key.lower()
|
|
82
|
+
n = self.node(nid, where=where)
|
|
83
|
+
if key in RELS:
|
|
84
|
+
for target in _ids(value, self.kinds):
|
|
85
|
+
self.links.add((nid, RELS[key], target))
|
|
86
|
+
elif key == "status":
|
|
87
|
+
n["status"] = value.strip().lower().rstrip(".")
|
|
88
|
+
elif key == "metrics":
|
|
89
|
+
n["metrics"].update(_metrics(value))
|
|
90
|
+
elif key == "expects":
|
|
91
|
+
n.setdefault("expects", []).append(value.strip())
|
|
92
|
+
elif key in ("sources", "source"):
|
|
93
|
+
n["sources"] = sorted(set(n["sources"]) | {s.strip() for s in re.split(r"[,;]", value) if s.strip()})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_lines(m: Model, lines: Iterable[str], where: str, def_rx: re.Pattern) -> List[Tuple[str, int, int]]:
|
|
97
|
+
"""Definitions and their attribute lines. Returns (id, first line, last line) of each definition."""
|
|
98
|
+
found: List[Tuple[str, int, int]] = []
|
|
99
|
+
current: Optional[str] = None
|
|
100
|
+
for i, line in enumerate(lines, 1):
|
|
101
|
+
if not line.strip():
|
|
102
|
+
continue # a heading is usually followed by a blank line before its `status:` and links
|
|
103
|
+
d = def_rx.match(line)
|
|
104
|
+
a = ATTR_RX.match(line)
|
|
105
|
+
if d and not a:
|
|
106
|
+
current = d.group(1)
|
|
107
|
+
m.node(current, d.group(2).strip(" *"), where)
|
|
108
|
+
found.append((current, i, i))
|
|
109
|
+
elif a and current:
|
|
110
|
+
m.attr(current, a.group(1), a.group(2), where)
|
|
111
|
+
found[-1] = (found[-1][0], found[-1][1], i)
|
|
112
|
+
elif line.lstrip().startswith("#") and current:
|
|
113
|
+
current = None # a new heading ends the definition's attributes
|
|
114
|
+
return found
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_comment(m: Model, text: str, where: str, def_rx: re.Pattern) -> None:
|
|
118
|
+
"""`research-diff: E5 tests H2` / `research-diff: E5: title` / `research-diff: status: done` in a comment."""
|
|
119
|
+
last: Optional[str] = None
|
|
120
|
+
for body in COMMENT_RX.findall(text):
|
|
121
|
+
d = def_rx.match(body)
|
|
122
|
+
rel = re.match(rf"^([A-Za-z]\d+[a-z]?)\s+({'|'.join(RELS)})\s+(.+)$", body.strip(), re.I)
|
|
123
|
+
if rel:
|
|
124
|
+
last = rel.group(1)
|
|
125
|
+
m.node(last, where=where)
|
|
126
|
+
m.attr(last, rel.group(2), rel.group(3), where)
|
|
127
|
+
elif d:
|
|
128
|
+
last = d.group(1)
|
|
129
|
+
m.node(last, d.group(2), where)
|
|
130
|
+
elif last:
|
|
131
|
+
a = ATTR_RX.match(body)
|
|
132
|
+
if a:
|
|
133
|
+
m.attr(last, a.group(1), a.group(2), where)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _mini_yaml_block(text: str) -> Optional[dict]:
|
|
137
|
+
"""The `research:` block of a YAML file: one level of `key: value` (lists as [a, b] or `- a` lines)."""
|
|
138
|
+
lines = text.splitlines()
|
|
139
|
+
for i, line in enumerate(lines):
|
|
140
|
+
m = re.match(r"^(\s*)research\s*:\s*$", line)
|
|
141
|
+
if not m:
|
|
142
|
+
continue
|
|
143
|
+
indent, out, key = len(m.group(1)), {}, None
|
|
144
|
+
for nxt in lines[i + 1:]:
|
|
145
|
+
if not nxt.strip() or nxt.lstrip().startswith("#"):
|
|
146
|
+
continue
|
|
147
|
+
ind = len(nxt) - len(nxt.lstrip())
|
|
148
|
+
if ind <= indent:
|
|
149
|
+
break
|
|
150
|
+
item = re.match(r"^\s*-\s*(.+)$", nxt)
|
|
151
|
+
kv = re.match(r"^\s*([A-Za-z_ ]+?)\s*:\s*(.*)$", nxt)
|
|
152
|
+
if item and key:
|
|
153
|
+
out.setdefault(key, [])
|
|
154
|
+
if isinstance(out[key], list):
|
|
155
|
+
out[key].append(item.group(1).strip().strip("'\""))
|
|
156
|
+
elif kv:
|
|
157
|
+
key, val = kv.group(1).strip(), kv.group(2).strip()
|
|
158
|
+
if val.startswith("[") and val.endswith("]"):
|
|
159
|
+
out[key] = [x.strip().strip("'\"") for x in val[1:-1].split(",") if x.strip()]
|
|
160
|
+
elif val:
|
|
161
|
+
out[key] = val.strip("'\"")
|
|
162
|
+
return out
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def config_block(text: str, path: str) -> Optional[dict]:
|
|
167
|
+
try:
|
|
168
|
+
if path.endswith(".json"):
|
|
169
|
+
data = json.loads(text)
|
|
170
|
+
return data.get("research") if isinstance(data, dict) and isinstance(data.get("research"), dict) else None
|
|
171
|
+
if path.endswith(".toml"):
|
|
172
|
+
data = tomllib.loads(text)
|
|
173
|
+
return data.get("research") if isinstance(data.get("research"), dict) else None
|
|
174
|
+
except (ValueError, tomllib.TOMLDecodeError):
|
|
175
|
+
return None
|
|
176
|
+
return _mini_yaml_block(text)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def read_results(text: str, path: str) -> Dict[str, float]:
|
|
180
|
+
"""The numbers of a results file: a JSON object (nested keys joined by '.') or a CSV's last row."""
|
|
181
|
+
out: Dict[str, float] = {}
|
|
182
|
+
if path.endswith(".json"):
|
|
183
|
+
try:
|
|
184
|
+
data = json.loads(text)
|
|
185
|
+
except ValueError:
|
|
186
|
+
return {}
|
|
187
|
+
|
|
188
|
+
def walk(prefix: str, v: Any) -> None:
|
|
189
|
+
if isinstance(v, bool):
|
|
190
|
+
return
|
|
191
|
+
if isinstance(v, (int, float)):
|
|
192
|
+
out[prefix] = float(v)
|
|
193
|
+
elif isinstance(v, dict):
|
|
194
|
+
for k, x in v.items():
|
|
195
|
+
walk(f"{prefix}.{k}" if prefix else str(k), x)
|
|
196
|
+
walk("", data)
|
|
197
|
+
elif path.endswith((".csv", ".tsv")):
|
|
198
|
+
rows = list(csv.DictReader(io.StringIO(text), delimiter="\t" if path.endswith(".tsv") else ","))
|
|
199
|
+
for k, v in (rows[-1] if rows else {}).items():
|
|
200
|
+
if k and v and NUM_RX.match(v.strip()):
|
|
201
|
+
out[k.strip()] = float(v)
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _resolve(base: str, ref: str, files: set) -> Optional[str]:
|
|
206
|
+
for cand in (str(PurePosixPath(base).parent / ref), ref):
|
|
207
|
+
norm = str(PurePosixPath(cand))
|
|
208
|
+
parts: List[str] = []
|
|
209
|
+
for p in norm.split("/"):
|
|
210
|
+
if p == "..":
|
|
211
|
+
if parts:
|
|
212
|
+
parts.pop()
|
|
213
|
+
elif p and p != ".":
|
|
214
|
+
parts.append(p)
|
|
215
|
+
joined = "/".join(parts)
|
|
216
|
+
if joined in files:
|
|
217
|
+
return joined
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def extract(source, cfg: Dict[str, Any]) -> dict:
|
|
222
|
+
"""The research map of one source (a commit, a snapshot of the working tree, or a folder)."""
|
|
223
|
+
kinds = cfg["kinds"]
|
|
224
|
+
def_rx = _def_rx(kinds)
|
|
225
|
+
skip = segment_rx(cfg["skip"])
|
|
226
|
+
ignore = [glob_rx(p) for p in cfg.get("ignore", [])]
|
|
227
|
+
files = [f for f in source.files if not skip.search(f) and not any(r.match(f) for r in ignore)]
|
|
228
|
+
fileset = set(source.files)
|
|
229
|
+
pick = lambda globs: [f for f in files if any(glob_rx(g).match(f) for g in globs)] # noqa: E731
|
|
230
|
+
m = Model(kinds)
|
|
231
|
+
warnings: List[str] = []
|
|
232
|
+
|
|
233
|
+
for f, text in source.read_many(pick(cfg["notes"])).items():
|
|
234
|
+
read_lines(m, text.splitlines(), f, def_rx)
|
|
235
|
+
for f, text in source.read_many(pick(cfg["paper"])).items():
|
|
236
|
+
lines = text.splitlines()
|
|
237
|
+
comments = [(re.search(r"(?<!\\)%(.*)$", ln) or [None, ""])[1] for ln in lines]
|
|
238
|
+
for nid, first, last in read_lines(m, comments, f, def_rx):
|
|
239
|
+
# A claim's sources: the citations of the paragraph its marker sits in.
|
|
240
|
+
lo = first
|
|
241
|
+
while lo > 1 and lines[lo - 2].strip():
|
|
242
|
+
lo -= 1
|
|
243
|
+
hi = last
|
|
244
|
+
while hi < len(lines) and lines[hi].strip():
|
|
245
|
+
hi += 1
|
|
246
|
+
cites = {k.strip() for ln in lines[lo - 1:hi] for ks in CITE_RX.findall(ln) for k in ks.split(",")}
|
|
247
|
+
if cites:
|
|
248
|
+
n = m.node(nid)
|
|
249
|
+
n["sources"] = sorted(set(n["sources"]) | cites)
|
|
250
|
+
for f, text in source.read_many(pick(cfg["scripts"]) + pick(cfg["configs"])).items():
|
|
251
|
+
read_comment(m, text, f, def_rx)
|
|
252
|
+
block = config_block(text, f) if f in pick(cfg["configs"]) else None
|
|
253
|
+
if not block or not block.get("id"):
|
|
254
|
+
continue
|
|
255
|
+
nid = str(block["id"])
|
|
256
|
+
m.node(nid, str(block.get("title") or ""), f)
|
|
257
|
+
for key in ("tests", "answers", "relies on", "supports", "weakens"):
|
|
258
|
+
val = block.get(key) or block.get(key.replace(" ", "_"))
|
|
259
|
+
if val:
|
|
260
|
+
m.attr(nid, key, ", ".join(val) if isinstance(val, list) else str(val), f)
|
|
261
|
+
if block.get("status"):
|
|
262
|
+
m.attr(nid, "status", str(block["status"]), f)
|
|
263
|
+
for ref in ([block["results"]] if isinstance(block.get("results"), str) else block.get("results") or []):
|
|
264
|
+
path = _resolve(f, ref, fileset)
|
|
265
|
+
if path is None:
|
|
266
|
+
warnings.append(f"{nid}: results file {ref} is not in the repository")
|
|
267
|
+
continue
|
|
268
|
+
r = m.node(f"{nid}.results", "", path) # its box shows the file under the id
|
|
269
|
+
r["metrics"].update(read_results(source.read(path), path))
|
|
270
|
+
m.links.add((f"{nid}.results", "of", nid))
|
|
271
|
+
|
|
272
|
+
from .trackers import run_nodes
|
|
273
|
+
for f, text in source.read_many([x for x in cfg.get("runs", []) if x in fileset]).items():
|
|
274
|
+
for run in run_nodes(text):
|
|
275
|
+
rid = f"{run['experiment']}.{run['source']}.{str(run['run'])[:8]}"
|
|
276
|
+
r = m.node(rid, str(run.get("name") or ""), f)
|
|
277
|
+
r["metrics"].update({k: float(v) for k, v in (run.get("metrics") or {}).items()})
|
|
278
|
+
m.links.add((rid, "of", run["experiment"]))
|
|
279
|
+
|
|
280
|
+
nodes = m.nodes
|
|
281
|
+
for src, rel, dst in sorted(m.links):
|
|
282
|
+
if dst not in nodes:
|
|
283
|
+
warnings.append(f"{src} {rel} {dst}, which is not defined anywhere")
|
|
284
|
+
by = defaultdict(list)
|
|
285
|
+
for src, rel, dst in m.links:
|
|
286
|
+
by[(rel, dst)].append(src)
|
|
287
|
+
by[(src, rel)].append(dst)
|
|
288
|
+
for nid, n in sorted(nodes.items()):
|
|
289
|
+
if n["kind"] == "experiment" and not by[(nid, "tests")]:
|
|
290
|
+
warnings.append(f"{nid} tests no hypothesis")
|
|
291
|
+
if n["kind"] == "hypothesis" and not by[("tests", nid)] and n["status"] in ("", "open"):
|
|
292
|
+
warnings.append(f"{nid} is open and no experiment tests it")
|
|
293
|
+
if n["kind"] == "claim":
|
|
294
|
+
basis = by[(nid, "relies on")] + by[("supports", nid)]
|
|
295
|
+
if not basis:
|
|
296
|
+
warnings.append(f"{nid} relies on nothing: no result or hypothesis backs it")
|
|
297
|
+
for b in by[(nid, "relies on")]:
|
|
298
|
+
if nodes.get(b, {}).get("status") == "refuted":
|
|
299
|
+
warnings.append(f"{nid} relies on {b}, which is refuted")
|
|
300
|
+
for w in by[("weakens", nid)]:
|
|
301
|
+
warnings.append(f"{nid} is weakened by {w}")
|
|
302
|
+
for e in n.get("expects", []):
|
|
303
|
+
warnings += check_expect(nid, e, nodes)
|
|
304
|
+
return {"ref": source.label, "nodes": {k: nodes[k] for k in sorted(nodes, key=_order)},
|
|
305
|
+
"links": [list(x) for x in sorted(m.links)], "warnings": sorted(set(warnings))}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _order(nid: str) -> tuple:
|
|
309
|
+
m = re.match(r"([A-Za-z])(\d+)([a-z]?)(.*)", nid)
|
|
310
|
+
return ("QHERC".find(m.group(1)), int(m.group(2)), m.group(3), m.group(4)) if m else (9, 0, "", nid)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
EXPECT_RX = re.compile(r"^(\S+)\s+(\S+)\s*(>=|<=|==|>|<)\s*(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*$")
|
|
314
|
+
OPS = {">": lambda a, b: a > b, ">=": lambda a, b: a >= b, "<": lambda a, b: a < b, "<=": lambda a, b: a <= b,
|
|
315
|
+
"==": lambda a, b: abs(a - b) < 1e-12}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def check_expect(nid: str, expect: str, nodes: Dict[str, dict]) -> List[str]:
|
|
319
|
+
"""`expects: E1.results acc.b32 > 0.85` on a claim: a warning when the number is missing or does not meet it."""
|
|
320
|
+
m = EXPECT_RX.match(expect)
|
|
321
|
+
if not m:
|
|
322
|
+
return [f"{nid}: cannot read `expects: {expect}` (write: RESULT METRIC > NUMBER)"]
|
|
323
|
+
target, metric, op, want = m.group(1), m.group(2), m.group(3), float(m.group(4))
|
|
324
|
+
got = nodes.get(target, {}).get("metrics", {}).get(metric)
|
|
325
|
+
if got is None:
|
|
326
|
+
return [f"{nid} expects {target} {metric} {op} {want:g}, but {target} has no {metric}"]
|
|
327
|
+
return [] if OPS[op](got, want) else [f"{nid} expects {target} {metric} {op} {want:g}, but it is {got:g}"]
|
research_diff/graph.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The research map and diff as pictures, drawn by system-diff's renderer: a column per kind (questions, hypotheses,
|
|
2
|
+
experiments, results, claims), a blue band on what is supported, a dashed box around what is refuted, and lines for
|
|
3
|
+
what links to what: grey for links, blue for supports, amber for weakens."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from system_diff import pictures
|
|
9
|
+
|
|
10
|
+
EDGE_KIND = {"supports": "data", "weakens": "network"}
|
|
11
|
+
LEGEND = [("band", "new", "supported"), ("dashed", "", "refuted"),
|
|
12
|
+
("line", "call", "links"), ("line", "data", "supports"), ("line", "network", "weakens")]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _node(nid: str, n: dict, status: str = "") -> dict:
|
|
16
|
+
sub = " · ".join(x for x in (n["status"], f"{len(n['sources'])} sources" if n["sources"] else "") if x)
|
|
17
|
+
lines = [{"text": f"{k} = {v:.4g}", "code": True} for k, v in list(n["metrics"].items())[:4]]
|
|
18
|
+
style = "new" if n["status"] == "supported" else "legacy" if n["status"] == "refuted" else ""
|
|
19
|
+
node = {"id": nid, "col": n["kind"], "title": f"{nid} {n['title']}".strip(), "sub": sub or (n["files"][0] if n["files"] else ""),
|
|
20
|
+
"type": "store" if n["kind"] == "result" else "component", "style": style, "lines": lines}
|
|
21
|
+
if status:
|
|
22
|
+
node["status"] = status
|
|
23
|
+
return node
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def map_graph(bp: dict, cfg: Dict[str, Any]) -> dict:
|
|
27
|
+
nodes = [_node(k, n) for k, n in bp["nodes"].items() if n["kind"] in {c[0] for c in cfg["picture"]["columns"]}]
|
|
28
|
+
ids = {n["id"] for n in nodes}
|
|
29
|
+
edges = [{"from": s, "to": t, "kind": EDGE_KIND.get(r, "call")} for s, r, t in bp["links"] if s in ids and t in ids]
|
|
30
|
+
styles = {n["style"] for n in nodes}
|
|
31
|
+
kinds = {e["kind"] for e in edges}
|
|
32
|
+
legend = [x for x in LEGEND if (x[0] == "band" and "new" in styles) or (x[0] == "dashed" and "legacy" in styles)
|
|
33
|
+
or (x[0] == "line" and x[1] in kinds)]
|
|
34
|
+
warn = bp.get("warnings") or []
|
|
35
|
+
return {"columns": cfg["picture"]["columns"], "nodes": nodes, "edges": edges, "legend": legend,
|
|
36
|
+
"footer": [f"{len(warn)} warning{'' if len(warn) == 1 else 's'}: " + "; ".join(warn[:3])
|
|
37
|
+
+ (" …" if len(warn) > 3 else "")] if warn else []}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def map_svg(bp: dict, cfg: Dict[str, Any]) -> str:
|
|
41
|
+
return pictures.map_svg(map_graph(bp, cfg), label="Research map")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def diff_graph(d: dict, a: dict, b: dict, cfg: Dict[str, Any]) -> Optional[dict]:
|
|
45
|
+
"""What changed, lit, with the ideas it links to beside it; None when nothing changed."""
|
|
46
|
+
status: Dict[str, str] = {}
|
|
47
|
+
lines: Dict[str, List[dict]] = {}
|
|
48
|
+
links_changed: Dict[tuple, str] = {}
|
|
49
|
+
word = {"add": "added", "remove": "removed", "change": "changed"}
|
|
50
|
+
for c in d["changes"]:
|
|
51
|
+
op = word[c["op"]]
|
|
52
|
+
if c["entity"] in ("question", "hypothesis", "experiment", "result", "claim"):
|
|
53
|
+
status[c["id"]] = op
|
|
54
|
+
elif c["entity"] in ("status", "title"):
|
|
55
|
+
status.setdefault(c["id"], "changed")
|
|
56
|
+
if c["entity"] == "status":
|
|
57
|
+
lines.setdefault(c["id"], []).append({"text": c["label"].split(": ", 1)[1], "status": "changed"})
|
|
58
|
+
elif c["entity"] == "metric":
|
|
59
|
+
nid = c["id"].split(" ", 1)[0]
|
|
60
|
+
status.setdefault(nid, "changed")
|
|
61
|
+
lines.setdefault(nid, []).append({"text": c["label"].split(" ", 1)[1], "code": True, "status": op})
|
|
62
|
+
elif c["entity"] == "link":
|
|
63
|
+
s, rest = c["id"].split(" ", 1)
|
|
64
|
+
rel, t = rest.rsplit(" ", 1)
|
|
65
|
+
links_changed[(s, rel, t)] = op
|
|
66
|
+
hot = set(status) | {x for (s, _r, t) in links_changed for x in (s, t)}
|
|
67
|
+
if not hot:
|
|
68
|
+
return None
|
|
69
|
+
both = {**a["nodes"], **b["nodes"]}
|
|
70
|
+
near = set(hot)
|
|
71
|
+
for s, r, t in b["links"]:
|
|
72
|
+
if s in hot or t in hot:
|
|
73
|
+
near |= {s, t}
|
|
74
|
+
nodes = []
|
|
75
|
+
for nid in both:
|
|
76
|
+
if nid in near and both[nid]["kind"] in {c[0] for c in cfg["picture"]["columns"]}:
|
|
77
|
+
n = _node(nid, both[nid], status.get(nid, "same"))
|
|
78
|
+
if nid in lines:
|
|
79
|
+
n["lines"] = lines[nid]
|
|
80
|
+
nodes.append(n)
|
|
81
|
+
ids = {n["id"] for n in nodes}
|
|
82
|
+
edges = [{"from": s, "to": t, "kind": EDGE_KIND.get(r, "call"), "status": op}
|
|
83
|
+
for (s, r, t), op in links_changed.items() if s in ids and t in ids]
|
|
84
|
+
seen = {(e["from"], e["to"]) for e in edges}
|
|
85
|
+
edges += [{"from": s, "to": t, "kind": EDGE_KIND.get(r, "call"), "status": "same"} for s, r, t in b["links"]
|
|
86
|
+
if s in ids and t in ids and (s, t) not in seen]
|
|
87
|
+
new_w = [c["id"] for c in d["changes"] if c["entity"] == "warning" and c["op"] == "add"]
|
|
88
|
+
return {"columns": cfg["picture"]["columns"], "nodes": nodes, "edges": edges,
|
|
89
|
+
"footer": ["New warning: " + "; ".join(new_w)] if new_w else []}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def diff_svg(d: dict, a: dict, b: dict, cfg: Dict[str, Any]) -> Optional[str]:
|
|
93
|
+
g = diff_graph(d, a, b, cfg)
|
|
94
|
+
return pictures.map_svg(g, diff=True, label="Research diff") if g else None
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""`research-diff mcp`: research-diff as an MCP server over stdio (extra: pip install "research-diff[mcp]").
|
|
2
|
+
|
|
3
|
+
research_map and research_diff read; log_result writes a result marker into the research log (a Markdown file in the
|
|
4
|
+
repository, so it is on the map, in git, and in the next diff); checkpoint marks what the person has understood."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from system_diff.pictures import write_if_changed
|
|
13
|
+
from system_diff.sources import git, repo_root
|
|
14
|
+
|
|
15
|
+
from . import compare as cmp
|
|
16
|
+
from . import graph
|
|
17
|
+
|
|
18
|
+
try: # the MCP SDK's 2.x name; 1.x called it FastMCP
|
|
19
|
+
from mcp.server.mcpserver import MCPServer as Server
|
|
20
|
+
except ImportError: # pragma: no cover
|
|
21
|
+
from mcp.server.fastmcp import FastMCP as Server
|
|
22
|
+
|
|
23
|
+
INSTRUCTIONS = (
|
|
24
|
+
"research-diff keeps a map of this research: questions (Q), hypotheses (H), experiments (E), results (R) and claims "
|
|
25
|
+
"(C), from markers in the notes, configs, scripts and paper. After an iteration, call research_diff and tell the "
|
|
26
|
+
"person what changed, from its plan only. Record an experiment's outcome with log_result. Call checkpoint only "
|
|
27
|
+
"when the person says they have understood the current state."
|
|
28
|
+
)
|
|
29
|
+
LOG = "research/log.md"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _out(root: Path) -> Path:
|
|
33
|
+
d = Path(git("rev-parse", "--absolute-git-dir", cwd=root).strip()) / "research-diff" / "mcp"
|
|
34
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
return d
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def tool_map(root: Path, at: str = ".") -> Dict[str, Any]:
|
|
39
|
+
bp, cfg, label = cmp.read(root, at)
|
|
40
|
+
path = _out(root) / "research.svg"
|
|
41
|
+
write_if_changed(path, graph.map_svg(bp, cfg))
|
|
42
|
+
return {"of": label, "picture": str(path), "warnings": bp["warnings"],
|
|
43
|
+
"nodes": [{"id": k, "kind": n["kind"], "title": n["title"], "status": n["status"], "metrics": n["metrics"]}
|
|
44
|
+
for k, n in bp["nodes"].items()],
|
|
45
|
+
"links": [" ".join(x) for x in bp["links"]]}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def tool_diff(root: Path, base: str = "checkpoint", head: str = ".") -> Dict[str, Any]:
|
|
49
|
+
if head == ".":
|
|
50
|
+
cmp.sync_trackers(root)
|
|
51
|
+
c = cmp.compare(root, base, head)
|
|
52
|
+
svg = graph.diff_svg(c.diff, c.before, c.after, c.cfg)
|
|
53
|
+
path = _out(root) / "diff.svg"
|
|
54
|
+
if svg:
|
|
55
|
+
write_if_changed(path, svg)
|
|
56
|
+
elif path.is_file():
|
|
57
|
+
path.unlink()
|
|
58
|
+
return {"from": c.base, "headline": c.diff["headline"], "plan": c.diff["plan"], "changes": len(c.diff["changes"]),
|
|
59
|
+
"warnings": c.diff["warnings"], "picture": str(path) if svg else None}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def tool_log(root: Path, experiment: str, metrics: Dict[str, float], title: str = "", supports: List[str] = (),
|
|
63
|
+
weakens: List[str] = (), note: str = "", log: str = LOG) -> Dict[str, Any]:
|
|
64
|
+
bp, _cfg, _ = cmp.read(root, ".")
|
|
65
|
+
taken = [int(m.group(1)) for k in bp["nodes"] for m in [re.match(r"R(\d+)$", k)] if m]
|
|
66
|
+
rid = f"R{max(taken, default=0) + 1}"
|
|
67
|
+
lines = [f"## {rid}: {title or 'a run of ' + experiment} ({time.strftime('%Y-%m-%d')})", "", f"of: {experiment}",
|
|
68
|
+
"metrics: " + ", ".join(f"{k}={v:g}" for k, v in metrics.items())]
|
|
69
|
+
lines += [f"supports: {', '.join(supports)}"] if supports else []
|
|
70
|
+
lines += [f"weakens: {', '.join(weakens)}"] if weakens else []
|
|
71
|
+
lines += ["", note] if note else []
|
|
72
|
+
path = root / log
|
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
before = path.read_text(encoding="utf-8") if path.is_file() else "# Research log\n\nResults, newest last. Written by research-diff's log_result.\n"
|
|
75
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
76
|
+
f.write(before.rstrip("\n") + "\n\n" + "\n".join(lines) + "\n")
|
|
77
|
+
return {"recorded": rid, "in": log}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build() -> Server:
|
|
81
|
+
server = Server("research-diff", instructions=INSTRUCTIONS)
|
|
82
|
+
|
|
83
|
+
@server.tool()
|
|
84
|
+
def research_map(at: str = ".") -> Dict[str, Any]:
|
|
85
|
+
"""Every question, hypothesis, experiment, result and claim, their statuses, metrics and links, the open
|
|
86
|
+
warnings, and the picture's path. `at`: a commit, or "." for the working tree."""
|
|
87
|
+
return tool_map(repo_root(), at)
|
|
88
|
+
|
|
89
|
+
@server.tool()
|
|
90
|
+
def research_diff(base: str = "checkpoint", head: str = ".") -> Dict[str, Any]:
|
|
91
|
+
"""What changed in the research from `base` ("checkpoint": what the person last marked as understood) to
|
|
92
|
+
`head` ("." the working tree): one sentence, the exact plan, new warnings and the picture's path."""
|
|
93
|
+
return tool_diff(repo_root(), base, head)
|
|
94
|
+
|
|
95
|
+
@server.tool()
|
|
96
|
+
def log_result(experiment: str, metrics: Dict[str, float], title: str = "", supports: Optional[List[str]] = None,
|
|
97
|
+
weakens: Optional[List[str]] = None, note: str = "") -> Dict[str, Any]:
|
|
98
|
+
"""Record what an experiment produced, as a result marker in research/log.md: its metrics, and the hypotheses
|
|
99
|
+
or claims it supports or weakens. Only for a run that actually happened."""
|
|
100
|
+
return tool_log(repo_root(), experiment, metrics, title, supports or [], weakens or [], note)
|
|
101
|
+
|
|
102
|
+
@server.tool()
|
|
103
|
+
def checkpoint(ref: str = "") -> Dict[str, Any]:
|
|
104
|
+
"""Mark the working tree (or `ref`) as understood by the person. Only when they say so."""
|
|
105
|
+
sha = cmp.checkpoint(repo_root(), ref or None)
|
|
106
|
+
return {"checkpoint": sha[:9]}
|
|
107
|
+
|
|
108
|
+
return server
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main() -> None:
|
|
112
|
+
build().run("stdio")
|
research_diff/readme.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The README's generated research map: the picture, and the warnings that stand open."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from system_diff.pictures import write_if_changed
|
|
8
|
+
|
|
9
|
+
from . import compare, graph
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def section(bp: dict, cfg: dict) -> str:
|
|
13
|
+
r = cfg["readme"]
|
|
14
|
+
warn = "".join(f"- {w}\n" for w in bp["warnings"])
|
|
15
|
+
return (f"{r['begin']}\n\n\n"
|
|
16
|
+
+ (f"<details><summary>{len(bp['warnings'])} open warning{'' if len(bp['warnings']) == 1 else 's'}"
|
|
17
|
+
f"</summary>\n\n{warn}\n</details>\n" if warn else "")
|
|
18
|
+
+ f"{r['end']}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(root: Path, config_path: Optional[str] = None, check: bool = False) -> int:
|
|
22
|
+
bp, cfg, _ = compare.read(root, ".", config_path)
|
|
23
|
+
r = cfg["readme"]
|
|
24
|
+
path, image = root / r["file"], root / r["svg"]
|
|
25
|
+
text = path.read_text(encoding="utf-8")
|
|
26
|
+
if r["begin"] not in text or r["end"] not in text:
|
|
27
|
+
print(f"{path} has no {r['begin']} … {r['end']} section: add the two lines where the map goes")
|
|
28
|
+
return 1
|
|
29
|
+
start, end = text.index(r["begin"]), text.index(r["end"]) + len(r["end"])
|
|
30
|
+
new_text, svg = text[:start] + section(bp, cfg) + text[end:], graph.map_svg(bp, cfg)
|
|
31
|
+
if check:
|
|
32
|
+
current = new_text == text and image.is_file() and image.read_text(encoding="utf-8") == svg
|
|
33
|
+
print("README research map " + ("is current" if current else "is out of date"))
|
|
34
|
+
return 0 if current else 1
|
|
35
|
+
image.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
changed = write_if_changed(image, svg) | write_if_changed(path, new_text)
|
|
37
|
+
print("README research map " + ("updated" if changed else "already current"))
|
|
38
|
+
return 0
|