make trials reported "positions unreachable: 4, target 0 — the recording exists but the position moved". All four were false. A recording holds one hash, the final state, and 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: vary only WHEN a note was written and the answer flips, with nothing having moved. The root cause was not the metric. play again reused state belonging to a game: it overwrote the previous game's recording (data loss), never cleared the journal (game 2's log opened with game 1's commands), and so a note's command index pointed into a recording without those commands. Fixing reachability alone would have gone green while a session still destroyed its own evidence. A note now binds by (game, after) — an index into the recording's own commands list, which a reader can replay to. The hash keeps a job as the integrity check at the end of a game, where it can actually fail. Game 1 keeps the path it was given, so GameDesign §5's documented invocation is unchanged; later games get -2, -3 and nothing is overwritten. Legacy 5-column logs stay readable and are reported as legacy, never as orphans — an unsubstantiated orphan claim is the defect being fixed. All three fixes mutation-proven, including at the call site via a real two-game session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4ddff3b17c
commit
edab11c0e4
10 changed files with 909 additions and 41 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
114
decisions/ADR-0019-a-game-is-the-unit.md
Normal file
114
decisions/ADR-0019-a-game-is-the-unit.md
Normal file
|
|
@ -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 |
|
||||
|
|
@ -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<Vec<TrialNote>>,
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
/// 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("<!-- trial-log:begin -->\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<!-- trial-log:end -->\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<cb_render_html::doc::LogNote> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -315,6 +315,10 @@ pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<S
|
|||
// point — it is declared here, beside the listener and the seed,
|
||||
// because those are the other two things that survive `play again`.
|
||||
let mut tally = MatchTally::default();
|
||||
// The path the caller gave us, kept UNCHANGED. Deriving the next
|
||||
// game's path from the previous game's would compound the suffix --
|
||||
// game 3 would land in `x-2-3.yaml`.
|
||||
let record_base = config.record.clone();
|
||||
loop {
|
||||
// CB-WP-0027 T02: the panel shows the series *during* the next
|
||||
// game, not only after it.
|
||||
|
|
@ -326,6 +330,19 @@ pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<S
|
|||
return Ok(summary);
|
||||
}
|
||||
cfg.seed = cfg.seed.wrapping_add(1);
|
||||
// ADR-0019 D1. The journal and the recording belong to a GAME;
|
||||
// the notes and the tally belong to the session. Nothing advanced
|
||||
// the first pair, so the second game opened with the first one's
|
||||
// log still in it and then overwrote its recording.
|
||||
if let Some(s) = &server {
|
||||
s.next_game();
|
||||
}
|
||||
// ADR-0019 D2. The server holds which game this is; asking it
|
||||
// keeps ONE counter rather than a second copy here that could
|
||||
// disagree with the one the notes are stamped with.
|
||||
if let (Some(s), Some(base)) = (&server, &record_base) {
|
||||
cfg.record = Some(crate::hotseat::game_path(base, s.game()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
172
tools/trials.py
172
tools/trials.py
|
|
@ -30,7 +30,13 @@ NOTE_EXPIRY_DAYS = 30
|
|||
# The trial-log table's columns, named once. `write_trial_log` in
|
||||
# `hotseat.rs` writes them; this reads them; a row that is not this shape
|
||||
# is an error rather than a row to skip.
|
||||
COLUMNS = ("n", "round", "step", "state_hash", "comment")
|
||||
COLUMNS = ("n", "game", "after", "round", "step", "state_hash", "comment")
|
||||
|
||||
# ADR-0019 D4. Logs written before the binding was fixed. They are parsed
|
||||
# and reported, and they are NOT called orphans: their positions were
|
||||
# never checkable by anything this repo can run, and an unsubstantiated
|
||||
# orphan claim is the exact defect ADR-0019 exists to delete.
|
||||
LEGACY_COLUMNS = ("n", "round", "step", "state_hash", "comment")
|
||||
|
||||
|
||||
def parse(text):
|
||||
|
|
@ -56,14 +62,43 @@ def parse(text):
|
|||
# at once. Adding a sixth column to the trial log (which CB-WP-0032
|
||||
# considered, and did not do) would have emptied every existing
|
||||
# log without a word.
|
||||
if len(cells) != len(COLUMNS):
|
||||
if len(cells) == len(COLUMNS):
|
||||
rows.append(dict(zip(COLUMNS, cells)))
|
||||
elif len(cells) == len(LEGACY_COLUMNS):
|
||||
row = dict(zip(LEGACY_COLUMNS, cells))
|
||||
row["legacy"] = True
|
||||
rows.append(row)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"trial-log row has {len(cells)} columns, expected {len(COLUMNS)}: {line!r}"
|
||||
f"trial-log row has {len(cells)} columns, expected "
|
||||
f"{len(COLUMNS)} or {len(LEGACY_COLUMNS)}: {line!r}"
|
||||
)
|
||||
rows.append(dict(zip(COLUMNS, cells)))
|
||||
return rows
|
||||
|
||||
|
||||
def recording_for(md_path, row):
|
||||
"""The recording holding this note's game (ADR-0019 D2).
|
||||
|
||||
Game 1 keeps the base path; later games carry `-2`, `-3`. A legacy row
|
||||
names no game, so it gets the base path and is judged as legacy.
|
||||
"""
|
||||
stem = os.path.splitext(md_path)[0]
|
||||
n = int(row.get("game", 1) or 1)
|
||||
path = f"{stem}.yaml" if n <= 1 else f"{stem}-{n}.yaml"
|
||||
return path if os.path.exists(path) else None
|
||||
|
||||
|
||||
def command_count(recording):
|
||||
"""How many commands the recording holds — the range a note's `after`
|
||||
must fall inside. Counted from the `commands:` block, because that is
|
||||
the list `after` indexes into."""
|
||||
text = open(recording).read()
|
||||
if "commands:" not in text:
|
||||
return 0
|
||||
block = text.split("commands:", 1)[1].split("\nexpect:", 1)[0]
|
||||
return sum(1 for line in block.splitlines() if line.startswith("- actor:"))
|
||||
|
||||
|
||||
def logs(root=TRIALS):
|
||||
"""Every trial log, with its recording beside it if there is one."""
|
||||
out = []
|
||||
|
|
@ -98,9 +133,22 @@ def reachability(row, recording):
|
|||
worth knowing, by the same reasoning that rewrote `gr-e01` rather than
|
||||
retiring it.
|
||||
"""
|
||||
if row.get("legacy"):
|
||||
return "legacy"
|
||||
if not recording:
|
||||
return "pending"
|
||||
return "ok" if row["state_hash"] in open(recording).read() else "orphan"
|
||||
after = int(row["after"])
|
||||
n = command_count(recording)
|
||||
if after > 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()
|
||||
|
|
|
|||
153
trials/2026-08-07-1558.yaml
Normal file
153
trials/2026-08-07-1558.yaml
Normal file
|
|
@ -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
|
||||
19
trials/2026-08-07-1612.md
Normal file
19
trials/2026-08-07-1612.md
Normal file
|
|
@ -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.
|
||||
|
||||
<!-- trial-log:begin -->
|
||||
|
||||
| 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. |
|
||||
|
||||
<!-- trial-log:end -->
|
||||
149
trials/2026-08-07-1612.yaml
Normal file
149
trials/2026-08-07-1612.yaml
Normal file
|
|
@ -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
|
||||
|
|
@ -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:**
|
||||
|
|
|
|||
99
workplans/CB-WP-0033-a-game-is-the-unit.md
Normal file
99
workplans/CB-WP-0033-a-game-is-the-unit.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue