"""Every field the decision record keeps is one the observation actually sends. `SdsClient.record` builds each `positions` entry by reading named fields off the observation it just sent. Jackson's `JsonNode.path` does not fail on a name that is not there - it returns a missing node, and `asInt()` on that is **0**, `asBoolean()` is **false**, `asText()` is **""**. So a typo does not throw and does not log: it writes a plausible figure into the only copy of the ground truth, and every analysis built on it is wrong in a way nothing points at. That is the defect this repository has paid for more than once - a `0.00` standing in for "no answer" - and it is worth a test rather than an eye. The record is also the file three separate questions could not be answered from in one night, so what it carries is checked here as well: the fields are listed because a summary that quietly narrows is how it got thin the first time. """ import re import unittest from pathlib import Path ROOT = Path(__file__).resolve().parent.parent CLIENT = ROOT / "bridge" / "sds" / "SdsClient.java" OBSERVATION = ROOT / "bridge" / "sds" / "Observation.java" def block(path: Path, start: str, end: str) -> str: text = path.read_text() at = text.index(start) return text[at : text.index(end, at)] def kept() -> set[str]: """Field names `record` writes into a `positions` entry.""" body = block(CLIENT, 'ArrayNode seen = line.putArray("positions")', "\n }") return set(re.findall(r'at\.put\("(\w+)"', body)) def read() -> set[str]: """Field names `record` reads off the observation to fill them.""" body = block(CLIENT, 'ArrayNode seen = line.putArray("positions")', "\n }") return set(re.findall(r'u\.path\("(\w+)"', body)) def emitted() -> set[str]: """Field names `Observation.unit` puts on the wire.""" body = block(OBSERVATION, "ObjectNode unit(", "\n }\n") return set(re.findall(r'u\.(?:put|set|putArray|putObject)\("(\w+)"', body)) def phase_counters() -> set[str]: """Counter names `SdsClient` writes into a seat's per-phase object.""" body = block(CLIENT, "ObjectNode phase = phases.putObject(", "\n }") return set(re.findall(r'phase\.put\("(\w+)"', body)) def expected_counters() -> set[str]: """Counter names `sds/runstats.py` reads back out of it.""" text = (ROOT / "sds" / "runstats.py").read_text() at = text.index("COUNTS = (") return set(re.findall(r'"(\w+)"', text[at : text.index(")", at)])) class TestSeatCounters(unittest.TestCase): """The other half of the same seam, and it has already been wrong once. `runstats.py` reads counters out of a `Counter`, so a name the client never writes does not raise - it reads **0**, for every match, forever. Two counters were added to `Tally` and to `COUNTS` and left out of the JSON, and nothing failed: the column would simply have been zero and read as "this never happens". """ def test_every_counter_the_stats_expect_is_one_the_client_writes(self) -> None: missing = expected_counters() - phase_counters() self.assertEqual( missing, set(), f"sds/runstats.py reads {sorted(missing)} which {CLIENT.name} never " "writes into a phase - it would read 0 rather than fail", ) def test_the_outcomes_account_for_every_decision(self) -> None: """Every decision lands in exactly one outcome, and the sum says so. `answered`, `passed` and `neverAnswered` partition `decisions`, where `neverAnswered` is `unanswered + illegal + failed`. The point is not the arithmetic - it is that an outcome added later without a counter fails here instead of vanishing into the gap, which is exactly how `rejected` and `exhausted` once read zero forever and were believed. Checked against the source rather than a run: every `tally.++` inside a decision method has to be one of the names the partition knows. """ text = CLIENT.read_text() known = { "decisions", # the partition "answered", "passed", "unanswered", "illegal", "failed", "defaulted", # counted per rejection, deliberately outside it "rejected", # bookkeeping, not outcomes "eligible", "chose", "differed", "substituted", "prone", "pronePinned", "pinnedGyro", "pinnedNoMp", "pinnedLimb", "timedOut", # A duty carried out beside the decision rather than instead of it: # a unit can spot and shoot in the same turn, so this partitions # nothing and is counted because the cost is invisible otherwise. "spotted", # The firing mode a declared attack was made in, and the # denominator it is a share of. Counted per weapon attack rather # than per decision, so neither partitions the decisions either. "indirect", "declaredAttacks", } seen = set(re.findall(r"tally\.(\w+)\+\+", text)) self.assertEqual( seen - known, set(), "a counter is incremented that the outcome partition does not know " "about; add it to the partition or to the bookkeeping list, or it " "will not be accounted for in any match", ) def test_never_answered_is_derived_from_its_parts(self) -> None: """`neverAnswered` is computed, not counted, so it cannot drift. A separately incremented counter is how two numbers that should agree stop agreeing. This one is written as the sum of the three things it rolls up, in both the per-phase object and the seat total. """ text = CLIENT.read_text() self.assertIn( "t.unanswered + t.illegal + t.failed", text, "the per-phase neverAnswered is no longer derived from its parts", ) self.assertIn( "unanswered + illegal + failed", text, "the seat-total neverAnswered is no longer derived from its parts", ) def test_declining_and_not_answering_are_counted_apart(self) -> None: """A bot that passed and a bot that never replied are different facts. `defaulted` conflated them, and on one vehicle bench all 64 of its movement decisions were an immobilised unit answering correctly. An immobilised unit must not read as never-answered. """ text = CLIENT.read_text() self.assertIn("tally.unanswered++", text) self.assertIn("tally.passed++", text) # And nothing increments `defaulted` except the helper that splits it. bare = [n for n, line in enumerate(text.split("\n"), 1) if "tally.defaulted++" in line] self.assertEqual( len(bare), 1, f"defaulted is incremented outside countDeclined: lines {bare}" ) def test_the_retry_counters_reach_the_json(self) -> None: """Named, because they are the ones that were missing. `rejected`, `passed`, `unanswered` and `neverAnswered` are what say what happened during a match. Silently zero, they would have said none of it ever happens. """ self.assertLessEqual( {"rejected", "passed", "unanswered", "neverAnswered"}, phase_counters() ) class TestDecisionRecord(unittest.TestCase): def test_every_field_the_record_reads_is_one_the_observation_sends(self) -> None: """The silent-zero guard. A name here that the observation does not send is not an error at run time. It is a column of zeroes in the corpus. """ missing = read() - emitted() self.assertEqual( missing, set(), f"{CLIENT.name} reads {sorted(missing)} for `positions`, which " f'{OBSERVATION.name} never sends - Jackson would write 0/false/"" ' "into the record rather than fail", ) def test_the_record_still_carries_what_the_questions_needed(self) -> None: """A summary that looks complete is the reason nobody checks it. Each of these was added because a real question could not be answered from this file: why a unit proposed nothing (the first four), what its allowance was when a move was refused (the next five), how to split a rate by mover type without matching on a name, and what the to-hit was computed from. """ required = { "immobile", "stuck", "elevationValid", "canStand", "walkMp", "runMp", "jumpMp", "jumpMpTerrain", "mpUsed", "movementMode", "gunnery", } self.assertEqual( required - kept(), set(), "the decision record has narrowed; see plan/observability.md", ) def test_the_reads_and_the_writes_line_up(self) -> None: """No field is written from a source that was never read, or vice versa. `id`, `name` and `friendly` and the rest all come off the observation, so the two sets are the same set. A field written from a local variable instead would be the one thing this file cannot check, and there is not one. """ self.assertEqual(kept(), read()) if __name__ == "__main__": unittest.main()