sots-engine/docs/M-movefleet.md
alex 2aa8cba614 movefleet: reproduce the original's float32 normalise; 8 of 45 live divergences -> 0
The behavioural compare found 8 of 45 StrategyServer::MoveFleet calls diverging by
one ULP on a position component. Read off the instruction stream, the cause is that
the engine's vector normalise narrows to float32 four separate times and we kept
everything in double:

  delta.c = f32(dest.c - pos.c)     stored back to a float32 slot before normalising
  sumsq   = f32(x*x + y*y + z*z)    products/adds in 53-bit regs, only the SUM stored
  len     = f32(sqrt(sumsq))
  inv     = f32(1.0 / len)          a reciprocal, MULTIPLIED through, not three divides
  dir.c   = f32(delta.c * inv)

and the same call returns the leg distance, so it is never recomputed in a wider
precision either. The position tail was already right, which is why the error was a
constant absolute ~1.2e-7 (half an ULP of the inputs) rather than a formula error.

Adds NormalizeVec3 / StraightLeg / StraightLegDistance / AdvanceAlongUnitDirection
and rebuilds AdvanceAlongDirection on them; the movement hook now takes both the
direction and the distance from one StraightLeg call, as the original does. The
arrival test is an exact float compare, so the distance has to be that same float32.

Tests pin float32 BIT PATTERNS, not tolerances: one case per narrowing plus four
independent legs component by component. A CHECK_NEAR would pass against the old
arithmetic.

sim::Distance is left in double on purpose and flagged at its declaration: it now
serves only the node-line/stutter geometry, which very likely needs the same
treatment but has zero behavioural coverage to correct it against.

Live, same VM/save/workload, run twice by this lane:
  control recap-7584bad-20260908T0615Z  45 calls, 45 compared, 8 diverged, exit 1
  fixed   mf-45bdf7d-dirty-20260908T0721Z 45 calls, 45 compared, 0 diverged, exit 0
with identical arguments, identical pos.before and identical ORIGINAL pos.after on
all 45 calls. The control reproduced the eight divergent call_ids exactly.

Coverage unchanged and still thin: all 15 moving calls are the same straight-run
waypoint type; types 2-5 were attempted and could not be reached (the only player
that would travel a node line has no ships on this save). See docs/M-movefleet.md.

ctest 32/32; tools/clean_room_check.sh OK.
2026-09-08 03:50:04 -04:00

116 lines
6.7 KiB
Markdown

