bayes for days
sds plan los.md
18 kB


id: los title: Line of sight computed here, checked against MegaMek status: open dependsOn: [] exitCriterion: > A Rust LOS agrees with MegaMek's LosEffects on a corpus dumped from real matches, and every positional feature uses it. #

los #

Candidate evaluation needs LOS from hexes the unit is not standing in. MegaMek's toHit only answers for actual positions, so we have to compute it ourselves regardless of speed.

It will also be the dominant cost of any real positional feature: ~1280 LOS queries a round at 8v8, versus microseconds for everything else measured.

Done, and what is still open:

The cache lives for the match #

LosCache was cleared every round, and the reason written on it - "the board does not change, but units move" - justified nothing. The key is AttackInfo, which holds both positions, both absolute heights, both unit heights, mek-ness and the water flags: every property of the two machines that changes the answer is already in it. A unit that moves produces a different key. The old entry becomes unreachable, not wrong.

The only thing that can make a cached answer wrong under an unchanged key is the board changing: woods burning down or being cleared, a building collapsing, smoke drifting in. The round tick was a conservative proxy for that, and it is paid every round whether or not anything changed.

It is safe to drop today because the benchmark suite has no terrain that changes. sds.scenario.MAPS excludes buildings and bridges and tests/test_maps.py reads the hexes to keep it that way, and smoke is not modelled at all. In every match we actually play the board is static.

Invalidation, when the terrain starts changing #

Not built. This is what to build when buildings, bridges or fire arrive, so that the answer is not re-derived under time pressure.

  • A line is invalidated by a change to any hex on it, both ends included. Not the endpoints alone: a hex halfway along is what decides cover, the crossing modifier and whether the shot exists. The invalidation set for a changed hex h is every cached key whose traced line passes through h, which means either keeping the traced hexes beside each answer or sweeping the map and re-tracing. Firing follows the same rule, because a to-hit is a line plus the target's own hex.
  • Move caches are invalidated by a different rule. A terrain change can open a route as well as close one - cleared woods cost less, a collapsed building leaves rubble, a burnt hex changes its MP cost - so a reachability memo cannot be invalidated by "the hexes on the answer": an answer that never touched h may still be wrong once h becomes passable. OptionsCache is keyed on the whole MoveBoard and rebuilding it on any change is correct and cheap; do that rather than inventing a second rule.
  • MegaMek already sends the change. The server sends UPDATE_CUT_HEXES once a round with the hexes it altered, and Client.receiveUpdateCutHexes applies it - so SdsClient's own Game is already correct. It is Princess's Precognition that cannot parse the packet, which is the ERROR line in every match log (see docs/UPSTREAM.md §7). Reading that packet in the bridge and sending the changed hexes over the wire is the invalidation signal. It is the right fix rather than a timer or a round tick: a round tick throws away work when nothing changed and is still wrong within a round when something did.

The cache outlives the thing it assumes #

LosCache lives for the match, and docs/PERFORMANCE.md argues for that lifetime explicitly: the old per-round clear cost almost nothing, and its stated reason - "units move" - was never true of a cache keyed on AttackInfo, which holds hexes and heights and no unit identity.

That argument is sound for the reason it gives and silent about the one that matters. Units moving is not what invalidates a line. Terrain changing is - and terrain does change in a real fight: buildings take damage and collapse, fires start and spread, smoke drifts across the board and thins. Every one of those changes a line of sight mid-match, in both directions, for pairs that were computed and cached rounds earlier.

Three things are currently keeping this from being a bug, and all three are accidents of what is not built yet:

  • The board document is sent once per match (docs/PROTOCOL.md), so the bot has no way to learn that a hex changed.
  • docs/PROTOCOL.md says so outright: "Terrain that changes mid-fight - fire, smoke, a collapsed building - is not tracked. A bot that cares must currently do without."
  • Nothing in the observation carries smoke or building state, so no feature can read one.

So the model is internally consistent today: a static board, and a cache that may live as long as the board does. It is consistent and knowingly wrong about the rules.

The landmine is the ordering. Whoever adds smoke or buildings will be adding them to the protocol and the observation, and nothing in either place points at the cache. The failure would be silent and would look like a bot that shoots through smoke: a line traced in round 1 answering a round 6 question, with no error, no warning and no test failing.

Worth knowing too that CLAUDE.md's invariant and the implementation already disagree: the invariant reads "LOS gets cached per turn, shared across the side", and the cache lives for the match. The per-turn form is the one that survives dynamic terrain.

Rejecting a pair without tracing it #

Three quarters of the sweep's pairs end at NoLine, and each one pays an AttackInfo::ground build and a LosCache::get to learn it. The obvious question is whether a cheaper test can answer "definitely no line" first. It can, and it is not worth building. The measurements are here so that it is not re-proposed from the pair count alone.

Everything below was measured over the corpus boards - Map Set 5/16x17 Open Terrain 1, Map Set 4/16x17 Heavy Forest 1, Map Set 2/16x17 Lake Area from tests/corpus/pathfind.jsonl - by walking every ordered hex pair and comparing against los::calculate.

What actually blocks these pairs #

Not elevation. In-range ordered pairs, standing Mek at both ends:

board no line blocked by hill foliage or smoke summing to 3
open 17,058 (23.2%) 0 17,058 (100%)
forest 58,994 (80.1%) 12,990 (22%) 46,004 (78%)
water 23,056 (31.3%) 12,054 (52%) 11,002 (48%)

A ridge is the minority blocker everywhere and blocks nothing at all on open terrain. What dominates is the cumulative foliage rule - three light-woods hexes on the line and the shot is gone - and that is a property of the line's midsection, not of its ends. Move either endpoint one hex and a different set of woods is crossed. Any scheme that hoped to partition the board on terrain geometry is hoping against the blocker that does most of the work.

