CB-WP-0046: the rule the placement encodes, and a log that names its rules
Some checks failed
ci / check (push) Failing after 4s

CB-WP-0045 left "nothing explains what a scope does" open and gave a
FALSE reason: that scoping is our Variant so no printed sentence exists.
The H2 package ships Rules_Text.csv -- twenty-two passages of
player-facing rules -- and nothing in clay-borg had ever read the file.
A wrong reason for an open item is worse than an open item; it retires
the question. Filed as F26 against ground-game: a package that adds a
FILE is invisible where one that adds a column is not.

The scope rule now renders under the table in the edition's own words,
only when a non-global scope is in play, matched by heading rather than
row number, and absent (never paraphrased) if the edition drops it.

The trial log stamps its variant on the begin marker -- a session
property, not an eighth column -- read off state.variant rather than the
--variant flag, because a bare `state.variant = v` leaves H2 inert and a
flag-stamped log would put false provenance on real player words. An
unstamped log reports `unrecorded`, never `ground-darvo-r0`.

Six mutations, six red. The sixth is the finding: every trials.py fixture
built its marker out of BEGIN, so nine checks followed BEGIN away from
what hotseat.rs writes and stayed green while real logs broke. A fixture
built from the constant under test cannot test the constant -- the
control is now a literal, asserted from both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-08 19:15:52 +02:00
parent df65e2dee1
commit de25c4cae0
10 changed files with 986 additions and 12 deletions

View file

@ -1306,6 +1306,25 @@ fn body(s: &mut String, view: &GroundView) {
s.push_str("<div class=\"card\">no problems in play</div>");
}
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,
"<details class=\"card\"><summary>what a Problem\u{2019}s scope does\
</summary>{}</details>",
esc(&rule)
);
}
}
s.push_str("<h2>seats</h2><div class=\"row\">");
for (id, p) in &view.players {
@ -1490,6 +1509,20 @@ fn ground_modes_text() -> Option<String> {
.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<String> {
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;

View file

@ -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

View file

@ -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<BTreeMap<u8, StressScope>, 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<Vec<RulesPassage>, 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<String> {
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

View file

@ -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 |
<!-- design-register:end -->
- **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

View file

@ -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("<!-- trial-log:begin -->\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, "<!-- trial-log:begin variant={variant} -->\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("<!-- trial-log:begin variant=h2-scoped-problem-stress -->"),
"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()));

View file

@ -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 = "<!-- trial-log:begin -->"
# The begin marker carries attributes since CB-WP-0046, so it is matched
# by PREFIX. A bare `<!-- trial-log:begin -->` is still a valid marker —
# every log written before this one has it.
BEGIN = "<!-- trial-log:begin"
END = "<!-- trial-log:end -->"
# 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"
"<!-- trial-log:begin variant=h2-scoped-problem-stress -->\n\n"
"| n | game | after | round | step | state_hash | comment |\n"
"|---|---|---|---|---|---|---|\n"
"| 1 | 1 | 4 | 3 | Select | abc123def456 | it felt harder |\n"
"\n<!-- trial-log:end -->\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(
"<!-- trial-log:begin variant=h2-scoped-problem-stress -->",
"<!-- trial-log:begin -->"))
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.

View file

@ -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

View file

@ -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

157
trials/2026-08-08-1903.yaml Normal file
View file

@ -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

View file

@ -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:
```
<!-- trial-log:begin variant=h2-scoped-problem-stress -->
```
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.