CB-WP-0036 done: the pace flag and the first ornament declarations
Some checks failed
ci / check (push) Failing after 4s

--pace speed|interactive, defaulting to Speed. Nothing reads it yet, and
that is the point: it is the seam clay-animate attaches to, and a seam is
cheap now where a retrofit would not be. A misspelt pace is refused rather
than defaulting, because quietly falling back to Speed would look exactly
like the renderer being broken.

I3 is asserted rather than intended: the same scripted game at both paces
must produce a byte-identical serialised recording and the same end state
hash. Mutation-proven — leak the pace into the seed and it fails with "the
recording differs by pace, so a renderer has become mechanism".

specs/OrnamentRegister.md carries four declarations. This reverses the
reasoning written in T03 earlier, which said the first declarations would
come from F18's unvendored files: instances already existed. Hand order is
what prompted the category, and "who deals" was the maintainer's own
example. O3 is the interesting one — seat ORDER is mechanism because
GR-R08 rotates Lead, while where a seat is drawn is not.

I5 is executable: check_ornament_falsifier fails any row still declared
that names no falsifier, mutation-proven red on O1. Presence, never
adequacy, and the finding text says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-07 22:15:38 +02:00
parent b231c1b5e2
commit bd9e168af5
7 changed files with 315 additions and 9 deletions

View file

@ -1215,6 +1215,7 @@ mod tests {
serve: Some(0),
trial: None,
mode: games_ground::ScoringMode::SharedGround,
pace: crate::table::Pace::Speed,
},
std::io::Cursor::new(Vec::new()),
out,
@ -1404,6 +1405,7 @@ mod tests {
serve: Some(0),
trial: None,
mode: games_ground::ScoringMode::SharedGround,
pace: crate::table::Pace::Speed,
},
std::io::Cursor::new(Vec::new()),
out,

View file

@ -764,6 +764,7 @@ mod tests {
serve: None,
trial: None,
mode: games_ground::ScoringMode::SharedGround,
pace: crate::table::Pace::Speed,
};
let mut sink: Vec<u8> = Vec::new();
let summary =

View file

