Menü-Demo: die Neuplanung rechnet gegen ein NaN-Ziel – keine einzige Muster-Strategie wird je geprüft #17

Closed
opened 2026-09-02 15:40:22 +02:00 by gilli · 2 comments
Owner

⁨⁩⁨Menü-Demo: die Neuplanung rechnet gegen ein NaN-Ziel – keine einzige Muster-Strategie wird je geprüft⁩ ⁨⁩#⁨17⁩⁨⁩"
By ⁨⁩⁨gilli⁩⁨⁩ — ⁨⁨⁩Closed⁨⁩⁩
bug
consistency
game-feel
medium

Komponente: GAME (startDemo, updateDemo), CORE (liveStrategy, simProbe)
Betroffene Version: main @ 42f3cce, index.html (3.333 Zeilen, MD5 aae5f94c1dc05903f2c214d255d2fe9d),
Zeilen 1285, 1258–1280, 2668, 3072, 3117

Beschreibung

Issue #15 hat der Menü-Demo die Neuplanung gegeben: an der Grenze zu einem neuen Muster prüft
CORE.liveStrategy(), ob die Muster-Strategie ab dem lebenden Zustand der Demo überlebt,
und nimmt sonst die erste Strategie, die es schafft. Die Demo übergibt den Plan aber ohne x1:

// index.html:2668 (startDemo) und index.html:3072 (updateDemo)
(placed) => { plans.push({ x0: placed.x0, strategy: placed.strategy }); }

liveStrategy leitet daraus das Prüfziel ab:

// index.html:1285
const untilX = (plan ? plan.x1 : body.x + 1400) + 80;   // undefined + 80  →  NaN

simProbe() endet nur mit { ok: true }, wenn die Probefigur landet und b.x >= untilX
erreicht (Zeile 1276). Gegen NaN ist dieser Vergleich immer false – jede Probe läuft
deshalb ihre volle Schrittmenge (maxSteps = ceil(K.SIM_MAX_TIME / dt) = 552 Schritte) und
verwirft die Strategie. Von den bis zu 48 Kandidaten (tryList-Obergrenze, Zeile 1293) kann
also nie einer durchkommen, die Funktion fällt in jedem einzelnen Fall auf
plan.strategy zurück (Zeile 1296) – auf genau das Verhalten also, das vor Issue #15
ausgeliefert war, nur mit bezahlter Rechenarbeit.

Messung

Node gegen den extrahierten CORE-Block, nachgebauter Demo-Pfad wie startDemo/updateDemo
(gleicher Same, fester Schritt K.FIXED_TIMESTEP, generateAhead mit onCommit,
pruneWorld), 30 s Demo-Zeit:

Plan-Daten Strategiewechsel Kosten gesamt teuerster Wechsel Ergebnis
wie ausgeliefert (x0, strategy) 32 83,5 ms 5,0 ms 32/32 Rückfall auf plan.strategy
mit x1 im Plan 32 8,1 ms 0,9 ms Proben greifen, wechseln früh ab

Beide Läufe legen dieselbe Strecke zurück (16 135 px) – die Neuplanung entscheidet schlicht
nie anders, sie kostet nur das Zehnfache. Direkt nachgerechnet:

untilX bei plan {x0, strategy} = NaN

Im Browser ist das dasselbe Bild: an jeder Muster-Grenze bis zu 48 Kandidaten × 552
stepBody-Aufrufe, also bis zu ~26 500 Simulationsschritte in einem Frame – on top der
Generierung derselben Bildrate (gemessen p99 6,7 ms, max 12,6 ms pro generateAhead-Aufruf).

