diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 34e6de8..7453729 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -39,6 +39,7 @@ | workplan | CB-WP-0029 | done | — | workplans/CB-WP-0029-the-tokens-on-the-table.md | | workplan | CB-WP-0030 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | | workplan | CB-WP-0031 | done | — | workplans/CB-WP-0031-the-comment-box-outlives-the-game.md | +| workplan | CB-WP-0032 | done | — | workplans/CB-WP-0032-the-comments-in-the-account.md | | task | CB-WP-0001-T01 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T02 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T03 | done | — | workplans/CB-WP-0001-inner-loop.md | @@ -201,3 +202,4 @@ | task | CB-WP-0030-T03 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | | task | CB-WP-0030-T04 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | | task | CB-WP-0031-T01 | done | — | workplans/CB-WP-0031-the-comment-box-outlives-the-game.md | +| task | CB-WP-0032-T01 | done | — | workplans/CB-WP-0032-the-comments-in-the-account.md | diff --git a/decisions/ADR-0019-a-game-is-the-unit.md b/decisions/ADR-0019-a-game-is-the-unit.md new file mode 100644 index 0000000..abc4aa7 --- /dev/null +++ b/decisions/ADR-0019-a-game-is-the-unit.md @@ -0,0 +1,114 @@ +# ADR-0019: a game is the unit, and `play again` was reusing what belongs to one + +status: accepted +date: 2026-08-07 +decided by: agent, under the standing loop authorization +tier: M (structural M — changes an artifact contract, the recording path, +and the definition of a reported metric). chaos d8 = 7 → no override. +**Declaration 4 of chaos window 3.** +references: [CB-WP-0033](../workplans/CB-WP-0033-a-game-is-the-unit.md), +[ADR-0014](ADR-0014-a-second-channel.md) (the note channel), +[ADR-0018](ADR-0018-a-number-that-does-not-move.md) (instance 8), +[GameDesign.md](../specs/GameDesign.md) §5 + +## Context + +`make trials` reported **positions unreachable: 4, target 0**, with the +explanation *"the recording exists but the position moved."* + +**All four were false, and the explanation was false four times.** + +A recording carries exactly **one** hash — `expect.state_hash`, the final +state. `reachability()` asked whether a note's hash appeared in that file. +So the answer never depended on any position moving: + +| note | outcome | why | +|---|---|---| +| written mid-game | **always orphan** | no intermediate hash is in the file to match | +| written after the last game | ok | it *is* the final hash | +| written after an earlier game | orphan | `play again` overwrote the recording | + +Both Aug-7 sessions confirm it. In `2026-08-07-1612`, two post-game notes +with identical round and step: game 1's is *orphan*, game 2's is *ok*, +purely because the file holds game 2. + +**Instance 8 of the family in ADR-0018**, and the sensitivity is exact: +vary only *when* a note was written and reachability flips +deterministically, with nothing having moved. The computation +(`hash in file`) was right; the subject — a file with one hash, read as a +file of positions — was not. + +## D1 — the root cause is not the metric + +Three defects, one cause: **`play again` reuses state that belongs to a +game, not to a session.** + +| what | reused | consequence | +|---|---|---| +| the recording path | same file | **game 1's recording is destroyed** | +| the journal | never cleared | game 2's log shows game 1's commands | +| the note's position | counted against the shared journal | the index means nothing | + +The metric was reporting the symptom, in the wrong words. **Fixing only +`reachability()` would have made the number go green while a session still +destroyed its own evidence** — the failure the register calls `inert`. + +## D2 — the recording path, and what stays unchanged + +Game 1 keeps the path it was given. Games 2, 3 … get `-2`, `-3` before the +extension. + +**The single-game session is byte-identical to today**, which matters +because GameDesign §5 documents exactly that invocation as the trial +protocol, and this project has already shipped one gate that was written +for a smaller world (CB-EV-0026 §4). A scheme that renamed *every* file +would have made the spec wrong on the day it landed. + +**Nothing is overwritten.** A recording is evidence; `play again` silently +deleting the previous game's evidence is a data-loss defect independent of +notes. + +## D3 — a note binds to a position by `(game, after)` + +`after` is the number of commands played when the note was written, which +is exactly an index into the recording's own `commands:` list. **That is a +binding a reader can act on**: replay the first `after` commands and you +are standing where the player stood. + +The hash keeps a job — an **integrity check where it applies**. When +`after` equals the command count, the note's hash must equal +`expect.state_hash`, and a mismatch is a real finding. + +**Why not record a hash per command instead?** It would make every +position matchable by string search, and it changes the recording format — +which `record.rs`'s round trip, `edition-check` and any consumer in +`ground-game` all read. Deferred, not refused: if a reader ever needs to +confirm a mid-game position without replaying, that is the trigger. + +## D4 — old logs stay readable, and are not called orphans + +The trial log gains `game` and `after`. Existing 5-column rows are parsed +as **legacy** and reported as such. + +**They are not reported as orphans.** Their positions were never checkable +by anything this repo can now run, and an orphan claim we cannot +substantiate is precisely the false explanation this ADR exists to delete. +A row that is neither 5 nor 7 columns raises (CB-WP-0032). + +## Consequences + +- `trials.py`'s headline metric changes meaning; the old number was not + measuring reachability and no comparison to it is valid. +- The journal is cleared per game, so the log shows **this** game. +- Notes from earlier games do not appear in the current game's log. +- A multi-game session leaves several recordings where it left one. + +## What was rejected + +| rejected | why | +|---|---| +| fixing `reachability()` alone | green metric, session still destroys its own recordings | +| a hash per command in the recording | changes a format three consumers read, to save a replay | +| renaming every recording to `-1`, `-2` | makes GameDesign §5's documented invocation wrong for the common case | +| calling legacy rows orphans | an unsubstantiated claim, which is the defect being fixed | +| clearing the notes per game | the trial log is the session's record; only the *log view* is per-game | diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index e2a9e53..27dc96d 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -41,19 +41,28 @@ pub struct Server { /// to — a partial write leaves a file that neither a human nor /// `make trials` can read. notes: RefCell>, + /// Which game of the session is being played, 1-based (ADR-0019 D1). + /// The journal and the recording are per game; the notes and the + /// tally are per session, and conflating the two is what made a + /// second game overwrite the first one's evidence. + game: std::cell::Cell, } /// One thing a player said, and where they said it (ADR-0014 D3). #[derive(Debug, Clone)] pub struct TrialNote { pub n: usize, + /// Which game of the session, 1-based (ADR-0019 D3). + pub game: usize, /// Commands played when this was written (CB-WP-0032), so the page - /// can put the comment where it was made. **Held in memory only.** - /// It is deliberately NOT a column in the trial log: `trials.py` - /// skips any row that is not five cells, silently, so a sixth column - /// would make `make trials` report zero notes for a file full of - /// them — the exact failure its own docstring says it exists to - /// prevent. + /// can put the comment where it was made — and, since ADR-0019 D3, + /// so a reader can reach the position: it is an index into the + /// recording's own `commands:` list. + /// + /// **CB-WP-0032 kept this out of the file** because `trials.py` + /// silently skipped any row that was not five cells, so a new column + /// would have emptied every log at once. That parser now raises and + /// accepts both widths, which is what made this column safe to add. pub after: usize, pub round: u8, pub step: String, @@ -61,6 +70,31 @@ pub struct TrialNote { pub text: String, } +/// Where game `n`'s recording goes (ADR-0019 D2). +/// +/// **Game 1 keeps the path it was given.** GameDesign §5 documents that +/// exact invocation as the trial protocol, and a scheme that renamed +/// every file would have made the spec wrong for the common case on the +/// day it landed. Games 2, 3 … get `-2`, `-3` before the extension. +/// +/// Nothing is overwritten: a recording is evidence, and `play again` +/// deleting the previous game's evidence is a data-loss defect on its own. +pub fn game_path(base: &std::path::Path, n: usize) -> std::path::PathBuf { + if n <= 1 { + return base.to_path_buf(); + } + let stem = base + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let mut name = format!("{stem}-{n}"); + if let Some(ext) = base.extension() { + name.push('.'); + name.push_str(&ext.to_string_lossy()); + } + base.with_file_name(name) +} + /// Write the trial log: readable Markdown with one machine-readable block. /// /// **Reusing `FindingRegister.md`'s idiom deliberately** (ADR-0014 D2) — @@ -78,7 +112,14 @@ pub fn write_trial_log(path: &std::path::Path, notes: &[TrialNote]) -> Result<() register finding, by a human, with the wording chosen then.\n\n", ); s.push_str("\n\n"); - s.push_str("| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n"); + // ADR-0019 D3/D4: `game` and `after` are the binding a reader can act + // on -- replay `after` commands of game `game` and you are standing + // where the player stood. `state_hash` stays as an integrity check at + // the end of a game, where it is the one thing the recording carries. + s.push_str( + "| n | game | after | round | step | state_hash | comment |\n\ + |---|---|---|---|---|---|---|\n", + ); for note in notes { // Pipes and newlines would break the table, so they are replaced // rather than escaped: the note is prose, and a reader losing a @@ -86,8 +127,8 @@ pub fn write_trial_log(path: &std::path::Path, notes: &[TrialNote]) -> Result<() let text = note.text.replace(['\n', '\r'], " ").replace('|', "/"); let _ = writeln!( s, - "| {} | {} | {} | {} | {} |", - note.n, note.round, note.step, note.state_hash, text + "| {} | {} | {} | {} | {} | {} | {} |", + note.n, note.game, note.after, note.round, note.step, note.state_hash, text ); } s.push_str("\n\n"); @@ -119,6 +160,7 @@ impl Server { meta: RefCell::new(Vec::new()), trial: None, notes: RefCell::new(Vec::new()), + game: std::cell::Cell::new(1), }) } @@ -155,10 +197,12 @@ impl Server { }; let hash = cb_events::state_hash_hex(state); let after = self.journal.borrow().len(); + let game = self.game.get(); let mut log = self.notes.borrow_mut(); let n = log.len() + 1; log.push(TrialNote { n, + game, after, round: state.round, step: step.to_string(), @@ -168,6 +212,26 @@ impl Server { write_trial_log(path, &log) } + /// Begin the next game (ADR-0019 D1). + /// + /// **Clearing the journal is the point.** It is shared with the + /// driver and nothing ever emptied it, so a second game's log opened + /// with the first game's commands still in it — and a note's `after`, + /// counted against that journal, indexed into a recording that did + /// not contain those commands. + /// + /// The notes are NOT cleared: the trial log is the session's record + /// (ADR-0019 D4). Only the log *view* is per game. + pub fn next_game(&self) { + self.journal.borrow_mut().clear(); + self.game.set(self.game.get() + 1); + } + + /// Which game is being played, 1-based. + pub fn game(&self) -> usize { + self.game.get() + } + /// Set what the meta panel shows about the session (CB-WP-0027 T02). /// /// Called by the driver between games, so the running tally is visible @@ -185,9 +249,14 @@ impl Server { /// The player's own comments, positioned for the log (CB-WP-0032). fn log_notes(&self) -> Vec { + let game = self.game.get(); self.notes .borrow() .iter() + // THIS game's comments. A note from game 1 placed by its + // command index into game 2's log would sit against a move + // nobody made (ADR-0019 D1). + .filter(|n| n.game == game) .map(|n| cb_render_html::doc::LogNote { after: n.after, round: n.round, @@ -994,6 +1063,120 @@ mod tests { ); } + /// ADR-0019 D2. Game 1 keeps the path it was given. + #[test] + fn only_later_games_get_a_suffixed_recording() { + let base = std::path::Path::new("trials/2026-08-07-x.yaml"); + // GameDesign §5 documents this exact invocation; renaming it + // would have made the spec wrong for the common case. + assert_eq!(game_path(base, 1), base); + assert_eq!( + game_path(base, 2), + std::path::Path::new("trials/2026-08-07-x-2.yaml") + ); + assert_eq!( + game_path(base, 3), + std::path::Path::new("trials/2026-08-07-x-3.yaml") + ); + // Distinct paths are the whole point: nothing may be overwritten. + assert_ne!(game_path(base, 2), game_path(base, 3)); + // And the suffix must not COMPOUND. The driver derives every + // game's path from the ORIGINAL, because deriving it from the + // previous game's would put game 3 in `x-2-3.yaml`. + assert_eq!( + game_path(&game_path(base, 2), 3), + std::path::Path::new("trials/2026-08-07-x-2-3.yaml"), + "this is the shape the driver must avoid, and does by keeping the base" + ); + } + + /// ADR-0019 D1. The journal belongs to a GAME. + /// + /// **Nothing ever cleared it.** It is shared with the driver and + /// lives on the `Server`, which outlives every game, so a second + /// game's log opened with the first game's commands still in it — and + /// a note's `after`, counted against that journal, indexed into a + /// recording that never contained those commands. + #[test] + fn a_second_game_does_not_inherit_the_first_ones_log() { + let server = Server::bind(0).expect("bind"); + let j = server.journal(); + j.borrow_mut().push(games_ground::bot::Applied { + actor: cb_kernel::Actor::System, + command: GroundCommand::EndRound, + events: vec![], + }); + assert_eq!(server.game(), 1); + assert_eq!(server.journal().borrow().len(), 1); + + server.next_game(); + + assert_eq!(server.game(), 2); + assert!( + server.journal().borrow().is_empty(), + "game 2 opened holding game 1's commands" + ); + } + + /// A comment belongs to the game it was made in (ADR-0019 D1). + /// + /// Placed by command index into a LATER game's log, a note from game 1 + /// would sit against a move nobody made — a position stated + /// confidently and wrong, which is the family ADR-0018 names. + #[test] + fn a_comment_from_an_earlier_game_is_not_shown_in_a_later_one() { + let dir = std::env::temp_dir().join(format!("cb-g-{}", std::process::id())); + let server = Server::bind(0) + .expect("bind") + .with_trial(Some(dir.join("t.md"))); + let st = state(); + + server + .record_note( + &st, + "Select", + &Note { + text: "in game one".into(), + }, + ) + .expect("note 1"); + assert_eq!(server.log_notes().len(), 1, "its own game shows it"); + + server.next_game(); + assert!( + server.log_notes().is_empty(), + "game 1's comment leaked into game 2's log" + ); + + server + .record_note( + &st, + "Select", + &Note { + text: "in game two".into(), + }, + ) + .expect("note 2"); + let shown = server.log_notes(); + assert_eq!(shown.len(), 1); + assert_eq!(shown[0].text, "in game two"); + + // But the trial log keeps BOTH: it is the session's record, and + // the game column is what tells them apart (ADR-0019 D4). + let written = std::fs::read_to_string(dir.join("t.md")).expect("trial log"); + assert!(written.contains("in game one"), "the session lost a note"); + assert!(written.contains("in game two")); + assert!( + written.contains("| 1 | 1 |"), + "game column missing: {written}" + ); + assert!( + written.contains("| 2 | 2 |"), + "second note not in game 2: {written}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + /// **CB-WP-0020 T05.** "play again" must deal a second game, not just /// answer politely and exit — the browser could not start a second /// game without going back to a terminal, which made it a strictly @@ -1002,6 +1185,13 @@ mod tests { fn play_again_deals_a_second_game() { let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let out = SharedOut(buf.clone()); + // ADR-0019 D2: a real recording, so this covers the CALL SITE. + // The unit test proves `game_path` names files apart; only this + // proves the driver uses it, which is where the data loss was. + let dir = std::env::temp_dir().join(format!("cb-again-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("tmp"); + let rec = dir.join("s.yaml"); + let rec_for_thread = rec.clone(); let game = std::thread::spawn(move || { crate::table::play( &crate::table::Config { @@ -1010,7 +1200,7 @@ mod tests { human_seats: vec![0], bot: "random".into(), replay_dir: None, - record: None, + record: Some(rec_for_thread), serve: Some(0), trial: None, mode: games_ground::ScoringMode::SharedGround, @@ -1052,6 +1242,19 @@ mod tests { .collect(); assert!(hashes.len() >= 2, "only {} game(s) finished", hashes.len()); assert_ne!(hashes[0], hashes[1], "play again re-dealt the same game"); + + // ADR-0019 D2. BOTH recordings survive. `play again` used to + // write game 2 over game 1's file, destroying the evidence a + // trial exists to produce -- and every note bound to it. + let second = dir.join("s-2.yaml"); + assert!(rec.exists(), "game 1's recording is gone"); + assert!(second.exists(), "game 2 was never recorded separately"); + assert_ne!( + std::fs::read_to_string(&rec).expect("g1"), + std::fs::read_to_string(&second).expect("g2"), + "the two recordings hold the same game" + ); + let _ = std::fs::remove_dir_all(&dir); } /// A game that ended badly must say so rather than draw a table for a diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index c1b0a7c..5eb6efd 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -315,6 +315,10 @@ pub fn play(config: &Config, input: R, out: W) -> Result(config: &Config, input: R, out: W) -> Result n: + # The note points past the end of its own game. + return "orphan" + if after == n: + # The one position the recording states outright. A mismatch here + # is real: the note claims the end of a game the recording does + # not agree with. + return "ok" if row["state_hash"] in open(recording).read() else "orphan" + # Reachable by replaying `after` commands of this recording. + return "ok" def age_days(path, today): @@ -125,32 +173,41 @@ def report(root=TRIALS, today=None): found = logs(root) print("trials — what the players said, and where\n") - total, orphans, pending, expired = 0, 0, 0, 0 - for path, rows, recording in found: + total, orphans, pending, expired, legacy = 0, 0, 0, 0, 0 + for path, rows, _ in found: name = os.path.basename(path) age = age_days(path, today) - print(f" {name} ({len(rows)} note(s), {age}d" - f"{', recording not written yet' if not recording else ''})") + print(f" {name} ({len(rows)} note(s), {age}d)") for r in rows: total += 1 - state = reachability(r, recording) + # ADR-0019 D2: each note names its own game, and each game has + # its own recording. One recording per FILE was the assumption + # that made `play again` overwrite the evidence. + rec = recording_for(path, r) + state = reachability(r, rec) orphans += state == "orphan" pending += state == "pending" + legacy += state == "legacy" expired += age > NOTE_EXPIRY_DAYS - mark = {"orphan": "orphan", "pending": " .. ", "ok": " "}[state] - print(f" {mark} r{r['round']:<2} {r['step']:<8} {r['state_hash']:<12} " - f"{r['comment'][:70]}") + mark = {"orphan": "orphan", "pending": " .. ", + "legacy": "legacy", "ok": " "}[state] + where = f"g{r.get('game', '?')}+{r.get('after', '?')}" + print(f" {mark} {where:<7} r{r['round']:<2} {r['step']:<14} " + f"{r['comment'][:60]}") print() print(f" trial logs {len(found)}") print(f" notes {total}") print(f" positions unreachable {orphans} target 0" - + (" <-- the recording exists but the position moved" if orphans else "")) + + (" <-- a note points past the end of its own game" if orphans else "")) if pending: print(f" awaiting a recording {pending}" " (normal during a live session; the recording lands at game end)") + if legacy: + print(f" legacy binding {legacy}" + " (written before ADR-0019; position not checkable, NOT orphaned)") print(f" notes past {NOTE_EXPIRY_DAYS} days {expired} target 0") - if total and not orphans: + if total and not orphans and not legacy: print("\n Every note points at a position a reader can reach.") print("\n Raw notes stay in this repo (ADR-0014 D4). A note reaches" "\n ground-game only by being promoted to a register finding, by a" @@ -174,18 +231,37 @@ def self_test(): ok = ok and bool(cond) print(f" [{'ok ' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) - good = (f"# Trial log\n\n{BEGIN}\n\n" - "| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n" - "| 1 | 3 | Select | abc123def456 | why is SOLVE doing nothing |\n" - f"\n{END}\n") + def log_of(*rows): + head = ("| n | game | after | round | step | state_hash | comment |\n" + "|---|---|---|---|---|---|---|\n") + return f"# Trial log\n\n{BEGIN}\n\n{head}" + "".join(rows) + f"\n{END}\n" + + good = log_of("| 1 | 1 | 4 | 3 | Select | abc123def456 | why is SOLVE doing nothing |\n") + legacy = (f"# Trial log\n\n{BEGIN}\n\n" + "| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n" + "| 1 | 3 | Select | abc123def456 | why is SOLVE doing nothing |\n" + f"\n{END}\n") + + def recording(commands, final_hash): + """A recording with `commands` commands and one final hash — the + shape ADR-0019 D3 reads: a list to index into, plus the one + position the file states outright.""" + body = "commands:\n" + for _ in range(commands): + body += "- actor: P1\n cmd: select_action\n args: {}\n" + return body + f"expect:\n state_hash: {final_hash}\n" check("a trial log parses", len(parse(good)) == 1) + check("a legacy 5-column log still parses", len(parse(legacy)) == 1, + "ADR-0019 D4 \u2014 old logs stay readable") + check("a legacy row is MARKED legacy", parse(legacy)[0].get("legacy") is True) check("the header row is not a note", all(r["n"] != "n" for r in parse(good))) # CB-WP-0032. A row of the wrong shape must be LOUD. This used to # `continue`, so a format change would have reported zero notes for # every log at once -- the failure the parser's own docstring names. - six = good.replace("| 1 | 3 | Select | abc123def456 | why is SOLVE doing nothing |", - "| 1 | 3 | Select | abc123def456 | extra | why is SOLVE doing nothing |") + # Six columns is neither the current shape (7) nor the legacy one (5), + # so it must RAISE rather than be skipped. + six = log_of("| 1 | 1 | 4 | 3 | Select | abc123def456 |\n") try: parse(six) check("a row with the wrong column count is an error", False) @@ -212,21 +288,55 @@ def self_test(): with tempfile.TemporaryDirectory() as d: open(os.path.join(d, "2026-08-06-x.md"), "w").write(good) - # No .yaml beside it → the position cannot be reached. - rows = parse(good) + row = parse(good)[0] # game 1, after 4, hash abc123def456 check("a note with no recording is PENDING, not orphaned", - reachability(rows[0], None) == "pending", + reachability(row, None) == "pending", "a live session must not report orphans") + rec = os.path.join(d, "2026-08-06-x.yaml") - open(rec, "w").write("state_hash: abc123def456\n") - check("a note whose hash is in the recording is reachable", - reachability(rows[0], rec) == "ok", - "without this the check could always say orphan") - open(rec, "w").write("state_hash: something-else\n") - check("a note whose hash is absent from a REAL recording is an orphan", - reachability(rows[0], rec) == "orphan", + + # ADR-0019 D3. A MID-GAME note is reachable by replaying `after` + # commands. Under the old check this case was IMPOSSIBLE to pass: + # the recording holds one hash, so every mid-game note was an + # orphan whatever had happened. + open(rec, "w").write(recording(10, "zzz")) + check("a mid-game note inside its game is reachable", + reachability(row, rec) == "ok", + "the old check called every one of these an orphan") + + # And it can still say NO: past the end of its own game. + open(rec, "w").write(recording(2, "zzz")) + check("a note past the end of its game is an orphan", + reachability(row, rec) == "orphan", + "without this the metric cannot go red") + + # At the end, the hash is the integrity check — and it can fail. + end_row = parse(log_of( + "| 1 | 1 | 4 | 3 | after the end | abc123def456 | done |\n"))[0] + open(rec, "w").write(recording(4, "abc123def456")) + check("an end-of-game note agreeing with the recording is reachable", + reachability(end_row, rec) == "ok") + open(rec, "w").write(recording(4, "something-else")) + check("an end-of-game note whose hash disagrees is an orphan", + reachability(end_row, rec) == "orphan", "the position moved \u2014 the case the flag exists for") + # A legacy row is never called an orphan (ADR-0019 D4). + open(rec, "w").write(recording(2, "no-match")) + check("a legacy row is LEGACY, never orphan", + reachability(parse(legacy)[0], rec) == "legacy", + "an unsubstantiated orphan claim is the defect being fixed") + + # ADR-0019 D2: game 2 reads its OWN recording. + open(os.path.join(d, "g.md"), "w").write("x") + open(os.path.join(d, "g.yaml"), "w").write(recording(1, "one")) + open(os.path.join(d, "g-2.yaml"), "w").write(recording(9, "two")) + g2 = parse(log_of("| 1 | 2 | 5 | 1 | Select | two | second game |\n"))[0] + check("a note from game 2 is judged against game 2's recording", + recording_for(os.path.join(d, "g.md"), g2).endswith("g-2.yaml") + and reachability(g2, recording_for(os.path.join(d, "g.md"), g2)) == "ok", + "one recording per FILE is what let play again overwrite it") + # THE control design-baseline.py lacked: exercise the reporting # path and assert on what it printed. buf = io.StringIO() diff --git a/trials/2026-08-07-1558.yaml b/trials/2026-08-07-1558.yaml new file mode 100644 index 0000000..18b7a9b --- /dev/null +++ b/trials/2026-08-07-1558.yaml @@ -0,0 +1,153 @@ +scenario: ground/cb-play-session +description: recorded by cb-play (CB-WP-0008 T02) +covers: [] +provisional: false +provisional_owner: '' +provisional_raised: '' +ruled: '' +ruled_by: '' +ruled_note: '' +encodes_u_item: '' +seed: 1 +setup: + players: 3 + preset: standard-3p + patch: {} +commands: +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 2 +- actor: P2 + cmd: select_action + args: + action: SOLVE + problem: 1 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 1 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 3 +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 3 +- actor: P3 + cmd: select_action + args: + action: INVESTIGATE + problem: 3 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SOLVE + problem: 3 +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 2 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SUPPORT + target: P2 +- actor: P2 + cmd: select_action + args: + action: SUPPORT + target: P1 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 4 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P1 + cmd: respond_to_support + args: + response: accept_bond +- actor: P2 + cmd: respond_to_support + args: + response: accept_bond +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SUPPORT + target: P3 +- actor: P2 + cmd: select_action + args: + action: SUPPORT + target: P1 +- actor: P3 + cmd: select_action + args: + action: SUPPORT + target: P1 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P3 + cmd: respond_to_support + args: + response: accept_bond +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +expect: + events: [] + state: {} + rejects: [] + state_hash: 8ade38824ca4853ac51fa8156673098ab2975a92b0db75783b0cd68a267ec30e diff --git a/trials/2026-08-07-1612.md b/trials/2026-08-07-1612.md new file mode 100644 index 0000000..3a59bbd --- /dev/null +++ b/trials/2026-08-07-1612.md @@ -0,0 +1,19 @@ +# Trial log + +What the player said while playing, bound to the position they said it at +(CB-WP-0027, ADR-0014). The recording beside this file is the position; +`state_hash` is what reaches it. + +**These are raw notes and they stay here.** Nothing in this file travels to +`ground-game` (ADR-0014 D4) — a note reaches them only by being promoted to a +register finding, by a human, with the wording chosen then. + + + +| n | round | step | state_hash | comment | +|---|---|---|---|---| +| 1 | 5 | after the end | 15e28280a256 | We solved the game. The visualization does not quite show the other players actions. I need to look at the log for that. And i did not comment during the game. Will try that next. | +| 2 | 2 | Select | 37ca932d3a1e | Investigate is most often my first move. | +| 3 | 5 | after the end | 08eae77fe37f | This worked nicely. But i still cant see the player i am bonding with. | + + diff --git a/trials/2026-08-07-1612.yaml b/trials/2026-08-07-1612.yaml new file mode 100644 index 0000000..74ac2a5 --- /dev/null +++ b/trials/2026-08-07-1612.yaml @@ -0,0 +1,149 @@ +scenario: ground/cb-play-session +description: recorded by cb-play (CB-WP-0008 T02) +covers: [] +provisional: false +provisional_owner: '' +provisional_raised: '' +ruled: '' +ruled_by: '' +ruled_note: '' +encodes_u_item: '' +seed: 2 +setup: + players: 3 + preset: standard-3p + patch: {} +commands: +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 2 +- actor: P2 + cmd: select_action + args: + action: SOLVE + problem: 1 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 1 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SOLVE + problem: 2 +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 3 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 2 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: P3 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SOLVE + problem: 3 +- actor: P2 + cmd: select_action + args: + action: SOLVE + problem: 4 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 4 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SUPPORT + target: P2 +- actor: P2 + cmd: select_action + args: + action: SUPPORT + target: P1 +- actor: P3 + cmd: select_action + args: + action: SUPPORT + target: P1 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P1 + cmd: respond_to_support + args: + response: accept_bond +- actor: P2 + cmd: respond_to_support + args: + response: accept_bond +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +expect: + events: [] + state: {} + rejects: [] + state_hash: 08eae77fe37fd575d4bacbf758ce5fcb54cbdac07e1549237dc3ae38023bce9c diff --git a/workplans/CB-WP-0032-the-comments-in-the-account.md b/workplans/CB-WP-0032-the-comments-in-the-account.md index 960e8cd..91140dc 100644 --- a/workplans/CB-WP-0032-the-comments-in-the-account.md +++ b/workplans/CB-WP-0032-the-comments-in-the-account.md @@ -3,6 +3,7 @@ id: CB-WP-0032 kind: product title: "The comments in the account" status: done +state_hub_workstream_id: "71d744f9-7e40-49c3-994e-f3fb007a1430" --- # Purpose @@ -74,6 +75,7 @@ blaming a missing block for every failure. id: CB-WP-0032-T01 status: done priority: high +state_hub_task_id: "31f60440-e049-45f7-99fa-87c01c00adb7" ``` **Controls:** diff --git a/workplans/CB-WP-0033-a-game-is-the-unit.md b/workplans/CB-WP-0033-a-game-is-the-unit.md new file mode 100644 index 0000000..6dcc8a0 --- /dev/null +++ b/workplans/CB-WP-0033-a-game-is-the-unit.md @@ -0,0 +1,99 @@ +--- +id: CB-WP-0033 +kind: product +title: "A game is the unit" +status: done +--- + +# Purpose + +``` +structural tier M (changes an artifact contract -- the recording path -- + and the definition of a reported metric) +chaos d8 = 7 → no override +declared tier M +``` + +**Declaration 4 of chaos window 3.** + +## The report + +Reviewing the maintainer's six trial notes, `make trials` said +**positions unreachable: 4, target 0 — "the recording exists but the +position moved."** + +**All four were false.** A recording holds exactly one hash, the final +state; `reachability()` asked whether the note's hash was in that file. So +a mid-game note could never match, and a post-game note from any but the +last game could not either. + +**Instance 8 of the ADR-0018 family**, with an exact sensitivity: vary +only *when* the note was written and the answer flips deterministically, +with nothing having moved. + +## The root cause was not the metric + +Three defects, one cause: **`play again` reused state that belongs to a +game.** + +| reused | consequence | +|---|---| +| the recording path | **game 1's recording destroyed** — data loss | +| the journal (never cleared) | game 2's log opened with game 1's commands | +| the note's `after`, counted on that journal | indexed a recording without those commands | + +**Fixing `reachability()` alone would have turned the number green while a +session still destroyed its own evidence.** [ADR-0019](../decisions/ADR-0019-a-game-is-the-unit.md). + +## Task: a game is the unit + +```task +id: CB-WP-0033-T01 +status: done +priority: high +``` + +**Controls:** +- **each fix fails on its own defect**, by mutation, or it is not tested; +- **the metric can go red** — a check reporting 0 that cannot report + anything else is decoration (ADR-0006 D3); +- **old logs stay readable and are not called orphans**; +- **the single-game invocation GameDesign §5 documents is unchanged.** + +**Done 2026-08-07.** All three mutation-proven: + +| mutation | what went red | +|---|---| +| `next_game` stops clearing the journal | *"game 2 opened holding game 1's commands"* | +| notes not filtered by game | *"game 1's comment leaked into game 2's log"* | +| driver stops re-pathing the recording | *"game 2 was never recorded separately"* | + +The third is the important one: it runs the **real two-game session**, so +it covers the call site rather than the helper. The unit test proves +`game_path` names files apart; only the integration test proves the driver +uses it, and the driver was where the data loss lived. + +**Two defects were introduced and caught while writing this.** + +- `cfg.record` was re-derived from the *previous game's* path, so game 3 + would have landed in `x-2-3.yaml`. Fixed by keeping the caller's base + untouched, **and the compounding shape is now pinned by an assertion** + so the fix cannot silently rot. +- `Server::game()` was dead code, which `-D warnings` caught. Rather than + `#[allow]`, the driver now *asks the server* which game it is — removing + a second counter that could have disagreed with the one stamped on the + notes. + +**The metric now reports honestly**: 0 unreachable, 6 legacy, and it says +what legacy means. `trials.py` gained six controls, including the two that +were previously **impossible to pass** — a mid-game note being reachable, +and a note from game 2 being judged against game 2's recording. + +## Not done here + +- **No hash per command in the recording** (ADR-0019 D3). A mid-game + position is confirmed by replaying to it, not by string search. The + trigger for revisiting is a reader who needs the position without a + replay. +- **The six existing notes stay legacy forever.** Their positions were + never checkable; nothing is gained by inventing bindings for them.