@ -29,6 +29,9 @@ play:
--replay DIR write a .cbreplay bundle of the finished game to DIR
--record FILE write the finished game as a scenario YAML
--trial FILE write a trial log: what the player said, bound to where
--pace P speed (default) or interactive: how much ornamentation
is performed. Never changes the game -- the recording is
byte-identical either way (specs/Ornamentation.md)
--mode M scoring mode: shared (GR-E02), common (GR-E03),
coalitions (GR-E04). Default shared.
--serve PORT play human seats in a browser on 127.0.0.1:PORT instead
@ -127,6 +130,18 @@ fn parse_args(argv: &[String]) -> Result<Mode, String> {
};
i += 2;
}
// CB-WP-0036 T02. A SEPARATE axis from --mode: that one is
// ScoringMode, a rule of the game; this is how much of what
// the rules cannot see gets performed (Ornamentation §4).
//
// **The flag exists before anything reads it, deliberately.**
// It is the seam `clay-animate` attaches to, and a seam is
// cheap now where a retrofit would not be.
"--pace" => {
play_flags.push(flag.into());
config.pace = value(i, argv, flag)?.parse()?;
i += 2;
}
"--trial" => {
play_flags.push(flag.into());
config.trial = Some(value(i, argv, flag)?.into());
@ -290,6 +305,7 @@ mod tests {
serve: None,
trial: None,
mode: games_ground::ScoringMode::SharedGround,
pace: table::Pace::Speed,
};
let script = "0\n".repeat(400);
let mut out: Vec<u8> = Vec::new();
@ -312,6 +328,78 @@ mod tests {
}
}
/// **Ornamentation §5, I3** — the invariant the clay-borg /
/// clay-animate split rests on.
///
/// > The same seed and the same decisions produce a **byte-identical
/// > recording** at any pace.
///
/// Asserted rather than intended. If this ever fails, something that
/// was called ornamentation has become mechanism, and the boundary in
/// `specs/Ornamentation.md` has stopped being real — which §7 names
/// as wrong at the root rather than patchable at the edges.
#[test]
fn pace_cannot_change_the_game() {
let at = |pace| {
let config = Config {
seed: 42,
players: 3,
human_seats: vec![0],
bot: "greedy".into(),
replay_dir: None,
record: None,
serve: None,
trial: None,
mode: games_ground::ScoringMode::SharedGround,
pace,
};
let script = "0\n".repeat(400);
let mut out: Vec<u8> = Vec::new();
let s = table::play(&config, script.as_bytes(), &mut out).expect("game");
(
serde_yaml::to_string(&s.scenario).expect("yaml"),
s.end_state_hash,
)
};
let (speed_yaml, speed_hash) = at(table::Pace::Speed);
let (inter_yaml, inter_hash) = at(table::Pace::Interactive);
// The recording, byte for byte.
assert_eq!(
speed_yaml, inter_yaml,
"the recording differs by pace, so a renderer has become mechanism"
);
// And the state hash, which is what §1.1 uses to tell the two
// categories apart in the first place.
assert_eq!(speed_hash, inter_hash, "pace moved the state hash");
}
/// `--pace` parses, defaults to speed, and refuses what it cannot do.
///
/// **Speed is the default** because `sim`, `trials`, the benchmarks
/// and every bot game run at it (Ornamentation §4).
#[test]
fn pace_parses_and_defaults_to_speed() {
assert_eq!(Config::default().pace, table::Pace::Speed);
assert_eq!(
play_args(&["--pace", "interactive"]).expect("parse").pace,
table::Pace::Interactive
);
assert_eq!(
play_args(&["--pace", "speed"]).expect("parse").pace,
table::Pace::Speed
);
// A misspelling must not silently mean the default: pace is the
// seam clay-animate attaches to, and a typo that quietly selects
// Speed would look like the renderer being broken.
let e = match play_args(&["--pace", "cinematic"]) {
Err(e) => e,
Ok(_) => panic!("a misspelt pace was accepted"),
};
assert!(e.contains("cinematic"), "{e}");
assert!(e.contains("speed") && e.contains("interactive"), "{e}");
}
/// K13 at the boundary that matters: what the human is *shown* must
/// not contain another seat's face-down selection.
///
@ -331,6 +419,7 @@ mod tests {
serve: None,
trial: None,
mode: games_ground::ScoringMode::SharedGround,
pace: table::Pace::Speed,
};
let mut out: Vec<u8> = Vec::new();
table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game");
@ -395,6 +484,7 @@ mod tests {
serve: None,
trial: None,
mode: games_ground::ScoringMode::SharedGround,
pace: table::Pace::Speed,
};
let mut out: Vec<u8> = Vec::new();
let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game");
@ -457,6 +547,7 @@ mod tests {
serve: None,
trial: None,
mode: games_ground::ScoringMode::SharedGround,
pace: table::Pace::Speed,
};
let mut out: Vec<u8> = Vec::new();
let summary = table::play(&config, "".as_bytes(), &mut out).expect("game");

View file

@ -47,6 +47,43 @@ pub struct Config {
/// patch, so two of the three shipped modes were unreachable from the
/// only way anyone actually plays.
pub mode: games_ground::ScoringMode,
/// How much ornamentation is performed (CB-WP-0036,
/// [`specs/Ornamentation.md`]).
///
/// **A separate axis from `mode`, deliberately.** `mode` is
/// `ScoringMode` — a rule of the game. `pace` is how much of what the
/// rules cannot see gets shown. Calling both of them "mode" on one
/// driver is a collision waiting to be mis-read.
pub pace: Pace,
}
/// Speed or Interactive (Ornamentation §4).
///
/// **Speed is the default and always will be.** `sim`, `trials`, the
/// benchmarks and every bot game run at it, and ornamentation must cost
/// them *nothing* — not "little".
///
/// **Pace can never select different mechanism** (Ornamentation §5, I3):
/// the same seed and the same decisions produce a byte-identical
/// recording at either pace. That is the invariant the whole
/// clay-borg / clay-animate split rests on, so it is asserted, not
/// assumed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Pace {
#[default]
Speed,
Interactive,
}
impl std::str::FromStr for Pace {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s.to_ascii_lowercase().as_str() {
"speed" | "fast" => Ok(Pace::Speed),
"interactive" | "inter" => Ok(Pace::Interactive),
other => Err(format!("unknown --pace {other:?} (speed, interactive)")),
}
}
}
impl Default for Config {
@ -61,6 +98,7 @@ impl Default for Config {
serve: None,
trial: None,
mode: games_ground::ScoringMode::SharedGround,
pace: Pace::Speed,
}
}
}