Auswirkung

  • Das Feature aus Issue #15 ist im ausgelieferten Stand unwirksam: die Demo fährt stur die
    Muster-Strategie, auch wenn die aus dem lebenden Zustand (Ankunft mitten im Flug,
    mitgebrachter Schwung, gerade erst gekühlter Haken) tödlich ist. Der Schutz gegen die
    Rückfall-Situation „Demo kommt fliegend an“ existiert nur dem Namen nach.
  • Unnötige Frame-Spitzen an jeder Muster-Grenze (~5 ms in Node, im Browser mindestens
    derselbe Aufwand an JS) – bei 32 Grenzen in 30 s Dauerlast im Hauptmenü.
  • untilX ist ein NaN, das durch simProbe durchgereicht wird; die einzige Stelle, die es
    hätte auffangen können (b.grounded && b.x >= untilX) ist dafür die stille.
  • tests/issue15.mjs sieht das nicht: er misst Neustarts und Strecke – beides bleibt beim
    Rückfall unverändert gut.

Lösungsvorschlag

  1. x1 mitgeben – in beiden Sammelstellen:
    plans.push({ x0: placed.x0, x1: placed.x1, strategy: placed.strategy })
    (Zeile 2668 und 3072). placed ist das Muster-Objekt und trägt x1 bereits.
  2. Und absichern: liveStrategy soll ein fehlendes x1 nicht als Ziel akzeptieren,
    z. B. const untilX = (plan && isFinite(plan.x1) ? plan.x1 : body.x + 1400) + 80; – damit
    ein künftiger Aufrufer ohne x1 nicht wieder stumm 26 500 Schritte ins Leere rechnet.
  3. Optional ein Zähler/Ereignis an liveStrategy (probed, fallback), damit ein Test die
    Trefferquote der Proben sehen kann statt nur das Ergebnis.

Tests

Ergänzung zu tests/issue15.mjs (oder eigene Suite), gemessen an derselben Nachbildung des
Demo-Pfads:

  • liveStrategy bekommt einen Plan mit x1 und liefert in ≥ 1 von N Fällen eine
    Strategie, die per simProbe als überlebend bestätigt wurde (nicht nur den Rückfallwert).
  • Aufwands-Schranke: mittlere Kosten pro Strategiewechsel ≤ 1 ms und höchster Einzelwert
    ≤ 2 ms (Node, K.FIXED_TIMESTEP). Auslieferung: 2,6 ms im Mittel, 5,0 ms Spitze.
  • Strukturprüfung gegen index.html: beide plans.push-Stellen übergeben x1
    (Textanker, wie tests/issue4.mjs ihn für die Puffer-Suite nutzt), und untilX ist über
    isFinite abgesichert.

Dokumentiert in issues/17-demo-neuplanung-rechnet-gegen-nan-ziel.md (Commit 42f3cce).

⁨2⁩ comments