Regions are strictly dominated by one bit per pair #

The idea was to segment the board so that a hex in region A can never see a hex in region B, and reject the pair on an integer comparison. Soundness has to be one-sided: "definitely no line" or "ask properly", because a false "no line" is a shot the bot cannot see and nothing in this repository would catch it.

An agglomerative search for the best such partition - merging hexes while losing the least rejection, over the intersected blocked-set of each region - gets:

regions open forest water
128 78.8% 92.4% 76.7%
64 54.7% 82.6% 56.6%
32 34.0% 71.1% 40.0%
16 21.6% 57.5% 27.7%
8 13.5% 41.8% 16.0%
4 6.5% 17.8% 7.0%

All 272 hexes have a distinct blocked-row on all three boards, so regions only get small by discarding information, and they discard a lot of it early.

That table is not the argument, though. The argument is structural: to build the regions you have to know which pairs are blocked, which means tracing every pair. Once you have traced every pair you can store the answer exactly, one bit per ordered pair - 36 KiB for the 544-hex two-sheet board. The exact bitmap has the same build cost, the same one-memory-access query, rejects 100% instead of 34-92%, and needs no soundness proof at all. Regions cost more to justify and answer less. There is no version of the region idea that beats the bitmap unless it can be built geometrically without tracing pairs, and the blocker census above says the geometry available - elevation - accounts for 0-52% of the blocks.

The bitmap has to be keyed by height, and that is what kills it #

Line of sight is a pure function of the two hexes only once the two units are fixed. Mover::presence sets elevation: self.elevation, so a unit's elevation is constant across every stand it can reach - but height is not one value. A Mek is 1 standing, 0 prone, 2 superheavy; vehicles and infantry are not meks.

Take the standing-Mek-against-standing-Mek table on the two-sheet board and use it for another profile:

table used for false "no line" claims, of 295,936
height 0 target (prone Mek, vehicle) 1
height 2 target (superheavy) 130,424 (44%)

44% of the board answered "no line" when there is a line. Silent, and exactly the class of bug with no test to catch it. So the structure has to be built per (height, mek-ness) pair at both ends: 25 tables, 903 KiB, and each one is its own full precompute. A single table conservative across all 25 keeps only 30.4% of the rejections the Mek-versus-Mek table makes.

One full precompute for one profile pair - 544x544 traces - is ~1,020 ms. A whole match currently traces ~64,000 distinct lines in total. For a force of nothing but standing Meks the trade is about a second of precompute against the ask time saved; for any mix of heights it inverts.

Bloom loses here, but the direction is right #

Sized against the has-line set - 108,530 pairs on the two-sheet board - at a 1% false positive rate: ~9.6 bits an element, ~130 KB, against the exact bitmap's 36 KiB. Bigger, approximate, and two or more memory accesses. At this pair count the exact bitmap wins outright.

The direction is the sound one and worth keeping if the pair count ever grows past what a bitmap can hold: store the has-line set, so that "absent" is definitive and a false positive only costs a trace that was going to happen. A Bloom over the blocked set would answer "no line" wrongly, which is the one answer that must never be wrong.

What this is worth, and where the cost actually is #

One warm ask - AttackInfo::ground plus LosCache::get - is 72-78 ns over the 34,180 keys a unit-decision touches, against a hex::distance control at 3-9 ns in the same loop shape. That is 10-13% of the sweep on the build this was measured against. Three quarters of the pairs are no-line, so making all of them free is a ceiling of roughly 8% of the sweep, before the structure's own lookup and before the per-profile precompute is charged against it.

The remaining cost is in the quarter of exchanges that do produce a volley, at several microseconds each. That is where the sweep's runtime is, and it is not reachable from anything in this section.

Tried and dropped: warming the memo while nothing has asked #

The seat is pinned to a core of its own and turns alternate, so for more than half a match that core is idle - 789s of Princess's turns against 1462s of match, on the pinned run in plan/candidates.md. The board arrives once, well before anybody asks for a turn, and AttackInfo holds hexes, heights and water flags and no unit identity: a line needs the board and nothing else. So a filler could run on a thread of its own and hand every decision a warm memo.

It was built, measured, and it cannot pay. Every ordered pair on a 16x17 board - 73,712 lines, more than the 71,566 a whole 25-round match creates - is about 100ms, at 1334ns a line. Against a match that runs to minutes that is an absolute ceiling of well under a tenth of a percent, and the mechanism is not worth the surface. los::tests::every_line_a_board_holds_is_a_tenth_of_a_second is that measurement, kept so the arithmetic can be rechecked rather than re-argued.

The intuition failed on the difference between a lookup and a miss. The section above measures a warm ask at 72-78ns and calls the lookup 10-13% of the sweep, and a filler removes none of it: every ask still probes the map. What a filler removes is misses, and there are few. Read off a mirror-lance log:

deployment decision lines held asks hit rate
first 0 0 -
second 21,171 91,744 76%
fourth 37,666 502,533 92%
end of match, round 25 71,566 6,180,136 98%

The memo is 76% warm after the second decision that asks anything, and 98% by the end, entirely on its own. There was never much cold to remove.

Two things learned that outlast the idea. The idle window is not where it looks. A filler started when the board lands gets microseconds: the board and the first observation arrive back to back, and a run with it enabled warmed 767 lines before the first decision stopped it. Reaching the between-turn idle would mean restarting after every reply, which is more machinery for the same ceiling. And the bot runs in a container, so a variable set on the host reaches nothing: the first run of this read "0 warmed", which looks exactly like a change that does nothing rather than one that never ran. That is the mistake SDS_CANDIDATES made, and it is worth one line of -e plumbing to be sure which of the two a null result is.