|
37 | 37 | if(CFG.levels) tests=tests.filter(t=>CFG.levels.includes(t.lvl)); |
38 | 38 | const CATS=[...new Set(tests.map(t=>t.cat))]; |
39 | 39 |
|
40 | | - const state={ sel:new Set(servers.map(s=>s.name)), cats:new Set(CATS), divOnly:false, scoredOnly:false }; |
| 40 | + const defaultServers = CFG.defaultTiers ? servers.filter(s=>CFG.defaultTiers.includes(s.tier)) : servers; |
| 41 | + const state={ sel:new Set(defaultServers.map(s=>s.name)), cats:new Set(CATS), divOnly:false, scoredOnly:false }; |
| 42 | + let INSIGHT=null; // matrix-study metrics for the Entropy page (recomputed each render) |
41 | 43 |
|
42 | 44 | // ----- helpers ----- |
43 | 45 | const el=(t,c,h)=>{const e=document.createElement(t);if(c)e.className=c;if(h!=null)e.innerHTML=h;return e;}; |
|
75 | 77 | return h; |
76 | 78 | } |
77 | 79 | const ENT_MAX=Math.log2(3); // max for {Pass,Warn,Fail} — bar normalisation |
| 80 | + const ANOM_T=0.75; // |residual| above which a cell is a genuine anomaly (confident model, contradicted) |
| 81 | + |
| 82 | + // ---- matrix-study helpers (Entropy page insight panel) ---- |
| 83 | + function pearson(a,b){ |
| 84 | + const N=a.length; let sa=0,sb=0; for(let i=0;i<N;i++){sa+=a[i];sb+=b[i];} |
| 85 | + const ma=sa/N,mb=sb/N; let num=0,da=0,db=0; |
| 86 | + for(let i=0;i<N;i++){const x=a[i]-ma,y=b[i]-mb; num+=x*y; da+=x*x; db+=y*y;} |
| 87 | + return (da>1e-12&&db>1e-12)?num/Math.sqrt(da*db):0; |
| 88 | + } |
| 89 | + function jacobiEigenvalues(A){ // eigenvalues of a symmetric matrix, descending |
| 90 | + const N=A.length, a=A.map(r=>r.slice()); |
| 91 | + for(let sweep=0;sweep<50;sweep++){ |
| 92 | + let off=0; for(let p=0;p<N;p++)for(let q=p+1;q<N;q++)off+=a[p][q]*a[p][q]; |
| 93 | + if(off<1e-11) break; |
| 94 | + for(let p=0;p<N;p++)for(let q=p+1;q<N;q++){ |
| 95 | + if(Math.abs(a[p][q])<1e-13) continue; |
| 96 | + const phi=0.5*Math.atan2(2*a[p][q],a[q][q]-a[p][p]), c=Math.cos(phi), s=Math.sin(phi); |
| 97 | + for(let k=0;k<N;k++){const kp=a[k][p],kq=a[k][q]; a[k][p]=c*kp-s*kq; a[k][q]=s*kp+c*kq;} |
| 98 | + for(let k=0;k<N;k++){const pk=a[p][k],qk=a[q][k]; a[p][k]=c*pk-s*qk; a[q][k]=s*pk+c*qk;} |
| 99 | + } |
| 100 | + } |
| 101 | + return a.map((r,i)=>r[i]).sort((x,y)=>y-x); |
| 102 | + } |
| 103 | + function effectiveRank(V){ // participation ratio of singular values of grand-centered V |
| 104 | + const rows=V.length, cols=V[0].length; let g=0; for(const r of V)for(const v of r)g+=v; g/=rows*cols; |
| 105 | + const useCols=cols<=rows, d=useCols?cols:rows; |
| 106 | + const G=Array.from({length:d},()=>new Array(d).fill(0)); |
| 107 | + for(let i=0;i<d;i++)for(let j=i;j<d;j++){ let s=0; |
| 108 | + if(useCols){ for(let k=0;k<rows;k++) s+=(V[k][i]-g)*(V[k][j]-g); } |
| 109 | + else { for(let k=0;k<cols;k++) s+=(V[i][k]-g)*(V[j][k]-g); } |
| 110 | + G[i][j]=G[j][i]=s; |
| 111 | + } |
| 112 | + const ev=jacobiEigenvalues(G).filter(x=>x>1e-8); |
| 113 | + const tot=ev.reduce((a,b)=>a+b,0); if(tot<=0) return {rank:0,var2:0}; |
| 114 | + let h=0; for(const l of ev){const p=l/tot; if(p>0) h-=p*Math.log(p);} |
| 115 | + return {rank:Math.exp(h), var2:(ev[0]+(ev[1]||0))/tot}; |
| 116 | + } |
| 117 | + function raschResiduals(V){ // additive-logit fit logit p = theta_col - beta_row; residual = obs - pred |
| 118 | + const m=V.length, n=V[0].length; |
| 119 | + const rowsum=V.map(r=>r.reduce((a,b)=>a+b,0)); |
| 120 | + const colsum=new Array(n).fill(0); for(let i=0;i<m;i++)for(let j=0;j<n;j++)colsum[j]+=V[i][j]; |
| 121 | + const theta=new Array(n).fill(0), beta=new Array(m).fill(0); |
| 122 | + const lg=x=>1/(1+Math.exp(-Math.max(-30,Math.min(30,x)))); |
| 123 | + for(let it=0;it<200;it++){ |
| 124 | + for(let i=0;i<m;i++){ let pr=0,dd=0; for(let j=0;j<n;j++){const p=lg(theta[j]-beta[i]); pr+=p; dd+=p*(1-p);} |
| 125 | + beta[i]=Math.max(-8,Math.min(8, beta[i]+(pr-rowsum[i])/Math.max(dd,1e-6))); } |
| 126 | + for(let j=0;j<n;j++){ let pc=0,dd=0; for(let i=0;i<m;i++){const p=lg(theta[j]-beta[i]); pc+=p; dd+=p*(1-p);} |
| 127 | + theta[j]=Math.max(-8,Math.min(8, theta[j]-(pc-colsum[j])/Math.max(dd,1e-6))); } |
| 128 | + } |
| 129 | + const gm=rowsum.reduce((a,b)=>a+b,0)/(m*n); let ssTot=0,ssRes=0; |
| 130 | + const resid=V.map((r,i)=>r.map((v,j)=>{const e=v-lg(theta[j]-beta[i]); ssTot+=(v-gm)**2; ssRes+=e*e; return e;})); |
| 131 | + return {resid, explained:ssTot>0?1-ssRes/ssTot:0}; |
| 132 | + } |
| 133 | + function computeInsight(vts,srvs){ |
| 134 | + const m=vts.length,n=srvs.length,enc={Pass:1,Warn:0.5,Fail:0}; |
| 135 | + const V=vts.map(t=>srvs.map(s=>{const r=s.byId[t.id]; return r?(enc[r.verdict]??0):0;})); |
| 136 | + const density=V.reduce((a,r)=>a+r.reduce((x,y)=>x+y,0),0)/(m*n); |
| 137 | + const colpat=new Set(srvs.map(s=>vts.map(t=>{const r=s.byId[t.id];return r?r.verdict:"NA";}).join("|"))).size; |
| 138 | + const er=effectiveRank(V), rf=raschResiduals(V); |
| 139 | + const colsum=srvs.map((s,j)=>{let x=0;for(let i=0;i<m;i++)x+=V[i][j];return x;}); |
| 140 | + const byId={}, residKey={}; let anom=0; |
| 141 | + for(let i=0;i<m;i++){ |
| 142 | + const disc=pearson(V[i],colsum); |
| 143 | + const passN=srvs.reduce((a,s)=>a+(((s.byId[vts[i].id]||{}).verdict==="Pass")?1:0),0); |
| 144 | + byId[vts[i].id]={disc,passN,H:entropy(vts[i],srvs)}; |
| 145 | + for(let j=0;j<n;j++){const r=rf.resid[i][j]; residKey[vts[i].id+"|"+srvs[j].name]=r; if(Math.abs(r)>=ANOM_T)anom++;} |
| 146 | + } |
| 147 | + return {density,colpat,n,effRank:er.rank,var2:er.var2,explained:rf.explained,anom,byId,residKey}; |
| 148 | + } |
| 149 | + function renderInsight(INS){ |
| 150 | + const box=document.getElementById("insight"); if(!box) return; |
| 151 | + if(!INS){ box.innerHTML=""; return; } |
| 152 | + const pct=Math.round(INS.explained*100); |
| 153 | + const tiles=[ |
| 154 | + ["Density",(INS.density*100).toFixed(0)+"%","MUST verdicts that pass (warn = ½)"], |
| 155 | + ["Distinct behaviours",INS.colpat+" / "+INS.n,"unique verdict fingerprints among shown servers"], |
| 156 | + ["Effective rank",INS.effRank.toFixed(1),"independent behavioural axes · 1 = pure strictness"], |
| 157 | + ["Strictness explains",pct+"%","of the pattern — the other "+(100-pct)+"% is residual divergence"], |
| 158 | + ["Anomalies",String(INS.anom),"cells defying the strictness model (|residual| ≥ 0.75)"], |
| 159 | + ]; |
| 160 | + box.innerHTML=tiles.map(([k,v,d])=>`<div class="itile"><div class="iv">${v}</div><div class="ik">${k}</div><div class="idesc">${d}</div></div>`).join(""); |
| 161 | + } |
78 | 162 | function visibleTests(srvs){ |
79 | 163 | let ts=tests.filter(t=>state.cats.has(t.cat)&&(!state.scoredOnly||t.scored)); |
80 | 164 | ts=ts.map(t=>({t,d:disagreement(t,srvs),h:entropy(t,srvs)})); |
|
182 | 266 | th.innerHTML=`<div class="rot">${s.name}</div><div class="sc">${s.score}</div>`;tr.appendChild(th);}); |
183 | 267 | head.appendChild(tr); |
184 | 268 | const vts=visibleTests(srvs); |
| 269 | + INSIGHT=(CFG.showEntropy && vts.length>=3 && srvs.length>=3)?computeInsight(vts,srvs):null; |
| 270 | + renderInsight(INSIGHT); |
185 | 271 | const frag=document.createDocumentFragment(); |
186 | 272 | vts.forEach(t=>{ |
187 | 273 | const row=el("tr",t.scored?null:"unscored"); |
|
200 | 286 | srvs.forEach(s=>{ |
201 | 287 | const r=s.byId[t.id]; const v=r?r.verdict:"NA"; |
202 | 288 | const td=el("td","cell "+v); |
| 289 | + if(INSIGHT){const rk=INSIGHT.residKey[t.id+"|"+s.name]; if(rk!==undefined&&Math.abs(rk)>=ANOM_T) td.classList.add("anom");} |
203 | 290 | const code=esc(statusText(r)); |
204 | 291 | td.innerHTML=t.url?`<a href="${t.url}" tabindex="-1">${code}</a>`:`<span>${code}</span>`; |
205 | 292 | td.dataset.s=s.name;td.dataset.t=t.id;td.dataset.v=v; |
|
223 | 310 | const rid=e.target.closest("td.rid"); |
224 | 311 | if(rid){ |
225 | 312 | const t=tById[rid.dataset.t]; if(!t) return; |
| 313 | + const st=INSIGHT&&INSIGHT.byId[t.id]; |
226 | 314 | tip.innerHTML= |
227 | 315 | `<div class="tip-h"><b>${esc(t.id)}</b></div>`+ |
228 | 316 | `<div class="tip-sub">${esc(t.cat)} · ${esc(t.lvl==="Must"?"MUST":t.lvl)}${t.rfc?" · "+esc(t.rfc):""} · expected ${esc(t.exp||"?")}</div>`+ |
229 | 317 | `<div class="tip-desc">${esc(t.desc||"No description available.")}</div>`+ |
| 318 | + (st?`<div class="tip-stat">entropy <b>${st.H.toFixed(2)}</b> bits · discrimination <b>${st.disc>=0?"+":""}${st.disc.toFixed(2)}</b> · <b>${st.passN}/${INSIGHT.n}</b> pass${st.disc<0.2?` · <span class="warn-tag">low-discrimination</span>`:""}</div>`:"")+ |
230 | 319 | (t.url?`<div class="tip-foot">Click the name to open the full test page →</div>`:""); |
231 | 320 | positionTip(rid,tip); |
232 | 321 | document.querySelectorAll("td.cell.hl").forEach(x=>x.classList.remove("hl")); |
|
237 | 326 | const req=(r&&r.rawRequest)?trunc(r.rawRequest,700):"(request unavailable)"; |
238 | 327 | const res=(r&&r.rawResponse)?trunc(r.rawResponse,700) |
239 | 328 | :((r&&r.connectionState==="ClosedByServer")?"(connection closed by server — no response)":"(no response captured)"); |
| 329 | + let residHtml=""; |
| 330 | + if(INSIGHT){ const rk=INSIGHT.residKey[c.dataset.t+"|"+c.dataset.s]; |
| 331 | + if(rk!==undefined) residHtml=`<div class="tip-stat">residual <b>${rk>=0?"+":""}${rk.toFixed(2)}</b>${Math.abs(rk)>=ANOM_T?` · <span class="warn-tag">anomaly — ${rk>0?"passes where its strictness predicts a fail":"fails where its strictness predicts a pass"}</span>`:""}</div>`; } |
240 | 332 | tip.innerHTML= |
241 | 333 | `<div class="tip-h"><b>${esc(c.dataset.s)}</b> · <span class="v-${c.dataset.v}">${(c.dataset.v||"n/a").toUpperCase()}</span> → ${esc(statusText(r))}</div>`+ |
242 | 334 | `<div class="tip-sub">${esc(t.id)} · ${esc(t.cat)} · ${esc(t.lvl)} · expected ${esc(t.exp||"")}</div>`+ |
| 335 | + residHtml+ |
243 | 336 | `<span class="lbl">request sent</span><pre>${esc(req)}</pre>`+ |
244 | 337 | `<span class="lbl">response</span><pre>${esc(res)}</pre>`; |
245 | 338 | positionTip(c,tip); |
|
0 commit comments