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/agent.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Claude Code: `research-diff hook`, run by its Stop hook after every turn, and `research-diff setup claude`, which adds
|
|
2
|
+
the hook, a skill on when to show the research diff, and the MCP server. The hook never blocks and never fails a turn;
|
|
3
|
+
setup prints each change, and `--undo` takes it back."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable, Optional
|
|
13
|
+
|
|
14
|
+
from system_diff.agent import _claude, _is_repo, _load, _save, _split
|
|
15
|
+
from system_diff.sources import git
|
|
16
|
+
|
|
17
|
+
from .compare import compare, sync_trackers
|
|
18
|
+
|
|
19
|
+
NAME = "research-diff"
|
|
20
|
+
SKILL_MARK = "<!-- written by research-diff setup claude -->"
|
|
21
|
+
SKILL = f"""---
|
|
22
|
+
name: research-diff
|
|
23
|
+
description: Shows the person what an iteration did to their research (questions, hypotheses, experiments, results, claims). Use after running an experiment, changing a hypothesis, notes, a config or the paper, before asking them to review, or when they ask where the research stands.
|
|
24
|
+
---
|
|
25
|
+
{SKILL_MARK}
|
|
26
|
+
|
|
27
|
+
# Showing the research diff
|
|
28
|
+
|
|
29
|
+
The research is described by markers in the repository's own files: `H2: title` with `status:`, `tests:`,
|
|
30
|
+
`supports:`, `weakens:`, `relies on:` lines in notes, `% C1: …` in the paper, a `research:` block or a
|
|
31
|
+
`# research-diff: E5 tests H2` comment in experiment configs and scripts.
|
|
32
|
+
|
|
33
|
+
1. When you add an idea, an experiment or a claim, write its marker, so it is on the map.
|
|
34
|
+
2. After running an experiment, record what came out with the `log_result` tool (the metrics, and which hypothesis it
|
|
35
|
+
supports or weakens), or as a results file the experiment's `research:` block names.
|
|
36
|
+
3. Call `research_diff` and tell the person, in two or three plain sentences built only from its plan, what changed:
|
|
37
|
+
statuses that moved, numbers that changed, new warnings (a claim relying on a refuted hypothesis, an open hypothesis
|
|
38
|
+
no experiment tests).
|
|
39
|
+
4. Point them at the picture, or at `research-diff watch` for a live page.
|
|
40
|
+
5. When they say they have understood it, call `checkpoint`, so the next diff shows only what is new to them.
|
|
41
|
+
|
|
42
|
+
Never report a result the plan does not show, and never mark a hypothesis supported or refuted without their say.
|
|
43
|
+
"""
|
|
44
|
+
SCOPES = ("user", "project", "local")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def hook_message(root: Path) -> Optional[str]:
|
|
48
|
+
sync_trackers(root) # runs logged to MLflow or W&B during the turn count too
|
|
49
|
+
c = compare(root, "checkpoint", ".")
|
|
50
|
+
if not c.diff["changes"]:
|
|
51
|
+
return None
|
|
52
|
+
return f"Research diff since {c.base}: {c.diff['headline']} (`research-diff watch` shows the picture.)"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_hook(stdin_text: str, emit: Callable[[str], None] = print) -> int:
|
|
56
|
+
try:
|
|
57
|
+
payload = json.loads(stdin_text) if stdin_text.strip() else {}
|
|
58
|
+
except ValueError:
|
|
59
|
+
payload = {}
|
|
60
|
+
cwd = Path(payload.get("cwd") or os.getcwd())
|
|
61
|
+
if not _is_repo(cwd):
|
|
62
|
+
return 0
|
|
63
|
+
try:
|
|
64
|
+
message = hook_message(Path(git("rev-parse", "--show-toplevel", cwd=cwd).strip()))
|
|
65
|
+
except (OSError, subprocess.CalledProcessError, ValueError, KeyError):
|
|
66
|
+
return 0
|
|
67
|
+
if message:
|
|
68
|
+
emit(json.dumps({"systemMessage": message}))
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def command() -> str:
|
|
73
|
+
if shutil.which(NAME):
|
|
74
|
+
return NAME
|
|
75
|
+
exe = sys.executable
|
|
76
|
+
return f'"{exe}" -m research_diff' if " " in exe else f"{exe} -m research_diff"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _paths(scope: str, root: Path) -> tuple:
|
|
80
|
+
home = Path.home() / ".claude"
|
|
81
|
+
settings = {"user": home / "settings.json", "project": root / ".claude" / "settings.json",
|
|
82
|
+
"local": root / ".claude" / "settings.local.json"}[scope]
|
|
83
|
+
return settings, (home / "skills" if scope == "user" else root / ".claude" / "skills") / NAME / "SKILL.md"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ours(entry: dict) -> bool:
|
|
87
|
+
return any(c in h.get("command", "") for h in entry.get("hooks", []) for c in ("research-diff hook", "research_diff hook"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def setup_claude(root: Path, scope: str = "user", undo: bool = False, out: Callable[[str], None] = print) -> int:
|
|
91
|
+
settings_path, skill_path = _paths(scope, root)
|
|
92
|
+
settings = _load(settings_path)
|
|
93
|
+
stop = settings.setdefault("hooks", {}).setdefault("Stop", [])
|
|
94
|
+
ours = [e for e in stop if _ours(e)]
|
|
95
|
+
has_claude = shutil.which("claude") is not None
|
|
96
|
+
if undo:
|
|
97
|
+
if ours:
|
|
98
|
+
settings["hooks"]["Stop"] = [e for e in stop if not _ours(e)]
|
|
99
|
+
if not settings["hooks"]["Stop"]:
|
|
100
|
+
del settings["hooks"]["Stop"]
|
|
101
|
+
if not settings["hooks"]:
|
|
102
|
+
del settings["hooks"]
|
|
103
|
+
_save(settings_path, settings)
|
|
104
|
+
out(f"removed the Stop hook from {settings_path}")
|
|
105
|
+
if skill_path.is_file() and SKILL_MARK in skill_path.read_text(encoding="utf-8"):
|
|
106
|
+
skill_path.unlink()
|
|
107
|
+
if not any(skill_path.parent.iterdir()):
|
|
108
|
+
skill_path.parent.rmdir()
|
|
109
|
+
out(f"removed the skill {skill_path}")
|
|
110
|
+
if has_claude and _claude(["mcp", "get", NAME]).returncode == 0:
|
|
111
|
+
got = _claude(["mcp", "remove", NAME, "--scope", scope])
|
|
112
|
+
out("removed the MCP server" if got.returncode == 0 else f"could not remove the MCP server: {got.stderr.strip()[:200]}")
|
|
113
|
+
return 0
|
|
114
|
+
if ours:
|
|
115
|
+
out(f"the Stop hook is already in {settings_path}")
|
|
116
|
+
else:
|
|
117
|
+
stop.append({"hooks": [{"type": "command", "command": f"{command()} hook", "timeout": 30}]})
|
|
118
|
+
_save(settings_path, settings)
|
|
119
|
+
out(f"added a Stop hook to {settings_path}: after each turn, one line on what changed in the research")
|
|
120
|
+
if skill_path.is_file() and SKILL_MARK not in skill_path.read_text(encoding="utf-8"):
|
|
121
|
+
out(f"left {skill_path} alone: someone else wrote it")
|
|
122
|
+
elif not (skill_path.is_file() and skill_path.read_text(encoding="utf-8") == SKILL):
|
|
123
|
+
skill_path.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
with open(skill_path, "w", encoding="utf-8", newline="\n") as f:
|
|
125
|
+
f.write(SKILL)
|
|
126
|
+
out(f"wrote the skill {skill_path}")
|
|
127
|
+
if not has_claude:
|
|
128
|
+
out("`claude` is not on PATH, so the MCP server was not registered; run setup again where it is")
|
|
129
|
+
return 0
|
|
130
|
+
try:
|
|
131
|
+
import mcp # noqa: F401
|
|
132
|
+
except ImportError:
|
|
133
|
+
out('the MCP server needs its extra: pip install "research-diff[mcp]", then run setup again')
|
|
134
|
+
return 0
|
|
135
|
+
if _claude(["mcp", "get", NAME]).returncode == 0:
|
|
136
|
+
out("the MCP server is already registered")
|
|
137
|
+
else:
|
|
138
|
+
got = _claude(["mcp", "add", "--scope", scope, "--transport", "stdio", NAME, "--", *_split(command()), "mcp"])
|
|
139
|
+
out(f"registered the MCP server ({scope} scope)" if got.returncode == 0
|
|
140
|
+
else f"could not register the MCP server: {(got.stderr or got.stdout).strip()[:200]}")
|
|
141
|
+
return 0
|
research_diff/cli.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""research-diff: your research as a map (questions, hypotheses, experiments, results, claims), and what each iteration
|
|
2
|
+
changed in it.
|
|
3
|
+
|
|
4
|
+
research-diff map [--at REF|.] [--out DIR] [--check] -> research.json, research.svg
|
|
5
|
+
research-diff diff [--base checkpoint|REF] [--head .|REF] [--out DIR]
|
|
6
|
+
-> diff.json, diff.svg, plan.txt, headline.txt
|
|
7
|
+
research-diff sync -> MLflow / W&B runs into research/runs.json
|
|
8
|
+
research-diff suggest [FILE...] -> your LLM proposes markers, as a patch
|
|
9
|
+
research-diff checkpoint [--ref REF] -> mark what you have understood
|
|
10
|
+
research-diff watch [--port 8766] -> a live page of the diff since then
|
|
11
|
+
research-diff readme [--check] -> the README's generated research map
|
|
12
|
+
research-diff setup claude [--scope user|project|local] [--undo] -> the hook, a skill and the MCP server
|
|
13
|
+
research-diff hook | mcp -> what Claude Code runs
|
|
14
|
+
|
|
15
|
+
`.` is the working tree, untracked files included, read without touching your index, HEAD or branches.
|
|
16
|
+
`checkpoint` is what you last marked as understood (HEAD until you mark something).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import List, Optional
|
|
25
|
+
|
|
26
|
+
from system_diff.pictures import write_if_changed
|
|
27
|
+
from system_diff.sources import repo_root
|
|
28
|
+
|
|
29
|
+
from . import __version__, compare, graph, readme
|
|
30
|
+
from .diff import PLURAL
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write(path: Path, text: str, check: bool) -> bool:
|
|
34
|
+
if check:
|
|
35
|
+
return not path.is_file() or path.read_text(encoding="utf-8") != text
|
|
36
|
+
write_if_changed(path, text)
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _map(args, root: Path) -> int:
|
|
41
|
+
bp, cfg, label = compare.read(root, args.at, args.config)
|
|
42
|
+
out = Path(args.out)
|
|
43
|
+
if not args.check:
|
|
44
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
stale = _write(out / "research.json", json.dumps(bp, indent=1, ensure_ascii=False) + "\n", args.check)
|
|
46
|
+
stale |= _write(out / "research.svg", graph.map_svg(bp, cfg), args.check)
|
|
47
|
+
if args.check:
|
|
48
|
+
print(f"research map {'is out of date' if stale else 'is current'}: {out}/research.svg")
|
|
49
|
+
return 1 if stale else 0
|
|
50
|
+
kinds = {}
|
|
51
|
+
for n in bp["nodes"].values():
|
|
52
|
+
kinds[n["kind"]] = kinds.get(n["kind"], 0) + 1
|
|
53
|
+
print(f"research map of {label}: " + ", ".join(f"{v} {k if v == 1 else PLURAL.get(k, k + 's')}" for k, v in kinds.items())
|
|
54
|
+
+ f", {len(bp['warnings'])} warnings -> {out}/research.svg")
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _diff(args, root: Path) -> int:
|
|
59
|
+
c = compare.compare(root, args.base, args.head, args.config)
|
|
60
|
+
out = Path(args.out)
|
|
61
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
for name, text in (("diff.json", json.dumps(c.diff, indent=1, ensure_ascii=False)), ("plan.txt", c.diff["plan"]),
|
|
63
|
+
("headline.txt", c.diff["headline"])):
|
|
64
|
+
(out / name).write_text(text + "\n", encoding="utf-8", newline="\n")
|
|
65
|
+
svg = graph.diff_svg(c.diff, c.before, c.after, c.cfg)
|
|
66
|
+
if svg:
|
|
67
|
+
write_if_changed(out / "diff.svg", svg)
|
|
68
|
+
elif (out / "diff.svg").is_file():
|
|
69
|
+
(out / "diff.svg").unlink()
|
|
70
|
+
print(c.diff["headline"])
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parser() -> argparse.ArgumentParser:
|
|
75
|
+
ap = argparse.ArgumentParser(prog="research-diff", description=__doc__.split("\n")[0],
|
|
76
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
77
|
+
epilog="\n".join(__doc__.split("\n")[2:]))
|
|
78
|
+
ap.add_argument("--version", action="version", version=f"research-diff {__version__}")
|
|
79
|
+
ap.add_argument("--repo", help="the repository (default: the one holding the current directory)")
|
|
80
|
+
ap.add_argument("--config", help="a TOML or JSON config (default: research-diff.toml or [tool.research-diff])")
|
|
81
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
82
|
+
m = sub.add_parser("map", help="the research as it is: research.json and research.svg")
|
|
83
|
+
m.add_argument("--at", default=".", help="a commit, or . for the working tree (default)")
|
|
84
|
+
m.add_argument("--out", default=".")
|
|
85
|
+
m.add_argument("--check", action="store_true", help="write nothing; exit 1 if the files are out of date")
|
|
86
|
+
d = sub.add_parser("diff", help="what an iteration changed")
|
|
87
|
+
d.add_argument("--base", default="checkpoint", help="a commit, or checkpoint (default)")
|
|
88
|
+
d.add_argument("--head", default=".", help="a commit, or . for the working tree (default)")
|
|
89
|
+
d.add_argument("--out", default=".")
|
|
90
|
+
sub.add_parser("sync", help="copy MLflow and W&B runs tagged with an experiment id into research/runs.json")
|
|
91
|
+
sg = sub.add_parser("suggest", help="your LLM proposes markers for untagged files, as a patch you apply or not")
|
|
92
|
+
sg.add_argument("files", nargs="*", help="files to read (default: notes and paper files with no marker)")
|
|
93
|
+
sg.add_argument("--out", default="", help="where to write the patch (default .git/research-diff/suggest.patch)")
|
|
94
|
+
c = sub.add_parser("checkpoint", help="mark what you have understood")
|
|
95
|
+
c.add_argument("--ref", help="a commit to mark instead of the working tree")
|
|
96
|
+
w = sub.add_parser("watch", help="a live local page of the diff since the checkpoint")
|
|
97
|
+
w.add_argument("--base", default="checkpoint")
|
|
98
|
+
w.add_argument("--port", type=int, default=8766)
|
|
99
|
+
w.add_argument("--interval", type=float, default=2.0)
|
|
100
|
+
r = sub.add_parser("readme", help="rewrite the README's research-map section")
|
|
101
|
+
r.add_argument("--check", action="store_true")
|
|
102
|
+
st = sub.add_parser("setup", help="wire research-diff into an AI coding tool")
|
|
103
|
+
st.add_argument("tool", choices=["claude"])
|
|
104
|
+
st.add_argument("--scope", default="user", choices=["user", "project", "local"])
|
|
105
|
+
st.add_argument("--undo", action="store_true")
|
|
106
|
+
sub.add_parser("hook", help="what Claude Code's Stop hook runs")
|
|
107
|
+
sub.add_parser("mcp", help="the MCP server over stdio (needs the [mcp] extra)")
|
|
108
|
+
return ap
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
112
|
+
args = parser().parse_args(argv)
|
|
113
|
+
root = repo_root(Path(args.repo) if args.repo else None)
|
|
114
|
+
if args.cmd == "map":
|
|
115
|
+
return _map(args, root)
|
|
116
|
+
if args.cmd == "diff":
|
|
117
|
+
return _diff(args, root)
|
|
118
|
+
if args.cmd == "sync":
|
|
119
|
+
got = compare.sync_trackers(root, args.config)
|
|
120
|
+
print(f"{got['runs']} tracker runs" + (" written to research/runs.json" if got["written"] else ", nothing changed"))
|
|
121
|
+
return 0
|
|
122
|
+
if args.cmd == "suggest":
|
|
123
|
+
from . import suggest
|
|
124
|
+
return suggest.run(root, args.files, args.out, args.config)
|
|
125
|
+
if args.cmd == "checkpoint":
|
|
126
|
+
sha = compare.checkpoint(root, args.ref)
|
|
127
|
+
print(f"checkpoint: {args.ref or 'the working tree'} ({sha[:9]}) is what you have understood")
|
|
128
|
+
return 0
|
|
129
|
+
if args.cmd == "readme":
|
|
130
|
+
return readme.run(root, args.config, args.check)
|
|
131
|
+
if args.cmd == "watch":
|
|
132
|
+
from . import watch
|
|
133
|
+
watch.serve(root, args.base, args.port, args.config, args.interval)
|
|
134
|
+
return 0
|
|
135
|
+
if args.cmd == "setup":
|
|
136
|
+
from . import agent
|
|
137
|
+
return agent.setup_claude(root, args.scope, args.undo)
|
|
138
|
+
if args.cmd == "hook":
|
|
139
|
+
from . import agent
|
|
140
|
+
return agent.run_hook(sys.stdin.read() if not sys.stdin.isatty() else "")
|
|
141
|
+
if args.cmd == "mcp":
|
|
142
|
+
try:
|
|
143
|
+
from . import mcp_server
|
|
144
|
+
except ImportError:
|
|
145
|
+
print('research-diff: the MCP server needs its extra: pip install "research-diff[mcp]"', file=sys.stderr)
|
|
146
|
+
return 1
|
|
147
|
+
mcp_server.main()
|
|
148
|
+
return 0
|
|
149
|
+
return 2
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
sys.exit(main())
|
research_diff/compare.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Two points in the research's life, compared: a commit, the working tree (`.`, read as a snapshot that touches
|
|
2
|
+
nothing the person uses), or the CHECKPOINT, the last state they marked as understood. research-diff keeps its own
|
|
3
|
+
checkpoint (refs/research-diff/checkpoint), apart from system-diff's, in this clone only."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from system_diff.compare import snapshot
|
|
12
|
+
from system_diff.sources import GitSource, git
|
|
13
|
+
|
|
14
|
+
from . import config
|
|
15
|
+
from .diff import diff
|
|
16
|
+
from .extract import extract
|
|
17
|
+
|
|
18
|
+
CHECKPOINT_REF = "refs/research-diff/checkpoint"
|
|
19
|
+
WORKTREE = (".", "worktree")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _rev(root: Path, ref: str) -> Optional[str]:
|
|
23
|
+
try:
|
|
24
|
+
return git("rev-parse", "--verify", "-q", ref, cwd=root).strip() or None
|
|
25
|
+
except subprocess.CalledProcessError:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sync_trackers(root: Path, config_path: Optional[str] = None) -> Dict[str, Any]:
|
|
30
|
+
"""Copy the trackers' tagged runs into research/runs.json (see trackers.py); a no-op without tracker folders."""
|
|
31
|
+
from .trackers import sync
|
|
32
|
+
t = config.load(None, root, config_path)["trackers"]
|
|
33
|
+
return sync(root, t.get("mlflow", "mlruns"), t.get("wandb", "wandb"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def checkpoint(root: Path, ref: Optional[str] = None) -> str:
|
|
37
|
+
sha = git("rev-parse", "--verify", f"{ref}^{{commit}}", cwd=root).strip() if ref else snapshot(root)
|
|
38
|
+
git("update-ref", CHECKPOINT_REF, sha, cwd=root)
|
|
39
|
+
return sha
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve(root: Path, point: str) -> tuple:
|
|
43
|
+
if point == "checkpoint":
|
|
44
|
+
sha = _rev(root, CHECKPOINT_REF)
|
|
45
|
+
return (sha, "checkpoint") if sha else (_rev(root, "HEAD") or snapshot(root), "HEAD (no checkpoint yet)")
|
|
46
|
+
if point in WORKTREE:
|
|
47
|
+
return snapshot(root), "working tree"
|
|
48
|
+
return git("rev-parse", "--verify", f"{point}^{{commit}}", cwd=root).strip(), point
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read(root: Path, point: str = ".", config_path: Optional[str] = None) -> tuple:
|
|
52
|
+
"""(map, config, label) of one point."""
|
|
53
|
+
sha, label = resolve(root, point)
|
|
54
|
+
src = GitSource(sha, root)
|
|
55
|
+
cfg = config.load(src, root, config_path)
|
|
56
|
+
bp = extract(src, cfg)
|
|
57
|
+
bp["ref"] = label if point == "checkpoint" or point in WORKTREE else bp["ref"]
|
|
58
|
+
return bp, cfg, label
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Comparison:
|
|
63
|
+
diff: Dict[str, Any]
|
|
64
|
+
before: Dict[str, Any]
|
|
65
|
+
after: Dict[str, Any]
|
|
66
|
+
cfg: Dict[str, Any]
|
|
67
|
+
base: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def compare(root: Path, base: str = "checkpoint", head: str = ".", config_path: Optional[str] = None) -> Comparison:
|
|
71
|
+
b, cfg, _ = read(root, head, config_path)
|
|
72
|
+
a_sha, a_label = resolve(root, base)
|
|
73
|
+
a = extract(GitSource(a_sha, root), cfg) # one vocabulary for both sides: the head's
|
|
74
|
+
if base == "checkpoint" or base in WORKTREE:
|
|
75
|
+
a["ref"] = a_label
|
|
76
|
+
return Comparison(diff(a, b), a, b, cfg, a_label)
|
research_diff/config.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""What a research project tells research-diff about itself: which files hold its notes, experiment configs, scripts
|
|
2
|
+
and paper. Read from `--config`, else `research-diff.toml`, else `[tool.research-diff]` in pyproject.toml; every key
|
|
3
|
+
is optional, and with none every Markdown, config, Python and LaTeX file is read for markers.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import tomllib
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
FILE = "research-diff.toml"
|
|
14
|
+
|
|
15
|
+
DEFAULTS: Dict[str, Any] = {
|
|
16
|
+
"notes": ["**/*.md"],
|
|
17
|
+
"configs": ["**/*.yaml", "**/*.yml", "**/*.json", "**/*.toml"],
|
|
18
|
+
"scripts": ["**/*.py", "**/*.ipynb"],
|
|
19
|
+
"paper": ["**/*.tex"],
|
|
20
|
+
"runs": ["research/runs.json"], # tracker runs, written by `research-diff sync`
|
|
21
|
+
"trackers": {"mlflow": "mlruns", "wandb": "wandb"},
|
|
22
|
+
"skip": ["node_modules", "__pycache__", "dist", "build", ".venv*", "venv", ".git", "site-packages"],
|
|
23
|
+
"ignore": ["README.md", "CHANGELOG.md"], # never read for markers (globs)
|
|
24
|
+
"kinds": {"Q": "question", "H": "hypothesis", "E": "experiment", "R": "result", "C": "claim"},
|
|
25
|
+
"picture": {"columns": [["question", "Questions"], ["hypothesis", "Hypotheses"], ["experiment", "Experiments"],
|
|
26
|
+
["result", "Results"], ["claim", "Claims"]]},
|
|
27
|
+
"readme": {"file": "README.md", "svg": "docs/research.svg", # the picture's file; `image` links to it (default: the file)
|
|
28
|
+
"image": "",
|
|
29
|
+
"begin": "<!-- research-map:begin -->", "end": "<!-- research-map:end -->"},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def merge(base: Dict[str, Any], over: Dict[str, Any]) -> Dict[str, Any]:
|
|
34
|
+
out = deepcopy(base)
|
|
35
|
+
for k, v in (over or {}).items():
|
|
36
|
+
out[k] = merge(out[k], v) if isinstance(v, dict) and isinstance(out.get(k), dict) else deepcopy(v)
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse(text: str, name: str) -> Dict[str, Any]:
|
|
41
|
+
if not text.strip():
|
|
42
|
+
return {}
|
|
43
|
+
if name.endswith(".json"):
|
|
44
|
+
return json.loads(text)
|
|
45
|
+
data = tomllib.loads(text)
|
|
46
|
+
return data.get("tool", {}).get("research-diff", {}) if name.endswith("pyproject.toml") else data
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load(source=None, root: Optional[Path] = None, path: Optional[str] = None) -> Dict[str, Any]:
|
|
50
|
+
"""The project's settings over the defaults; the commit's own research-diff.toml wins over the one on disk."""
|
|
51
|
+
if path:
|
|
52
|
+
p = Path(path)
|
|
53
|
+
return merge(DEFAULTS, parse(p.read_text(encoding="utf-8"), p.name))
|
|
54
|
+
for name in (FILE, "pyproject.toml"):
|
|
55
|
+
text = source.read(name) if source is not None and name in source.files else ""
|
|
56
|
+
if not text and root is not None and (root / name).is_file():
|
|
57
|
+
text = (root / name).read_text(encoding="utf-8")
|
|
58
|
+
got = parse(text, name) if text else {}
|
|
59
|
+
if got:
|
|
60
|
+
return merge(DEFAULTS, got)
|
|
61
|
+
return deepcopy(DEFAULTS)
|
research_diff/diff.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""What an iteration did to the research: ideas added or dropped, statuses that moved, numbers that changed, links made
|
|
2
|
+
or broken, and warnings that appeared or went away. One sentence (headline) and the exact list (plan)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List
|
|
6
|
+
|
|
7
|
+
PLURAL = {"question": "questions", "hypothesis": "hypotheses", "experiment": "experiments", "result": "results",
|
|
8
|
+
"claim": "claims", "note": "notes"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _num(x: float) -> str:
|
|
12
|
+
return f"{x:.4g}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def diff(a: dict, b: dict) -> dict:
|
|
16
|
+
changes: List[dict] = []
|
|
17
|
+
|
|
18
|
+
def add(op, entity, key, label="", detail=(), impact=()):
|
|
19
|
+
changes.append({"op": op, "entity": entity, "id": key, "label": label or key, "detail": list(detail),
|
|
20
|
+
"impact": list(impact)})
|
|
21
|
+
|
|
22
|
+
na, nb = a["nodes"], b["nodes"]
|
|
23
|
+
for nid in [k for k in nb if k not in na] + [k for k in na if k not in nb]:
|
|
24
|
+
n = nb.get(nid) or na[nid]
|
|
25
|
+
op = "add" if nid in nb else "remove"
|
|
26
|
+
add(op, n["kind"], nid, f"{nid}: {n['title']}" if n["title"] else nid,
|
|
27
|
+
[f"status: {n['status']}"] if n["status"] and op == "add" else [], [("new-" if op == "add" else "dropped-") + n["kind"]])
|
|
28
|
+
for nid in [k for k in nb if k in na]:
|
|
29
|
+
x, y = na[nid], nb[nid]
|
|
30
|
+
if x["status"] != y["status"]:
|
|
31
|
+
impact = "refuted" if y["status"] == "refuted" else "supported" if y["status"] == "supported" else "status-change"
|
|
32
|
+
add("change", "status", nid, f"{nid}: {x['status'] or 'no status'} → {y['status'] or 'no status'}", [], [impact])
|
|
33
|
+
if x["title"] != y["title"] and x["title"] and y["title"]:
|
|
34
|
+
add("change", "title", nid, f"{nid}: {y['title']}", [f"was: {x['title']}"])
|
|
35
|
+
for key in sorted(set(x["metrics"]) | set(y["metrics"])):
|
|
36
|
+
u, v = x["metrics"].get(key), y["metrics"].get(key)
|
|
37
|
+
if u == v:
|
|
38
|
+
continue
|
|
39
|
+
if u is None:
|
|
40
|
+
add("add", "metric", f"{nid} {key}", f"{nid} {key}: {_num(v)}", [], ["new-result"])
|
|
41
|
+
elif v is None:
|
|
42
|
+
add("remove", "metric", f"{nid} {key}", f"{nid} {key}", [f"was {_num(u)}"], ["dropped-result"])
|
|
43
|
+
else:
|
|
44
|
+
d = v - u
|
|
45
|
+
add("change", "metric", f"{nid} {key}", f"{nid} {key}: {_num(u)} → {_num(v)}",
|
|
46
|
+
[f"{'+' if d >= 0 else '−'}{_num(abs(d))}" + (f" ({d / u * 100:+.1f}%)" if u else "")], ["result-moved"])
|
|
47
|
+
la, lb = {tuple(x) for x in a["links"]}, {tuple(x) for x in b["links"]}
|
|
48
|
+
for link in sorted(lb - la) + sorted(la - lb):
|
|
49
|
+
add("add" if link in lb else "remove", "link", " ".join(link), "", [], [])
|
|
50
|
+
wa, wb = set(a["warnings"]), set(b["warnings"])
|
|
51
|
+
for w in sorted(wb - wa):
|
|
52
|
+
add("add", "warning", w, "", [], ["new-warning"])
|
|
53
|
+
for w in sorted(wa - wb):
|
|
54
|
+
add("remove", "warning", w, "", [], ["resolved-warning"])
|
|
55
|
+
return {"from": a["ref"], "to": b["ref"], "changes": changes, "headline": headline(changes),
|
|
56
|
+
"plan": plan(changes), "warnings": b["warnings"]}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def headline(changes: List[dict]) -> str:
|
|
60
|
+
"""One sentence: what the iteration did to the research."""
|
|
61
|
+
parts: List[str] = []
|
|
62
|
+
for op, verb in (("add", "adds"), ("remove", "drops")):
|
|
63
|
+
groups: Dict[str, List[str]] = {}
|
|
64
|
+
for c in changes:
|
|
65
|
+
if c["op"] == op and c["entity"] in PLURAL:
|
|
66
|
+
groups.setdefault(c["entity"], []).append(c["id"])
|
|
67
|
+
phrases = [f"{len(ids)} {k if len(ids) == 1 else PLURAL[k]} ({', '.join(ids[:3])}{' +' + str(len(ids) - 3) + ' more' if len(ids) > 3 else ''})"
|
|
68
|
+
for k, ids in groups.items()]
|
|
69
|
+
if phrases:
|
|
70
|
+
parts.append(verb + " " + (", ".join(phrases[:-1]) + " and " + phrases[-1] if len(phrases) > 1 else phrases[0]))
|
|
71
|
+
parts += [c["label"] for c in changes if c["entity"] == "status"]
|
|
72
|
+
moved = [c for c in changes if c["entity"] == "metric" and c["op"] == "change"]
|
|
73
|
+
if moved:
|
|
74
|
+
shown = "; ".join(f"{c['label']} ({c['detail'][0]})" for c in moved[:2])
|
|
75
|
+
parts.append(shown + (f" and {len(moved) - 2} more numbers" if len(moved) > 2 else ""))
|
|
76
|
+
new_metrics = [c for c in changes if c["entity"] == "metric" and c["op"] == "add"]
|
|
77
|
+
if new_metrics:
|
|
78
|
+
parts.append(f"{len(new_metrics)} new number{'' if len(new_metrics) == 1 else 's'}")
|
|
79
|
+
links = [c for c in changes if c["entity"] == "link"]
|
|
80
|
+
if links:
|
|
81
|
+
made = sum(1 for c in links if c["op"] == "add")
|
|
82
|
+
parts.append(" and ".join(x for x in (f"{made} new link{'' if made == 1 else 's'}" if made else "",
|
|
83
|
+
f"{len(links) - made} link{'' if len(links) - made == 1 else 's'} gone"
|
|
84
|
+
if len(links) - made else "") if x))
|
|
85
|
+
new_w = [c["id"] for c in changes if c["entity"] == "warning" and c["op"] == "add"]
|
|
86
|
+
if new_w:
|
|
87
|
+
parts.append(f"{len(new_w)} new warning{'' if len(new_w) == 1 else 's'} ({new_w[0]}{'…' if len(new_w) > 1 else ''})")
|
|
88
|
+
gone_w = sum(1 for c in changes if c["entity"] == "warning" and c["op"] == "remove")
|
|
89
|
+
if gone_w:
|
|
90
|
+
parts.append(f"{gone_w} warning{'' if gone_w == 1 else 's'} resolved")
|
|
91
|
+
if not parts:
|
|
92
|
+
return "No change to the research."
|
|
93
|
+
s = "; ".join(parts)
|
|
94
|
+
return s[0].upper() + s[1:] + "."
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def plan(changes: List[dict]) -> str:
|
|
98
|
+
"""The exact list, `terraform plan`-style."""
|
|
99
|
+
sym = {"add": "+", "change": "~", "remove": "-"}
|
|
100
|
+
out = []
|
|
101
|
+
for c in changes:
|
|
102
|
+
tag = f" [{', '.join(c['impact'])}]" if c["impact"] else ""
|
|
103
|
+
label = f" — {c['label']}" if c["label"] != c["id"] else ""
|
|
104
|
+
out.append(f"{sym[c['op']]} {c['entity']} {c['id']}{label}{tag}")
|
|
105
|
+
out += [f" {x}" for x in c["detail"]]
|
|
106
|
+
return "\n".join(out) or "no change to the research"
|