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:
commit
c0bfd413ff
100 changed files with 9863 additions and 0 deletions
59
experiments/README.md
Normal file
59
experiments/README.md
Normal 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).
|
||||
134
experiments/exp1-time-model.mjs
Normal file
134
experiments/exp1-time-model.mjs
Normal 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);
|
||||
242
experiments/exp2-ab-update-sim.mjs
Normal file
242
experiments/exp2-ab-update-sim.mjs
Normal 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);
|
||||
137
experiments/exp3-map-bench.html
Normal file
137
experiments/exp3-map-bench.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue