diff --git a/plan/candidates.md b/plan/candidates.md index 74d5518..5750aa3 100644 --- a/plan/candidates.md +++ b/plan/candidates.md @@ -318,6 +318,158 @@ is a column a `StandScore` carries - so they are written as different points on the damage-against-exposure trade, which is a weaker claim than their names make. They need their own axes rather than different numbers. +### Facts a stand carries + +Two columns - damage dealt per enemy, damage taken - cannot express what is +being asked of them. `Flank` and `BreakLos` have no axis at all and are written +as different points on the same trade. Five of fifteen surfacing labels land on +one hex. And **four different situations all read `deal 0.00`**: no line at all, +a line but nothing bears, an arc but no bracket, and everything working with the +damage merely low. The first of those is cover, and is exactly what `BreakLos` +wants. + +`volley::NoVolley` already separates `NoLine` from `NothingBears`, and the +collapse threw the answer away: the counters that recorded it lived in +`VolleyStats` and were global. `crates/sds-core/src/facts.rs` keeps it per +`(stand, enemy)` instead, on `StandScore::facts`. + +**The prune did not change.** Skipping the volley for a pair with no line is +correct and stays. Every `L x M` pair is walked as before and the volley is +still the only thing skipped; what is new is that the prune's *outcome* is +recorded. One test per pair is genuinely new - the arc-only `any_bears` call +that separates "nothing bears" from "out of bracket" - and it runs only on +pairs the volley was already skipped for. + +What a stand now carries: + +| fact | shape | comes from | +|---|---|---| +| range to each enemy | min, max, mean over that enemy's `M` | the distance the bracket test already computed | +| blind share | share of `M` with no line, per enemy, per direction | `NoVolley::NoLine` | +| bears share | share of `M` where a weapon is in arc, per direction | `NoVolley::NothingBears` against `any_bears` | +| firing share | share of `M` that produced a volley | `gather` returning a key | +| damage by their arc | front/left/right/rear, per enemy | `VolleyKey::side` on the outgoing key | +| damage by our arc | front/left/right/rear, per enemy | `VolleyKey::side` on the incoming key | +| tmm | integer | `Reach::target_movement_modifier` | +| gait and attacker to-hit | still / walked / ran, and 0 / +1 / +2 | `mp_spent` against `walk_mp` | +| elevation delta | integer | the board, end level minus start level | +| retreat breadth | legal steps out of the hex, 0-6 | `MoveBoard::step_mp` on the six neighbours | + +Line of sight is symmetric, so the blind share is one number read two ways: "I +cannot shoot" and "I cannot be shot", the second of which is the `BreakLos` axis. +Nothing else is symmetric - our arcs and brackets are not theirs - so bears and +the arc splits are recorded per direction. + +The arc split is the **arithmetic mean** over `M`, not the power mean. Per pair +it is one-hot: from one stand against one target hex and facing the shot lands +in exactly one arc. Splitting a power mean by category is not defined - the +exponent is a statement about which member of `M` the enemy picks - so the two +collapses agree only at `p = 1`, and the split says so. + +`retreat_breadth` is a **proxy and reads `None` when nobody handed over a +`MoveBoard`**. The real question is how much of the board is still reachable next +turn, and that is a second `reachable` per stand: `L` searches where a turn runs +one. Six `step_mp` calls catch the case that matters, a hex ringed by cliff or +map edge, and "nowhere to go" stays distinct from "nobody asked". + +**What it reads on the corpus boards**, at `L x M` pairs per board +(`cargo run -p sds-core --example stands `): + +| board | pairs | no line | arc blocked | out of bracket | firing | +|---|---|---|---|---|---| +| `open` | 594 | 37.4% | 10.5% | 1.9% | 50.1% | +| `forest` | 120 | 53.5% | 8.9% | 0.0% | 37.5% | +| `water` | 291 | 38.1% | 13.1% | 4.7% | 44.1% | + +And the separation itself, on `open` at `p -> -inf`: 494 of 594 pairs deal +`0.00`, and by the reason holding over most of that enemy's `M` they are 228 +blind, 60 nothing-bears, 6 out-of-bracket and 200 firing-but-the-collapse-says- +zero. Those were one number. The last group is the interesting one: it is not a +hex with a problem, it is minimax finding one bad position in `M`. + +Damage dealt on `open` splits 54.7% front, 17.3% left, 14.9% right, 13.1% rear, +and what we take splits 46.8/17.5/17.8/17.9 across our own arcs. `Flank` has an +axis now, and so does its defensive mirror. + +Cost, measured on `examples/stands.rs` at release with the `open` board: 48 B a +stand plus 84 B a pair, 58 KB over 198 stands and 3 enemies. Wall time over the +whole example - three exponent sweeps, surfacing, the heatmap and the proposals - +went from about 0.11 s to about 0.13 s, and peak RSS from 5.58 MB to 5.83 MB. + +**Nothing in `sds-bot` reads any of this.** Play is unchanged and no control run +was needed. + +**Open question: what gait is a pure turn.** Turning in place spends MP and +moves no hexes. `Gait::of` reads any spend at or below the walk allowance as +walking, so such a path is priced at `+1`, and which `EntityMovementType` +MegaMek's `MovePath` assigns it has not been checked against the jar. `+1` is +the conservative direction - it never prices a shot lower than the rules do - +and it is written here rather than left as a silent guess. + +### Promoting a fact to a feature + +**Adding a fact is cheap. Adding a name to the feature basis is not.** +`sds/corpus.py` fingerprints the basis by name, so one new feature name retires +every recorded training corpus. So none of the above is a `features/` entry, and +promoting any of them should happen **once, as a batch**, rather than piecemeal. + +How normalisation constrains that. `features/score.rs` seals three +normalisations - `Bounded`, `Rank`, `Local` - and implements `Learnable` for the +first two only. `Weights::from_document` refuses a `local` name with +`WeightsError::NotLearnable`, and `FeatureVector::local_features` lists the ones +a vector is carrying; `damage_lead` is the only one today. A `local` column is +min-maxed across one decision's own candidates, so it ranks candidates correctly +and means nothing across decisions, and fitting a weight against one fits the +board. So a fact can only become a *learnable* feature if it has a domain a rule +gives. + +Candidates for promotion, and why: + +- **blind share** and **bears share**, both directions. Already `0.0..=1.0` by + construction, so `Bounded` with no denominator to argue over. These are the + strongest candidates: `BreakLos` is not expressible without the first, and it + is currently written as a point on a trade it has nothing to do with. +- **rear and flank shares of damage dealt**, and of damage taken. Also shares, + also `Bounded`. `rear_arc_gain` already exists but answers a different + question - it is a headcount of enemies whose *placed* hex puts us behind + them, unweighted by damage and not taken over `M`. A damage-weighted share + over `M`, with the defensive mirror beside it, is what `Flank` needs. +- **attacker to-hit**, `0..=2` from a rule. Cheap, and the cost of running is + invisible to every column today. +- **retreat breadth**, `0..=6` from the hex grid. `Bounded`, though the proxy + should be argued over before it is fitted against. + +Not candidates as they stand: + +- **range min/max/mean** have no bounded domain without a denominator, and + picking one is a decision. `Posture` uses `board_span` for `cohesion`; the + weapon's own long bracket is the other candidate and is the more meaningful + one. `range_band_fit` already answers a nearby but different question. +- **elevation delta** has no rule-given range - a board's levels are the board's - + so it needs the same kind of denominator argument. +- **tmm** is already a feature: `positional::TmmGained`, `Bounded` over + `0..=MAX_TARGET_MOVEMENT_MODIFIER`. The fact carries the raw integer beside it + so a reader does not have to invert a normalisation, and adds no name. + +### Deliberately out of scope + +Both of these were decided, not overlooked. + +**Piloting rolls and fall risk.** `Proposal.risk` exists and nothing fills it. +Filling it needs MegaMek's PSR rules read out of the jar, it barely arises on +these boards, and it is its own piece of work. Zero rather than a guess. + +**Water's effects on combat - and only on combat.** Partial cover from standing +in depth 1, heat dissipation, prone-in-water, any depth-dependent to-hit: none of +it is modelled, and nothing in `facts.rs` branches on depth. + +**Movement through water is modelled and stays modelled.** `pathfind.rs` charges +depth 1 at 1 MP for the arm plus the floor change and depth 2 at 3, and +`tests/pathfind_differential.rs` asserts our reachable set equals MegaMek's over +36 cases and 1979 states with `Map Set 2/16x17 Lake Area` in the corpus for +exactly this. Making water impassable there would drop legal states MegaMek +accepts and fail that test immediately. Do not "simplify" it. + ### Known limitation: `damage_taken` must not be summed over a force Every unit reports damage taken as "if all of them shoot me". Sum that over