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
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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue