From ea68f4559cdec58fc5c7b3b7bb6594b154837bcc Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 4 Sep 2026 12:53:18 -0400 Subject: [PATCH] test(tactics): a tactic nothing can reach, found by recomputing the set `situation::orderable` is what decides whether a tactic can reach a force, and two other places state the same fact independently: a formation's `Opening` and a tactic's `Status`. Both can disagree with it silently, and the force then plays `Engage` under another name. Change-Id: Ifd7a45688f623297d3c8f87567181e13bded0a5b --- crates/sds-core/tests/reachability.rs | 108 ++++++++++++++++++++++++++ plan/tactics.md | 6 ++ 2 files changed, 114 insertions(+) create mode 100644 crates/sds-core/tests/reachability.rs diff --git a/crates/sds-core/tests/reachability.rs b/crates/sds-core/tests/reachability.rs new file mode 100644 index 0000000..461975a --- /dev/null +++ b/crates/sds-core/tests/reachability.rs @@ -0,0 +1,108 @@ +//! Which tactics a force can actually reach, recomputed rather than listed. +//! +//! A tactic nothing can reach is a weight vector that can never price a +//! decision. It passes its own unit test, it draws its own figure in +//! `docs/TACTICS.md`, and it can never affect play - which is the one defect +//! a test of the tactic itself cannot find, because nothing is wrong with it. +//! +//! There are two routes onto a tactic and no third. A formation hands a force +//! its **opening**, and the situation layer **proposes** one every round after +//! that. Both end at [`sds_core::situation::orderable`]: a proposal is scored +//! over that set, and `sds_bot`'s `named_tactic` resolves the coordinator's +//! order by looking the name up in it. So the set is the whole of what a force +//! can be on, and everything here is recomputed from it rather than compared +//! against a table somebody has to keep current. +//! +//! The asymmetry that makes the first test worth having: an opening outside +//! that set is not merely unproposable, it is **unholdable**. `harmonise` +//! scores a held tactic by finding it among the proposals, and a held tactic +//! nothing proposed is read at nought - so the first proposal that beats the +//! switching cost displaces it, in round one, and no later round can offer it +//! back. + +use std::collections::BTreeSet; + +use sds_core::formation::CATALOGUE; +use sds_core::situation::orderable; +use sds_core::tactic::{catalogue, Status}; + +/// Every tactic a force can be proposed onto and ordered onto, by name. +fn proposable() -> BTreeSet<&'static str> { + orderable().iter().map(|tactic| tactic.name()).collect() +} + +/// An opening a force cannot then be proposed back onto is a one-way door that +/// shuts on the first round it is opened. +/// +/// This is the check that a formation's reading of a composition is a reading +/// the rest of the bot can carry out. Adding an `Opening` variant is not enough +/// to make one reachable, and the gap between the two is silent everywhere +/// else: the force opens on the tactic, plays one round of it at most, and +/// every log after that reads as an ordinary transition. +#[test] +fn a_formation_s_opening_is_a_tactic_a_force_can_also_be_proposed_onto() { + let proposable = proposable(); + let unreachable: Vec<(&str, &str)> = CATALOGUE + .iter() + .map(|formation| (formation.name, formation.opening.assignment().name())) + .filter(|(_, opens_on)| !proposable.contains(opens_on)) + .collect(); + assert!( + unreachable.is_empty(), + "these formations open on a tactic `situation::orderable` does not \ + contain, so a force is displaced off it in its first round and can \ + never be proposed back onto it: {unreachable:?}" + ); +} + +/// The declared status and the reachable set are one fact, held in two places. +/// +/// `Status::Implemented` says a force can be ordered onto it and +/// `situation::orderable` is what decides whether that is true. A tactic that +/// gains an `Assignment` variant without joining the set reads as built +/// everywhere a person looks and is reachable nowhere. +#[test] +fn status_implemented_is_exactly_what_a_force_can_be_ordered_onto() { + let proposable = proposable(); + let declared: BTreeSet<&'static str> = catalogue() + .iter() + .filter(|entry| entry.status == Status::Implemented) + .map(|entry| entry.name) + .collect(); + assert_eq!( + declared, proposable, + "`Status::Implemented` and `situation::orderable` disagree about which \ + tactics a force can be ordered onto" + ); +} + +/// A block outlives the thing it was blocking unless something says so. +/// +/// `Opening::Blocked` is a composition asking for a tactic that is not built, +/// and it opens on `Engage` instead. Once the tactic is built the block is a +/// force being denied the opening its formation asks for, which looks like +/// nothing at all in a log - the force simply opens on `Engage`, the way most +/// of the catalogue does. +#[test] +fn a_blocked_opening_names_a_tactic_that_is_still_unbuilt() { + let known: Vec<(&str, Status)> = catalogue() + .iter() + .map(|entry| (entry.name, entry.status)) + .collect(); + for formation in CATALOGUE { + let Some(wanted) = formation.opening.blocked() else { + continue; + }; + let status = known + .iter() + .find(|(name, _)| *name == wanted) + .map(|(_, status)| *status); + assert_eq!( + status, + Some(Status::Unimplemented), + "{} is blocked on `{wanted}`, which is not a tactic that is waiting \ + to be built", + formation.name + ); + } +} diff --git a/plan/tactics.md b/plan/tactics.md index d2591f2..6ceebde 100644 --- a/plan/tactics.md +++ b/plan/tactics.md @@ -419,6 +419,12 @@ match until `plan/harness.md` has the determinism defect. Measured on one match: force-rounds holding `advance` went from 0 to 17 when it was added. **Adding a tactic means adding it here**, and this line is the rule rather than the bug report +- [x] **The rule above is now recomputed rather than remembered.** + `crates/sds-core/tests/reachability.rs` derives the reachable set from + `situation::orderable` and checks the two places that can disagree with + it: a formation whose opening is not in the set, and a `Status` that says + a tactic is orderable when the set does not list it. A written rule goes + stale silently, and this one did - [ ] Two limits come with it and should be written into the epic rather than discovered later. The contact sample is small - an occupied hex is still refused by the sweep, so a machine can now *price* contact properly and -- 2.51.2