View file

@ -306,6 +306,51 @@ def check_gate_registry(root=REPO):
return out
def check_ornament_falsifier(root=REPO):
"""Ornamentation I5 — an open declaration names what would refute it.
**A declaration is a claim that something does not matter**, and this
project's finding register is largely a list of times that claim was
wrong. One that cannot be wrong is not a claim, it is a preference
with a table row.
Presence, never adequacy -- the same split as ADR-0018 D5.
"""
out = []
reg = os.path.join(root, "specs", "OrnamentRegister.md")
if not os.path.exists(reg):
return out
with open(reg) as fh:
text = fh.read()
m = re.search(r"<!-- ornament-register:begin -->(.*?)"
r"<!-- ornament-register:end -->", text, re.S)
if not m:
return out
for line in m.group(1).splitlines():
line = line.strip()
if not line.startswith("|") or line.startswith("|---"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) != 5 or cells[0] == "id":
continue
oid, state = cells[0], cells[3]
# A refuted or withdrawn row is history; the rule binds a claim
# that is still being made.
if state != "declared":
continue
body = re.search(rf"^- \*\*{re.escape(oid)} [^\n]*(?:\n(?!- \*\*O\d).*)*",
text, re.M)
if not body or "Falsifier:" not in body.group(0):
out.append(Finding(
"ornament",
"specs/OrnamentRegister.md",
f"{oid} is declared ornamentation and names no falsifier "
f"(Ornamentation.md I5). Say what would make it mechanism. "
f"NOTE: this checks presence, not adequacy.",
))
return out
def check_sensitivity_stated(root=REPO):
"""GameDesign §1.4 / ADR-0018 — a finding whose claim is arithmetic
must name the variable it depends on.
@ -384,6 +429,7 @@ CHECKS = (
check_reporting_tools_self_test,
check_gate_registry,
check_sensitivity_stated,
check_ornament_falsifier,
)
@ -473,6 +519,29 @@ def self_test():
check("sensitivity: a note has no measurement to be sensitive about",
not check_sensitivity_stated(tmp), "GameDesign §3.1")
# Ornamentation I5. Same shape: it must say NO and it must say YES.
def orn(state, prose):
body = ("<!-- ornament-register:begin -->\n\n"
"| id | ornaments | grounded | state | raised |\n"
"|---|---|---|---|---|\n"
f"| O9 | a thing | provisional | {state} | 2026-01-01 |\n"
"\n<!-- ornament-register:end -->\n\n"
f"- **O9 — a thing.** {prose}\n")
with open(os.path.join(tmp, "specs", "OrnamentRegister.md"), "w") as fh:
fh.write(body)
orn("declared", "It does not matter.")
check("ornament: a declaration with no falsifier is caught",
len(check_ornament_falsifier(tmp)) == 1,
"a claim that cannot be wrong is not a claim")
orn("declared", "It does not matter. **Falsifier:** a rule naming it.")
check("ornament: naming a falsifier clears it",
not check_ornament_falsifier(tmp),
"without this it would fire on everything")
orn("refuted", "It does not matter.")
check("ornament: a refuted row is history, not a live claim",
not check_ornament_falsifier(tmp))
wp("ready", ["done", "todo"])
f = check_workplan_lifecycle(tmp)
check("lifecycle detects `ready` after work has started",