# M — the `MoveFleet` position rounding (2026-09-08)
The first arithmetic divergence the behavioural compare found **on its own**. Everything of
this kind before it (`ApplyTechEffect`'s early return, the RNG divisor) was found by reading
the instruction stream and then confirmed; this one was found by widening a workload.
`docs/R-recapture.md` reported 8 of 45 live `StrategyServer::MoveFleet` calls diverging by one
ULP on a position component. The cause is below, read off the binary rather than fitted.
## 1. The mechanism: four float32 narrowings, not one
The original does **not** compute a direction in double and round the result. Every `Vec3` in
the engine is three float32 words, and its normalise helper stores to a 4-byte slot and
reloads it four separate times:
| # | step | held as |
|---|---|---|
| 0 | `delta.c = dest.c - pos.c`, computed on the x87 stack | **stored to float32** before the helper is called |
| 1 | `sumsq = x*x + y*y + z*z` — three products, two adds, all in 53-bit registers | **stored to float32** |
| 2 | `len = sqrt(sumsq)` | **stored to float32** |
| 3 | `inv = 1.0 / len` — a **reciprocal**, then multiplied through | **stored to float32** |
| 4 | `dir.c = delta.c * inv` | **stored to float32** (its own word) |
and the helper *returns* `len` — the same float32 — so the leg distance is never recomputed
in a wider precision either. The position tail is then two roundings per component and no
more: `tmp.c = float32(dir.c * move)`, `pos.c = float32(pos.c + tmp.c)`.
What we had instead:
```cpp
const Vec3 delta = Sub(dest, pos); // exact difference, double
const double len = std::sqrt(Dot(delta, delta)); // double sumsq, double root
const Vec3 dir{delta.x / len, delta.y / len, delta.z / len}; // three double DIVIDES
```
Three of the five narrowings missing, plus a divide where the original multiplies by a
rounded reciprocal. The position tail (`F32(pos + F32(dir*move))`) was already right, which
is exactly why the error was a *constant absolute* ~1.2e-7 rather than a formula error: a
direction wrong in its last float32 bit, scaled by a step of 2, is 2 x half-an-ULP-of-1 ≈
1.2e-7 wherever the fleet is. The 64-ULP case in the report is that same 1.2e-7 landing on a
result near zero.
Which narrowing mattered is not uniform, which is worth knowing before anyone "simplifies"
this back: on the fleet-50 legs it is the **delta** narrowing that decides the answer (an
exact delta with float32 length and reciprocal still reproduces the *old* wrong value), while
on one fleet-34 leg it is the **reciprocal-vs-divide** that decides it. Only all five
together reproduce the original.
`ResolveMoveStep`'s arrival test is an exact float comparison (`move == distance`), so the
distance it is capped against has to be the same float32 the normalise produced. Feeding it a
double-precision distance made `arrived` a coincidence rather than an identity; that is fixed
by the hook taking both the direction and the distance from one `StraightLeg` call, which is
literally what the original does with one call.
One original bug worth recording: on the zero-length branch the helper zeroes the output and
returns with an **empty x87 stack** — no return value at all, so the caller's `fstp` for the
distance underflows. Only reachable on a degenerate zero-length leg; `NormalizeVec3` returns 0
there and says so in a comment.
## 2. What changed
* `sim::NormalizeVec3` — new, the helper reproduced narrowing for narrowing.
* `sim::StraightLeg` / `sim::StraightLegDistance` — the delta narrowed to float32 and then
normalised; one call yields direction *and* distance.
* `sim::AdvanceAlongUnitDirection` — the position tail on its own.
* `sim::AdvanceAlongDirection` — now built on the above; same signature, same callers.
* `movement_inputs.cpp` — one `StraightLeg` call replaces `Distance` + `AdvanceAlongDirection`.
* `sim::Distance` is **unchanged and still double**. It is now used only by the node-line and
stutter geometry, which almost certainly needs the same treatment — but waypoint types 2-5
have no behavioural coverage at all, so there is nothing to correct it against. The header
says so at the declaration.
Host tests pin **float32 bit patterns**, not tolerances: `test_normalise_precision` isolates
each of the four narrowings (a vector whose sum of squares rounds to 1.0f, a `sqrt(2)` that
differs in float32, a reciprocal that lands on a different float32 than the divide, the
epsilon branch, and a delta whose float32 rounding matters), and `test_advance_bit_exact`
pins four independent legs component by component. A `CHECK_NEAR` would have passed against
the broken arithmetic.
## 3. Live result
Same VM, same save, same five End Turns, same `shim.cfg.recapmisc`, twice — once with the
unchanged build as a control, once with the fix:
| build | calls | compared | diverged | `tracecmp` exit |
|---|---|---|---|---|
| `recap-7584bad-20260908T0615Z` (control) | 45 | 45 | **8** | 1 |
| `mf-45bdf7d-dirty-20260908T0721Z` (fixed) | 45 | 45 | **0** | 0 |
The control reproduced lane R's eight divergent `call_id`s exactly (42, 79, 80, 116, 118,
154, 156, 157), so the before/after is a controlled comparison on this lane's own runs and
not a comparison against someone else's report.
## 4. Coverage — read this before quoting the zero
* **15 of the 45 calls move.** The other 30 are six waypointless fleets taking the early out.
* **All 15 moving calls are the same straight-run waypoint type.** Waypoint types 2 (node
line), 3 (node route), 4 (gate teleport) and 5 (probabilistic jump) **never occurred**, so
this result says nothing about them, and the generator never moved in this hook.
* The node-line step is still **knowingly wrong**: `NodeLineStep` / `BuildStutterSegments`
exist and are unit-tested but are not wired into the hook, which steps every waypoint type
as `speed x dt`.
* The two arrivals in the run compare clean and still mean nothing — no arrival machinery is
declared.
* 42 undeclared writes in 15 calls, unchanged by this work.
**Types 2-5 were attempted and could not be reached.** The only player whose fleets move in
this workload is the AI, and every one of its moves is a straight run. The player whose travel
would produce a node-line waypoint has **no ships at all** on this save — its fleet-order
buttons are greyed out on every turn — so reaching a node-line move means building a ship over
several turns first. That is a separate piece of work and it drifts the save away from the
reference. Types 2, 3, 4 and 5 remain at zero behavioural coverage; the cheapest way to close
them is a purpose-built save that starts with a fleet in orbit next to a node line.
So: the straight-run position update is now bit-exact against the original on every straight
leg the reference workload produces, and that is the whole claim.