CB-WP-0048 T01: Configuration and Rules — identity split from behaviour

catalog.rs reads ground-game's schema 2 and refuses an unknown schema by
name: a reader that silently accepted schema 1 would answer questions
about aspects over a file that has none.

config.rs holds the two types ADR-0022 chose. Configuration is identity
and round-trips anything the catalog names, including modules with no
kernel path; Rules is behaviour, exhaustive, no catch-all. resolve() is
the boundary and emits the two distinct errors -- "known module with no
kernel path (status: proposed)" vs "not a module the catalog has" --
which is the entire reason this shape was chosen over a per-aspect enum.

Nothing about aspects, defaults or module status is written in our
source; all of it is read. The tests find the proposed module by
searching for status: proposed rather than naming one, so implementing
it upstream makes the test look elsewhere instead of going stale.

Four mutations, four red. scoped_plus_attack_soothe now resolves -- the
combination the three-armed enum could not express.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-08 22:30:05 +02:00
parent a87afca48f
commit e75f3afe4d
3 changed files with 655 additions and 0 deletions

182
games/ground/src/catalog.rs Normal file
View file

@ -0,0 +1,182 @@
//! ground-game's rules catalog, schema 2 (CB-WP-0048, [ADR-0022]).
//!
//! An **aspect** is an orthogonal design dimension of the game; a
//! **module** is one concrete answer on one aspect; a **profile** is a
//! named list of modules. A configuration is a baseline plus at most one
//! module per aspect — a point in aspect space.
//!
//! **This reads; it does not decide.** The aspect list, the module list,
//! which modules are default and which are merely proposed are all
//! ground-game's (ADR-0011). Hardcoding any of them here is F25's shape:
//! a second copy of a number the edition already prints.
//!
//! [ADR-0022]: ../../../decisions/ADR-0022-a-configuration-is-a-point-in-aspect-space.md
use serde::Deserialize;
use std::collections::BTreeMap;
/// The vendored catalog. Digested and freshness-checked by
/// `edition-check` against the sibling checkout.
const CATALOG_YAML: &str = include_str!("../../../editions/catalog.yaml");
/// An orthogonal design dimension.
#[derive(Debug, Clone, Deserialize)]
pub struct Aspect {
pub id: String,
#[serde(default)]
pub title: String,
pub default_module: String,
#[serde(default)]
pub summary: String,
}
/// One concrete design on one aspect.
#[derive(Debug, Clone, Deserialize)]
pub struct Module {
pub module_id: String,
pub aspect: String,
#[serde(default)]
pub path: String,
#[serde(default)]
pub is_default: bool,
#[serde(default = "yes")]
pub selectable: bool,
/// `baseline-default`, `measured`, `unmeasured`, `proposed`, …
///
/// **Read, never assumed.** CB-RES-0010 §5.6 requires a proposed
/// module to be refused or to no-op *loudly*; we refuse, and this is
/// the field that says which are which.
#[serde(default)]
pub status: String,
#[serde(default)]
pub rules_delta: Option<String>,
/// The monolithic experiment ids this module used to be part of.
#[serde(default)]
pub legacy_experiment_ids: Vec<String>,
#[serde(default)]
pub summary: String,
}
fn yes() -> bool {
true
}
/// A named list of modules.
#[derive(Debug, Clone, Deserialize)]
pub struct Profile {
pub profile_id: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub modules: Vec<String>,
/// The monolithic experiment this profile replaces, if any. **This is
/// where `--variant h2` resolves**, and it is read from the catalog
/// rather than mapped in our source (ADR-0022 D2).
#[serde(default)]
pub legacy_experiment_id: Option<String>,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub status: String,
}
#[derive(Debug, Clone, Deserialize)]
struct CatalogFile {
schema_version: u32,
default_baseline: String,
#[serde(default)]
default_profile: Option<String>,
#[serde(default)]
aspects: Vec<Aspect>,
#[serde(default)]
modules: Vec<Module>,
#[serde(default)]
profiles: Vec<Profile>,
}
/// The catalog, parsed.
#[derive(Debug, Clone)]
pub struct Catalog {
pub default_baseline: String,
pub default_profile: Option<String>,
pub aspects: Vec<Aspect>,
pub modules: Vec<Module>,
pub profiles: Vec<Profile>,
}
/// The schema this reader understands.
///
/// **Refusing a newer schema is the point.** Schema 1 named monolithic
/// `variant_id` packages; a reader that silently accepted either would
/// answer questions about aspects over a file that has none, which is the
/// wrong-subject family (ADR-0018). If ground-game moves to schema 3 this
/// says so by name rather than returning an empty aspect list.
pub const SCHEMA: u32 = 2;
/// Parse the vendored catalog.
pub fn catalog() -> Result<Catalog, String> {
let f: CatalogFile =
serde_yaml::from_str(CATALOG_YAML).map_err(|e| format!("catalog.yaml: {e}"))?;
if f.schema_version != SCHEMA {
return Err(format!(
"catalog.yaml is schema {}; this reader understands schema {SCHEMA} \
the aspect vocabulary may have moved",
f.schema_version
));
}
if f.aspects.is_empty() {
return Err("catalog.yaml declares no aspects".into());
}
Ok(Catalog {
default_baseline: f.default_baseline,
default_profile: f.default_profile,
aspects: f.aspects,
modules: f.modules,
profiles: f.profiles,
})
}
impl Catalog {
pub fn aspect(&self, id: &str) -> Option<&Aspect> {
self.aspects.iter().find(|a| a.id == id)
}
pub fn module(&self, id: &str) -> Option<&Module> {
self.modules.iter().find(|m| m.module_id == id)
}
pub fn profile(&self, id: &str) -> Option<&Profile> {
self.profiles.iter().find(|p| p.profile_id == id)
}
/// Every aspect at its default module — the printed game.
pub fn defaults(&self) -> BTreeMap<String, String> {
self.aspects
.iter()
.map(|a| (a.id.clone(), a.default_module.clone()))
.collect()
}
/// The profile a legacy experiment id names (ADR-0022 D2).
///
/// **From the catalog's own `legacy_experiment_id`**, so the mapping
/// has one home. A table in our source would be a second copy of
/// ground-game's decision, which is exactly what F25 is about.
pub fn profile_for_legacy(&self, experiment_id: &str) -> Option<&Profile> {
self.profiles
.iter()
.find(|p| p.legacy_experiment_id.as_deref() == Some(experiment_id))
}
/// A module id's aspect, taken from the module entry rather than by
/// splitting on the dot.
///
/// **The dot is a naming convention, not a schema.** `module_id`
/// carries the aspect prefix today and the entry carries an explicit
/// `aspect:` field; parsing the string would work until the day a
/// module id contains a second dot, and would then be confidently
/// wrong rather than absent.
pub fn aspect_of(&self, module_id: &str) -> Option<&str> {
self.module(module_id).map(|m| m.aspect.as_str())
}
}

