mandelbrot/tests/navigation_continuity.mjs
2026-09-29 23:44:26 +09:00

186 lines
12 KiB
JavaScript

// Navigation-continuity regression for deep FAST perturbation.
//
// Goal (#8 only): the same target view must not change numerically merely
// because its FAST reference came from a nearby pan/zoom view that production
// considers reusable.
//
// Default mode is a strict regression test: any classification/iteration
// discontinuity exits non-zero. Use --report to emit diagnostics without
// failing; this is useful while fixing a known continuity defect.
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import vm from 'node:vm';
import {createHash} from 'node:crypto';
import {performance} from 'node:perf_hooks';
const reportOnly=process.argv.includes('--report');
const prodUrl=new URL('../index.html',import.meta.url);
const prodHtml=await fs.readFile(prodUrl,'utf8');
const productionSha256=createHash('sha256').update(prodHtml).digest('hex');
const scripts=[...prodHtml.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)].map(m=>m[1]);
assert.equal(scripts.length,2,'expected the production kernel and app scripts');
for(const script of scripts)new vm.Script(script);
const kernelContext={};
vm.runInNewContext(scripts[0],kernelContext);
const kernels=kernelContext.MANDEL_WEBGPU_KERNELS;
function appContext(){
const element={width:1365,height:768,clientWidth:1365,clientHeight:768,style:{},hidden:false,value:'',textContent:'',disabled:false,addEventListener(){},classList:{toggle(){}},setAttribute(){},removeAttribute(){},showModal(){},close(){},toBlob(){},getBoundingClientRect(){return{left:0,top:0,width:1365,height:768}}};
const context={
MANDEL_WEBGPU_KERNELS:kernels,
document:{querySelector:()=>element,getElementById:()=>element,documentElement:{classList:{toggle(){}}},body:{classList:{toggle(){}},appendChild(){}}},
navigator:{hardwareConcurrency:4,deviceMemory:8},location:{search:'',hash:''},history:{replaceState(){}},performance,
URLSearchParams,URL:{createObjectURL:()=>`blob:test`,revokeObjectURL(){}},TextEncoder,Blob:class{},console,
addEventListener(){},requestAnimationFrame:()=>1,cancelAnimationFrame(){},setTimeout,clearTimeout,setInterval,clearInterval,
innerWidth:1365,innerHeight:768,devicePixelRatio:1,localStorage:{getItem(){return null},setItem(){},removeItem(){}}
};
const cut=scripts[1].indexOf('// ── boot / teardown');
assert.ok(cut>0,'boot marker missing');
const code=scripts[1].slice(0,cut)+`\nglobalThis.internal={fastReferenceWorkerSource,referenceWorkerSource,fastReferenceBits,pixelPrecisionBits,referencePixelForSource,spanMantExp,adaptiveProbeSources,adaptiveProbeChoice,adaptiveIterationBonus,log2FixedAt,reusableReferencePixel};})();`;
vm.runInNewContext(code,context);
return context.internal;
}
const app=appContext();
function runWorker(source,data){
let result;
const context={self:{},postMessage:d=>{result=d},performance,console,BigInt,Math,ArrayBuffer,DataView,Float32Array,Float64Array,Uint32Array,Uint8Array};
vm.runInNewContext(source,context);
context.self.onmessage({data});
if(!result)throw new Error('worker produced no result');
if(result.type==='error')throw new Error(result.error);
return result;
}
function maxAdaptive(snap,w){
const spanLog2=app.log2FixedAt(snap.span,snap.bits);
const z=Math.max(0,Math.log10(3.4)-spanLog2/Math.log2(10));
const screenBits=Math.max(0,Math.log2(w)+z*Math.LOG2E*Math.LN10);
const bonus=app.adaptiveIterationBonus(screenBits,1);
return Math.min(16384,Math.max(350,350+bonus));
}
function initialIter(snap,w,h){
const adaptive=maxAdaptive(snap,w);
const sources=app.adaptiveProbeSources(snap,w,h);
const targetBits=Math.max(app.pixelPrecisionBits(snap,w,40)+32);
const scored=runWorker(app.referenceWorkerSource(),{
type:'score',id:1,bits:snap.bits,targetBits,re:snap.re.toString(),im:snap.im.toString(),span:snap.span.toString(),width:w,height:h,iter:adaptive,
candidates:sources.map(c=>({re:c.re.toString(),im:c.im.toString()}))
});
return {adaptive,...app.adaptiveProbeChoice(scored.escapes||[],adaptive,350)};
}
function buildFast(snap,iter,w,h,id){
const targetBits=app.fastReferenceBits(snap,w);
const d=runWorker(app.fastReferenceWorkerSource(),{
type:'build',id,key:`nav-${id}`,sourceBits:snap.bits,targetBits,re:snap.re.toString(),im:snap.im.toString(),span:snap.span.toString(),width:w,height:h,iter
});
d.source={bits:snap.bits,re:BigInt(d.referenceRe),im:BigInt(d.referenceIm),viewRe:snap.re,viewIm:snap.im,span:snap.span,iter};
return d;
}
const f=Math.fround;
const add=(a,b)=>f(f(a)+f(b));
const sub=(a,b)=>f(f(a)-f(b));
const mul=(a,b)=>f(f(a)*f(b));
const div=(a,b)=>f(f(a)/f(b));
const maxabs=v=>Math.max(Math.abs(v[0]),Math.abs(v[1]));
function cmul(a,b){return[add(mul(a[0],b[0]),-mul(a[1],b[1])),add(mul(a[0],b[1]),mul(a[1],b[0]))]}
function ld(v,e){return f(v*Math.pow(2,e))}
function smoothEscape(n,mag2){const u=Math.log2(Math.max(4.0000005,mag2));return f(n+1-Math.log2(Math.max(1e-20,.5*u)))}
function refsFrom(buf,n){const dv=new DataView(buf),out=[];for(let i=0;i<=n;i++){const o=i*16;out.push([[dv.getFloat32(o,true),dv.getFloat32(o+4,true)],[dv.getFloat32(o+8,true),dv.getFloat32(o+12,true)]])}return out}
function simulate(snap,w,h,iter,ctx,index){
if(!ctx._navRefs)ctx._navRefs=refsFrom(ctx.refs,ctx.refLen);
const refs=ctx._navRefs,px=index%w,py=Math.floor(index/w),rpix=app.referencePixelForSource(ctx.source,snap,w,h),se=app.spanMantExp(snap);
const gx=f(px+.5),gy=f(py+.5),dx=div(sub(gx,f(rpix.x)),f(w)),dy=div(sub(f(rpix.y),gy),f(w));
let d=[mul(f(se.mant),dx),mul(f(se.mant),dy)],ww=[0,0],scaleExp=se.exp,n=0,m=0;
for(;;){
const rp=refs[Math.min(m,ctx.refLen)],delta=[ld(ww[0],scaleExp),ld(ww[1],scaleExp)],z=[add(rp[0][0],add(rp[1][0],delta[0])),add(rp[0][1],add(rp[1][1],delta[1]))],mag=add(mul(z[0],z[0]),mul(z[1],z[1]));
if(mag>4)return{kind:'escape',n,smooth:smoothEscape(n,mag)};
if(n>=iter)return{kind:'operation-limit',n,smooth:0};
if(m>=ctx.refLen)return{kind:'reference-end',n,smooth:0};
const linearHi=cmul(rp[0],ww),linearLo=cmul(rp[1],ww),linear=[f(2*f(linearHi[0]+linearLo[0])),f(2*f(linearHi[1]+linearLo[1]))],sq0=cmul(ww,ww),sq=[ld(sq0[0],scaleExp),ld(sq0[1],scaleExp)];
ww=[add(add(linear[0],sq[0]),d[0]),add(add(linear[1],sq[1]),d[1])];m++;n++;
const mm=Math.max(maxabs(ww),maxabs(d));
if(!Number.isFinite(mm)||mm>=1e30)return{kind:'range',n,smooth:0};
if(mm>65536){ww=[mul(ww[0],1/65536),mul(ww[1],1/65536)];d=[mul(d[0],1/65536),mul(d[1],1/65536)];scaleExp+=16}
else if(mm>0&&mm<1/65536&&scaleExp>se.exp){ww=[mul(ww[0],65536),mul(ww[1],65536)];d=[mul(d[0],65536),mul(d[1],65536)];scaleExp-=16}
if(scaleExp>126)return{kind:'range',n,smooth:0};
}
}
function grid(w,h,nx=16,ny=10){const out=[];for(let j=0;j<ny;j++)for(let i=0;i<nx;i++){const x=Math.min(w-1,Math.floor((i+.5)*w/nx)),y=Math.min(h-1,Math.floor((j+.5)*h/ny));out.push(y*w+x)}return out}
function shiftSnap(s,fx,fy,scale=1){const million=1000000n,span=(s.span*BigInt(Math.round(scale*1e6)))/million;return{bits:s.bits,re:s.re+(s.span*BigInt(Math.round(fx*1e6)))/million,im:s.im+(s.span*BigInt(Math.round(fy*1e6)))/million,span:span>0n?span:1n}}
const cases=[
{name:'mini-1',bits:256,re:'-148292766829529091721540000955948596563327036751005092894699321061162899553437',im:'7497924611962244671337883039784517355486082422571896698766224071500989488498',sp:'1051264689747601722664903477680432560343035141150552570739220680259730166'},
{name:'mini-2',bits:256,re:'44279008708751415573784266681073018338222298986985889938567243759258661091705',im:'11537730301689015207261638283847108540669173974016900715268231497454915998519',sp:'136466963900827996566957710260403731102960944336062216372133428878224980'},
{name:'mini-3-black',bits:256,re:'15717041212470055224256502413765101609014610520343947152977545154690386564664',im:'74092703473662244164463718953593537954306995881211045997604051198538528963626',sp:'7384741851176872716004457996517031059911173801417894624776297655376888'},
{name:'refend-zoom-black',bits:273,re:'-20475306797689005889663451277157868621960315439319427815381950343245479294092514544',im:'966333796787038295532838729476816377447677486901484166221710883227030438393551731',sp:'14873556985132116323960339070653445992212160392759733161044631107644716'},
{name:'refend-undrawn',bits:307,re:'46025019095185088783974324228976729149454131232585283954369753562823949096514860778965742615',im:'-151398041000872486580541963077907528105710741688397340075259729545288600585999778752201285140',sp:'1361049793375351627487861104794890130303800780964576514408724611446175'}
];
const paths=[
{name:'pan-right-0.10',fx:.1,fy:0,scale:1},{name:'pan-left-0.10',fx:-.1,fy:0,scale:1},
{name:'pan-up-0.10',fx:0,fy:.1,scale:1},{name:'pan-down-0.10',fx:0,fy:-.1,scale:1},
{name:'zoom-from-0.90',fx:0,fy:0,scale:.9},{name:'zoom-from-1.10',fx:0,fy:0,scale:1.1},
{name:'pan-diagonal-a',fx:.2,fy:.15,scale:1},{name:'pan-diagonal-b',fx:-.2,fy:-.15,scale:1}
];
const w=1365,h=768,indices=grid(w,h),results=[];
let referenceSerial=10,totalPaths=0,totalComparisons=0,classDifferences=0,iterationDifferences=0,smoothDifferences=0,maxSmoothDelta=0;
const SMOOTH_TOLERANCE=1e-4;
for(const item of cases){
const snap={bits:item.bits,re:BigInt(item.re),im:BigInt(item.im),span:BigInt(item.sp)};
const selected=initialIter(snap,w,h),iter=selected.iter,fresh=buildFast(snap,iter,w,h,referenceSerial++);
const freshRows=indices.map(index=>({index,result:simulate(snap,w,h,iter,fresh,index)}));
const caseResult={case:item.name,iter,adaptive:selected.adaptive,freshRefLen:fresh.refLen,paths:[]};
for(const path of paths){
const sourceSnap=shiftSnap(snap,path.fx,path.fy,path.scale),candidate=buildFast(sourceSnap,iter,w,h,referenceSerial++);
const requiredBits=app.fastReferenceBits(snap,w),requiredLen=Math.min(iter,Math.max(4096,Math.floor(iter/3)));
const reusable=app.reusableReferencePixel(candidate,snap,requiredLen,w,h,requiredBits,true);
if(!reusable){caseResult.paths.push({...path,reusable:false,refLen:candidate.refLen});continue;}
totalPaths++;
let classDiff=0,iterDiff=0,smoothDiff=0,pathMaxSmooth=0;
const examples=[];
for(const row of freshRows){
const got=simulate(snap,w,h,iter,candidate,row.index),base=row.result;
totalComparisons++;
const cls=got.kind!==base.kind;
const it=!cls&&got.n!==base.n;
const sd=(!cls&&!it&&got.kind==='escape')?Math.abs(got.smooth-base.smooth):0;
const sm=sd>SMOOTH_TOLERANCE;
if(cls){classDiff++;classDifferences++}
if(it){iterDiff++;iterationDifferences++}
if(sm){smoothDiff++;smoothDifferences++}
if(sd>pathMaxSmooth)pathMaxSmooth=sd;if(sd>maxSmoothDelta)maxSmoothDelta=sd;
if((cls||it||sm)&&examples.length<8)examples.push({index:row.index,x:row.index%w,y:Math.floor(row.index/w),fresh:base,candidate:got,smoothDelta:sd});
}
caseResult.paths.push({...path,reusable:true,refLen:candidate.refLen,referencePixel:reusable.pixel,classDifferences:classDiff,iterationDifferences:iterDiff,smoothDifferences:smoothDiff,maxSmoothDelta:pathMaxSmooth,examples});
}
results.push(caseResult);
}
const summary={
generatedAt:new Date().toISOString(),productionSha256,width:w,height:h,samplesPerTarget:indices.length,
cases:results,totalReusablePaths:totalPaths,totalComparisons,
discontinuities:{classification:classDifferences,escapeIteration:iterationDifferences,smooth:smoothDifferences,maxSmoothDelta},
smoothTolerance:SMOOTH_TOLERANCE,
invariant:'For any reference that production reusableReferencePixel(..., allowNearby=true) accepts, the target view must match a fresh target reference at sampled pixels. Classification and escape iteration are strict; smooth has a small diagnostic tolerance.'
};
console.log(JSON.stringify(summary,null,2));
if(totalPaths===0)throw new Error('navigation-continuity corpus produced no production-reusable nearby references');
const hardDifferences=classDifferences+iterationDifferences;
if(!reportOnly){
assert.equal(hardDifferences,0,`navigation continuity failed: ${classDifferences} classification + ${iterationDifferences} escape-iteration differences across ${totalComparisons} comparisons`);
assert.equal(smoothDifferences,0,`navigation smooth continuity failed: ${smoothDifferences} samples exceeded ${SMOOTH_TOLERANCE}`);
console.error(`PASS: navigation continuity (${totalPaths} reusable paths, ${totalComparisons} sampled comparisons)`);
}else{
console.error(`REPORT: navigation continuity found ${classDifferences} classification, ${iterationDifferences} iteration, ${smoothDifferences} smooth differences across ${totalComparisons} comparisons`);
}