[object Object]

← 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

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 →