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,2 @@
1
+ """research-diff: your research as a map, and what each iteration changed in it."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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())
@@ -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)
@@ -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"