Stage 1: project foundation (strict TS, lint boundaries B-1..B-7, directory structure, CI, boundary tests)

- dedicated git repo at /home/avi/Projects/Lumen (main)
- TypeScript strict (target ES2022, bundler, exactOptionalPropertyTypes, noUncheckedIndexedAccess)
- ESLint 9 + typescript-eslint strictTypeChecked + eslint-plugin-boundaries for B-1..B-7, no-restricted-globals/syntax for B-1/B-7
- Prettier 3.5
- Structure per IMPLEMENTATION-CONTRACT.md §4 (src/platform/idb|cache|sw, storage, data, sync/{transport,verifier}, domain/{emergency,schedule,map,festival,readiness,clock,favorites}, ui/{components,views,router,render}, app, emergency-baseline, assets, public, content, pipeline, tests, scripts)
- CI: .github/workflows/ci.yml (typecheck + lint + format + test)
- Boundary tests: tests/unit/boundaries.test.ts (4 tests) + scripts/check-boundaries.ts
- No feature code, no PWA/IDB/sync/mesh/accounts per contract Stage 1
This commit is contained in:
Lumen Stage1 2026-08-30 23:25:35 -05:00
commit c0bfd413ff
100 changed files with 9863 additions and 0 deletions

59
experiments/README.md Normal file
View file

@ -0,0 +1,59 @@
# Lumen — Validation Experiments (Architecture Validation Phase)
These are **disposable validation experiments**. They are **not** application
code, not part of the future product, and must not be imported by the app.
They exist to falsify or confirm specific architectural assumptions before
the architecture is frozen. Each is referenced by the spike document that
consumes its results.
| File | Validates | Spike | Runtime |
|------|-----------|-------|---------|
| `exp1-time-model.mjs` | UTC storage, IANA-zone rendering, day boundaries, DST, skew, sanity window, now/next | SPIKE-08 | `node exp1-time-model.mjs` |
| `exp2-ab-update-sim.mjs` | A/B dual-slot atomic update state machine under crash/fault injection | SPIKE-02 | `node exp2-ab-update-sim.mjs` |
| `exp3-map-bench.html` | Map representation approaches (raster+DOM, SVG, canvas) | SPIKE-03 | headless Chromium (see below) |
## Evidence classes used throughout
Every claim in the spike documents is labelled:
- **CONFIRMED** — observed by running an experiment here, or directly
documented by the platform vendor as guaranteed behavior.
- **INFERRED** — follows from documented platform behavior + architectural
reasoning, but not directly observed in this environment.
- **UNVERIFIED** — cannot be established on this Linux dev box; requires a
physical device/browser.
## Environment
- Linux dev machine, Node v22.23.2 (full-ICU), headless Chromium
(`chromium-browser`), Firefox available.
- **No iOS hardware or iOS Simulator is reachable from this environment.**
Nothing labelled iOS is CONFIRMED here; iOS items are INFERRED or
UNVERIFIED and are listed in each spike's "must test on physical iOS"
section.
## How the map benchmark is run (EXP-3)
```
chromium-browser --headless=new --disable-gpu --no-sandbox \
--window-size=800,600 --virtual-time-budget=120000 \
--dump-dom "file:///…/experiments/exp3-map-bench.html"
```
**Interpretation caveat (important):** the benchmark measures synchronous JS
work per animation frame on a desktop GPU/CPU. All three approaches measured
≈ 0 ms/frame here. That does **not** mean they are equivalent on a low-end
Android phone: the dominant costs there are GPU compositing of transformed
layers, texture memory, and raster image decode — none of which a headless
desktop run represents. EXP-3's value is confirming that none of the three
has a disqualifying *JS-side* cost and that the DOM-overlay and SVG variants
are mechanically viable; the final representation decision still requires the
physical-device protocol in SPIKE-03.
## Observed results (run 2026-08-30)
- EXP-1: **24/24 PASS**. (Two earlier failures were test-data errors in the
experiment itself, corrected; the time logic was sound.)
- EXP-2: **16/16 PASS** across all crash/fault points.
- EXP-3: all variants complete; per-frame JS work ~0 ms across the board
(see caveat).

View file