460
games/ground/src/config.rs Normal file
View file

@ -0,0 +1,460 @@
//! A configuration is a point in aspect space ([ADR-0022]).
//!
//! ## Two types, because there are two jobs
//!
//! [`Configuration`] is **identity**: which baseline, which module on each
//! aspect. It is GAME-stratum data and it round-trips **anything the
//! catalog names**, including modules this kernel cannot run.
//!
//! [`Rules`] is **behaviour**: what the kernel does. It is ENGINE-stratum
//! code, exhaustive, with no catch-all — so a module ground-game adds
//! cannot be silently ignored, the compiler names it.
//!
//! [`Configuration::resolve`] is the boundary, and it is where the two
//! errors live:
//!
//! ```text
//! problem_deal.pressure_deck is a known module with no kernel path
//! (catalog status: proposed)
//! problem_deal.presure_deck is not a module the catalog has
//! ```
//!
//! **Two facts, two errors.** A per-aspect enum could only say "unknown
//! module" for both, making a proposed module indistinguishable from a
//! typo — a false statement about the edition, and this project's
//! signature failure shape (ADR-0018).
//!
//! [ADR-0022]: ../../../decisions/ADR-0022-a-configuration-is-a-point-in-aspect-space.md
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Which baseline, and which module on each aspect (ADR-0022 D1).
///
/// **In the state, therefore in the hash, therefore in the recording** —
/// the same reasoning that put `variant` there. `modules` is always
/// *resolved*: every aspect the catalog declares has an entry, defaults
/// filled in, so a recording states the whole point in aspect space
/// rather than the delta from an assumed origin.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Configuration {
/// The content package (ADR-0011), e.g. `ground-darvo-r0`.
pub baseline: String,
/// aspect id → module id, resolved.
pub modules: BTreeMap<String, String>,
/// The profile that was asked for, if one was. **Kept for the
/// account, never consulted for behaviour** — two runs with the same
/// modules play identically whether or not a profile named them, and
/// a reader still wants to know what was requested.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
}
impl Default for Configuration {
/// The printed game: every aspect at its default module.
///
/// Falls back to an empty module map if the catalog cannot be read,
/// which is the baseline by construction — every `Rules` field
/// defaults to the printed behaviour, so a missing catalog cannot
/// silently turn a module *on*.
fn default() -> Self {
let modules = crate::catalog::catalog()
.map(|c| c.defaults())
.unwrap_or_default();
Configuration {
baseline: "ground-darvo-r0".to_string(),
modules,
profile: None,
}
}
}
/// How unclaimed Problems raise Stress at Round End.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ProblemStress {
/// `problem_stress.none` — the printed rules.
#[default]
None,
/// `problem_stress.flat_any_open` — +1 to every seat if any Problem
/// is unclaimed. Former H1-A.
FlatAnyOpen,
/// `problem_stress.scoped` — +1 per unclaimed Problem to the seats in
/// its `stress_scope`. Former H2.
Scoped,
}
/// Whether resolving ATTACK can reduce the attacker's Stress.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AttackRelief {
/// `attack_relief.none` — the printed rules.
#[default]
None,
/// `attack_relief.self_soothe_ge4` — an uncancelled ATTACK by a seat
/// at Stress ≥4 gives that seat 1. Former H1-B.
SelfSootheGe4,
}
/// What the kernel actually does (ENGINE stratum).
///
/// **No catch-all anywhere.** CB-WP-0034's exhaustive match caught two
/// commands that would have shipped as `Debug` dumps, before any test
/// ran; the same discipline here means a module added to the catalog is a
/// compile error at the resolve site rather than a silent no-op.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Rules {
pub problem_stress: ProblemStress,
pub attack_relief: AttackRelief,
}
impl Configuration {
/// Build from a baseline plus an explicit module list, filling every
/// other aspect with its default.
///
/// **Refuses two modules on one aspect** — that is ground-game's rule
/// about the game, so checking it is GAME↔MODEL *validation*, not
/// verification of our arithmetic (ADR-0022 D0).
pub fn from_modules(baseline: &str, modules: &[String]) -> Result<Self, String> {
let cat = crate::catalog::catalog()?;
let mut chosen: BTreeMap<String, String> = cat.defaults();
let mut claimed: BTreeMap<String, String> = BTreeMap::new();
for id in modules {
let Some(aspect) = cat.aspect_of(id) else {
return Err(unknown_module(&cat, id));
};
if let Some(first) = claimed.get(aspect) {
return Err(format!(
"{first} and {id} are both on aspect {aspect} — a configuration \
takes at most one module per aspect"
));
}
claimed.insert(aspect.to_string(), id.clone());
chosen.insert(aspect.to_string(), id.clone());
}
Ok(Configuration {
baseline: baseline.to_string(),
modules: chosen,
profile: None,
})
}
/// Build from a named profile.
pub fn from_profile(profile_id: &str) -> Result<Self, String> {
let cat = crate::catalog::catalog()?;
let p = cat.profile(profile_id).ok_or_else(|| {
format!(
"unknown profile {profile_id:?} (the catalog has {})",
cat.profiles
.iter()
.map(|p| p.profile_id.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
let mut c = Configuration::from_modules(&cat.default_baseline, &p.modules)?;
c.profile = Some(p.profile_id.clone());
Ok(c)
}
/// Build from anything a person might type: a profile id, a legacy
/// experiment id, or a short alias (ADR-0022 D2).
///
/// **Legacy ids resolve through the catalog's own
/// `legacy_experiment_id`**, so ground-game's mapping has one home.
pub fn select(name: &str) -> Result<Self, String> {
let cat = crate::catalog::catalog()?;
let want = name.trim();
if cat.profile(want).is_some() {
return Configuration::from_profile(want);
}
if let Some(p) = cat.profile_for_legacy(want) {
let id = p.profile_id.clone();
return Configuration::from_profile(&id);
}
// Short aliases the maintainer actually types. `baseline` and
// `r0` name the printed game; `h1`/`h2` are profile ids already.
if matches!(want, "ground-darvo-r0" | "r0" | "baseline") {
return Configuration::from_profile("baseline")
.or_else(|_| Ok(Configuration::default()));
}
Err(format!(
"unknown configuration {name:?} (profiles: {}; legacy ids: {})",
cat.profiles
.iter()
.map(|p| p.profile_id.as_str())
.collect::<Vec<_>>()
.join(", "),
cat.profiles
.iter()
.filter_map(|p| p.legacy_experiment_id.as_deref())
.collect::<Vec<_>>()
.join(", ")
))
}
/// The module on one aspect, if the catalog declares that aspect.
pub fn module_on(&self, aspect: &str) -> Option<&str> {
self.modules.get(aspect).map(|s| s.as_str())
}
/// Every module that is not its aspect's default — what a reader
/// means by "which modules are live".
pub fn active_modules(&self) -> Vec<String> {
let Ok(cat) = crate::catalog::catalog() else {
return Vec::new();
};
let mut out: Vec<String> = self
.modules
.iter()
.filter(|(aspect, id)| cat.aspect(aspect).is_some_and(|a| a.default_module != **id))
.map(|(_, id)| id.clone())
.collect();
out.sort();
out
}
/// **Identity → behaviour.** The boundary in ADR-0022 D1.
///
/// Refuses, by name and with the reason, any module the catalog has
/// and this kernel cannot run.
pub fn resolve(&self) -> Result<Rules, String> {
let cat = crate::catalog::catalog()?;
let mut rules = Rules::default();
for (aspect, id) in &self.modules {
// An aspect the catalog does not declare is not something to
// guess about.
if cat.aspect(aspect).is_none() {
return Err(format!("{aspect:?} is not an aspect the catalog declares"));
}
match id.as_str() {
"problem_stress.none" => rules.problem_stress = ProblemStress::None,
"problem_stress.flat_any_open" => rules.problem_stress = ProblemStress::FlatAnyOpen,
"problem_stress.scoped" => rules.problem_stress = ProblemStress::Scoped,
"attack_relief.none" => rules.attack_relief = AttackRelief::None,
"attack_relief.self_soothe_ge4" => {
rules.attack_relief = AttackRelief::SelfSootheGe4
}
// Aspects whose only implemented module is the default.
// Naming them is not padding: it is the difference
// between "we run the printed rules here" and "we did not
// look", and the `other` arm below can then be a real
// refusal rather than a shrug.
"end_condition.fixed_rounds_5" | "problem_deal.fixed_setup" => {}
other => return Err(no_kernel_path(&cat, other)),
}
}
Ok(rules)
}
}
/// The module is not in the catalog at all — probably a typo.
fn unknown_module(cat: &crate::catalog::Catalog, id: &str) -> String {
let known: Vec<&str> = cat.modules.iter().map(|m| m.module_id.as_str()).collect();
format!(
"{id:?} is not a module the catalog has (it has {})",
known.join(", ")
)
}
/// The module IS in the catalog, and this kernel cannot run it.
///
/// **A different sentence from `unknown_module`, deliberately.** These
/// are different facts about the world and reporting them identically is
/// how a proposed module reads as a typo (ADR-0022 D1).
fn no_kernel_path(cat: &crate::catalog::Catalog, id: &str) -> String {
match cat.module(id) {
Some(m) => format!(
"{id} is a known module with no kernel path (catalog status: {}{})",
if m.status.is_empty() {
"unstated"
} else {
&m.status
},
if m.rules_delta.is_none() {
", and it declares no rules_delta"
} else {
""
}
),
None => unknown_module(cat, id),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// **A proposed module is refused BY NAME, distinguishably from a
/// typo** (ADR-0022 D1).
///
/// This is the test the whole design was chosen for. A per-aspect
/// enum could only say "unknown module" for both, which makes a
/// module the edition really has read as a misspelling — a false
/// statement about the edition, and the wrong-subject family again.
#[test]
fn a_proposed_module_and_a_typo_are_different_errors() {
let cat = crate::catalog::catalog().expect("catalog.yaml");
// A module the catalog has, with a rules_delta, that no kernel
// path implements. Found in the catalog, not hardcoded: if
// ground-game implements it upstream this test looks elsewhere
// rather than going stale.
let proposed = cat
.modules
.iter()
.find(|m| m.status == "proposed")
.expect("the catalog has at least one proposed module");
let mut c = Configuration::default();
c.modules
.insert(proposed.aspect.clone(), proposed.module_id.clone());
let refused = c.resolve().expect_err("a proposed module must be refused");
assert!(
refused.contains(&proposed.module_id) && refused.contains("no kernel path"),
"a proposed module was not refused by name: {refused}"
);
assert!(
!refused.contains("is not a module the catalog has"),
"a module the catalog HAS was reported as unknown: {refused}"
);
// And a real typo says the other thing.
let mut typo = Configuration::default();
typo.modules.insert(
proposed.aspect.clone(),
format!("{}_zzz", proposed.module_id),
);
let unknown = typo.resolve().expect_err("a typo must be refused");
assert!(
unknown.contains("is not a module the catalog has"),
"a typo was not reported as unknown: {unknown}"
);
// The two sentences must not be the same sentence — which is the
// entire claim, so it is asserted rather than left to reading.
assert_ne!(refused, unknown);
}
/// **Two modules on one aspect are refused** — GAME↔MODEL validation.
#[test]
fn a_configuration_takes_at_most_one_module_per_aspect() {
let e = Configuration::from_modules(
"ground-darvo-r0",
&[
"problem_stress.scoped".to_string(),
"problem_stress.flat_any_open".to_string(),
],
)
.expect_err("two modules on one aspect must be refused");
assert!(
e.contains("problem_stress") && e.contains("at most one module per aspect"),
"{e}"
);
}
/// **A configuration must be NAMEABLE before it is runnable.**
///
/// The identity type round-trips anything the catalog says, including
/// what this kernel cannot execute — that is the property that keeps
/// a GAME-stratum addition from becoming an ENGINE-stratum parse
/// failure (ADR-0022 D0).
#[test]
fn a_module_with_no_kernel_path_still_names_a_configuration() {
let cat = crate::catalog::catalog().expect("catalog.yaml");
let proposed = cat
.modules
.iter()
.find(|m| m.status == "proposed")
.expect("a proposed module");
let c = Configuration::from_modules(
"ground-darvo-r0",
std::slice::from_ref(&proposed.module_id),
)
.expect("selecting a catalog module must NAME a configuration");
assert_eq!(
c.module_on(&proposed.aspect),
Some(proposed.module_id.as_str())
);
// It round-trips, so a recording can carry it.
let s = serde_yaml::to_string(&c).unwrap();
let back: Configuration = serde_yaml::from_str(&s).unwrap();
assert_eq!(back, c);
// And it still refuses to RUN.
assert!(c.resolve().is_err());
}
/// The printed game is every aspect at its default, and it resolves
/// to the printed behaviour.
#[test]
fn the_default_configuration_is_the_printed_game() {
let cat = crate::catalog::catalog().expect("catalog.yaml");
let c = Configuration::default();
assert_eq!(c.modules.len(), cat.aspects.len(), "an aspect is unfilled");
for a in &cat.aspects {
assert_eq!(c.module_on(&a.id), Some(a.default_module.as_str()));
}
assert_eq!(c.resolve().unwrap(), Rules::default());
assert!(
c.active_modules().is_empty(),
"the printed game has no live modules"
);
}
/// **Legacy ids alias forever, through the CATALOG** (ADR-0022 D2).
///
/// Asserted on the resolved configuration rather than on outcomes, so
/// the equivalence is exact and not a coincidence of behaviour.
#[test]
fn legacy_experiment_ids_expand_to_their_profiles() {
let cat = crate::catalog::catalog().expect("catalog.yaml");
for p in &cat.profiles {
let Some(legacy) = p.legacy_experiment_id.as_deref() else {
continue;
};
let by_legacy =
Configuration::select(legacy).unwrap_or_else(|e| panic!("{legacy}: {e}"));
let by_profile = Configuration::from_profile(&p.profile_id).unwrap();
assert_eq!(
by_legacy.modules, by_profile.modules,
"{legacy} and profile {} are not the same point in aspect space",
p.profile_id
);
}
// The two the maintainer types, spelled out — a loop over the
// catalog would pass if the catalog listed no legacy ids at all.
assert_eq!(
Configuration::select("h2").unwrap().active_modules(),
vec!["problem_stress.scoped".to_string()]
);
assert_eq!(
Configuration::select("h1").unwrap().active_modules(),
vec![
"attack_relief.self_soothe_ge4".to_string(),
"problem_stress.flat_any_open".to_string(),
],
"H1 is TWO modules on two aspects — that decomposition is the \
whole point of schema 2"
);
assert!(Configuration::select("r0")
.unwrap()
.active_modules()
.is_empty());
}
/// **The combination that could not previously be named.**
#[test]
fn two_aspects_can_carry_a_module_at_once() {
let c = Configuration::select("scoped_plus_attack_soothe")
.expect("the catalog ships this profile");
assert_eq!(
c.active_modules(),
vec![
"attack_relief.self_soothe_ge4".to_string(),
"problem_stress.scoped".to_string(),
]
);
let r = c.resolve().expect("both modules have kernel paths");
assert_eq!(r.problem_stress, ProblemStress::Scoped);
assert_eq!(r.attack_relief, AttackRelief::SelfSootheGe4);
// The old three-armed selector had no arm for this, which is why
// it was not "unimplemented" but unrepresentable.
}
}

View file

@ -16,6 +16,19 @@ pub mod bot;
/// only one of them is optional.
pub mod edition;
/// ground-game's rules catalog, schema 2 (CB-WP-0048, ADR-0022).
///
/// Gated with `scenarios` because it parses YAML and that is where
/// `serde_yaml` lives. The *identity* type in `config` is not gated: a
/// configuration must be readable off a recording whether or not this
/// build can validate it against the catalog.
#[cfg(feature = "scenarios")]
pub mod catalog;
/// A configuration is a point in aspect space (ADR-0022).
#[cfg(feature = "scenarios")]
pub mod config;
/// K13's per-player projection (CB-WP-0008 T02) — the trait's first
/// implementor. Needs the runtime's `Project`, which the game already
/// depends on, so it is not feature-gated either.