feat(06-02): honest ETA — per-axis EMA + independent display deadbands + arrival clock (GREEN)

- updateEta(prev|null, remainingS, remainingM): numbers-only signature
  (D-07 structural — no Fix can enter); first update seeds directly
- EMA alpha 0.25/fix per axis; display refreshes only past its axis
  deadband (+-1 min time / +-5% remaining, axes independent)
- composeEtaClock: 24-hour HH:MM instrument format (local wall clock)
- suite E1-E8 green: seed, jitter-holds-display, real-drop-updates,
  independent axes, deadband symmetry, pinned constants, purity gate
This commit is contained in:
araharah-executor
2026-10-24 12:48:29 +08:00
parent 65852940cc
commit 299827a469
2 changed files with 92 additions and 1 deletions
+86
View File
@@ -0,0 +1,86 @@
/*
* eta.ts — the honest ETA: EMA smoothing + a display deadband (D-07).
* ----------------------------------------------------------------------
* Guidance re-derives remaining time/distance from the ROUTE MODEL + the
* snapped progress on every throttled fix. Raw per-fix values wobble (GPS
* noise, speed-class steps) — an unsmoothed clock visibly whiplashes and a
* whiplashing instrument is a lying instrument. Two named mechanisms:
*
* EMA (EMA_ETA_ALPHA 0.25/fix) — per-axis exponential smoothing; the first
* update SEEDS directly (no smoothing up from zero).
*
* Deadband — the displayed value only refreshes when the smoothed value
* has moved meaningfully: ±1 min on time (DEADBAND_MIN_S), ±5% of
* remaining on distance (DEADBAND_REMAINING_PCT). The axes are
* INDEPENDENT: a time jump refreshes the clock without touching the
* metres and vice versa.
*
* D-07's law is structural here: updateEta consumes NUMBERS — the caller's
* remainingS/remainingM computed from the route model and projection — so a
* raw fix-to-destination distance cannot enter by accident. There is no
* Fix type in this module, no console, no storage, no network (gated by
* the eta suite's structural case).
*
* composeEtaClock renders the UI-SPEC instrument format: the 24-hour
* HH:MM arrival wall-clock (now + displayed eta), identical in en/ms.
*/
/** Per-fix exponential smoothing factor (D-07; replay-tuned). */
export const EMA_ETA_ALPHA = 0.25;
/** Display refresh deadband on remaining time (the ±1 min rule). */
export const DEADBAND_MIN_S = 60;
/** Display refresh deadband on remaining distance (the ±5% rule). */
export const DEADBAND_REMAINING_PCT = 0.05;
/** One honest-ETA cell: raw smoothed values + what the display shows. */
export interface EtaState {
/** EMA-smoothed remaining seconds. */
etaS: number;
/** EMA-smoothed remaining meters. */
remainingM: number;
/** What the instrument shows (deadband-held). */
displayedEtaS: number;
displayedRemainingM: number;
}
/**
* Fold one remaining-time/distance pair into the ETA state.
*
* `remainingS`/`remainingM` come from the caller's route-model math
* (projection + step durations) — never from a raw fix-to-destination
* measurement (D-07). First call seeds everything; afterwards the EMA
* smooths per axis and the display refreshes only past that axis's
* deadband (either bound passes -> THAT axis refreshes; axes independent).
*/
export function updateEta(prev: EtaState | null, remainingS: number, remainingM: number): EtaState {
if (prev === null) {
return {
etaS: remainingS,
remainingM,
displayedEtaS: remainingS,
displayedRemainingM: remainingM,
};
}
const a = EMA_ETA_ALPHA;
const etaS = a * remainingS + (1 - a) * prev.etaS;
const remaining = a * remainingM + (1 - a) * prev.remainingM;
const displayedEtaS =
Math.abs(etaS - prev.displayedEtaS) >= DEADBAND_MIN_S ? etaS : prev.displayedEtaS;
const displayedRemainingM =
Math.abs(remaining - prev.displayedRemainingM) >= DEADBAND_REMAINING_PCT * remainingM
? remaining
: prev.displayedRemainingM;
return { etaS, remainingM: remaining, displayedEtaS, displayedRemainingM };
}
/**
* The instrument clock: the 24-hour HH:MM wall time `etaS` seconds from
* `nowMs` (the UI-SPEC arrival format; identical in en/ms). Local time —
* the driver's clock, not the server's.
*/
export function composeEtaClock(etaS: number, nowMs: number): string {
const at = new Date(nowMs + etaS * 1000);
const hh = String(at.getHours()).padStart(2, "0");
const mm = String(at.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
+6 -1
View File
@@ -78,7 +78,12 @@ test("E4 a genuine 5-minute drop updates within 2 fixes and converges to the tar
// alpha 0.25 convergence: (1 - 0.25)^n < 0.1 at n = 9 — 12 fixes lands inside 10% // alpha 0.25 convergence: (1 - 0.25)^n < 0.1 at n = 9 — 12 fixes lands inside 10%
for (let i = 0; i < 12; i++) s = updateEta(s, 300, 10000); for (let i = 0; i < 12; i++) s = updateEta(s, 300, 10000);
assert.ok(Math.abs(s.etaS - 300) <= 30, `smoothed converges to the drop (got ${s.etaS})`); assert.ok(Math.abs(s.etaS - 300) <= 30, `smoothed converges to the drop (got ${s.etaS})`);
assert.ok(Math.abs(s.displayedEtaS - 300) <= 30, `display converges too (got ${s.displayedEtaS})`); // the display trails the converged value by at most the deadband — its
// whole contract (and why the instrument never whiplashes)
assert.ok(
Math.abs(s.displayedEtaS - s.etaS) <= DEADBAND_MIN_S,
`display within one deadband of the smoothed truth (got ${s.displayedEtaS} vs ${s.etaS})`,
);
}); });
/* ————— E5 — independent axes ————— */ /* ————— E5 — independent axes ————— */