From 8006e2e7017724df0db3a8cf7d6c456250dfd524 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 18:30:58 -0400 Subject: [PATCH] docs(candidates): record the surfacing rule and why units stopped pruning The measurements that removed the cap, the Tchebycheff argument, the Screen case, and how near-duplicates are collapsed. --- crates/sds-core/examples/stands.rs | 4 +- plan/candidates.md | 93 +++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/crates/sds-core/examples/stands.rs b/crates/sds-core/examples/stands.rs index d3449b8..f93e04d 100644 --- a/crates/sds-core/examples/stands.rs +++ b/crates/sds-core/examples/stands.rs @@ -36,7 +36,9 @@ use sds_core::features::{firing, positional, Feature, Weights}; use sds_core::heatmap::HexMap; use sds_core::pathfind::MoveBoard; use sds_core::stance::{Intent, Stance}; -use sds_core::stands::{pareto_frontier, propose, prune_selectivity, score_stands, Params, Ranking}; +use sds_core::stands::{ + pareto_frontier, propose, prune_selectivity, score_stands, Params, Ranking, +}; use sds_core::surface::{surface, vocabulary}; use sds_core::volley::VolleyCache; use sds_core::wire::Coord; diff --git a/plan/candidates.md b/plan/candidates.md index 7dc2015..74d5518 100644 --- a/plan/candidates.md +++ b/plan/candidates.md @@ -188,17 +188,18 @@ 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 **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, +- [x] Emit **every scored state** as a proposal. The unit does not prune: see + "The unit does not prune" below. `stands::Ranking` carries every state + scored, and the non-dominated set and the two top-*K* lists beside it as + instruments +- [x] Turn the scored set 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 +- [x] Surface the few options worth showing a person, by intent. Tchebycheff + decomposition over the stance vocabulary in `stands`' sibling + `surface.rs`: see "Surfacing" below - [ ] 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 @@ -239,6 +240,84 @@ 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. +### The unit does not prune + +Units report the firehose. `stands::propose` emits every scored state, and +nothing between the estimator and the force removes one. + +The cap it replaced was written before anything was measured, and the +measurements contradict it. 16v16 unpruned is **2,416 candidates**, about +**198 KB**, and **41k multiply-adds** to score every one. Generating and +scoring 198 stands against three enemies costs **27 ms**; the pathfinding half +is **29 us** per unit; the reachability search benchmarks **50-80x faster** +than MegaMek's own `ShortestPathFinder` and `LongestPathFinder` on the same +boards and starts. `ForceThinker::command` loops each unit's proposals and +takes an argmax, so it is linear in what it is handed. + +So the prune bought nothing, and it could only ever lose the one candidate the +force needed - which is the failure this epic exists to remove. A cap, if one +is ever wanted, is set by the force algorithm that needs it, and not chosen in +advance by a unit that cannot know what the force is about to ask. + +The frontier stays as an instrument, not a gate. Reading it beside the scored +set is how the degenerate emission above was found, and the share of surfaced +picks that sit on it is the diagnostic below. + +### Surfacing: the few options worth showing a person + +Selection is no longer a gate on what the force sees, so it is free to be about +something else - the ten or so options a **reader** can hold in their head, in +the render and in the match log. Nothing is lost by not surfacing something, so +the criteria are diversity and interpretability rather than safety. +`crates/sds-core/src/surface.rs`. + +**Tchebycheff decomposition over the stance vocabulary.** For each weight +vector `w`, surface `argmin_x max_i w_i * (z*_i - x_i)` on axes normalised to +the scored set's own range, where the ideal point `z*` is the best value on +each axis across the set. The axes are the ones the frontier already uses: the +per-target damage columns, plus exposure. + +**Why not a weighted sum.** A linear scalarisation can only ever return a point +on the *convex hull* of the frontier. Discrete stand sets are lumpy and their +frontiers have concave stretches, and no positive weight vector reaches a point +inside one: the hex that is the compromise between two extremes is invisible to +a dot product and reachable by min-max. A test builds a concave fixture, checks +that every weighted sum over it picks an end, and checks that the balanced +Tchebycheff vector picks the middle. + +**Every surfaced stand carries the name of the intent that reached it.** That +label is the point. A scatter of two thousand hexes is not readable; `close`, +`focus on 3`, `screen` beside three hexes is. + +**`Screen` is what proves the design.** A screening unit is meant to absorb +fire, and the Pareto rule cannot express that at all - "less taken is better" +is baked into its dominance test, so the hex a screen wants is dominated by +construction and can never be on the frontier. Under decomposition `Screen` is +just a weight vector whose exposure term is oriented the other way. Measured on +the three corpus boards, it lands 45, 24 and 73 points of incoming above +`Withdraw`, and its hex is dominated on all three. + +**The frontier is the diagnostic.** The share of surfaced picks that are +non-dominated says whether the weight spread is any good - 71% on `open`, 67% +on `water` at `p -> -inf` - and domination never filters anything. + +**Deduplication.** The reachable set is full of same-hex-different-facing +near-duplicates. Each stand's normalised axes are rounded onto a grid 24 steps +wide, and stands landing on one cell are one option. That collapses on +*outcome* rather than on position, so two different hexes producing the same +trade collapse too, and it needs no threshold in points. The representative is +the cheapest way to get that outcome: least `mp_spent`, then hex and facing. On +`open` at `p -> -inf` it takes 198 stands to 66 options. + +**The weight table is a placeholder.** There is still no `Stance` to `Weights` +bridge in the tree, and what a stance emphasises is doctrine nobody has agreed. +The per-intent numbers are marked `PLACEHOLDER-DOCTRINE-gallimaufry`. `Flank` +and `BreakLos` are honestly not separable on these axes at all - one wants an +arc the enemy is not facing and the other wants terrain in the way, and neither +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. + ### 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 -- 2.51.2