diff --git a/plan/candidates.md b/plan/candidates.md index 3353c84..7dc2015 100644 --- a/plan/candidates.md +++ b/plan/candidates.md @@ -188,21 +188,83 @@ good offence and bad defence, and a single operator over `N` cannot say so. summed incoming. Test: a state good against one enemy and exposed to three scores high on one and low on the other. `stands::score_stands`, and the five volley outputs stay five through both aggregations -- [x] Emit the top *K* states as proposals. `stands::Ranking` carries every - state scored and **two** top-*K* lists, most dealt and least taken, each - in a total order so the emitted set does not depend on the order the - states were reached in; the proposal set is their union. There is no - combined ranking on purpose. A single scalar is an exchange rate between - the two columns, which is doctrine's and not the estimator's, and it - undoes the reason `Aggregate` carries five columns at all. It also gave a - wrong answer: ranking on `offence - defence` made the winners at a low - exponent the hexes with no exchange at all, since zero minus zero beats - every hex that trades. Nothing consumes the lists yet - `sds-bot` still - offers its four verbs +- [x] Emit the **non-dominated set** as proposals, over one damage column per + enemy against damage taken. `stands::Ranking` carries every state scored, + the frontier, and the two top-*K* lists kept as instruments. Bounded by + spreading along the frontier rather than truncating, with the length + before and after in `StandStats`. See below for why top *K* down each + axis was replaced +- [x] Turn the frontier into `plan::Proposal`s in the existing feature basis, + so weights can choose along it. Nine positional columns from + `features::positional::Posture`, four firing columns from the aggregate, + and `damage_by_target` beside the summary scalar. Nothing consumes it + yet - `sds-bot` still offers its four verbs - [ ] Features read `tmm_gained`, `elevation_gain`, `cover_quality` from the state rather than re-deriving them. Test: values match the old path for the same end hex +### Why top *K* down each axis was replaced + +The first emission was two lists: the top *K* by damage dealt and the top *K* +by damage taken. The second is degenerate **by construction**. "The stands that +take the least" is a description of the hex nothing can see, so its picks had no +line to any enemy at all - `deal 0.00` - and at `p = 1` it ran for the map edge. +Defence barely discriminates either: across the top eight on the `open` board it +spanned 47.1 to 53.1, a 12% spread, against 0.0 to 31.7 for damage dealt. +Ranking on the flat axis turns noise into a decision. + +The frontier fixes it without an exchange rate. A stand dealing 25 while taking +50 dominates one dealing 0 while taking 50, so standing where nothing can happen +survives only where it is genuinely the safest thing available, and a position +that fights is on the frontier whenever one exists. No objective term, no +distance-to-enemy term, no scalar: a rate between the two columns is doctrine's, +it belongs to [strategies](strategies.md), and it is what `StandScore::margin` +was deleted for. + +**One damage column per enemy, not a maximum over enemies.** Dominance on "best +target" is not dominance on anything a force reasons about. A stand that deals +less to its own best target but is the only one with a line on the third enemy +is dominated on two axes and gone before the force sees it - and that is exactly +the hex that lets a lance concentrate, screen, or hold a lane. Measured on the +three corpus boards, the extra axes rescue between 0 and 34 stands per regime, +and the frontier runs 1 to 45 stands out of 151 reachable rather than 1 to 13. + +The bound is explicit. Both ends go in first, then repeatedly the stand furthest +from everything already picked, with every axis normalised to the frontier's own +range. The length before and after is in `StandStats` and is printed by +`examples/stands.rs`, because a bounded coverage that is not logged is a silent +truncation. + +The two top-*K* lists are kept as instruments. Reading the defence list beside +the frontier is how the degeneracy was found, and it is what the example prints +to show 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 +eight units and the enemy is credited with eight times its firepower. It is a +conditional, not a share: the enemy allocates fire, it does not multiply it, so +a force's real exposure is an allocation problem whose worst case is +concentration on one unit. Nothing here may add these numbers up, and the fix is +force-level - see [hierarchy](hierarchy.md) and [strategies](strategies.md). + +### What this exposed + +There is no bridge from a `Stance` to a `Weights` anywhere in the tree. +`ForceThinker::command` scores every proposal with one weight set, and +`sds-bot/src/unit.rs` takes a `stance` argument and writes `let _ = stance;`. +Choosing along a frontier needs one, and `examples/stands.rs` carries a stand-in +so the demonstration can exist - in the example on purpose, because what a +stance emphasises is a tactical opinion nobody has argued over yet. Writing that +bridge for real belongs to [strategies](strategies.md). + +The firing group does not fully survive the aggregation either. +`expected_damage`, `p_kill`, `p_mission_kill` and `value_destroyed` come off the +aggregate; `heat_incurred`, `ammo_spent`, `overkill`, `weapon_concentration` and +`p_psr_threshold` are properties of a chosen weapon allocation, and `p_breach` +is a probability where the aggregate carries an expected count. No feature name +was invented to fill the gap. + **Rollout** - [ ] Behind a flag, old generator default, so one match can produce both sets