⁨⁩⁨Menü-Demo: die Neuplanung rechnet gegen ein NaN-Ziel – keine einzige Muster-Strategie wird je geprüft⁩ ⁨⁩#⁨17⁩⁨⁩" By ⁨⁩⁨gilli⁩⁨⁩ — ⁨⁨⁩Closed⁨⁩⁩ bug consistency game-feel medium > **Komponente:** GAME (`startDemo`, `updateDemo`), CORE (`liveStrategy`, `simProbe`) > **Betroffene Version:** `main` @ `42f3cce`, `index.html` (3.333 Zeilen, MD5 `aae5f94c1dc05903f2c214d255d2fe9d`), > Zeilen 1285, 1258–1280, 2668, 3072, 3117 > > ## Beschreibung > > Issue #15 hat der Menü-Demo die Neuplanung gegeben: an der Grenze zu einem neuen Muster prüft > `CORE.liveStrategy()`, ob die Muster-Strategie ab dem *lebenden* Zustand der Demo überlebt, > und nimmt sonst die erste Strategie, die es schafft. Die Demo übergibt den Plan aber ohne `x1`: > > ```js > // index.html:2668 (startDemo) und index.html:3072 (updateDemo) > (placed) => { plans.push({ x0: placed.x0, strategy: placed.strategy }); } > ``` > > `liveStrategy` leitet daraus das Prüfziel ab: > > ```js > // index.html:1285 > const untilX = (plan ? plan.x1 : body.x + 1400) + 80; // undefined + 80 → NaN > ``` > > `simProbe()` endet nur mit `{ ok: true }`, wenn die Probefigur **landet und `b.x >= untilX`** > erreicht (Zeile 1276). Gegen `NaN` ist dieser Vergleich immer `false` – **jede** Probe läuft > deshalb ihre volle Schrittmenge (`maxSteps = ceil(K.SIM_MAX_TIME / dt)` = 552 Schritte) und > verwirft die Strategie. Von den bis zu 48 Kandidaten (`tryList`-Obergrenze, Zeile 1293) kann > also nie einer durchkommen, die Funktion fällt in jedem einzelnen Fall auf > `plan.strategy` zurück (Zeile 1296) – auf genau das Verhalten also, das vor Issue #15 > ausgeliefert war, nur mit bezahlter Rechenarbeit. > > ## Messung > > Node gegen den extrahierten CORE-Block, nachgebauter Demo-Pfad wie `startDemo`/`updateDemo` > (gleicher Same, fester Schritt `K.FIXED_TIMESTEP`, `generateAhead` mit `onCommit`, > `pruneWorld`), 30 s Demo-Zeit: > > | Plan-Daten | Strategiewechsel | Kosten gesamt | teuerster Wechsel | Ergebnis | > |---|---|---|---|---| > | wie ausgeliefert (`x0`, `strategy`) | 32 | **83,5 ms** | **5,0 ms** | 32/32 Rückfall auf `plan.strategy` | > | mit `x1` im Plan | 32 | 8,1 ms | 0,9 ms | Proben greifen, wechseln früh ab | > > Beide Läufe legen dieselbe Strecke zurück (16 135 px) – die Neuplanung entscheidet schlicht > nie anders, sie kostet nur das Zehnfache. Direkt nachgerechnet: > > ``` > untilX bei plan {x0, strategy} = NaN > ``` > > Im Browser ist das dasselbe Bild: an jeder Muster-Grenze bis zu 48 Kandidaten × 552 > `stepBody`-Aufrufe, also bis zu ~26 500 Simulationsschritte in **einem** Frame – on top der > Generierung derselben Bildrate (gemessen p99 6,7 ms, max 12,6 ms pro `generateAhead`-Aufruf). > > ## Auswirkung > > * Das Feature aus Issue #15 ist im ausgelieferten Stand **unwirksam**: die Demo fährt stur die > Muster-Strategie, auch wenn die aus dem lebenden Zustand (Ankunft mitten im Flug, > mitgebrachter Schwung, gerade erst gekühlter Haken) tödlich ist. Der Schutz gegen die > Rückfall-Situation „Demo kommt fliegend an“ existiert nur dem Namen nach. > * Unnötige Frame-Spitzen an jeder Muster-Grenze (~5 ms in Node, im Browser mindestens > derselbe Aufwand an JS) – bei 32 Grenzen in 30 s Dauerlast im Hauptmenü. > * `untilX` ist ein `NaN`, das durch `simProbe` durchgereicht wird; die einzige Stelle, die es > hätte auffangen können (`b.grounded && b.x >= untilX`) ist dafür die stille. > * `tests/issue15.mjs` sieht das nicht: er misst Neustarts und Strecke – beides bleibt beim > Rückfall unverändert gut. > > ## Lösungsvorschlag > > 1. **`x1` mitgeben** – in beiden Sammelstellen: > `plans.push({ x0: placed.x0, x1: placed.x1, strategy: placed.strategy })` > (Zeile 2668 und 3072). `placed` ist das Muster-Objekt und trägt `x1` bereits. > 2. **Und absichern:** `liveStrategy` soll ein fehlendes `x1` nicht als Ziel akzeptieren, > z. B. `const untilX = (plan && isFinite(plan.x1) ? plan.x1 : body.x + 1400) + 80;` – damit > ein künftiger Aufrufer ohne `x1` nicht wieder stumm 26 500 Schritte ins Leere rechnet. > 3. Optional ein Zähler/Ereignis an `liveStrategy` (`probed`, `fallback`), damit ein Test die > Trefferquote der Proben sehen kann statt nur das Ergebnis. > > ## Tests > > Ergänzung zu `tests/issue15.mjs` (oder eigene Suite), gemessen an derselben Nachbildung des > Demo-Pfads: > > * `liveStrategy` bekommt einen Plan **mit** `x1` und liefert in ≥ 1 von N Fällen eine > Strategie, die per `simProbe` als überlebend bestätigt wurde (nicht nur den Rückfallwert). > * Aufwands-Schranke: mittlere Kosten pro Strategiewechsel ≤ 1 ms und höchster Einzelwert > ≤ 2 ms (Node, `K.FIXED_TIMESTEP`). Auslieferung: 2,6 ms im Mittel, 5,0 ms Spitze. > * Strukturprüfung gegen `index.html`: beide `plans.push`-Stellen übergeben `x1` > (Textanker, wie `tests/issue4.mjs` ihn für die Puffer-Suite nutzt), und `untilX` ist über > `isFinite` abgesichert. > > --- > > *Dokumentiert in `issues/17-demo-neuplanung-rechnet-gegen-nan-ziel.md` (Commit `42f3cce`).* ⁨2⁩ comments
Author
Owner

