From 4fbcd90f7c8ebfd48b0418c9b2aa2163d66910a9 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sun, 30 Aug 2026 02:52:43 -0400 Subject: [PATCH] feat(observability): record the three inputs to can_move A unit that proposed nothing could not be told from one the rules will not move, which is what made a 21.3% vehicle default rate take an inference to settle. SdsClient.record writes immobile, stuck and elevationValid per unit, and Unit::immobility names which one for the turn label. --- bridge/sds/SdsClient.java | 16 +++++++++ crates/sds-bot/src/main.rs | 14 +++++++- crates/sds-core/src/wire.rs | 65 +++++++++++++++++++++++++++++++++++++ plan/observability.md | 32 ++++++++++++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) diff --git a/bridge/sds/SdsClient.java b/bridge/sds/SdsClient.java index 45e4a34..379d5bf 100644 --- a/bridge/sds/SdsClient.java +++ b/bridge/sds/SdsClient.java @@ -1045,6 +1045,22 @@ public final class SdsClient extends BotClient { at.put("facing", u.path("facing").asInt()); at.put("done", u.path("done").asBoolean()); at.put("destroyed", u.path("destroyed").asBoolean()); + // **The three inputs to `Unit::can_move`, because the + // question "why did this unit propose nothing" could not be + // answered from this file.** `candidates::generate` returns + // no menu for a machine the rules will not move and the host + // then stands it still, counted `defaulted` - which is + // correct, and is indistinguishable here from a candidate + // generator that failed. A pure-vehicle bench defaulted + // 21.3% of its movement decisions and settling whether that + // was motive damage or a defect took an inference from the + // shape of the run rather than a read of the flag. + // + // Three booleans per unit per decision, against a rationale + // measured in hundreds of kilobytes. + at.put("immobile", u.path("immobile").asBoolean()); + at.put("stuck", u.path("stuck").asBoolean()); + at.put("elevationValid", u.path("elevationValid").asBoolean(true)); } } if (reply != null) { diff --git a/crates/sds-bot/src/main.rs b/crates/sds-bot/src/main.rs index c80e7bb..b599769 100644 --- a/crates/sds-bot/src/main.rs +++ b/crates/sds-bot/src/main.rs @@ -1353,7 +1353,19 @@ impl Bot { // is the common case. None if self.unoffered.contains(id) => TurnOption { unit: *id, - label: "no menu: nothing was proposed for it".to_string(), + // And why, when the unit itself says why. Most of these are + // a machine the rules will not move, which is correct and + // used to be indistinguishable in a log from a generator + // that failed. + label: match observation + .units + .iter() + .find(|unit| unit.id == *id) + .and_then(|unit| unit.immobility()) + { + Some(why) => format!("no menu: {why}"), + None => "no menu: nothing was proposed for it".to_string(), + }, value: None, }, None => TurnOption { diff --git a/crates/sds-core/src/wire.rs b/crates/sds-core/src/wire.rs index 78c81c9..1df4b15 100644 --- a/crates/sds-core/src/wire.rs +++ b/crates/sds-core/src/wire.rs @@ -828,6 +828,26 @@ impl Unit { && (!self.stuck || (self.can_unstick_by_jumping && self.jump_allowance() > 0)) } + /// Why the rules will not move this unit, or `None` if they will. + /// + /// The same three inputs [`Unit::can_move`] reads, named rather than + /// reduced to a boolean. A unit the rules will not move gets no menu from + /// `candidates::generate` and the host stands it still, counted + /// `defaulted` - which is correct behaviour and reads in a log exactly like + /// a candidate generator that failed. Naming the cause is what tells a + /// reader which of the two they are looking at without a second run. + pub fn immobility(&self) -> Option<&'static str> { + if self.immobile { + Some("immobile") + } else if !self.elevation_valid { + Some("standing at an elevation the rules refuse") + } else if self.stuck && !(self.can_unstick_by_jumping && self.jump_allowance() > 0) { + Some("stuck with no way out") + } else { + None + } + } + /// Can this unit walk or run? /// /// [`Unit::can_move`] and not [`Unit::stuck`]: a swamp holds the legs and @@ -2281,6 +2301,51 @@ mod tests { assert!(!wading.can_move()); } + /// And it says *which* of the three, because a log that only says "no menu" + /// cannot be told from a candidate generator that failed. + /// + /// A pure-vehicle bench defaulted 21.3% of its movement decisions and the + /// question of whether that was motive damage or a defect had to be settled + /// by inference - every unit that defaulted never answered again - because + /// nothing in the record carried the flag. `immobility` is that answer, and + /// `SdsClient.record` now writes the three inputs beside it. + #[test] + fn a_unit_the_rules_will_not_move_says_which_rule() { + let running: Unit = serde_json::from_str(CURRENT) + .map(|o: Observation| o.units[0].clone()) + .expect("parses"); + assert_eq!( + running.immobility(), + None, + "it can move; there is no reason" + ); + + let mut off = running.clone(); + off.immobile = true; + assert_eq!(off.immobility(), Some("immobile")); + + let mut sunk = running.clone(); + sunk.elevation_valid = false; + assert_eq!( + sunk.immobility(), + Some("standing at an elevation the rules refuse") + ); + + let mut bogged = running.clone(); + bogged.stuck = true; + assert_eq!(bogged.immobility(), Some("stuck with no way out")); + // Stuck with jets that are worth something is not stuck for this + // purpose - it has an air search - so the reason goes away with it. + bogged.can_unstick_by_jumping = true; + assert_eq!(bogged.immobility(), None); + + // Every reason it gives is one `can_move` agrees with, which is what + // stops the two drifting into different answers. + for unit in [&running, &off, &sunk, &bogged] { + assert_eq!(unit.immobility().is_none(), unit.can_move(), "{unit:?}"); + } + } + /// `shutDown` and `immobile` are not the same field. /// /// An unconscious pilot immobilises a machine whose reactor is running, and diff --git a/plan/observability.md b/plan/observability.md index d3e8e3c..d8ad879 100644 --- a/plan/observability.md +++ b/plan/observability.md @@ -262,3 +262,35 @@ The two are not better and worse. A narrow suite that says the bot cannot fly a Hunchback has learned something a broad suite cannot, and has learned nothing about the nine hundred machines it never fielded. Pick the question per run, and say which one the run asked. + +## Three questions in one night that the record could not answer + +Each was answerable only by inference, a second run, or reading the source; each +is a field the record already had the inputs for. + +- **Why did this unit propose nothing?** A pure-vehicle bench defaulted **21.3%** + of its movement decisions, against 0.23%-1.06% on the Mek corpora, and it was + raised as a defect. Settling it took an inference - 17 units defaulted, all 17 + never answered again, which is permanent immobilisation and not transient + failure - because nothing in `decisions.jsonl` carried `immobile`, + `elevationValid` or `stuck`. `SdsClient.record` writes all three per unit now, + and `Unit::immobility` names which one so the turn label says it outright. +- **What was the to-hit, and what arc was the shot in?** The vehicles agent hit + the same wall twice on the firing side. + +The shape is one shape: **the observation carries the field, the bot reads it, +and the record drops it.** Everything in `Observation.unit` is available at +`record` time; what gets written is a chosen summary, and the choice was made +before anyone had the question. So the test is not "is this field interesting +today" but **"if this decision looked wrong, could I say why from this file +alone?"** + +The cost of being wrong is asymmetric and that is the argument. A field nobody +reads is a few bytes a decision against a rationale already measured in hundreds +of kilobytes. A field nobody wrote is a re-run - and a re-run on a suite that +has since changed cannot be compared with the run that raised the question +anyway, which is the trap `plan/harness.md` is about. + +**A summary is a proxy, and the proxy rule applies to it.** `positions` looked +complete - id, name, side, hex, facing, done, destroyed - which is exactly why +nobody checked it for the field they needed. -- 2.51.2