mandelbrot/tests/regression.mjs
2026-09-06 23:28:03 +09:00

189 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import vm from 'node:vm';
import {createHash} from 'node:crypto';
const html=await fs.readFile(new URL('../index.html',import.meta.url),'utf8');
const scripts=[...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)].map(m=>m[1]);
assert.equal(scripts.length,2);
for(const script of scripts)new vm.Script(script);
assert.doesNotMatch(html,/\bBLA\b|bla[A-Z]|useBla|run\w*Shadow|Canary|frontier-gate/);
// Pin the reviewed kernels; numerical behavior is also exercised on the GPU.
const kernelContext={};vm.runInNewContext(scripts[0],kernelContext);
const kernels=kernelContext.MANDEL_WEBGPU_KERNELS;
const expected=JSON.parse(await fs.readFile(new URL('./kernel_hashes.json',import.meta.url),'utf8'));
assert.deepEqual(Object.fromEntries(Object.entries(kernels).map(([k,v])=>[k,createHash('sha256').update(v).digest('hex')])),expected);
function appContext(search=''){
const element={width:800,height:600,clientWidth:800,clientHeight:600,style:{},addEventListener(){},classList:{toggle(){}},setAttribute(){}};
const context={MANDEL_WEBGPU_KERNELS:kernels,document:{querySelector:()=>element},navigator:{hardwareConcurrency:4},location:{search},performance,URLSearchParams,TextEncoder,console,addEventListener(){}};
const code=scripts[1].slice(0,scripts[1].indexOf('// ── boot / teardown'))+
'globalThis.internal={state,runtime,refs,refinePixelFrontier,setTestPresentation:fn=>{presentProvisional=fn},WebGpuRenderer,chooseBackend,startRenderClock,finishRenderClock,renderTimeLabel,fromDec,operationLimitIndices,numericalFailureIndices,pixelPrecisionBits,precisionBatchSize,certifiedInteriorTiles,referenceWorkerSource,fastReferenceWorkerSource,precisionFallbackWorkerSource};})();';
vm.runInNewContext(code,context);
return context;
}
const normal=appContext(),diagnostic=appContext('?test');
assert.equal(normal.__MANDEL_TEST__,undefined);
assert.equal(typeof diagnostic.__MANDEL_TEST__.state,'function');
const a=normal.internal;
assert.equal(a.runtime.generationMetrics.length,0);
// Wall time survives stage boundaries; stale completions cannot finish a new
// generation. No real timer remains running after completion or cancellation.
{
const c=appContext(),clock=c.internal;let now=100,live=0;
c.performance={now:()=>now};c.setInterval=()=>{live++;return live};c.clearInterval=()=>{live--};
clock.startRenderClock(1,now);now=2240;
assert.equal(clock.renderTimeLabel(),'描画中… 2.14 s');
clock.finishRenderClock(1);assert.equal(live,0);assert.equal(clock.state.lastRender,2140);
now=5000;assert.equal(clock.renderTimeLabel(),'2.14 s');
clock.startRenderClock(2,now);now=5400;clock.finishRenderClock(1);
assert.equal(clock.state.renderClock.status,'running');assert.equal(live,1);
clock.finishRenderClock(2,'cancelled');assert.equal(clock.renderTimeLabel(),'中断 400 ms');assert.equal(live,0);
}
// Exercise actual backend selection, including both perturbation paths.
const backendCases=[['3.4','fast','direct'],['0.00000000000034','fast','fast-extended'],['3.4','accurate','direct'],['0.00000000000034','accurate','fast-extended']];
for(const [span,mode,expectedBackend] of backendCases){
a.state.renderMode=mode;
const snap={bits:256,re:a.fromDec('-0.743643887037151'),im:a.fromDec('0.13182590420533'),span:a.fromDec(span)};
assert.equal(a.chooseBackend(snap,800).backend,expectedBackend);
}
// Recovery must distinguish iteration limits from numerical failures.
const fixture=Uint32Array.from([6<<20,1<<20,2<<20,3<<20,4<<20,5<<20,1<<28,3<<28,0]);
assert.deepEqual(Array.from(a.operationLimitIndices(fixture)),[0]);
assert.deepEqual(Array.from(a.numericalFailureIndices(fixture)),[1,2,3,4,5]);
const renderer=Object.create(a.WebGpuRenderer.prototype);
for(const width of [320,800,1920,3840])for(const iter of [350,2900,12000,150000])for(const deep of [false,true]){
const {width:tileWidth,rows}=renderer.numericTileShape(width,iter,deep);
assert.ok(rows>=1&&tileWidth>=1);
assert.ok(tileWidth*rows*iter<=(deep?4000000:48000000));
assert.ok(renderer.adaptNumericRows(rows,100,600,deep)<=rows);
const coverage=new Uint8Array(width*3);
for(let y=0;y<3;y+=rows)for(let x=0;x<width;x+=tileWidth)
for(let dy=0;dy<Math.min(rows,3-y);dy++)for(let dx=0;dx<Math.min(tileWidth,width-x);dx++)coverage[(y+dy)*width+x+dx]++;
assert.ok(coverage.every(n=>n===1),'tile coverage');
assert.ok(a.precisionBatchSize(iter,256)<=64);
}
// Catch dangling renderer calls after removing whole diagnostic subsystems.
const rendererSource=scripts[1].slice(scripts[1].indexOf('class WebGpuRenderer{'),scripts[1].indexOf('function numericalFailureCount'));
for(const [,method] of rendererSource.matchAll(/this\.(\w+)\(/g))assert.equal(typeof a.WebGpuRenderer.prototype[method],'function',method);
// Test actual frame routing, especially the argument following removed BLA plans.
{
const r=Object.create(a.WebGpuRenderer.prototype);let actualContext;
Object.assign(r,{ready:Promise.resolve(),device:{},ensureFastPipelines:async()=>{},ensureFrame:()=>({}),setFastContext(ctx){actualContext=ctx},computePerturbFrameTiled:async(...args)=>{assert.equal(args[2],false);return true}});
const ctx={referenceId:'fast-test'};
assert.equal(await r.computeFrame({},350,0,null,true,ctx),true);
assert.equal(actualContext,ctx);
}
// Run every surviving Worker, including real reference generation and fallback.
function worker(source,message){
let result;const c={self:{},postMessage:r=>{result=r},performance};
vm.runInNewContext(source,c);c.self.onmessage({data:message});
assert.ok(result);assert.notEqual(result.type,'error',result.error);return result;
}
const scale=1n<<256n;
for(const center of [0n,-scale,scale]){
const message={type:'build',id:1,key:'test',bits:256,sourceBits:256,targetBits:128,re:center.toString(),im:'0',span:'0',width:1,height:1,iter:64};
const deep=worker(a.referenceWorkerSource(),message),fast=worker(a.fastReferenceWorkerSource(),message);
assert.equal(deep.checkpointMismatch,false);
assert.equal(deep.refLen,fast.refLen);
assert.deepEqual(Buffer.from(deep.refs),Buffer.from(fast.refs));
assert.equal(deep.escape,center===scale?3:0);
}
for(const [center,escaped] of [[0n,false],[scale*3n,true]]){
const result=worker(a.precisionFallbackWorkerSource(),{type:'solve',id:1,bits:256,targetBits:128,re:center.toString(),im:'0',span:'0',width:1,height:1,iter:64,terminalClass:-1,indices:Uint32Array.of(0).buffer});
assert.equal(new Uint8Array(result.accepted)[0],1);
assert.equal((new Uint32Array(result.meta)[0]>>>28)===1,escaped);
}
// Reuse a live reference Worker across increasing and decreasing budgets.
{
let result;const c={self:{},postMessage:r=>{result=r},performance};
vm.runInNewContext(a.referenceWorkerSource(),c);
const message={type:'build',id:1,bits:256,targetBits:128,re:'0',im:'0',span:'0',width:1,height:1,fixedRe:'0',fixedIm:'0'};
let id;
for(const iter of [64,256,512,128]){
c.self.onmessage({data:{...message,iter,key:String(iter)}});
assert.equal(result.checkpointMismatch,false);assert.equal(result.refLen,iter);
const independent=worker(a.referenceWorkerSource(),{...message,iter});
assert.deepEqual(Buffer.from(result.refs),Buffer.from(independent.refs));
if(id)assert.equal(result.referenceId,id);id=result.referenceId;
if(iter===512){assert.equal(result.extendedFrom,256);assert.ok(result.checkpointCount<=3)}
}
c.self.onmessage({data:{...message,iter:512,targetBits:192}});
assert.notEqual(result.referenceId,id);assert.equal(result.extendedFrom,0);
}
// Instrument the actual fallback recurrence: failed comparisons must reuse
// the preceding high-precision result (five orbits instead of eight).
{
let result;const calls=[];const c={self:{},performance,postMessage:r=>{result=r},record:b=>calls.push(b)};
vm.runInNewContext(a.precisionFallbackWorkerSource(),c);
vm.runInNewContext('pixel=(s,i,bits)=>{record(bits);return {escape:bits,smooth:bits}}',c);
c.self.onmessage({data:{type:'solve',id:1,bits:256,targetBits:128,re:'0',im:'0',span:'0',width:1,height:1,iter:64,indices:Uint32Array.of(0).buffer}});
assert.deepEqual(calls,[128,160,192,224,256]);assert.equal(new Uint8Array(result.accepted)[0],0);
}
// A late CPU completion must be rejected before any GPU write or allocation.
assert.equal(await renderer.applyPrecisionFallback({indices:Uint32Array.of(0)},a.state.token-1),null);
// Exact rational certificates include only strict interior rectangles. Probe
// both sides of the cardioid cusp and the period-2 boundary at 256-bit depth.
{
const one=1n<<256n;
for(const [re,expected] of [[0n,true],[-one,true],[one/4n,false],[one/4n-1n,true],[one/4n+1n,false],[-5n*one/4n,false],[-5n*one/4n+1n,true],[-5n*one/4n-1n,false]]){
const result=a.certifiedInteriorTiles({bits:256,re,im:0n,span:0n},1,1);
assert.equal(result.pixels===1,expected,re.toString());
}
const touching=a.certifiedInteriorTiles({bits:256,re:one/4n,im:0n,span:one/100n},32,32);
assert.equal(touching.pixels,0,'rectangle crossing cusp must stay in the normal queue');
}
// Exercise the actual frontier controller with one repaired pixel and one
// healthy survivor. GPU state/queue correctness is tested in browser_smoke.
{
const snap={bits:256,re:0n,im:0n,span:a.fromDec('3.4')};Object.assign(a.state,snap);
const ctx={referenceId:'fixture',key:'fixture',source:snap,refLen:4096};
a.refs.request=async()=>ctx;a.refs.requestFixed=async()=>ctx;a.setTestPresentation(async()=>true);
const field=Uint32Array.of(6<<20|512,6<<20|512),replayed=[];let initializations=0,advances=0;
const stats=()=>({total:2,reasons:{errorBound:(field[0]>>20)===1?1:0,operationLimit:(field[0]>>20)===1?1:2}});
const r={certifyInterior:async()=>0,readMetaAll:async()=>field.slice(),readUnresolvedStats:async()=>stats(),setDeepContext(){},
beginOperationLimitContinuation:async()=>{initializations++;return{active:2,pixelIterations:0,dispatches:0}},
advancePriorityTarget:async(session,_snap,target)=>{advances++;assert.ok(target>512);field[0]=advances===1?1<<20:field[0];field[1]=6<<20|target;session.active=1;session.pixelIterations+=1;session.dispatches++;return session},
correctUnknownFrame:async()=>{field[0]=6<<20|1024;return{mode:'queue'}},
readActiveIndices:async()=>Uint32Array.of(1),
refineOperationLimitIndices:async(_snap,target,_token,_ctx,_pixel,indices)=>{replayed.push(Array.from(indices));for(const i of indices)field[i]=6<<20|target;return{stats:stats()}}
};
const result=await a.refinePixelFrontier(r,snap,512,a.state.token,stats());
assert.equal(initializations,1,'a repair must not reset the healthy session');
assert.equal(advances,2);assert.deepEqual(replayed,[[0]]);assert.equal(result.converged,true);
assert.equal(field[0]&0xfffff,result.iter);assert.equal(field[1]&0xfffff,result.iter);
}
{
const prior=new Uint32Array(5100).fill(6<<20|512);prior[0]=1<<28|10;
const q={kind:'queue'},c={kind:'count'},steps=[];let entries=Uint32Array.from({length:5000},(_,i)=>i+1);
const r=Object.create(a.WebGpuRenderer.prototype),session={active:5000,progress:512,f:{w:100,h:51},queues:[q,q],counts:[c,c],input:0};
r.device={queue:{writeBuffer:(target,_offset,data)=>{if(target.kind==='queue')entries=Uint32Array.from(data)}}};
r.readActiveIndices=async()=>entries.slice();
r.continueOperationLimitActive=async(s,_snap,target)=>{steps.push({count:s.active,target});s.progress=target;return s};
const result=await r.advancePriorityTarget(session,{},4096,a.state.token,{}, {},prior);
assert.equal(steps.length,2);assert.ok(steps[0].count<steps[1].count);
assert.equal(steps[0].count+steps[1].count,5000);assert.ok(steps.every(s=>s.target===4096));
assert.equal(result.active,5000);assert.equal(result.progress,4096);
assert.deepEqual(Array.from(entries).sort((x,y)=>x-y),Array.from({length:5000},(_,i)=>i+1));
}
// Exhausting the automatic finite budget is a valid image only when every
// remaining pixel reached it. A stale lower-iteration field is still rejected.
{
const snap={bits:256,re:0n,im:0n,span:a.fromDec('3.4')};Object.assign(a.state,snap,{continuationBudget:0});
const stats={total:1,reasons:{operationLimit:1}};
for(const [n,complete] of [[150000,true],[149999,false]]){
const r={certifyInterior:async()=>0,readMetaAll:async()=>Uint32Array.of(6<<20|n),readUnresolvedStats:async()=>stats};
const result=await a.refinePixelFrontier(r,snap,150000,a.state.token,stats);
assert.equal(result.converged,complete);assert.equal(result.membershipCertified,false);
assert.equal(result.policy,'automatic-finite-cap');
}
}
console.log(`PASS: scripts, ${Object.keys(kernels).length-1} pinned shaders, routing, tile coverage/work caps, reference extension, precision reuse, cancellation, generation timing, finite cap`);