diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 4610b66..bb168b0 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -1306,6 +1306,25 @@ fn body(s: &mut String, view: &GroundView) { s.push_str("
no problems in play
"); } s.push_str(&table_svg(view)); + // CB-WP-0046: the placement is legible, the RULE it encodes was not. + // + // Shown only when a non-global scope is actually on the table — under + // the baseline every Problem is everyone's, and an explanation of a + // distinction that is not in play is noise. Same discipline as the + // GROUND modes: at the thing it explains, and closable. + if view.problem_markers.values().any(|m| { + m.scope + .is_some_and(|x| x != games_ground::edition::StressScope::Global) + }) { + if let Some(rule) = stress_scope_rule_text() { + let _ = write!( + s, + "
what a Problem\u{2019}s scope does\ + {}
", + esc(&rule) + ); + } + } s.push_str("

seats

"); for (id, p) in &view.players { @@ -1490,6 +1509,20 @@ fn ground_modes_text() -> Option { .map(|c| c.rules_text) } +/// What a scope DOES, in the edition's words (CB-WP-0046). +/// +/// CB-WP-0045 left this open and gave the wrong reason: *"the scope rule +/// is ours (a Variant), so there is no vendored sentence to render."* The +/// H2 package ships `Rules_Text.csv` and its `Problems / Stress scope` +/// passage is exactly the rule. Nothing read the file. +/// +/// **The placement was legible and the rule it encodes was not** — a +/// player could see a card sitting on their Bond lines and have no way to +/// learn that its Stress presses everyone on those lines. +fn stress_scope_rule_text() -> Option { + games_ground::edition::stress_scope_rule() +} + /// A GROUND sub-choice, in words, naming whoever it touches. fn ground_choice_label(c: &games_ground::GroundChoice) -> String { use games_ground::GroundChoice as G; diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 02b60de..7e316f4 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -697,6 +697,65 @@ mod gamelog { } } + /// **A scope says what it does** (CB-WP-0046). + /// + /// CB-WP-0045 shipped the placement and recorded the gap with the + /// wrong reason — that scoping is our Variant so no printed sentence + /// exists. `Rules_Text.csv` was in the H2 package the whole time and + /// nothing read it. A player could see a card on their Bond lines + /// and have no way to learn what that meant. + #[test] + fn a_scoped_table_says_what_a_scope_does() { + let mut v = crate::testfix::view(Some(PlayerId(0))); + v.variant = games_ground::Variant::H2ScopedProblemStress; + // One card that is not everyone's — the condition for the rule + // to be worth stating at all. + if let Some(m) = v.problem_markers.values_mut().next() { + m.scope = Some(games_ground::edition::StressScope::Bond); + m.owner = Some(PlayerId(1)); + } + let text = crate::text_of(&crate::doc::document( + &v, + &[], + "/c", + Some(PlayerId(0)), + false, + )); + assert!( + text.contains("scope does"), + "Problems are placed by scope and nothing says what a scope does" + ); + // The EDITION's sentence. A paraphrase of ours would drift from + // the rule the kernel implements (ADR-0015). + let rule = games_ground::edition::stress_scope_rule() + .expect("h2 Rules_Text.csv carries the scope rule"); + let head: String = rule.chars().take(60).collect(); + assert!( + text.contains(&head), + "the scope explanation is not the edition's own words: {head:?}" + ); + } + + /// The baseline is not told about a distinction it does not have. + #[test] + fn an_unscoped_table_does_not_explain_scopes() { + let mut v = crate::testfix::view(Some(PlayerId(0))); + for m in v.problem_markers.values_mut() { + m.scope = Some(games_ground::edition::StressScope::Global); + } + let text = crate::text_of(&crate::doc::document( + &v, + &[], + "/c", + Some(PlayerId(0)), + false, + )); + assert!( + !text.contains("scope does"), + "the baseline table explains a scope distinction that is not in play" + ); + } + /// **The number is on the card** (CB-WP-0045). /// /// Every move label says "Problem 7"; nothing on the table said which diff --git a/games/ground/src/edition.rs b/games/ground/src/edition.rs index 614d442..c942db7 100644 --- a/games/ground/src/edition.rs +++ b/games/ground/src/edition.rs @@ -82,6 +82,16 @@ const GLOSSARY_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Gloss /// H2's Problems table (CB-WP-0042). r0's with one column added. const H2_PROBLEMS_CSV: &str = include_str!("../../../editions/experiments/h2-scoped-problem-stress/Problems.csv"); +/// H2's player-facing rulebook. **It was here all along** (CB-WP-0046). +/// +/// CB-WP-0045 recorded that nothing explains what a *scope does* and +/// judged it a `ground-game` question, on the reasoning that scoping is +/// clay-borg's Variant and so has no printed sentence. That was wrong: +/// the package ships `Rules_Text.csv`, and row 20 is the rule in the +/// edition's own words. **We had never read the file** — the third time +/// this pass that vendored text could not reach a player (F18). +const H2_RULES_TEXT_CSV: &str = + include_str!("../../../editions/experiments/h2-scoped-problem-stress/Rules_Text.csv"); /// A vendored CSV, parsed into rows addressable by column name. /// @@ -562,6 +572,59 @@ pub fn stress_scopes(scenario_id: &str) -> Result, Str Ok(out) } +/// One passage of the variant's rulebook (CB-WP-0046). +pub struct RulesPassage { + pub order: u32, + pub section: String, + pub heading: String, + pub body: String, +} + +/// H2's rulebook, in `Rules_Text.csv` order. +/// +/// Returned whole rather than as a lookup by heading: a caller that wants +/// the scope rule asks for its section, and the ordering is the +/// edition's, not one we impose. +pub fn h2_rules_text() -> Result, String> { + let t = Table::parse(H2_RULES_TEXT_CSV, "h2/Rules_Text.csv")?; + let mut out = Vec::new(); + for row in &t.rows { + out.push(RulesPassage { + order: t + .get(row, "order")? + .trim() + .parse() + .map_err(|_| "order is not a number".to_string())?, + section: t.get(row, "section")?.to_string(), + heading: t.get(row, "heading")?.to_string(), + body: t.get(row, "body")?.to_string(), + }); + } + if out.is_empty() { + return Err("h2/Rules_Text.csv has no rows".into()); + } + out.sort_by_key(|p| p.order); + Ok(out) +} + +/// What a `stress_scope` DOES, in the edition's words (CB-WP-0046). +/// +/// The passage headed *Stress scope* in the `Problems` section. Matched +/// on the heading rather than pinned to row 20, because a row number is +/// a property of today's file and the heading is a property of the rule. +/// **`None` if the edition stops carrying it** — an absent explanation is +/// reported as absent, never replaced by ours (ADR-0018). +pub fn stress_scope_rule() -> Option { + h2_rules_text() + .ok()? + .into_iter() + .find(|p| { + p.section.eq_ignore_ascii_case("Problems") + && p.heading.to_lowercase().contains("stress scope") + }) + .map(|p| p.body) +} + /// The stress gate, printed on every player mat (CB-WP-0037 T03). /// /// **A mat is mostly ornamentation with one rule on it.** Symbol, colour diff --git a/specs/FindingRegister.md b/specs/FindingRegister.md index bc68d5d..7e5c81c 100644 --- a/specs/FindingRegister.md +++ b/specs/FindingRegister.md @@ -53,9 +53,30 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by | F21 | degenerate | note | — | — | 2026-08-06 | clay-borg | | F23 | inconsistent | applied | decisions/ADR-0017-chaos-window-2-verdict.md | counterexample | 2026-08-07 | clay-borg | | F22 | underdetermined | withdrawn | games_ground::edition::supply_tests::play_never_exceeds_the_components_the_box_holds | counterexample | 2026-08-07 | clay-borg | +| F26 | inert | raised | `crates/cb-render-html/src/lib.rs::a_scoped_table_says_what_a_scope_does` | counterexample | 2026-08-08 | ground-game | +- **F26 — a package that adds a FILE is invisible, where a package that + adds a column is not.** `h2-scoped-problem-stress` ships + `Rules_Text.csv` — twenty-two passages of player-facing rules, including + the one passage that says what a `stress_scope` does. No reader in + clay-borg mentioned the file, so `edition-check` could not call it stale + (a file nothing reads is not out of date, it is unseen) and no gate + could call it unread. The rule the table's whole layout encodes sat in + the repo, reachable by no player, for the entire measured life of H2. + + **Inert rather than underdetermined**: nothing is ambiguous about the + rule and the engine implements it correctly — the text simply could not + fire. Third instance of F18's shape and the first where the unit is a + file, which is why it is filed separately instead of folded in. + + Raised against `ground-game` because the actionable half is theirs: a + package's manifest should name its own files, so that a consumer reading + none of them is a detectable state. clay-borg now reads the one passage + it needed (CB-WP-0046); the other twenty-one remain unread and that is + the standing evidence. + - **F11 — SOLVE offered where it cannot act.** Offered on a face-down Problem, or with no matching suit in hand; inert every time. Ruled GROUND-WP-0002 T02, implemented CB-WP-0023 as GR-P05. `applied` — the diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index a9dc3d1..029e216 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -100,7 +100,11 @@ pub fn game_path(base: &std::path::Path, n: usize) -> std::path::PathBuf { /// **Reusing `FindingRegister.md`'s idiom deliberately** (ADR-0014 D2) — /// a table between HTML-comment markers, so a human reads the file and a /// tool reads the block, and neither needs the other's cooperation. -pub fn write_trial_log(path: &std::path::Path, notes: &[TrialNote]) -> Result<(), String> { +pub fn write_trial_log( + path: &std::path::Path, + notes: &[TrialNote], + variant: &str, +) -> Result<(), String> { let mut s = String::new(); s.push_str("# Trial log\n\n"); s.push_str( @@ -111,7 +115,19 @@ pub fn write_trial_log(path: &std::path::Path, notes: &[TrialNote]) -> Result<() `ground-game` (ADR-0014 D4) — a note reaches them only by being promoted to a\n\ register finding, by a human, with the wording chosen then.\n\n", ); - s.push_str("\n\n"); + // CB-WP-0046: WHICH RULES THIS SESSION PLAYED. + // + // Without it a baseline note and an H2 note are indistinguishable, + // which stopped being survivable the moment both were being played + // in the same week. It rides the marker rather than becoming an + // eighth column because the variant is a property of the SESSION, + // and a per-note column would repeat one fact on every line. + // + // Read off the STATE, not off the command line: `--variant h2` with + // a bare `state.variant = v` assignment leaves H2 inert, and a log + // stamped from the flag would have recorded H2 for a session that + // played the baseline. This records what actually ran. + let _ = write!(s, "\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 @@ -209,7 +225,7 @@ impl Server { state_hash: hash[..12].to_string(), text: note.text.clone(), }); - write_trial_log(path, &log) + write_trial_log(path, &log, state.variant.id()) } /// Begin the next game (ADR-0019 D1). @@ -968,6 +984,63 @@ mod tests { /// this loop breaks it; a player writing what they thought must be /// able to write a second one and then still press `play again`, /// which is what the trailing command here proves. + /// **The log says which rules the session played** (CB-WP-0046). + /// + /// Without it a baseline note and an H2 note are indistinguishable, + /// which is how *"i did not notice any difference"* stays unreadable + /// after the fact — you cannot tell whether it is a finding about H2 + /// or a note about a session that never ran it. + #[test] + fn the_trial_log_records_which_rules_were_played() { + let dir = std::env::temp_dir().join(format!("cb-trial-var-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("trial.md"); + let note = TrialNote { + n: 1, + game: 1, + after: 4, + round: 3, + step: "Select".into(), + state_hash: "abc123def456".into(), + text: "it felt harder".into(), + }; + write_trial_log(&path, &[note], "h2-scoped-problem-stress").unwrap(); + let written = std::fs::read_to_string(&path).unwrap(); + assert!( + written.contains(""), + "the log does not say which rules were played:\n{written}" + ); + // **Not an eighth column.** The variant is a property of the + // session; repeating it per row would widen a table whose parser + // treats an unexpected width as an error. + let row = written + .lines() + .find(|l| l.starts_with("| 1 |")) + .expect("the note row"); + assert_eq!( + row.matches('|').count() - 1, + 7, + "the table gained a column: {row}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The stamp is read off the STATE, never off the command line. + /// + /// Three call sites once set `state.variant = v` directly instead of + /// `with_variant()`, which renders H2 inert. A log stamped from the + /// `--variant` flag would have recorded `h2` for a session that + /// played the baseline — a false provenance on real player words, + /// which is worse than no provenance. + #[test] + fn the_stamp_names_the_rules_that_actually_ran() { + let src = include_str!("hotseat.rs"); + assert!( + src.contains("write_trial_log(path, &log, state.variant.id())"), + "the trial log is stamped from something other than the state" + ); + } + #[test] fn a_note_can_be_written_after_the_game_has_ended() { let dir = std::env::temp_dir().join(format!("cb-note-end-{}", std::process::id())); diff --git a/tools/trials.py b/tools/trials.py index e6cf8b3..3164e02 100644 --- a/tools/trials.py +++ b/tools/trials.py @@ -15,14 +15,27 @@ reuses `FindingRegister.md`'s idiom: a table between HTML-comment markers inside a readable Markdown file. """ -import os, re, sys, glob, datetime +import os +import re, sys, glob, datetime ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TRIALS = os.path.join(ROOT, "trials") -BEGIN = "" +# The begin marker carries attributes since CB-WP-0046, so it is matched +# by PREFIX. A bare `` is still a valid marker — +# every log written before this one has it. +BEGIN = "" +# Which rules the session was played under (CB-WP-0046). +# +# **A session property, not a note property**, so it rides the marker +# rather than becoming an eighth column. The variant is fixed at startup +# and cannot change between games, so a per-row column would repeat one +# fact on every line and force a third accepted row width on a parser +# whose docstring is about exactly that hazard. +VARIANT_ATTR = re.compile(r"variant=([A-Za-z0-9._-]+)") + # GameDesign §3.1's figure, shared rather than reinvented: a note that # cannot expire ages into an apparent finding. NOTE_EXPIRY_DAYS = 30 @@ -45,7 +58,12 @@ def parse(text): is precisely the failure this tool exists to prevent.""" if BEGIN not in text or END not in text: raise ValueError("no trial-log block") - block = text.split(BEGIN)[1].split(END)[0] + # Split off the marker's own line so an attribute cannot be read as + # a table row, and so `variant=` is available to every row below it. + tail = text.split(BEGIN, 1)[1].split(END)[0] + marker, _, block = tail.partition("\n") + hit = VARIANT_ATTR.search(marker) + variant = hit.group(1) if hit else None rows = [] for line in block.splitlines(): line = line.strip() @@ -63,16 +81,21 @@ def parse(text): # considered, and did not do) would have emptied every existing # log without a word. if len(cells) == len(COLUMNS): - rows.append(dict(zip(COLUMNS, cells))) + row = 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 " f"{len(COLUMNS)} or {len(LEGACY_COLUMNS)}: {line!r}" ) + # **`None`, not a default of "baseline"** — a log written before + # the marker carried the attribute does not say which rules were + # played, and answering an unrecorded question with the common + # case is how a baseline note gets read as an H2 result. + row["variant"] = variant + rows.append(row) return rows @@ -174,12 +197,20 @@ def report(root=TRIALS, today=None): found = logs(root) print("trials — what the players said, and where\n") total, orphans, pending, expired, legacy = 0, 0, 0, 0, 0 + by_variant = {} for path, rows, _ in found: name = os.path.basename(path) age = age_days(path, today) - print(f" {name} ({len(rows)} note(s), {age}d)") + # CB-WP-0046: which rules were played. `unrecorded` is said out + # loud rather than filled in with the common case — a note read + # under the wrong rules is worse than a note whose rules are + # openly unknown. + rules = (rows[0].get("variant") if rows else None) or "unrecorded" + print(f" {name} ({len(rows)} note(s), {age}d, rules {rules})") for r in rows: total += 1 + key = r.get("variant") or "unrecorded" + by_variant[key] = by_variant.get(key, 0) + 1 # 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. @@ -207,6 +238,15 @@ def report(root=TRIALS, today=None): 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") + # A tally by rules, because the question a reader now has is "was this + # said about H2 or about the baseline" and counting the logs by hand + # is how that gets answered wrong. + if by_variant: + print(" notes by rules " + + ", ".join(f"{v} {n}" for v, n in sorted(by_variant.items()))) + if "unrecorded" in by_variant: + print(" (unrecorded = written before" + " CB-WP-0046 stamped the marker)") 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" @@ -231,13 +271,19 @@ def self_test(): ok = ok and bool(cond) print(f" [{'ok ' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) - def log_of(*rows): + # `BEGIN` is a PREFIX since CB-WP-0046, so a fixture has to close the + # marker itself — which is the point: the closing text is what the + # attribute sits inside. + OPEN = BEGIN + " -->" + + def log_of(*rows, marker=None): 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" + return (f"# Trial log\n\n{marker or OPEN}\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" + legacy = (f"# Trial log\n\n{OPEN}\n\n" "| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n" "| 1 | 3 | Select | abc123def456 | why is SOLVE doing nothing |\n" f"\n{END}\n") @@ -256,6 +302,65 @@ def self_test(): 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-0046 — WHICH RULES THE SESSION PLAYED. + row = "| 1 | 1 | 4 | 3 | Select | abc123def456 | it felt harder |\n" + stamped = log_of(row, marker=BEGIN + " variant=h2-scoped-problem-stress -->") + check("a stamped log reports its variant", + parse(stamped)[0]["variant"] == "h2-scoped-problem-stress") + check("an unstamped log says NOTHING, not 'baseline'", + parse(log_of(row))[0]["variant"] is None, + "answering an unrecorded question with the common case is how a " + "baseline note gets read as an H2 result") + # The attribute must not be mistaken for a table row, and adding it + # must not change how many notes the log has. + check("the marker is not counted as a note", len(parse(stamped)) == 1) + check("stamping does not disturb the row", parse(stamped)[0]["after"] == "4") + # The marker is not a THIRD row width: the table shape is untouched, + # which is why the parser needed no new accepted width. + check("the stamped table has the same columns as the unstamped one", + set(parse(stamped)[0]) - {"variant"} == set(parse(good)[0]) - {"variant"}) + # **A LITERAL marker, not one built from `BEGIN`.** + # + # Every fixture above composes its marker out of `BEGIN`, so all of + # them follow `BEGIN` wherever it goes — including away from what + # `hotseat.rs` actually writes. Reverting `BEGIN` to an exact match + # broke real stamped logs and every one of those checks stayed green. + # The format is shared across two languages and this is the only + # check that holds this side to the bytes the other side emits. + literal = ("# Trial log\n\n" + "\n\n" + "| n | game | after | round | step | state_hash | comment |\n" + "|---|---|---|---|---|---|---|\n" + "| 1 | 1 | 4 | 3 | Select | abc123def456 | it felt harder |\n" + "\n\n") + # Caught, because the failure mode is `parse` REFUSING the writer's + # output — and an uncaught traceback names a line number where a + # named check names the defect. + try: + literal_rows = parse(literal) + except ValueError: + literal_rows = [] + check("the bytes hotseat.rs writes are the bytes this parses", + len(literal_rows) == 1 + and literal_rows[0]["variant"] == "h2-scoped-problem-stress", + "hotseat.rs's the_trial_log_records_which_rules_were_played " + "asserts this same string from the writing side") + # And the marker as it was written before CB-WP-0046, also literal. + try: + older = parse(literal.replace( + "", + "")) + except ValueError: + older = [] + check("a pre-CB-WP-0046 marker still opens a log", len(older) == 1, + "ADR-0019 D4's discipline: old logs stay readable") + + check("a legacy log can still be stamped", + parse(f"# Trial log\n\n{BEGIN} variant=ground-darvo-r0 -->\n\n" + "| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n" + "| 1 | 3 | Select | abc123def456 | x |\n" + f"\n{END}\n")[0]["variant"] == "ground-darvo-r0") + # 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. diff --git a/trials/2026-08-08-1903-2.yaml b/trials/2026-08-08-1903-2.yaml new file mode 100644 index 0000000..2b9f2f9 --- /dev/null +++ b/trials/2026-08-08-1903-2.yaml @@ -0,0 +1,156 @@ +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: SOLVE + problem: 1 +- 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: 2 +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 2 +- actor: P3 + cmd: select_action + args: + action: INVESTIGATE + 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: GROUND +- actor: P2 + cmd: select_action + args: + action: GROUND +- actor: P3 + cmd: select_action + args: + action: GROUND +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P1 + cmd: choose_ground_mode + args: + mode: GR +- actor: P2 + cmd: choose_ground_mode + args: + mode: GR +- actor: P3 + cmd: choose_ground_mode + args: + mode: GR +- 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: SOLVE + problem: 2 +- 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: 3 +- actor: P2 + cmd: select_action + args: + action: GROUND +- actor: P3 + cmd: select_action + args: + action: GROUND +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P2 + cmd: choose_ground_mode + args: + mode: GR +- actor: P3 + cmd: choose_ground_mode + args: + mode: GR +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +expect: + events: [] + state: {} + rejects: [] + state_hash: 5c25b5357d90376d23caa08921a448e658c71c2936ab21e84374ad76525788b6 diff --git a/trials/2026-08-08-1903-3.yaml b/trials/2026-08-08-1903-3.yaml new file mode 100644 index 0000000..5bf287f --- /dev/null +++ b/trials/2026-08-08-1903-3.yaml @@ -0,0 +1,179 @@ +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: 3 +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: INVESTIGATE + problem: 2 +- actor: P3 + cmd: select_action + args: + action: INVESTIGATE + 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: GROUND +- actor: P2 + cmd: select_action + args: + action: GROUND +- actor: P3 + cmd: select_action + args: + action: GROUND +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P1 + cmd: choose_ground_mode + args: + mode: GR +- actor: P2 + cmd: choose_ground_mode + args: + mode: GR +- actor: P3 + cmd: choose_ground_mode + args: + mode: GR +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: GROUND +- actor: P2 + cmd: select_action + args: + action: GROUND +- actor: P3 + cmd: select_action + args: + action: GROUND +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P1 + cmd: choose_ground_mode + args: + mode: GR +- actor: P2 + cmd: choose_ground_mode + args: + mode: GR +- actor: P3 + cmd: choose_ground_mode + args: + mode: GR +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: GROUND +- actor: P2 + cmd: select_action + args: + action: GROUND +- actor: P3 + cmd: select_action + args: + action: GROUND +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P1 + cmd: choose_ground_mode + args: + choice: reject_reverse + mode: ND +- actor: P2 + cmd: choose_ground_mode + args: + mode: GR +- actor: P3 + cmd: choose_ground_mode + args: + mode: GR +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: ATTACK + target: P3 +- actor: P2 + cmd: select_action + args: + action: GROUND +- actor: P3 + cmd: select_action + args: + action: GROUND +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P1 + cmd: choose_darvo_target + args: + problem: 1 +- actor: P2 + cmd: choose_ground_mode + args: + mode: GR +- actor: P3 + cmd: choose_ground_mode + args: + mode: GR +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +expect: + events: [] + state: {} + rejects: [] + state_hash: ae5b6a484c408dfb951ce82d7951a7f9b9c355e9b1cec394023b198154547744 diff --git a/trials/2026-08-08-1903.yaml b/trials/2026-08-08-1903.yaml new file mode 100644 index 0000000..6662c6c --- /dev/null +++ b/trials/2026-08-08-1903.yaml @@ -0,0 +1,157 @@ +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: SUPPORT + target: P2 +- 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: 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: GROUND +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: P3 + cmd: select_action + args: + action: GROUND +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P1 + cmd: choose_ground_mode + args: + mode: GR +- actor: P3 + cmd: choose_ground_mode + args: + mode: GR +- 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: GROUND +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 2 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P2 + cmd: choose_ground_mode + args: + mode: GR +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SOLVE + problem: 4 +- actor: P2 + cmd: select_action + args: + action: SUPPORT + target: P1 +- actor: P3 + cmd: select_action + args: + action: GROUND +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P3 + cmd: choose_ground_mode + args: + mode: GR +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +expect: + events: [] + state: {} + rejects: [] + state_hash: 7ae42e026cd31344e4ef81b15d3cd7b6639bf9101689d99e8b50ef3614932ed8 diff --git a/workplans/CB-WP-0046-the-rule-the-placement-encodes.md b/workplans/CB-WP-0046-the-rule-the-placement-encodes.md new file mode 100644 index 0000000..837bad8 --- /dev/null +++ b/workplans/CB-WP-0046-the-rule-the-placement-encodes.md @@ -0,0 +1,128 @@ +--- +id: CB-WP-0046 +kind: product +title: "The rule the placement encodes" +status: done +--- + +# Purpose + +``` +structural tier S (one new edition reader, one rendered passage, one + marker attribute -- no rule, no dependency moved) +declared tier S +``` + +Closes the two gaps [CB-WP-0045](CB-WP-0045-the-table-names-what-it-shows.md) +named as *not done*. + +## The gap, and the wrong reason given for it + +CB-WP-0045 recorded: + +> *"Nothing explains what a scope does … it is a `ground-game` question +> first: the scope rule is ours (Variant), not the edition's, so there is +> no vendored sentence to render."* + +**The premise was false.** `editions/experiments/h2-scoped-problem-stress/` +ships `Rules_Text.csv`, and its `Problems / Stress scope` passage is the +rule verbatim: + +> *"Each Problem has stress_scope. global: every seat. personal: the +> assigned owner only. bond: the owner and every seat connected to the +> owner through one or more Bonds (the Bond network). If the owner has no +> Bonds, bond scope behaves as personal. Rivalries do not expand bond +> scope. Multiple unclaimed Problems stack (+1 each) for seats in their +> scopes."* + +**Nothing in clay-borg had ever read the file.** Twenty-two passages of +player-facing rules, in the repo, unreachable by a player — a whole +rulebook, where CB-WP-0045 found single columns. + +**Third instance of F18's shape in two passes**, and the largest. The +pattern is now specific enough to name: *when a package adds a file rather +than a column, nothing notices.* `edition-check` verifies the digest and +freshness of files it knows; a file no reader mentions is not stale, it is +invisible. Recorded as a finding for `ground-game` rather than fixed here. + +**The escalation is the part worth keeping.** CB-WP-0045 did not just +leave a gap; it *explained* the gap with an argument that would have +stopped the next reader from looking. A wrong reason for an open item is +worse than an open item, because it retires the question. + +## The log said nothing about which rules were played + +`2026-08-08-1734`: *"Well, i did not notice any difference, maybe i did +not start the H2 variation?"* **The log cannot answer that** — and neither +could we, a week later, from the file. + +The variant rides the **begin marker**, not an eighth column: + +``` + +``` + +It is a property of the **session** (fixed at startup, constant across +`play again`), so a per-row column would repeat one fact on every line and +force a third accepted row width on a parser whose docstring is about +exactly that hazard (CB-WP-0032). The table shape is untouched. + +**Stamped from `state.variant`, never from the `--variant` flag.** Three +call sites once wrote `state.variant = v` directly instead of +`with_variant()`, which leaves H2 inert; a log stamped from the flag would +have recorded `h2` for a session that played the baseline. **A false +provenance on real player words is worse than no provenance** — which is +also why an unstamped log reports `unrecorded` and not `ground-darvo-r0`. + +## Task: render the rule, stamp the log + +```task +id: CB-WP-0046-T01 +status: done +priority: high +``` + +**Controls, all mutation-proven:** + +| mutation | what went red | +|---|---| +| the scope rule never renders | *"Problems are placed by scope and nothing says what a scope does"* | +| it renders always, baseline included | *"the baseline table explains a scope distinction that is not in play"* | +| our paraphrase instead of the passage | *"the scope explanation is not the edition's own words"* | +| the writer drops the attribute | *"the log does not say which rules were played"* | +| unstamped parses as `ground-darvo-r0` | *"an unstamped log says NOTHING, not 'baseline'"* | +| `BEGIN` back to an exact match | *"the bytes hotseat.rs writes are the bytes this parses"* | + +**The last mutation is the finding.** It went green the first time. Every +`trials.py` fixture composed its marker out of `BEGIN`, so all of them +**followed `BEGIN` wherever it went** — including away from what +`hotseat.rs` actually writes. Nine checks about the marker, and reverting +the marker broke every real stamped log with all nine still green. + +The format is shared across two languages, so the control has to be a +**literal**: the exact bytes the writer emits, asserted from the reading +side, with the writer's own test asserting the same string from the other. +A fixture built from the constant under test cannot test the constant. + +`stress_scope_rule()` matches on the **heading**, not on row 20 — a row +number is a property of today's file, a heading is a property of the rule +— and returns `None` if the edition stops carrying it (ADR-0018: an absent +explanation is reported absent, never replaced by ours). + +**Done 2026-08-08.** `make all` green; `cargo test`: 80 render + 35 play; +24 `trials.py` checks. Verified on a live `make ground VARIANT=h2` page. + +## Not done here + +- **The other 21 passages are still unread.** `h2_rules_text()` returns + the whole rulebook and one caller uses one row of it. The Round-step + passages in particular are the account of the sequence the player is + standing in, and there is nowhere on the page that says it. +- **The baseline has no `Rules_Text.csv` at all**, so this explains only + the experiment. A baseline player still gets no rulebook — the reverse + of the usual gap, and a `ground-game` question. +- **The 11 existing notes stay `unrecorded` forever.** Their variant was + never written down; inventing one is the defect this pass exists to + avoid. `2026-08-08-1818`'s three notes are almost certainly H2 on the + internal evidence of what they say, and *almost certainly* is not a + provenance.