@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* EXP-1 — Time model validation (SPIKE-08)
* Disposable experiment. NOT application code. No dependencies.
*
* Validates, using Node's full-ICU Intl implementation (ECMA-402, the same
* spec browsers implement):
* T1 UTC epoch ms → festival-zone wall-clock rendering via Intl
* T2 dayKey derivation (calendar date in festival zone) incl. midnight edges
* T3 DST-transition behavior (spring/fall) for a DST zone
* T4 Non-DST and unusual-offset zones (fixed offsets, +5:45, 30-min DST)
* T5 Server-skew correction arithmetic (now = device + skew)
* T6 Sanity-window check logic
* T7 Now/Next classification using corrected clock
*
* Evidence class: spec-level behavior confirmed on V8/ICU. Browser engine
* parity (JavaScriptCore on iOS) is INFERRED, not proven here.
*/
'use strict';
let pass = 0, fail = 0;
const results = [];
function check(id, name, actual, expected) {
const ok = JSON.stringify(actual) === JSON.stringify(expected);
ok ? pass++ : fail++;
results.push({ id, name, ok, actual, expected });
}
const fmt = (tz) => new Intl.DateTimeFormat('en-US', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
});
const render = (ms, tz) => fmt(tz).format(new Date(ms));
// dayKey: calendar date parts in festival zone (publish-time precompute analog)
function dayKey(ms, tz) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
}).formatToParts(new Date(ms));
const g = (t) => parts.find((p) => p.type === t).value;
return `${g('year')}-${g('month')}-${g('day')}`;
}
// ---------- T1: UTC rendering in festival zone ----------
const CHI = 'America/Chicago';
// 2026-07-15T18:00:00Z == 13:00 CDT (UTC-5)
const t1 = Date.UTC(2026, 6, 15, 18, 0, 0);
check('T1a', 'UTC→Chicago CDT render', render(t1, CHI), '07/15/2026, 13:00:00');
// same instant in device-zone-agnostic rendering must NOT change with host tz
check('T1b', 'render independent of host tz', render(t1, CHI).includes('13:00:00'), true);
// ---------- T2: dayKey incl. local-midnight edges ----------
// 2026-07-16T04:59:00Z == 2026-07-15 23:59 CDT → dayKey 07-15
check('T2a', 'dayKey before local midnight', dayKey(Date.UTC(2026, 6, 16, 4, 59), CHI), '2026-07-15');
// 2026-07-16T05:00:00Z == 2026-07-16 00:00 CDT → dayKey 07-16
check('T2b', 'dayKey at local midnight', dayKey(Date.UTC(2026, 6, 16, 5, 0), CHI), '2026-07-16');
// UTC midnight is NOT local midnight: 2026-07-16T00:00Z == 07-15 19:00 CDT
check('T2c', 'UTC midnight ≠ festival day boundary', dayKey(Date.UTC(2026, 6, 16, 0, 0), CHI), '2026-07-15');
// ---------- T3: DST transitions (America/Chicago) ----------
// US 2026: spring forward Mar 8 02:00→03:00; fall back Nov 1 02:00→01:00
// 2026-03-08T07:59Z == 01:59 CST; 2026-03-08T08:00Z == 03:00 CDT
check('T3a', 'before spring-forward', render(Date.UTC(2026, 2, 8, 7, 59), CHI), '03/08/2026, 01:59:00');
check('T3b', 'after spring-forward', render(Date.UTC(2026, 2, 8, 8, 0), CHI), '03/08/2026, 03:00:00');
// Fall back: 2026-11-01T06:59Z == 01:59 CDT; 2026-11-01T07:00Z == 01:00 CST (ambiguous hour handled)
check('T3c', 'fall-back first pass', render(Date.UTC(2026, 10, 1, 6, 59), CHI), '11/01/2026, 01:59:00');
check('T3d', 'fall-back second pass', render(Date.UTC(2026, 10, 1, 7, 0), CHI), '11/01/2026, 01:00:00');
// Events spanning transition keep correct duration via epoch arithmetic
const spanMs = Date.UTC(2026, 10, 1, 7, 0) - Date.UTC(2026, 10, 1, 6, 59);
check('T3e', 'epoch arithmetic unaffected by DST', spanMs, 60000);
// ---------- T4: unusual zones ----------
const PHX = 'America/Phoenix'; // no DST, UTC-7 year-round
check('T4a', 'no-DST zone', render(Date.UTC(2026, 0, 15, 18, 0), PHX), '01/15/2026, 11:00:00');
const KTM = 'Asia/Kathmandu'; // UTC+5:45 fixed
check('T4b', '+5:45 fixed offset', render(Date.UTC(2026, 0, 15, 18, 0), KTM), '01/15/2026, 23:45:00');
const LH = 'Australia/Lord_Howe'; // UTC+10:30 std, +11:00 DST (30-min shift)
check('T4c', '30-min DST zone (Jan=DST)', render(Date.UTC(2026, 0, 15, 18, 0), LH), '01/16/2026, 05:00:00');
check('T4d', '30-min DST zone (Jul=std)', render(Date.UTC(2026, 6, 15, 18, 0), LH), '07/16/2026, 04:30:00');
// ---------- T5: server-skew correction ----------
// Device clock is +7 minutes fast; server Date header captured true time.
const deviceNow = Date.UTC(2026, 6, 15, 18, 7, 0);
const serverNow = Date.UTC(2026, 6, 15, 18, 0, 0);
const skew = serverNow - deviceNow; // -420000
const correctedNow = deviceNow + skew;
check('T5a', 'skew computation', skew, -420000);
check('T5b', 'corrected now', correctedNow, serverNow);
// monotonic drift detection analog: session elapsed via monotonic clock
const monoElapsedMs = 3600000; // 1h of monotonic time passed
const expectedDeviceNow = deviceNow + monoElapsedMs;
const observedDeviceNow = expectedDeviceNow + 300000; // user moved clock +5 min
const drift = observedDeviceNow - expectedDeviceNow;
check('T5c', 'mid-session drift detected', Math.abs(drift) > 120000, true);
// ---------- T6: sanity window ----------
const festStart = Date.UTC(2026, 8, 10); // Sep 10
const festEnd = Date.UTC(2026, 8, 13); // Sep 13
const WINDOW = 45 * 86400000;
const sane = (now) => now >= festStart - WINDOW && now <= festEnd + WINDOW;
check('T6a', 'now inside window', sane(serverNow), false); // July: outside 45d of Sep → warns
check('T6b', 'now at festival', sane(Date.UTC(2026, 8, 11, 12)), true);
check('T6c', 'now wildly wrong', sane(Date.UTC(2023, 0, 1)), false);
// NOTE: T6a intentionally demonstrates the ±45d window; a July clock is
// outside it for a September festival. If prep happens months early the
// warning must be suppressed until near the event — see SPIKE-08 finding F-3.
// ---------- T7: Now / Up Next classification ----------
const events = [
{ id: 'e1', startUtc: Date.UTC(2026, 8, 11, 17), endUtc: Date.UTC(2026, 8, 11, 18) },
{ id: 'e2', startUtc: Date.UTC(2026, 8, 11, 18), endUtc: Date.UTC(2026, 8, 11, 19) },
{ id: 'e3', startUtc: Date.UTC(2026, 8, 11, 18, 30), endUtc: Date.UTC(2026, 8, 11, 19, 30) },
];
function nowNext(now) {
const nowE = events.filter((e) => e.startUtc <= now && now < e.endUtc).map((e) => e.id);
const next = events.filter((e) => e.startUtc > now).sort((a, b) => a.startUtc - b.startUtc).slice(0, 2).map((e) => e.id);
return { nowE, next };
}
const r1 = nowNext(Date.UTC(2026, 8, 11, 18, 45));
check('T7a', 'now includes overlapping events', r1.nowE, ['e2', 'e3']);
check('T7b', 'up-next empty when events running', r1.next, []);
const r1b = nowNext(Date.UTC(2026, 8, 11, 18, 15));
check('T7d', 'not-yet-started is up-next', r1b.next, ['e3']);
const r2 = nowNext(Date.UTC(2026, 8, 11, 17, 30));
check('T7c', 'up-next before start', r2.next, ['e2', 'e3']);
// ---------- report ----------
console.log('EXP-1 TIME MODEL RESULTS');
for (const r of results) {
console.log(`${r.ok ? 'PASS' : 'FAIL'} ${r.id} ${r.name}${r.ok ? '' : ` actual=${JSON.stringify(r.actual)} expected=${JSON.stringify(r.expected)}`}`);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);