Behoben in 31c648d (Doku-Nachtrag 4a86d21), beide Punkte des Lösungsvorschlags plus Zähler.

Geändert

  1. startDemo/updateDemo übergeben x1 mit:
    plans.push({ x0: placed.x0, x1: placed.x1, strategy: placed.strategy }) — placed trägt
    das Feld seit buildPattern, es wurde nur weggeworfen.
  2. liveStrategy akzeptiert kein nicht-endliches x1 als Ziel:
    const untilX = (plan && isFinite(plan.x1) ? plan.x1 : body.x + 1400) + 80; — ein künftiger
    Aufrufer ohne x1 rechnet damit wieder ins Leere, aber nicht mehr 26 500 Schritte lang und
    nicht mehr stumm. Kommentar an simProbe hält die Regel fest (b.x >= NaN wird nie wahr).
  3. Punkt 3 des Vorschlags ist drin: liveStrategy(…, stats) zählt auf Wunsch untilX,
    probed, fallback (optionaler Ausgabeparameter, analog gen.stats). Das Produkt ruft ohne
    stats auf, im Frame entsteht kein Zusatzaufwand.

Gemessen (Demo-Nachbau wie startDemo/updateDemo, fester Schritt K.FIXED_TIMESTEP,
6 Samen × 45 s)

vorher nachher
Proben pro Grenze 48 auf jeder der 250 Grenzen Ø 3,1 (Vollscan noch 2 von 246 = 0,8 % — echte Rückfälle, an denen keine der 48 Strategien überlebt)
Zeit pro Grenze Ø 3,2 ms, p95 4,8 ms, Spitze 5,6 ms Ø 0,30 ms, p95 0,92 ms
gegen das Musterende bestätigte Strategien 196/250 (78 %) — und liveStrategy selbst hat nie eine bestätigt 244/246 (99 %)
Neustarts 2 0

Der Rückfall auf die Muster-Strategie war also nicht nur teuer: an 54 Grenzen wäre er real
in den Tod gegangen — die zwei Neustarts im Nachbau sind die Stellen, an denen die Demo
tatsächlich gestorben ist. Damit ist der Schutz aus #15 („Demo kommt fliegend an“) erstmals wirksam.

