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.
@@ -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")
@@ -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![Research map, generated from the notes, configs, results and paper]({r['image'] or r['svg']})\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