Skip to content

Commit 9fa5bc4

Browse files
simongonzalezdcSinter Testclaude
authored
feat(composition): measured render gate demotes washout/crush blends (#694)
**Tier-2 item 1 from the investor-audit register** — the composite washout/mud class (2/5 of the audit's graded composites). The gate renders the assembled composite headless, computes luminance stats in-page (no native image dep), and on a washout/too-dark verdict re-assembles once with offending non-base blends demoted to normal @ 0.75 opacity — keeping whichever frame measures better. No LLM calls; never throws; SINTER_COMPOSITE_GATE=0 opts out; matrix prints verdicts. Verification: gate suite 8/8; composition suites 810/810; LLM-free live probe — washout lum 1.00 → demoted → 0.79 ok → applied; eslint + build clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Sinter Test <liminal@example.test> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e8e4bb2 commit 9fa5bc4

5 files changed

Lines changed: 303 additions & 3 deletions

File tree

scripts/quality/matrix.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,13 @@ for (let i = 0; i < SPECS.length; i++) {
5151
await page.screenshot({ path: path.join(OUT, tag + '.png') });
5252
await page.close();
5353
const sz = fs.statSync(path.join(OUT, tag + '.png')).size;
54-
summary.push(`✅ ${tag}: ${ok}/${total} layers [${spec.layers.map(l => l.domain + ':' + (l.blendMode||'normal')).join(' + ')}] (${sz}b)`);
54+
const gate = result.renderGate;
55+
const gateNote = gate
56+
? gate.skipped
57+
? ' gate:skipped'
58+
: ` gate:${gate.verdict}(lum ${gate.measure.meanLuminance.toFixed(2)})${gate.remediation ? ` → ${gate.remediation.verdictAfter}${gate.remediation.applied ? ' [demoted ' + gate.remediation.demotedLayers.join(',') + ']' : ' [kept original]'}` : ''}`
59+
: '';
60+
summary.push(`✅ ${tag}: ${ok}/${total} layers [${spec.layers.map(l => l.domain + ':' + (l.blendMode||'normal')).join(' + ')}] (${sz}b)${gateNote}`);
5561
} catch (e) {
5662
summary.push(`⚠️ ${tag}: ${String(e.message).slice(0, 90)}`);
5763
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* CompositeRenderGate — measured quality gate for assembled compositions.
3+
*
4+
* The 2026-06 investor audit quantified a composite failure class that code
5+
* inspection cannot catch: stacked light-direction blends wash the frame to
6+
* near-white (dusk-bloom) or mud it toward black (reef-pulse) even when every
7+
* individual layer is good. The cure the hydra-washout investigation settled
8+
* on is RENDER MEASUREMENT: screenshot the assembled composite, read the
9+
* pixels, and act on what is actually on screen.
10+
*
11+
* The gate renders the composite headless, computes luminance statistics
12+
* in-page (a data-URL image drawn to a same-origin canvas — no native image
13+
* dependency), and when the frame measures washed-out or crushed-dark it
14+
* re-assembles ONCE with the offending blend modes demoted to 'normal' at
15+
* slightly reduced opacity — same generated art, safer compositing — keeping
16+
* whichever variant measures closer to a balanced frame. No regeneration, no
17+
* LLM calls, bounded wall-clock.
18+
*/
19+
20+
export interface CompositeMeasure {
21+
/** Mean perceptual luminance of the frame, 0..1 (0.299R+0.587G+0.114B). */
22+
meanLuminance: number;
23+
/** Fraction of pixels with luminance > 0.5 ("bright" content). */
24+
brightFraction: number;
25+
/** Fraction of pixels with luminance < 0.12 (the repo's DARK threshold). */
26+
darkFraction: number;
27+
}
28+
29+
export type CompositeVerdict = 'ok' | 'washout' | 'too-dark';
30+
31+
export interface CompositeGateReport {
32+
verdict: CompositeVerdict;
33+
/** Absent only when the gate was skipped before measuring. */
34+
measure?: CompositeMeasure;
35+
/** Present when a remediation re-assembly was attempted. */
36+
remediation?: {
37+
/** Layer indexes whose blend mode was demoted to 'normal'. */
38+
demotedLayers: number[];
39+
verdictAfter: CompositeVerdict;
40+
measureAfter: CompositeMeasure;
41+
/** True when the demoted variant was kept as the final html. */
42+
applied: boolean;
43+
};
44+
/** Set when the gate could not run (e.g. puppeteer unavailable). */
45+
skipped?: string;
46+
}
47+
48+
// A frame this bright is the washout signature: dusk-bloom measured ~0.93
49+
// mean, the acceptable composites 0.2–0.6. Symmetric crush guard mirrors the
50+
// gallery audit's calibrated DARK threshold (luminance.mjs): a very dark mean
51+
// is only a failure when there is no bright focal content either.
52+
const WASHOUT_MEAN_LUMINANCE = 0.8;
53+
const DARK_MEAN_LUMINANCE = 0.1;
54+
const DARK_MIN_BRIGHT_FRACTION = 0.02;
55+
56+
/** Blends that can only brighten the stack — the washout drivers. */
57+
const LIGHTENING_BLENDS = new Set(['screen', 'lighten']);
58+
/** Blends that can only darken the stack — the mud/crush drivers. */
59+
const DARKENING_BLENDS = new Set(['multiply', 'darken']);
60+
/** Direction-dependent blends: demoted for washout only (overlay brightened
61+
* dusk-bloom past white; it rarely crushes a frame to black on its own). */
62+
const WASHOUT_ALSO = new Set(['overlay']);
63+
64+
export function verdictFromMeasure(measure: CompositeMeasure): CompositeVerdict {
65+
if (measure.meanLuminance >= WASHOUT_MEAN_LUMINANCE) return 'washout';
66+
if (measure.meanLuminance <= DARK_MEAN_LUMINANCE && measure.brightFraction < DARK_MIN_BRIGHT_FRACTION) {
67+
return 'too-dark';
68+
}
69+
return 'ok';
70+
}
71+
72+
/**
73+
* Pick the NON-BASE layer indexes whose blend mode drives the failed verdict.
74+
* The base layer (index 0) is never demoted — it is the scene; remediating it
75+
* would replace the artwork rather than fix the compositing.
76+
*/
77+
export function layersToDemote(
78+
verdict: CompositeVerdict,
79+
blendModes: ReadonlyArray<string>,
80+
): number[] {
81+
if (verdict === 'ok') return [];
82+
const offending = verdict === 'washout'
83+
? (mode: string) => LIGHTENING_BLENDS.has(mode) || WASHOUT_ALSO.has(mode)
84+
: (mode: string) => DARKENING_BLENDS.has(mode);
85+
const picks: number[] = [];
86+
for (let i = 1; i < blendModes.length; i++) {
87+
if (offending(blendModes[i])) picks.push(i);
88+
}
89+
return picks;
90+
}
91+
92+
/** Opacity multiplier applied alongside a blend demotion: 'normal' at full
93+
* opacity would hide the layers underneath; keep the layer translucent. */
94+
export const DEMOTED_OPACITY_FACTOR = 0.75;
95+
96+
/**
97+
* Measure an assembled composite's frame statistics in headless Chrome.
98+
* Pixel math runs in-page on a same-origin canvas fed by the screenshot's
99+
* data URL, so no native image library is required.
100+
*/
101+
export async function measureCompositeHtml(
102+
html: string,
103+
options: { width?: number; height?: number; settleMs?: number } = {},
104+
): Promise<CompositeMeasure> {
105+
const { width = 900, height = 600, settleMs = 2500 } = options;
106+
const { default: puppeteer } = await import('puppeteer');
107+
const browser = await puppeteer.launch({ headless: true });
108+
try {
109+
const page = await browser.newPage();
110+
await page.setViewport({ width, height });
111+
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 30_000 });
112+
await new Promise((resolve) => setTimeout(resolve, settleMs));
113+
const shot = (await page.screenshot({ encoding: 'base64' })) as string;
114+
return await page.evaluate(async (b64: string) => {
115+
const img = new Image();
116+
img.src = `data:image/png;base64,${b64}`;
117+
await new Promise<void>((resolve, reject) => {
118+
img.onload = () => resolve();
119+
img.onerror = () => reject(new Error('screenshot decode failed'));
120+
});
121+
const canvas = document.createElement('canvas');
122+
canvas.width = img.width;
123+
canvas.height = img.height;
124+
const ctx = canvas.getContext('2d');
125+
if (!ctx) throw new Error('2d context unavailable');
126+
ctx.drawImage(img, 0, 0);
127+
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
128+
let sum = 0;
129+
let bright = 0;
130+
let dark = 0;
131+
const pixels = data.length / 4;
132+
for (let i = 0; i < data.length; i += 4) {
133+
const lum = (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255;
134+
sum += lum;
135+
if (lum > 0.5) bright++;
136+
if (lum < 0.12) dark++;
137+
}
138+
return { meanLuminance: sum / pixels, brightFraction: bright / pixels, darkFraction: dark / pixels };
139+
}, shot);
140+
} finally {
141+
await browser.close();
142+
}
143+
}
144+
145+
export function gateEnabled(): boolean {
146+
return process.env.SINTER_COMPOSITE_GATE !== '0';
147+
}

src/composition/CompositionOrchestrator.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ import { HTMLWrapper, type Domain as WrapDomain } from '../utils/htmlWrapper.js'
2525
import type { DomainType, BlendMode } from './types.js';
2626
import { buildLayerPrompt, paintsOpaqueBackground } from './LayerContract.js';
2727
import { capLayerBrightness, exceedsWashoutBudget } from './BlendBudget.js';
28+
import {
29+
measureCompositeHtml,
30+
verdictFromMeasure,
31+
layersToDemote,
32+
gateEnabled,
33+
DEMOTED_OPACITY_FACTOR,
34+
type CompositeGateReport,
35+
} from './CompositeRenderGate.js';
2836
import { LLMClient } from '../llm/LLMClient.js';
2937
import { Logger } from '../utils/Logger.js';
3038

@@ -67,6 +75,9 @@ export interface CompositionResult {
6775
layers: ComposedLayer[];
6876
/** Layers that generated successfully. */
6977
successCount: number;
78+
/** Measured-frame gate report (washout/crush detection + blend remediation).
79+
* Absent when the gate is disabled or no visual layer generated. */
80+
renderGate?: CompositeGateReport;
7081
}
7182

7283
/** Domains that produce audio rather than a visual — they need a start gesture. */
@@ -116,6 +127,19 @@ export class CompositionOrchestrator {
116127
);
117128

118129
const html = this.assemble(title, spec.background ?? '#000', results, results.map(r => r.code));
130+
131+
// Measured-frame gate: what is actually ON SCREEN decides, not the spec.
132+
// Washed-out or crushed frames get one blend-demoted re-assembly (same
133+
// generated art, safer compositing); the better-measuring variant wins.
134+
let finalHtml = html;
135+
let renderGate: CompositeGateReport | undefined;
136+
const hasVisualLayer = results.some(r => r.generated && r.code && !AUDIO_DOMAINS.has(r.spec.domain));
137+
if (gateEnabled() && hasVisualLayer) {
138+
const gated = await this.runRenderGate(title, spec.background ?? '#000', results, html);
139+
finalHtml = gated.html;
140+
renderGate = gated.report;
141+
}
142+
119143
const layers: ComposedLayer[] = results.map((r) => ({
120144
domain: r.spec.domain,
121145
prompt: r.spec.prompt,
@@ -127,7 +151,51 @@ export class CompositionOrchestrator {
127151
opaqueBackground: r.opaqueBackground,
128152
}));
129153

130-
return { html, title, layers, successCount: results.filter(r => r.generated).length };
154+
return { html: finalHtml, title, layers, successCount: results.filter(r => r.generated).length, renderGate };
155+
}
156+
157+
/**
158+
* Render-measure the assembled composite; on a washout/too-dark verdict,
159+
* demote the offending non-base blend modes to 'normal' (at reduced
160+
* opacity), re-assemble, and keep whichever frame measures better. Never
161+
* throws — a gate failure degrades to the unmeasured composite.
162+
*/
163+
private static async runRenderGate(
164+
title: string,
165+
background: string,
166+
results: Array<{ spec: CompositionLayerSpec; code: string; generated: boolean; opaqueBackground?: boolean; error?: string }>,
167+
html: string,
168+
): Promise<{ report: CompositeGateReport; html: string }> {
169+
try {
170+
const measure = await measureCompositeHtml(html);
171+
const verdict = verdictFromMeasure(measure);
172+
if (verdict === 'ok') return { report: { verdict, measure }, html };
173+
174+
const blendModes = results.map(r => (r.generated ? (r.spec.blendMode ?? 'normal') : 'normal'));
175+
const demote = layersToDemote(verdict, blendModes);
176+
if (demote.length === 0) {
177+
Logger.warn('CompositionOrchestrator', `[render-gate] ${verdict} frame (mean lum ${measure.meanLuminance.toFixed(2)}) but no demotable blend layer — keeping as-is`);
178+
return { report: { verdict, measure }, html };
179+
}
180+
181+
const demoted = results.map((r, i) => (demote.includes(i)
182+
? { ...r, spec: { ...r.spec, blendMode: 'normal' as BlendMode, opacity: (r.spec.opacity ?? 1) * DEMOTED_OPACITY_FACTOR } }
183+
: r));
184+
const retryHtml = this.assemble(title, background, demoted, demoted.map(r => r.code));
185+
const measureAfter = await measureCompositeHtml(retryHtml);
186+
const verdictAfter = verdictFromMeasure(measureAfter);
187+
const applied = verdictAfter === 'ok'
188+
|| Math.abs(measureAfter.meanLuminance - 0.5) < Math.abs(measure.meanLuminance - 0.5);
189+
Logger.info('CompositionOrchestrator', `[render-gate] ${verdict} (lum ${measure.meanLuminance.toFixed(2)}) → demoted layers [${demote.join(',')}] → ${verdictAfter} (lum ${measureAfter.meanLuminance.toFixed(2)}); ${applied ? 'kept demoted variant' : 'kept original'}`);
190+
return {
191+
report: { verdict, measure, remediation: { demotedLayers: demote, verdictAfter, measureAfter, applied } },
192+
html: applied ? retryHtml : html,
193+
};
194+
} catch (error) {
195+
const message = error instanceof Error && error.message ? error.message : String(error);
196+
Logger.warn('CompositionOrchestrator', `[render-gate] skipped: ${message}`);
197+
return { report: { verdict: 'ok', skipped: message }, html };
198+
}
131199
}
132200

133201
/**
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, it, expect, afterEach } from 'vitest';
2+
import {
3+
verdictFromMeasure,
4+
layersToDemote,
5+
gateEnabled,
6+
DEMOTED_OPACITY_FACTOR,
7+
} from '../../../src/composition/CompositeRenderGate.js';
8+
9+
const measure = (meanLuminance: number, brightFraction = 0.3, darkFraction = 0.1) => ({
10+
meanLuminance,
11+
brightFraction,
12+
darkFraction,
13+
});
14+
15+
describe('CompositeRenderGate', () => {
16+
describe('verdictFromMeasure', () => {
17+
it('flags washed-out frames at the calibrated threshold', () => {
18+
expect(verdictFromMeasure(measure(0.93))).toBe('washout'); // dusk-bloom class
19+
expect(verdictFromMeasure(measure(0.8))).toBe('washout'); // boundary inclusive
20+
expect(verdictFromMeasure(measure(0.79))).toBe('ok');
21+
});
22+
23+
it('flags crushed frames only when bright focal content is absent', () => {
24+
expect(verdictFromMeasure(measure(0.05, 0.0))).toBe('too-dark');
25+
expect(verdictFromMeasure(measure(0.1, 0.019))).toBe('too-dark'); // boundary inclusive
26+
// Legit dark design: dark ground WITH bright focal content passes —
27+
// the calibration that stops kinetic typography being forced into mud.
28+
expect(verdictFromMeasure(measure(0.05, 0.1))).toBe('ok');
29+
});
30+
31+
it('passes balanced frames', () => {
32+
expect(verdictFromMeasure(measure(0.47))).toBe('ok'); // the graded showpiece
33+
});
34+
});
35+
36+
describe('layersToDemote', () => {
37+
it('demotes lightening blends (and overlay) on washout, never the base layer', () => {
38+
expect(layersToDemote('washout', ['screen', 'screen', 'lighten', 'overlay', 'normal'])).toEqual([1, 2, 3]);
39+
});
40+
41+
it('demotes darkening blends on too-dark, never the base layer', () => {
42+
expect(layersToDemote('too-dark', ['multiply', 'multiply', 'darken', 'screen'])).toEqual([1, 2]);
43+
});
44+
45+
it('returns nothing for ok frames or when no blend is in the failed direction', () => {
46+
expect(layersToDemote('ok', ['screen', 'screen'])).toEqual([]);
47+
expect(layersToDemote('washout', ['normal', 'multiply', 'difference'])).toEqual([]);
48+
expect(layersToDemote('too-dark', ['normal', 'screen', 'lighten'])).toEqual([]);
49+
});
50+
});
51+
52+
describe('gate config', () => {
53+
const saved = process.env.SINTER_COMPOSITE_GATE;
54+
afterEach(() => {
55+
if (saved === undefined) delete process.env.SINTER_COMPOSITE_GATE;
56+
else process.env.SINTER_COMPOSITE_GATE = saved;
57+
});
58+
59+
it('is on by default and disabled by SINTER_COMPOSITE_GATE=0', () => {
60+
delete process.env.SINTER_COMPOSITE_GATE;
61+
expect(gateEnabled()).toBe(true);
62+
process.env.SINTER_COMPOSITE_GATE = '0';
63+
expect(gateEnabled()).toBe(false);
64+
});
65+
66+
it('keeps demoted layers translucent rather than occluding the stack', () => {
67+
expect(DEMOTED_OPACITY_FACTOR).toBe(0.75);
68+
});
69+
});
70+
});

test/unit/composition/composition-orchestrator.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi, beforeEach } from 'vitest';
1+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
22

33
// Mock at the boundaries: the generator registry (external generation) and the
44
// HTML wrapper. vi.hoisted is mandatory — vi.mock factories are hoisted above consts.
@@ -22,6 +22,15 @@ vi.mock('../../../src/utils/htmlWrapper.js', () => ({
2222
import { CompositionOrchestrator } from '../../../src/composition/CompositionOrchestrator.js';
2323
import { exceedsWashoutBudget } from '../../../src/composition/BlendBudget.js';
2424

25+
// The measured render gate launches headless Chrome — out of scope for every
26+
// unit test in this file (covered by the CompositeRenderGate suite + probe).
27+
beforeEach(() => {
28+
process.env.SINTER_COMPOSITE_GATE = '0';
29+
});
30+
afterEach(() => {
31+
delete process.env.SINTER_COMPOSITE_GATE;
32+
});
33+
2534
describe('CompositionOrchestrator.compose', () => {
2635
beforeEach(() => {
2736
mocks.entries = [

0 commit comments

Comments
 (0)