Files
arah2/web/test/guidance-eta.test.mjs
T
araharah-executor 299827a469 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
2026-10-24 12:48:29 +08:00

149 lines
7.0 KiB
JavaScript

#!/usr/bin/env node
/*
* guidance-eta.test.mjs — GUID-03: the honest ETA (D-07 EMA + deadband).
* ------------------------------------------------------------------------
* The eta module consumes NUMBERS (remainingS/remainingM computed by the
* caller from the route model + snapped progress) — never a Fix, never a
* raw fix-to-destination distance (the D-07 law is structural: the
* signature cannot lie). Cases:
*
* E1 constants pinned (EMA_ETA_ALPHA 0.25, deadbands 60 s / 5%)
* E2 the first update seeds the EMA directly (no smoothing toward zero)
* E3 ±40 s jitter around a steady value never moves the display
* E4 a genuine 5-minute drop updates the display within 2 fixes and
* converges (alpha 0.25: within 10% of target after ~9-12 fixes)
* E5 the axes are independent: a time jump moves only the clock, a
* distance jump moves only the remaining metres
* E6 the distance deadband: a 1% drift holds, a 6% jump updates
* E7 composeEtaClock renders the 24-hour HH:MM instrument format
* E8 structural: the module is pure and Fix-free (numbers only)
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "./harness.mjs";
const HERE = path.dirname(fileURLToPath(new URL(import.meta.url)));
const { updateEta, composeEtaClock, EMA_ETA_ALPHA, DEADBAND_MIN_S, DEADBAND_REMAINING_PCT } = await import(
"../src/guidance/eta.ts"
);
/* ————— E1 — the constants ————— */
test("E1 eta constants are exported at their pinned D-07 values", () => {
assert.equal(EMA_ETA_ALPHA, 0.25);
assert.equal(DEADBAND_MIN_S, 60);
assert.equal(DEADBAND_REMAINING_PCT, 0.05);
});
/* ————— E2 — seeding ————— */
test("E2 the first update seeds both axes directly — no smoothing toward zero", () => {
const s = updateEta(null, 600, 10000);
assert.equal(s.etaS, 600);
assert.equal(s.remainingM, 10000);
assert.equal(s.displayedEtaS, 600, "displayed clock seeds at the raw value");
assert.equal(s.displayedRemainingM, 10000, "displayed distance seeds at the raw value");
});
/* ————— E3 — jitter holds the display ————— */
test("E3 ±40 s jitter around a steady ETA never moves the display (no whiplash)", () => {
let s = updateEta(null, 600, 10000);
const displayed = [];
for (let i = 0; i < 60; i++) {
const raw = i % 2 === 0 ? 560 : 640; // ±40 s around 600
s = updateEta(s, raw, 10000);
displayed.push(s.displayedEtaS);
}
assert.ok(displayed.every((d) => d === 600), "the displayed clock holds through the whole jitter stream");
// the EMA itself tracks loosely around the truth (bounded oscillation)
assert.ok(Math.abs(s.etaS - 600) < 30, `the smoothed value stays near 600 (got ${s.etaS})`);
});
/* ————— E4 — a real drop propagates ————— */
test("E4 a genuine 5-minute drop updates within 2 fixes and converges to the target", () => {
let s = updateEta(null, 600, 10000);
for (let i = 0; i < 10; i++) s = updateEta(s, 600, 10000); // steady cruise
assert.equal(s.displayedEtaS, 600);
s = updateEta(s, 300, 10000); // motorway ramp — the honest drop
assert.equal(s.displayedEtaS, 525, "the display moves after ONE fix (75 s >= the 60 s deadband)");
s = updateEta(s, 300, 10000);
assert.ok(s.displayedEtaS < 600 - DEADBAND_MIN_S, "the display is on its way within 2 fixes");
// 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);
assert.ok(Math.abs(s.etaS - 300) <= 30, `smoothed converges to the drop (got ${s.etaS})`);
// 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 ————— */
test("E5 axes are independent: a time jump moves only the clock; a distance jump only the metres", () => {
// time jump with distance pinned: only displayedEtaS moves
let s = updateEta(null, 600, 10000);
for (let i = 0; i < 5; i++) s = updateEta(s, 600, 10000);
const before = { ...s };
for (let i = 0; i < 4; i++) s = updateEta(s, 300, 10000); // -5 min, distance constant
assert.ok(s.displayedEtaS <= before.displayedEtaS - DEADBAND_MIN_S, "the clock refreshed");
assert.equal(s.displayedRemainingM, before.displayedRemainingM, "the distance display never moved");
// distance jump with time pinned: only displayedRemainingM moves
s = updateEta(null, 600, 10000);
for (let i = 0; i < 5; i++) s = updateEta(s, 600, 10000);
const before2 = { ...s };
for (let i = 0; i < 4; i++) s = updateEta(s, 600, 12000); // +2 km, time constant
assert.ok(
s.displayedRemainingM >= before2.displayedRemainingM + DEADBAND_REMAINING_PCT * 12000,
"the distance display refreshed past its 5% deadband",
);
assert.equal(s.displayedEtaS, before2.displayedEtaS, "the clock never moved");
});
/* ————— E6 — the distance deadband ————— */
test("E6 distance deadband: a 1% drift holds the display; a real 6% jump updates it", () => {
let s = updateEta(null, 600, 10000);
for (let i = 0; i < 8; i++) s = updateEta(s, 600, 10100); // +1% creep
assert.equal(s.displayedRemainingM, 10000, "sub-deadband drift never reaches the display");
s = updateEta(null, 600, 10000);
for (let i = 0; i < 8; i++) s = updateEta(s, 600, 10600); // +6% detour
assert.ok(
s.displayedRemainingM > 10000,
`the display follows a genuine jump (got ${s.displayedRemainingM})`,
);
});
/* ————— E7 — the arrival clock ————— */
test("E7 composeEtaClock renders the 24-hour HH:MM instrument format (en/ms identical)", () => {
const tenOclock = new Date(2026, 0, 1, 10, 0, 0).getTime();
assert.equal(composeEtaClock(1800, tenOclock), "10:30");
assert.equal(composeEtaClock(3660, tenOclock), "11:01");
const justBeforeMidnight = new Date(2026, 0, 1, 23, 59, 0).getTime();
assert.equal(composeEtaClock(600, justBeforeMidnight), "00:09", "rolls over midnight");
const out = composeEtaClock(59.4, new Date(2026, 5, 15, 7, 2, 0).getTime());
assert.match(out, /^\d{2}:\d{2}$/, "exactly HH:MM");
assert.equal(composeEtaClock(0, new Date(2026, 0, 1, 5, 5, 0).getTime()), "05:05", "zero-padding");
});
/* ————— E8 — structural ————— */
test("E8 eta.ts is pure and Fix-free: numbers in, no console, no storage", () => {
const src = readFileSync(path.join(HERE, "..", "src", "guidance", "eta.ts"), "utf8");
const code = src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
assert.doesNotMatch(code, /console\./, "no console in the eta module");
assert.doesNotMatch(code, /\bFix\b/, "the signature takes numbers, never a Fix (D-07 structural)");
assert.doesNotMatch(code, /localStorage|sessionStorage|fetch\(/, "no storage, no network (T-06-05)");
});