Test: neue Suite tests/issue17.mjs (17 Prüfungen, in tests/run-all.sh aufgenommen)

  • Struktur über die zwei plans.push-Stellen des Dokuments — die Schlüssel werden aus dem
    Objekt-Literal gelesen und der Demo-Nachbau baut seine Pläne damit, sonst maße er eine
    Fassung, die es nicht gibt. Dazu isFinite-Anker an untilX.
  • Zähler-Vertrag: stats.probed/fallback/untilX, ein Plan ohne x1 liefert endliches Ziel
    und kurze Probenliste, die zurückgegebene Strategie überlebt genau dieses Ziel.
  • Aufwand/Trefferquote im Nachbau: Ø ≤ 6 Proben, Vollscan < 5 %, Ø ≤ 1 ms und p95 ≤ 2 ms pro
    Wechsel, bestätigte Proben ≥ 90 %, dazu unverändert 0 Neustarts, Seilkontakt und Strecke.

Gegen main (PULSE_HTML=git show HEAD:index.html) wären 11 dieser Prüfungen rot, jetzt grün.
tests/issue15.mjs und die übrigen Node-Suiten bleiben grün; run-all.sh komplett gelaufen —
einzig browser_flow.py zeigte die dokumentierte Bildraten-Unschärfe (28,4 px gegen 28,3 px
Schranke bei dt 27,7 ms), wechselnd gegen alte und neue Fassung gemessen; verbindlich dafür ist
tests/issue2.mjs.

Behoben in `31c648d` (Doku-Nachtrag `4a86d21`), beide Punkte des Lösungsvorschlags plus Zähler. **Geändert** 1. `startDemo`/`updateDemo` übergeben `x1` mit: `plans.push({ x0: placed.x0, x1: placed.x1, strategy: placed.strategy })` — `placed` trägt das Feld seit `buildPattern`, es wurde nur weggeworfen. 2. `liveStrategy` akzeptiert kein nicht-endliches `x1` als Ziel: `const untilX = (plan && isFinite(plan.x1) ? plan.x1 : body.x + 1400) + 80;` — ein künftiger Aufrufer ohne `x1` rechnet damit wieder ins Leere, aber nicht mehr 26 500 Schritte lang und nicht mehr stumm. Kommentar an `simProbe` hält die Regel fest (`b.x >= NaN` wird nie wahr). 3. Punkt 3 des Vorschlags ist drin: `liveStrategy(…, stats)` zählt auf Wunsch `untilX`, `probed`, `fallback` (optionaler Ausgabeparameter, analog `gen.stats`). Das Produkt ruft ohne `stats` auf, im Frame entsteht kein Zusatzaufwand. **Gemessen (Demo-Nachbau wie `startDemo`/`updateDemo`, fester Schritt `K.FIXED_TIMESTEP`, 6 Samen × 45 s)** | | vorher | nachher | |---|---|---| | Proben pro Grenze | 48 auf **jeder** der 250 Grenzen | Ø 3,1 (Vollscan noch 2 von 246 = 0,8 % — echte Rückfälle, an denen keine der 48 Strategien überlebt) | | Zeit pro Grenze | Ø 3,2 ms, p95 4,8 ms, Spitze 5,6 ms | Ø 0,30 ms, p95 0,92 ms | | gegen das Musterende bestätigte Strategien | 196/250 (78 %) — und `liveStrategy` selbst hat nie eine bestätigt | 244/246 (99 %) | | Neustarts | 2 | 0 | Der Rückfall auf die Muster-Strategie war also nicht nur teuer: an 54 Grenzen wäre er real in den Tod gegangen — die zwei Neustarts im Nachbau sind die Stellen, an denen die Demo tatsächlich gestorben ist. Damit ist der Schutz aus #15 („Demo kommt fliegend an“) erstmals wirksam. **Test**: neue Suite `tests/issue17.mjs` (17 Prüfungen, in `tests/run-all.sh` aufgenommen) * Struktur über die zwei `plans.push`-Stellen des Dokuments — die Schlüssel werden aus dem Objekt-Literal *gelesen* und der Demo-Nachbau baut seine Pläne damit, sonst maße er eine Fassung, die es nicht gibt. Dazu `isFinite`-Anker an `untilX`. * Zähler-Vertrag: `stats.probed`/`fallback`/`untilX`, ein Plan ohne `x1` liefert endliches Ziel und kurze Probenliste, die zurückgegebene Strategie überlebt genau dieses Ziel. * Aufwand/Trefferquote im Nachbau: Ø ≤ 6 Proben, Vollscan < 5 %, Ø ≤ 1 ms und p95 ≤ 2 ms pro Wechsel, bestätigte Proben ≥ 90 %, dazu unverändert 0 Neustarts, Seilkontakt und Strecke. Gegen `main` (`PULSE_HTML=git show HEAD:index.html`) wären 11 dieser Prüfungen rot, jetzt grün. `tests/issue15.mjs` und die übrigen Node-Suiten bleiben grün; `run-all.sh` komplett gelaufen — einzig `browser_flow.py` zeigte die dokumentierte Bildraten-Unschärfe (28,4 px gegen 28,3 px Schranke bei dt 27,7 ms), wechselnd gegen alte und neue Fassung gemessen; verbindlich dafür ist `tests/issue2.mjs`.
gilli closed this issue 2026-09-03 21:27:42 +02:00
Author
Owner

