← back to Quadrille Showroom
Add measured env-morph verifier: asserts real rendered wall-luminance delta (35 vs 85), not just intensity scalars
71784da3d0b505264fdeb239b09eab8f954c3298 · 2026-06-29 16:26:47 -0700 · Steve
Files touched
M 5x/loop-ledger.mdA scripts/verify-ageview-envmorph.mjs
Diff
commit 71784da3d0b505264fdeb239b09eab8f954c3298
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jun 29 16:26:47 2026 -0700
Add measured env-morph verifier: asserts real rendered wall-luminance delta (35 vs 85), not just intensity scalars
---
5x/loop-ledger.md | 1 +
scripts/verify-ageview-envmorph.mjs | 155 ++++++++++++++++++++++++++++++++++++
2 files changed, 156 insertions(+)
diff --git a/5x/loop-ledger.md b/5x/loop-ledger.md
index 4e83009..33f5f8f 100644
--- a/5x/loop-ledger.md
+++ b/5x/loop-ledger.md
@@ -1,3 +1,4 @@
# Quadrille Showroom — Refinement Loop Ledger
- iter1 (2026-06-29): FPS gate FAIL root-caused — phase5 sampler caught the morph-rebuild transient window (band 65 min=54). Fixed sampler to discard the band-change-straddling counter window + dedup per distinct window + 1200ms settle; floor unchanged at 55. All bands now steady-state 67-74 min. All 4 verifiers GREEN.
+- iter2 (2026-06-29): Backlog (a) DONE — new scripts/verify-ageview-envmorph.mjs MEASURES real rendered wall-luminance for the setEnvForAge morph (was only asserted by intensity scalars). Drives age 35 vs 85 on real GPU, samples pure-plaster wall regions: senior is +6.56 luminance brighter (morph proven to change the render), lower-glare hot-pixel frac non-increasing, OFF restores exact baseline (Δ0.00, avatarAge 28). PASS.
diff --git a/scripts/verify-ageview-envmorph.mjs b/scripts/verify-ageview-envmorph.mjs
new file mode 100644
index 0000000..29d19c4
--- /dev/null
+++ b/scripts/verify-ageview-envmorph.mjs
@@ -0,0 +1,155 @@
+/* ============================================================================
+ * verify-ageview-envmorph.mjs — MEASURED env-morph gate for AGE VIEW.
+ *
+ * AGE-VIEWS-SPEC says the room LIGHTING morphs per age band: senior bands raise
+ * ambient (brighter room for the aging lens) and cut harsh speculars (less
+ * glare). Until now that morph was only asserted by the INTENSITY SCALARS fed to
+ * setEnvForAge() — never by the ACTUALLY-RENDERED pixels. A scalar can change
+ * while the composited render does not (clamped light, wrong light handle, a
+ * later pass overwriting it). This verifier closes that gap: it drives ages
+ * 35 (neutral baseline) vs 85 (max senior) on a REAL GPU and measures the real
+ * screen luminance of the rendered PLASTER WALL — proving:
+ *
+ * (1) The morph actually CHANGES the render — the 35 vs 85 wall pixels differ
+ * by a meaningful mean-luminance delta (not a no-op).
+ * (2) Senior is BRIGHTER — mean wall luminance(85) > (35).
+ * (3) Senior is LOWER-GLARE — the blown-out-highlight pixel
+ * fraction(85) ≤ (35) (harsh speculars cut for the aging lens).
+ * (4) Regression — Age View OFF restores the
+ * baseline render (≈ age-28 env, not the senior env).
+ *
+ * Wall sampling regions are pure plaster, away from the wing book, the paper
+ * sculpture, and all HUD chrome (the Age View bar lives top-center).
+ *
+ * Run: NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/verify-ageview-envmorph.mjs
+ * ========================================================================== */
+import pw from '/Users/stevestudio2/.npm-global/lib/node_modules/playwright/index.js';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import sharp from '/Users/stevestudio2/Projects/quadrille-showroom/node_modules/sharp/dist/index.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const URL = process.env.SHOWROOM_URL || 'http://127.0.0.1:7690/';
+const USER = process.env.SHOWROOM_USER || 'admin';
+const PASS = process.env.SHOWROOM_PASS || 'DWSecure2024!';
+const OUT = path.join(__dirname, '..', 'recordings', 'phase5', 'envmorph');
+fs.mkdirSync(OUT, { recursive: true });
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+// Pure-plaster wall regions (1600×900 viewport): upper-left + upper-right
+// corners — wall only, no wing book, no paper sculpture, no HUD bar.
+const WALL_REGIONS = [
+ { x: 20, y: 200, width: 340, height: 220 }, // upper-left wall
+ { x: 1250, y: 200, width: 330, height: 180 }, // upper-right wall
+];
+
+// Per-pixel relative luminance (Rec.709) on 0..255 sRGB bytes.
+function lum(r, g, b) { return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
+
+// Decode a clipped PNG screenshot buffer → mean luminance + blown-highlight frac.
+async function analyze(pngBuf) {
+ const { data, info } = await sharp(pngBuf).raw().toBuffer({ resolveWithObject: true });
+ const ch = info.channels; // 3 (rgb) or 4 (rgba)
+ let sum = 0, n = 0, hot = 0;
+ for (let i = 0; i < data.length; i += ch) {
+ const L = lum(data[i], data[i + 1], data[i + 2]);
+ sum += L; n++;
+ if (L >= 245) hot++; // near-white = specular blowout
+ }
+ return { meanLum: sum / n, hotFrac: hot / n, n };
+}
+
+// Capture all wall regions for the current env and aggregate.
+async function measureWalls(page, tag) {
+ let sum = 0, hot = 0, n = 0;
+ const per = [];
+ for (let i = 0; i < WALL_REGIONS.length; i++) {
+ const clip = WALL_REGIONS[i];
+ const buf = await page.screenshot({ clip });
+ fs.writeFileSync(path.join(OUT, `${tag}-region${i}.png`), buf);
+ const a = await analyze(buf);
+ per.push(a);
+ sum += a.meanLum * a.n; hot += a.hotFrac * a.n; n += a.n;
+ }
+ return { meanLum: sum / n, hotFrac: hot / n, per };
+}
+
+(async () => {
+ const browser = await pw.chromium.launch({
+ channel: 'chrome',
+ args: ['--use-gl=angle', '--enable-webgl', '--ignore-gpu-blocklist'],
+ });
+ const ctx = await browser.newContext({
+ viewport: { width: 1600, height: 900 },
+ httpCredentials: { username: USER, password: PASS },
+ });
+ const page = await ctx.newPage();
+ const errors = [];
+ page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+ page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+ await page.addInitScript(() => {
+ try { localStorage.removeItem('qh_ageview_on'); localStorage.removeItem('qh_ageview_age');
+ localStorage.removeItem('qh_explore'); } catch (e) {}
+ });
+
+ console.log('→ loading', URL);
+ await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForFunction(() => window._ageview && typeof window._ageview.apply === 'function', { timeout: 20000 });
+ await page.waitForFunction(() => window._qh && typeof window._qh.setEnvForAge === 'function', { timeout: 15000 }).catch(() => {});
+ await sleep(3500); // loader fade + guided auto-focus
+
+ await page.evaluate(() => window._ageview.setOn(true));
+ await sleep(700);
+
+ // ---- Measure age 35 (neutral baseline) ----
+ await page.evaluate(() => window._ageview.apply(35));
+ await sleep(1300);
+ const m35 = await measureWalls(page, 'age35');
+ console.log(` age 35 meanLum=${m35.meanLum.toFixed(2)} hotFrac=${(m35.hotFrac * 100).toFixed(3)}%`);
+
+ // ---- Measure age 85 (max senior) ----
+ await page.evaluate(() => window._ageview.apply(85));
+ await sleep(1300);
+ const m85 = await measureWalls(page, 'age85');
+ console.log(` age 85 meanLum=${m85.meanLum.toFixed(2)} hotFrac=${(m85.hotFrac * 100).toFixed(3)}%`);
+
+ // ---- Regression: OFF restores baseline (≈ age-28 env, NOT senior) ----
+ await page.evaluate(() => window._ageview.setOn(false));
+ await sleep(1300);
+ const mOff = await measureWalls(page, 'off');
+ const offAge = await page.evaluate(() => window._qh && window._qh.avatarAge);
+ console.log(` OFF meanLum=${mOff.meanLum.toFixed(2)} hotFrac=${(mOff.hotFrac * 100).toFixed(3)}% avatarAge=${offAge}`);
+
+ // ---------------- Gates ----------------
+ const lumDelta = m85.meanLum - m35.meanLum;
+ const MIN_LUM_DELTA = 2.0; // morph must move the render meaningfully
+
+ const checks = {
+ morphChangesRender: { v: lumDelta.toFixed(2), need: `|Δ|≥${MIN_LUM_DELTA}`, pass: Math.abs(lumDelta) >= MIN_LUM_DELTA },
+ seniorBrighter: { v: lumDelta.toFixed(2), need: 'Δ>0', pass: lumDelta > 0 },
+ seniorLowerGlare: { v: `${(m85.hotFrac*100).toFixed(3)}% ≤ ${(m35.hotFrac*100).toFixed(3)}%`, need: 'hot(85)≤hot(35)', pass: m85.hotFrac <= m35.hotFrac + 1e-6 },
+ // OFF must be CLOSER to baseline(35) than to senior(85) in luminance — proves
+ // resetEnv put us back on the neutral env, not stuck on the senior morph.
+ regressionEnv: { v: `|off-35|=${Math.abs(mOff.meanLum-m35.meanLum).toFixed(2)} |off-85|=${Math.abs(mOff.meanLum-m85.meanLum).toFixed(2)}`,
+ need: 'closer to baseline', pass: Math.abs(mOff.meanLum - m35.meanLum) <= Math.abs(mOff.meanLum - m85.meanLum) + 1e-6 },
+ regressionAge: { v: String(offAge), need: '28', pass: offAge === 28 },
+ consoleErrs: { v: String(errors.length), need: '0', pass: errors.length === 0 },
+ };
+
+ console.log('\n================ ENV-MORPH MEASURED GATE ================');
+ let allPass = true;
+ for (const [k, c] of Object.entries(checks)) {
+ const ok = c.pass ? '✓' : '✗';
+ if (!c.pass) allPass = false;
+ console.log(` ${ok} ${k.padEnd(20)} = ${String(c.v).padEnd(34)} (need ${c.need})`);
+ }
+ const report = { url: URL, m35, m85, mOff, offAge, lumDelta, checks, errors: errors.slice(0, 20), verdict: allPass ? 'PASS' : 'FAIL' };
+ fs.writeFileSync(path.join(OUT, 'envmorph-result.json'), JSON.stringify(report, null, 2));
+ console.log(' OVERALL: ' + (allPass ? '✅ PASS' : '❌ FAIL'));
+ console.log(' → ' + path.join(OUT, 'envmorph-result.json'));
+
+ await browser.close();
+ process.exit(allPass ? 0 : 1);
+})();
← 08a8479 Age View FPS gate: measure steady-state by discarding morph-
·
back to Quadrille Showroom
·
walls-on-next: add Age-View-ON regression pass (paging under 796c25f →