View file

@ -0,0 +1,242 @@
#!/usr/bin/env node
/**
* EXP-2 — A/B atomic dataset update state-machine simulation (SPIKE-02)
* Disposable experiment. NOT application code. No dependencies.
*
* This simulates the *design logic* of the A/B dual-slot architecture with
* crash/fault injection at every stage. It does NOT test IndexedDB itself
* (platform behavior is out of scope on a dev machine; see SPIKE-01).
*
* Modeled mechanics (mirroring ARCHITECTURE-DESIGN §18):
* - system meta (single atomic store): activeSlot, activeVersion, verifiedVersion
* - two dataset slots; staging writes ONLY to the inactive slot
* - per-file staging is atomic: bytes + progress record commit together
* - activation is ONE atomic transaction flipping pointer + version + verification record
* - boot performs light verification; readback spot-check after activation
*
* INVARIANT under test:
* "Either the previous valid dataset remains active or the new valid dataset
* becomes active. The app never knowingly exposes a partial dataset."
*/
'use strict';
// ---------- tiny transactional store model ----------
class AtomicStore {
constructor() { this.map = new Map(); }
// a transaction: apply fn to a draft; commit is all-or-nothing
txn(fn) {
const draft = new Map(this.map);
fn(draft); // if fn throws, nothing commits
this.map = draft;
}
}
class Device {
constructor() {
this.system = new AtomicStore(); // lumen-system
this.slots = { A: new AtomicStore(), B: new AtomicStore() };
this.user = new AtomicStore(); // lumen-user (favorites)
this.system.txn((m) => m.set('meta', { activeSlot: 'A', activeVersion: 1, verifiedVersion: 1 }));
// seed active dataset v1 (old known-good)
this.writeCompleteDataset('A', 1, 'old-content');
this.user.txn((m) => m.set('fav:e-0001', { addedAt: 0 }));
}
writeCompleteDataset(slot, version, tag) {
const s = this.slots[slot];
s.txn((m) => {
m.set('manifest', { packageVersion: version, sections: ['emergency', 'schedule', 'map', 'info', 'assets'], tag });
for (const sec of ['emergency', 'schedule', 'map', 'info', 'assets']) {
m.set(`file:${sec}`, { bytes: `${tag}:${sec}:v${version}`, hash: `h-${tag}-${sec}-v${version}` });
}
m.set('staged', new Set(['emergency', 'schedule', 'map', 'info', 'assets']));
m.set('verified', version);
});
}
meta() { return this.system.map.get('meta'); }
// full dataset check = every manifest-listed file present with matching hash
isComplete(slot, expectVersion = null) {
const s = this.slots[slot];
const man = s.map.get('manifest');
if (!man) return false;
if (expectVersion !== null && man.packageVersion !== expectVersion) return false;
const staged = s.map.get('staged');
if (!staged || staged.size !== man.sections.length) return false;
for (const sec of man.sections) {
const f = s.map.get(`file:${sec}`);
if (!f || f.hash !== `h-${man.tag}-${sec}-v${man.packageVersion}`) return false;
}
return true;
}
// crash mid-transaction: fn throws → nothing committed (atomic store semantics)
}
// ---------- update pipeline with fault injection ----------
class UpdateSession {
constructor(dev, opts) { this.dev = dev; this.opts = opts; }
run() {
const { newVersion = 2, tag = 'new-content', fault = null, faultAt = 0 } = this.opts;
const manifest = { packageVersion: newVersion, sections: ['emergency', 'schedule', 'map', 'info', 'assets'], tag };
// 1) signature verification (fault: bad signature)
if (fault === 'signature') return { outcome: 'rejected-signature' };
// 2) compatibility check (fault: incompatible)
if (fault === 'compatibility') return { outcome: 'rejected-compatibility' };
// 3) choose inactive slot, wipe it (rollback data sacrificed — documented)
const target = this.dev.meta().activeSlot === 'A' ? 'B' : 'A';
this.dev.slots[target].txn((m) => m.clear());
const staged = new Set();
// 4) stage files; each file = ONE atomic txn (bytes + progress together)
for (const sec of manifest.sections) {
if (fault === 'crash-staging' && staged.size >= faultAt) return { outcome: 'crashed-staging', stagedSoFar: staged.size, target };
const bytes = fault === 'hash' && sec === 'map' ? 'CORRUPTED' : `${tag}:${sec}:v${newVersion}`;
this.dev.slots[target].txn((m) => {
m.set('manifest', manifest);
m.set(`file:${sec}`, { bytes, hash: `h-${tag}-${sec}-v${newVersion}` });
const s = m.get('staged') || new Set(); s.add(sec); m.set('staged', s);
});
staged.add(sec);
}
// 5) full verification: hashes then schema
for (const sec of manifest.sections) {
const f = this.dev.slots[target].map.get(`file:${sec}`);
if (f.bytes !== `${tag}:${sec}:v${newVersion}`) return { outcome: 'rejected-hash', target };
}
if (fault === 'schema') return { outcome: 'rejected-schema', target };
if (fault === 'validation') return { outcome: 'rejected-validation', target };
// 6) activation: single atomic transaction on system meta
if (fault === 'crash-before-flip') return { outcome: 'crashed-before-flip', target };
const flipCommitted = fault !== 'crash-during-flip';
if (flipCommitted) {
this.dev.system.txn((m) => m.set('meta', { activeSlot: target, activeVersion: newVersion, verifiedVersion: newVersion }));
} else {
return { outcome: 'crashed-during-flip', target }; // txn never committed
}
// 7) crash window after flip, before readback
if (fault === 'crash-after-flip') return { outcome: 'crashed-after-flip', target };
// 8) readback spot-check
if (fault === 'readback-fail') {
// simulate post-activation corruption discovered by readback
this.dev.slots[target].txn((m) => m.set('file:map', { bytes: 'BITROT', hash: 'h-bad' }));
}
const ok = this.dev.isComplete(target, newVersion);
if (!ok) {
// automatic rollback: flip back if previous slot still complete
const other = target === 'A' ? 'B' : 'A';
if (this.dev.isComplete(other, this.dev.meta().activeVersion === newVersion ? null : this.dev.meta().verifiedVersion) || this.dev.isComplete(other)) {
const prevVersion = this.dev.slots[other].map.get('manifest')?.packageVersion;
this.dev.system.txn((m) => m.set('meta', { activeSlot: other, activeVersion: prevVersion, verifiedVersion: prevVersion }));
return { outcome: 'rollback-after-readback', target };
}
return { outcome: 'recovery-needed', target };
}
return { outcome: 'activated', target };
}
}
// ---------- boot / recovery ----------
function boot(dev) {
const meta = dev.meta();
if (dev.isComplete(meta.activeSlot, meta.activeVersion)) return { state: 'READY', version: meta.activeVersion };
const other = meta.activeSlot === 'A' ? 'B' : 'A';
const om = dev.slots[other].map.get('manifest');
if (om && dev.isComplete(other)) {
dev.system.txn((m) => m.set('meta', { activeSlot: other, activeVersion: om.packageVersion, verifiedVersion: om.packageVersion }));
return { state: 'READY-via-fallback', version: om.packageVersion };
}
return { state: 'RECOVERY', baseline: true }; // embedded emergency baseline still present
}
// ---------- invariant assertion ----------
let pass = 0, fail = 0;
function invariant(name, dev, expect) {
const b = boot(dev);
const meta = dev.meta();
const activeComplete = dev.isComplete(meta.activeSlot);
const fav = dev.user.map.get('fav:e-0001') !== undefined;
const ok = activeComplete === expect.complete && fav === true &&
(expect.state ? b.state === expect.state || (expect.state === 'READY' && b.state === 'READY-via-fallback') : true) &&
(expect.version ? meta.activeVersion === expect.version : true);
ok ? pass++ : fail++;
console.log(`${ok ? 'PASS' : 'FAIL'} ${name} boot=${b.state} active=${meta.activeSlot} v${meta.activeVersion} complete=${activeComplete} favorites=${fav}`);
if (!ok) console.log(` expected: ${JSON.stringify(expect)}`);
}
console.log('EXP-2 A/B UPDATE STATE MACHINE — 14 SCENARIOS\n');
// S1 download begins, crash before any file staged
{ const d = new Device(); new UpdateSession(d, { fault: 'crash-staging', faultAt: 0 }).run();
invariant('S01 crash at download start', d, { complete: true, version: 1, state: 'READY' }); }
// S2 download partially completes
{ const d = new Device(); new UpdateSession(d, { fault: 'crash-staging', faultAt: 3 }).run();
invariant('S02 partial download', d, { complete: true, version: 1, state: 'READY' }); }
// S3 browser terminated (mid staging)
{ const d = new Device(); new UpdateSession(d, { fault: 'crash-staging', faultAt: 2 }).run();
invariant('S03 browser terminated', d, { complete: true, version: 1, state: 'READY' }); }
// S4 phone reboots (crash before flip)
{ const d = new Device(); new UpdateSession(d, { fault: 'crash-before-flip' }).run();
invariant('S04 reboot before activation', d, { complete: true, version: 1, state: 'READY' }); }
// S5 dataset validation fails (generic full-verification failure)
{ const d = new Device(); const r = new UpdateSession(d, { fault: 'validation' }).run();
invariant(`S05 validation fails (${r.outcome})`, d, { complete: true, version: 1, state: 'READY' }); }
// S6 integrity hash fails
{ const d = new Device(); const r = new UpdateSession(d, { fault: 'hash' }).run();
invariant(`S06 hash fails (${r.outcome})`, d, { complete: true, version: 1, state: 'READY' }); }
// S7 signature validation fails
{ const d = new Device(); const r = new UpdateSession(d, { fault: 'signature' }).run();
invariant(`S07 signature fails (${r.outcome})`, d, { complete: true, version: 1, state: 'READY' }); }
// S8 schema validation fails
{ const d = new Device(); const r = new UpdateSession(d, { fault: 'schema' }).run();
invariant(`S08 schema fails (${r.outcome})`, d, { complete: true, version: 1, state: 'READY' }); }
// S9 compatibility validation fails
{ const d = new Device(); const r = new UpdateSession(d, { fault: 'compatibility' }).run();
invariant(`S09 compatibility fails (${r.outcome})`, d, { complete: true, version: 1, state: 'READY' }); }
// S10 activation succeeds
{ const d = new Device(); const r = new UpdateSession(d, { newVersion: 2 }).run();
invariant(`S10 activation succeeds (${r.outcome})`, d, { complete: true, version: 2, state: 'READY' }); }
// S11 pointer update occurs (flip committed) — verified by S10/S12 state
// S12 browser terminates immediately after flip (before readback)
{ const d = new Device(); const r = new UpdateSession(d, { newVersion: 2, fault: 'crash-after-flip' }).run();
invariant(`S12 crash right after flip (${r.outcome})`, d, { complete: true, version: 2, state: 'READY' }); }
// S13 app starts again after crash during flip (transaction not committed)
{ const d = new Device(); const r = new UpdateSession(d, { newVersion: 2, fault: 'crash-during-flip' }).run();
invariant(`S13 restart after failed flip (${r.outcome})`, d, { complete: true, version: 1, state: 'READY' }); }
// S14 recovery: corruption discovered by readback after activation → rollback
{ const d = new Device(); new UpdateSession(d, { newVersion: 2 }).run(); // v2 activates, old slot retained
const r = new UpdateSession(d, { newVersion: 3, fault: 'readback-fail' }).run();
invariant(`S14 readback corruption → rollback (${r.outcome})`, d, { complete: true, state: 'READY' }); }
// X1 extra: corruption of ACTIVE slot discovered at boot, fallback slot intact
{ const d = new Device(); new UpdateSession(d, { newVersion: 2 }).run();
d.slots[d.meta().activeSlot].txn((m) => m.set('file:schedule', { bytes: 'BITROT', hash: 'bad' }));
invariant('X01 active corrupt at boot → fallback slot', d, { complete: true, state: 'READY', version: 1 }); }
// X2 extra: BOTH slots corrupt at boot → RECOVERY with baseline, favorites intact
{ const d = new Device();
d.slots.A.txn((m) => m.clear()); d.slots.B.txn((m) => m.clear());
invariant('X02 both slots lost → RECOVERY + baseline', d, { complete: false, state: 'RECOVERY' }); }
// X3 extra: user state survives a full successful update
{ const d = new Device(); new UpdateSession(d, { newVersion: 2 }).run();
const fav = d.user.map.get('fav:e-0001');
const ok = fav !== undefined; ok ? pass++ : fail++;
console.log(`${ok ? 'PASS' : 'FAIL'} X03 favorites survive update`); }
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);

View file

@ -0,0 +1,137 @@
<!doctype html>
<!--
EXP-3 — Map representation synthetic benchmark (SPIKE-03)
Disposable experiment. NOT application code. No dependencies.
Headless-desktop ONLY. Numbers describe relative JS/frame-work cost on a
dev machine; they are NOT representative of low-end mobile GPUs, memory,
or compositors. Final validation requires the real-device protocol in
SPIKE-03 §7.
Variants:
A) Raster base image + DOM POI overlay + CSS-transform pan/zoom
B) Full SVG (ellipses + circle markers) with CSS-transform pan/zoom
C) Canvas full redraw every frame
Metric: average synchronous JS work per animation frame (ms), 240 frames,
plus setup time and JS heap delta (Chromium-only).
-->
<html><head><meta charset="utf-8"><title>mapbench</title>
<style>
html,body{margin:0;background:#111}
#stage{position:fixed;inset:0;overflow:hidden;touch-action:none}
.poi{position:absolute;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;background:#f43f5e;will-change:transform}
svg,canvas{display:block}
</style></head><body>
<div id="stage"></div><pre id="out">RUNNING</pre>
<script>
'use strict';
const W=2048,H=1536,POI_COUNTS=[100,300],FRAMES=240;
const stage=document.getElementById('stage');
const out=document.getElementById('out');
const results={env:'headless-desktop',variants:[]};
function makeBaseImage(){
const c=document.createElement('canvas');c.width=W;c.height=H;
const x=c.getContext('2d');
x.fillStyle='#1c3326';x.fillRect(0,0,W,H);
for(let i=0;i<400;i++){x.fillStyle=`rgba(${60+(i%60)},${120+(i%80)},${70+(i%50)},.35)`;
x.beginPath();x.ellipse((i*97)%W,(i*211)%H,30+(i%90),20+(i%60),i,0,7);x.fill();}
x.strokeStyle='#c9a86a';x.lineWidth=10;
for(let r=0;r<6;r++){x.beginPath();x.moveTo(0,200+r*220);x.bezierCurveTo(W*.3,150+r*220,W*.6,260+r*220,W,200+r*220);x.stroke();}
return c;
}
const BASE=makeBaseImage();
function animate(step,done){
let frames=0,work=0;
function loop(){
if(frames>=FRAMES){done(frames,work);return;}
const p=frames/FRAMES;
const t0=performance.now();
step(p);
work+=performance.now()-t0;
frames++;
setTimeout(loop,1);
}
setTimeout(loop,1);
}
const easeScale=(p)=>1+0.8*Math.abs(Math.sin(p*Math.PI*2));
const panX=(p)=>80*Math.sin(p*Math.PI*4);
const panY=(p)=>60*Math.cos(p*Math.PI*4);
const mem=()=>performance.memory?performance.memory.usedJSHeapSize:NaN;
const metrics=(variant,n,s0,m0,frames,work)=>({
variant,pois:n,frames,
setupMs:Math.round(performance.now()-s0-(work||0)),
avgJsMsPerFrame:+(work/frames).toFixed(3),
heapDeltaMB:Math.round((mem()-m0)/1048576)
});
// ---------- Variant A: raster base + DOM overlay + transform ----------
function variantA(n,done){
const m0=mem(),s0=performance.now();
const wrap=document.createElement('div');
wrap.style.cssText='transform-origin:0 0;will-change:transform';
const img=document.createElement('img');
img.style.cssText=`width:${W/2}px;height:${H/2}px`;
wrap.appendChild(img);
for(let i=0;i<n;i++){const d=document.createElement('div');d.className='poi';
d.style.left=((i*197)%W)/2+'px';d.style.top=((i*263)%H)/2+'px';wrap.appendChild(d);}
stage.replaceChildren(wrap);
const start=()=>animate((p)=>{
wrap.style.transform=`translate(${panX(p)}px,${panY(p)}px) scale(${easeScale(p)})`;
},(frames,work)=>done(metrics('A-raster+dom-overlay',n,s0,m0,frames,work)));
img.src=BASE.toDataURL('image/webp',0.8);
if(img.complete&&img.naturalWidth>0)start();else{img.onload=start;img.onerror=start;}
}
// ---------- Variant B: full SVG ----------
function variantB(n,done){
const m0=mem(),s0=performance.now();
const NS='http://www.w3.org/2000/svg';
const svg=document.createElementNS(NS,'svg');
svg.setAttribute('viewBox',`0 0 ${W} ${H}`);
svg.style.cssText=`width:${W/2}px;height:${H/2}px;transform-origin:0 0;will-change:transform`;
const g=document.createElementNS(NS,'g');
for(let i=0;i<300;i++){const e=document.createElementNS(NS,'ellipse');
e.setAttribute('cx',(i*97)%W);e.setAttribute('cy',(i*211)%H);
e.setAttribute('rx',30+(i%90));e.setAttribute('ry',20+(i%60));
e.setAttribute('fill',`rgba(${60+(i%60)},${120+(i%80)},${70+(i%50)},.35)`);g.appendChild(e);}
for(let i=0;i<n;i++){const c=document.createElementNS(NS,'circle');
c.setAttribute('cx',(i*197)%W);c.setAttribute('cy',(i*263)%H);
c.setAttribute('r',10);c.setAttribute('fill','#f43f5e');g.appendChild(c);}
svg.appendChild(g);stage.replaceChildren(svg);
setTimeout(()=>animate((p)=>{
svg.style.transform=`translate(${panX(p)}px,${panY(p)}px) scale(${easeScale(p)})`;
},(frames,work)=>done(metrics('B-full-svg',n,s0,m0,frames,work))));
}
// ---------- Variant C: canvas redraw ----------
function variantC(n,done){
const m0=mem(),s0=performance.now();
const cv=document.createElement('canvas');
const dw=Math.min(innerWidth,W/2),dh=dw*H/W;
cv.width=dw*devicePixelRatio;cv.height=dh*devicePixelRatio;
cv.style.cssText=`width:${dw}px;height:${dh}px`;
const x=cv.getContext('2d');stage.replaceChildren(cv);
const pts=Array.from({length:n},(_,i)=>[(i*197)%W,(i*263)%H]);
animate((p)=>{
x.setTransform(1,0,0,1,0,0);x.clearRect(0,0,cv.width,cv.height);
const s=easeScale(p)*devicePixelRatio*(dw/W);
x.setTransform(s,0,0,s,panX(p)*devicePixelRatio,panY(p)*devicePixelRatio);
x.drawImage(BASE,0,0);
x.fillStyle='#f43f5e';
for(const[px,py]of pts){x.beginPath();x.arc(px,py,10/s*devicePixelRatio,0,7);x.fill();}
},(frames,work)=>done(metrics('C-canvas-redraw',n,s0,m0,frames,work)));
}
const queue=[];
for(const n of POI_COUNTS){queue.push(()=>variantA(n,next),()=>variantB(n,next),()=>variantC(n,next));}
function next(r){results.variants.push(r);document.title='step:'+r.variant+'-'+r.pois;run();}
function run(){
const f=queue.shift();
if(!f){out.textContent='RESULTS:'+JSON.stringify(results,null,1);document.title='DONE';return;}
setTimeout(()=>{try{f();}catch(e){out.textContent='ERROR:'+(e&&e.stack||e);document.title='ERROR';}},50);
}
document.title='started';
run();
</script></body></html>