Kleine Richtigstellung zu meiner Tabelle oben (Nachtrag 259a65b): die Zeile „bestätigte
Proben 0“ in der Commit-Botschaft zu 31c648d stammte aus einem Lauf, in dem die
Bestätigungsprüfung selbst kaputt war – simProbe(world, body, ctx, s, t0, untilX, dt, margin)
war mit vertauschten Argumenten aufgerufen (dt an Stelle von untilX), bestätigte also eine
einzige Schrittlänge statt des Musterendes.

Richtig gemessen liefert die Auslieferung 196 von 250 Grenzen (78 %) eine Strategie, die
gegen x1 + 80 überlebt – nur hat liveStrategy selbst nie eine davon bestätigt, weil seine
eigene Probe gegen NaN lief. Die Aufwandszahlen bleiben wie angegeben: 48 Proben auf jeder
Grenze, Ø 3,2 ms / p95 4,8 ms / Spitze 5,6 ms, 2 Neustarts (nach dem Fix 0).

Zusätzlich im Suite-Kopf vermerkt: PULSE_HTML verschiebt in tests/issue17.mjs nur die
Struktur-Anker – die Simulation kommt über harness.mjs immer aus dem index.html des
Arbeitsbaums. Für den Rot-Nachlauf gegen eine alte Fassung ist die also als index.html
einzusetzen (dann: 11 von 17 Prüfungen rot).

Kleine Richtigstellung zu meiner Tabelle oben (Nachtrag `259a65b`): die Zeile „bestätigte Proben 0“ in der Commit-Botschaft zu `31c648d` stammte aus einem Lauf, in dem die Bestätigungsprüfung selbst kaputt war – `simProbe(world, body, ctx, s, t0, untilX, dt, margin)` war mit vertauschten Argumenten aufgerufen (dt an Stelle von untilX), bestätigte also eine einzige Schrittlänge statt des Musterendes. Richtig gemessen liefert die Auslieferung **196 von 250 Grenzen (78 %)** eine Strategie, die gegen `x1 + 80` überlebt – nur hat `liveStrategy` selbst nie eine davon bestätigt, weil seine eigene Probe gegen NaN lief. Die Aufwandszahlen bleiben wie angegeben: 48 Proben auf jeder Grenze, Ø 3,2 ms / p95 4,8 ms / Spitze 5,6 ms, 2 Neustarts (nach dem Fix 0). Zusätzlich im Suite-Kopf vermerkt: `PULSE_HTML` verschiebt in `tests/issue17.mjs` nur die Struktur-Anker – die Simulation kommt über `harness.mjs` immer aus dem `index.html` des Arbeitsbaums. Für den Rot-Nachlauf gegen eine alte Fassung ist die also als `index.html` einzusetzen (dann: 11 von 17 Prüfungen rot).
Sign in to join this conversation.
No description provided.