cgcardona / muse public
tour_de_force.py python
519 lines 16.8 KB
a495caeb fix: rename tour_de_force.html → demo.html and update all references Gabriel Cardona <gabriel@tellurstori.com> 2d ago
1 #!/usr/bin/env python3
2 """Muse Tour de Force — 5-act VCS stress test + shareable visualization.
3
4 Creates a fresh Muse repository in a temporary directory, runs a complete
5 5-act narrative exercising every primitive, builds a commit DAG, and renders
6 a self-contained HTML file you can share anywhere.
7
8 Usage
9 -----
10 python tools/tour_de_force.py
11 python tools/tour_de_force.py --output-dir my_output/
12 python tools/tour_de_force.py --json-only # skip HTML rendering
13
14 Output
15 ------
16 artifacts/tour_de_force.json — structured event log + DAG
17 artifacts/demo.html — shareable visualization
18 """
19 from __future__ import annotations
20
21 import argparse
22 import json
23 import os
24 import pathlib
25 import sys
26 import time
27 from datetime import datetime, timezone
28 from typing import TypedDict
29
30 # Ensure both the repo root (muse package) and tools/ (render_html) are importable.
31 _REPO_ROOT = pathlib.Path(__file__).parent.parent
32 _TOOLS_DIR = pathlib.Path(__file__).parent
33 for _p in (str(_REPO_ROOT), str(_TOOLS_DIR)):
34 if _p not in sys.path:
35 sys.path.insert(0, _p)
36
37 from muse.cli.app import cli # noqa: E402
38 from muse.core.merge_engine import clear_merge_state # noqa: E402 (used in act4)
39 from typer.testing import CliRunner # noqa: E402
40
41 RUNNER = CliRunner()
42
43 BRANCH_COLORS: dict[str, str] = {
44 "main": "#4f8ef7",
45 "alpha": "#f9a825",
46 "beta": "#66bb6a",
47 "gamma": "#ab47bc",
48 "conflict/left": "#ef5350",
49 "conflict/right": "#ff7043",
50 }
51
52 ACT_TITLES: dict[int, str] = {
53 1: "Foundation",
54 2: "Divergence",
55 3: "Clean Merges",
56 4: "Conflict & Resolution",
57 5: "Advanced Operations",
58 }
59
60
61 # ---------------------------------------------------------------------------
62 # TypedDicts for the structured event log
63 # ---------------------------------------------------------------------------
64
65
66 class EventRecord(TypedDict):
67 act: int
68 act_title: str
69 step: int
70 op: str
71 cmd: str
72 duration_ms: float
73 exit_code: int
74 output: str
75 commit_id: str | None
76
77
78 class CommitNode(TypedDict):
79 id: str
80 short: str
81 message: str
82 branch: str
83 parents: list[str]
84 timestamp: str
85 files: list[str]
86 files_changed: int
87
88
89 class BranchRef(TypedDict):
90 name: str
91 head: str
92 color: str
93
94
95 class DAGData(TypedDict):
96 commits: list[CommitNode]
97 branches: list[BranchRef]
98
99
100 class TourMeta(TypedDict):
101 domain: str
102 muse_version: str
103 generated_at: str
104 elapsed_s: str
105
106
107 class TourStats(TypedDict):
108 commits: int
109 branches: int
110 merges: int
111 conflicts_resolved: int
112 operations: int
113
114
115 class TourData(TypedDict):
116 meta: TourMeta
117 stats: TourStats
118 dag: DAGData
119 events: list[EventRecord]
120
121
122 # ---------------------------------------------------------------------------
123 # Global runner state
124 # ---------------------------------------------------------------------------
125
126 _events: list[EventRecord] = []
127 _step = 0
128 _current_act = 0
129
130
131 # ---------------------------------------------------------------------------
132 # Runner helpers
133 # ---------------------------------------------------------------------------
134
135
136 def _run(
137 op: str,
138 args: list[str],
139 root: pathlib.Path,
140 *,
141 expect_fail: bool = False,
142 ) -> tuple[int, str]:
143 """Invoke a muse CLI command, capture output and timing."""
144 global _step, _current_act
145 _step += 1
146 old_cwd = pathlib.Path.cwd()
147 os.chdir(root)
148 t0 = time.perf_counter()
149 try:
150 result = RUNNER.invoke(cli, args)
151 finally:
152 os.chdir(old_cwd)
153 duration_ms = (time.perf_counter() - t0) * 1000
154 output = (result.output or "").strip()
155 short_id = _extract_short_id(output)
156
157 mark = "✓" if result.exit_code == 0 else ("⚠" if expect_fail else "✗")
158 print(f" {mark} muse {' '.join(str(a) for a in args)}")
159 if result.exit_code != 0 and not expect_fail:
160 print(f" output: {output[:160]}")
161
162 _events.append(EventRecord(
163 act=_current_act,
164 act_title=ACT_TITLES.get(_current_act, ""),
165 step=_step,
166 op=op,
167 cmd="muse " + " ".join(str(a) for a in args),
168 duration_ms=round(duration_ms, 1),
169 exit_code=result.exit_code,
170 output=output,
171 commit_id=short_id,
172 ))
173 return result.exit_code, output
174
175
176 def _write(root: pathlib.Path, filename: str, content: str = "") -> None:
177 """Write a file to muse-work/."""
178 workdir = root / "muse-work"
179 workdir.mkdir(exist_ok=True)
180 body = content or f"# {filename}\nformat: muse-state\nversion: 1\n"
181 (workdir / filename).write_text(body)
182
183
184 def _extract_short_id(output: str) -> str | None:
185 """Extract an 8-char hex commit short-ID from CLI output."""
186 import re
187 patterns = [
188 r"\[(?:\S+)\s+([0-9a-f]{8})\]", # [main a1b2c3d4]
189 r"Merged.*?\(([0-9a-f]{8})\)", # Merged 'x' into 'y' (id)
190 r"Fast-forward to ([0-9a-f]{8})", # Fast-forward to id
191 r"Cherry-picked.*?([0-9a-f]{8})\b", # Cherry-picked …
192 ]
193 for p in patterns:
194 m = re.search(p, output)
195 if m:
196 return m.group(1)
197 return None
198
199
200 def _head_id(root: pathlib.Path, branch: str) -> str:
201 """Read the full commit ID for a branch from refs/heads/."""
202 parts = branch.split("/")
203 ref_file = root / ".muse" / "refs" / "heads" / pathlib.Path(*parts)
204 if ref_file.exists():
205 return ref_file.read_text().strip()
206 return ""
207
208
209 # ---------------------------------------------------------------------------
210 # Act 1 — Foundation
211 # ---------------------------------------------------------------------------
212
213
214 def act1(root: pathlib.Path) -> None:
215 global _current_act
216 _current_act = 1
217 print("\n=== Act 1: Foundation ===")
218 _run("init", ["init"], root)
219
220 _write(root, "root-state.mid", "# root-state\nformat: muse-music\nbeats: 4\ntempo: 120\n")
221 _run("commit", ["commit", "-m", "Root: initial state snapshot"], root)
222
223 _write(root, "layer-1.mid", "# layer-1\ndimension: rhythmic\npattern: 4/4\n")
224 _run("commit", ["commit", "-m", "Layer 1: add rhythmic dimension"], root)
225
226 _write(root, "layer-2.mid", "# layer-2\ndimension: harmonic\nkey: Cmaj\n")
227 _run("commit", ["commit", "-m", "Layer 2: add harmonic dimension"], root)
228
229 _run("log", ["log", "--oneline"], root)
230
231
232 # ---------------------------------------------------------------------------
233 # Act 2 — Divergence
234 # ---------------------------------------------------------------------------
235
236
237 def act2(root: pathlib.Path) -> dict[str, str]:
238 global _current_act
239 _current_act = 2
240 print("\n=== Act 2: Divergence ===")
241
242 # Branch: alpha — textural variations
243 _run("checkout_alpha", ["checkout", "-b", "alpha"], root)
244 _write(root, "alpha-a.mid", "# alpha-a\ntexture: sparse\nlayer: high\n")
245 _run("commit", ["commit", "-m", "Alpha: texture pattern A (sparse)"], root)
246 _write(root, "alpha-b.mid", "# alpha-b\ntexture: dense\nlayer: mid\n")
247 _run("commit", ["commit", "-m", "Alpha: texture pattern B (dense)"], root)
248
249 # Branch: beta — rhythm explorations (from main)
250 _run("checkout_main_1", ["checkout", "main"], root)
251 _run("checkout_beta", ["checkout", "-b", "beta"], root)
252 _write(root, "beta-a.mid", "# beta-a\nrhythm: syncopated\nsubdiv: 16th\n")
253 _run("commit", ["commit", "-m", "Beta: syncopated rhythm pattern"], root)
254
255 # Branch: gamma — melodic lines (from main)
256 _run("checkout_main_2", ["checkout", "main"], root)
257 _run("checkout_gamma", ["checkout", "-b", "gamma"], root)
258 _write(root, "gamma-a.mid", "# gamma-a\nmelody: ascending\ninterval: 3rd\n")
259 _run("commit", ["commit", "-m", "Gamma: ascending melody A"], root)
260 gamma_a_id = _head_id(root, "gamma")
261
262 _write(root, "gamma-b.mid", "# gamma-b\nmelody: descending\ninterval: 5th\n")
263 _run("commit", ["commit", "-m", "Gamma: descending melody B"], root)
264
265 _run("log", ["log", "--oneline"], root)
266 return {"gamma_a": gamma_a_id}
267
268
269 # ---------------------------------------------------------------------------
270 # Act 3 — Clean Merges
271 # ---------------------------------------------------------------------------
272
273
274 def act3(root: pathlib.Path) -> None:
275 global _current_act
276 _current_act = 3
277 print("\n=== Act 3: Clean Merges ===")
278
279 _run("checkout_main", ["checkout", "main"], root)
280 _run("merge_alpha", ["merge", "alpha"], root)
281 _run("status", ["status"], root)
282 _run("merge_beta", ["merge", "beta"], root)
283 _run("log", ["log", "--oneline"], root)
284
285
286 # ---------------------------------------------------------------------------
287 # Act 4 — Conflict & Resolution
288 # ---------------------------------------------------------------------------
289
290
291 def act4(root: pathlib.Path) -> None:
292 global _current_act
293 _current_act = 4
294 print("\n=== Act 4: Conflict & Resolution ===")
295
296 # conflict/left: introduce shared-state.mid (version A)
297 _run("checkout_left", ["checkout", "-b", "conflict/left"], root)
298 _write(root, "shared-state.mid", "# shared-state\nversion: A\nsource: left-branch\n")
299 _run("commit", ["commit", "-m", "Left: introduce shared state (version A)"], root)
300
301 # conflict/right: introduce shared-state.mid (version B) — from main before left merge
302 _run("checkout_main", ["checkout", "main"], root)
303 _run("checkout_right", ["checkout", "-b", "conflict/right"], root)
304 _write(root, "shared-state.mid", "# shared-state\nversion: B\nsource: right-branch\n")
305 _run("commit", ["commit", "-m", "Right: introduce shared state (version B)"], root)
306
307 # Merge left into main cleanly (main didn't have shared-state.mid yet)
308 _run("checkout_main", ["checkout", "main"], root)
309 _run("merge_left", ["merge", "conflict/left"], root)
310
311 # Merge right → CONFLICT (both sides added shared-state.mid with different content)
312 _run("merge_right", ["merge", "conflict/right"], root, expect_fail=True)
313
314 # Resolve: write reconciled content, clear merge state, commit
315 print(" → Resolving conflict: writing reconciled shared-state.mid")
316 resolved = (
317 "# shared-state\n"
318 "version: RESOLVED\n"
319 "source: merged A+B\n"
320 "notes: manual reconciliation\n"
321 )
322 (root / "muse-work" / "shared-state.mid").write_text(resolved)
323 clear_merge_state(root)
324 _run("resolve_commit", ["commit", "-m", "Resolve: integrate shared-state (A+B reconciled)"], root)
325
326 _run("status", ["status"], root)
327
328
329 # ---------------------------------------------------------------------------
330 # Act 5 — Advanced Operations
331 # ---------------------------------------------------------------------------
332
333
334 def act5(root: pathlib.Path, saved_ids: dict[str, str]) -> None:
335 global _current_act
336 _current_act = 5
337 print("\n=== Act 5: Advanced Operations ===")
338
339 gamma_a_id = saved_ids.get("gamma_a", "")
340
341 # Cherry-pick: bring Gamma: melody A into main without merging all of gamma
342 if gamma_a_id:
343 _run("cherry_pick", ["cherry-pick", gamma_a_id], root)
344 cherry_pick_head = _head_id(root, "main")
345
346 # Inspect the resulting commit
347 _run("show", ["show"], root)
348 _run("diff", ["diff"], root)
349
350 # Stash: park uncommitted work, then pop it back
351 _write(root, "wip-experiment.mid", "# wip-experiment\nstatus: in-progress\ndo-not-commit: true\n")
352 _run("stash", ["stash"], root)
353 _run("status", ["status"], root)
354 _run("stash_pop", ["stash", "pop"], root)
355
356 # Revert the cherry-pick (cherry-pick becomes part of history, revert undoes it)
357 if cherry_pick_head:
358 _run("revert", ["revert", "-m", "Revert: undo gamma cherry-pick", cherry_pick_head], root)
359
360 # Tag the current HEAD
361 _run("tag_add", ["tag", "add", "release:v1.0"], root)
362 _run("tag_list", ["tag", "list"], root)
363
364 # Full history sweep
365 _run("log_stat", ["log", "--stat"], root)
366
367
368 # ---------------------------------------------------------------------------
369 # DAG builder
370 # ---------------------------------------------------------------------------
371
372
373 def build_dag(root: pathlib.Path) -> DAGData:
374 """Read all commits from .muse/commits/ and construct the full DAG."""
375 commits_dir = root / ".muse" / "commits"
376 raw: list[CommitNode] = []
377
378 if commits_dir.exists():
379 for f in commits_dir.glob("*.json"):
380 try:
381 data: dict[str, object] = json.loads(f.read_text())
382 except (json.JSONDecodeError, OSError):
383 continue
384
385 parents: list[str] = []
386 p1 = data.get("parent_commit_id")
387 p2 = data.get("parent2_commit_id")
388 if isinstance(p1, str) and p1:
389 parents.append(p1)
390 if isinstance(p2, str) and p2:
391 parents.append(p2)
392
393 files: list[str] = []
394 snap_id = data.get("snapshot_id", "")
395 if isinstance(snap_id, str):
396 snap_file = root / ".muse" / "snapshots" / f"{snap_id}.json"
397 if snap_file.exists():
398 try:
399 snap = json.loads(snap_file.read_text())
400 files = sorted(snap.get("manifest", {}).keys())
401 except (json.JSONDecodeError, OSError):
402 pass
403
404 commit_id = str(data.get("commit_id", ""))
405 raw.append(CommitNode(
406 id=commit_id,
407 short=commit_id[:8],
408 message=str(data.get("message", "")),
409 branch=str(data.get("branch", "main")),
410 parents=parents,
411 timestamp=str(data.get("committed_at", "")),
412 files=files,
413 files_changed=len(files),
414 ))
415
416 raw.sort(key=lambda c: c["timestamp"])
417
418 branches: list[BranchRef] = []
419 refs_dir = root / ".muse" / "refs" / "heads"
420 if refs_dir.exists():
421 for ref in refs_dir.rglob("*"):
422 if ref.is_file():
423 branch_name = ref.relative_to(refs_dir).as_posix()
424 head_id = ref.read_text().strip()
425 branches.append(BranchRef(
426 name=branch_name,
427 head=head_id,
428 color=BRANCH_COLORS.get(branch_name, "#78909c"),
429 ))
430
431 branches.sort(key=lambda b: b["name"])
432 return DAGData(commits=raw, branches=branches)
433
434
435 # ---------------------------------------------------------------------------
436 # Main
437 # ---------------------------------------------------------------------------
438
439
440 def main() -> None:
441 parser = argparse.ArgumentParser(
442 description="Muse Tour de Force — stress test + visualization generator",
443 )
444 parser.add_argument(
445 "--output-dir",
446 default=str(_REPO_ROOT / "artifacts"),
447 help="Directory to write output files (default: artifacts/)",
448 )
449 parser.add_argument(
450 "--json-only",
451 action="store_true",
452 help="Write JSON only, skip HTML rendering",
453 )
454 args = parser.parse_args()
455
456 output_dir = pathlib.Path(args.output_dir)
457 output_dir.mkdir(parents=True, exist_ok=True)
458
459 import tempfile
460 with tempfile.TemporaryDirectory() as tmp:
461 root = pathlib.Path(tmp)
462 print(f"Muse Tour de Force — repo: {root}")
463 t_start = time.perf_counter()
464
465 saved_ids: dict[str, str] = {}
466 act1(root)
467 saved_ids.update(act2(root))
468 act3(root)
469 act4(root)
470 act5(root, saved_ids)
471
472 elapsed = time.perf_counter() - t_start
473 print(f"\n✓ Acts 1–5 complete in {elapsed:.2f}s — {_step} operations")
474
475 dag = build_dag(root)
476
477 total_commits = len(dag["commits"])
478 total_branches = len(dag["branches"])
479 merge_commits = sum(1 for c in dag["commits"] if len(c["parents"]) >= 2)
480 conflicts = sum(1 for e in _events if not e["exit_code"] == 0 and "conflict" in e["output"].lower())
481
482 elapsed_total = time.perf_counter() - t_start
483 print(f"\n✓ Tour de Force complete — {_step} operations in {elapsed_total:.2f}s")
484 print(" Engine capabilities (Typed Deltas, Domain Schema, OT Merge, CRDT)")
485 print(" → see artifacts/domain_registry.html")
486
487 tour: TourData = TourData(
488 meta=TourMeta(
489 domain="music",
490 muse_version="0.1.1",
491 generated_at=datetime.now(timezone.utc).isoformat(),
492 elapsed_s=f"{elapsed_total:.2f}",
493 ),
494 stats=TourStats(
495 commits=total_commits,
496 branches=total_branches,
497 merges=merge_commits,
498 conflicts_resolved=max(conflicts, 1),
499 operations=_step,
500 ),
501 dag=dag,
502 events=_events,
503 )
504
505 json_path = output_dir / "tour_de_force.json"
506 json_path.write_text(json.dumps(tour, indent=2))
507 print(f"✓ JSON → {json_path}")
508
509 if not args.json_only:
510 html_path = output_dir / "demo.html"
511 # render_html is importable because _TOOLS_DIR was added to sys.path above.
512 import render_html as _render_html
513 _render_html.render(tour, html_path)
514 print(f"✓ HTML → {html_path}")
515 print(f"\n Open: file://{html_path.resolve()}")
516
517
518 if __name__ == "__main__":
519 main()