|
106 | 106 | for(let i=0;i<m;i++){ let fc=0; for(let j=0;j<n;j++) if(!B[i][j]) fc++; |
107 | 107 | totalFails+=fc; if(fc>=3) systematic+=fc; } |
108 | 108 | const systematicPct = totalFails>0 ? systematic/totalFails : 1; |
109 | | - // behavioural families: single-linkage clusters of servers agreeing >=85% of the time |
110 | | - const parent=srvs.map((_,i)=>i); |
111 | | - const find=x=>{while(parent[x]!==x){parent[x]=parent[parent[x]];x=parent[x];}return x;}; |
112 | | - for(let i=0;i<n;i++)for(let j=i+1;j<n;j++){ |
113 | | - let same=0; for(let k=0;k<m;k++) if(B[k][i]===B[k][j]) same++; |
114 | | - if(same/m>=0.85) parent[find(i)]=find(j); |
| 109 | + // behavioural families: complete-linkage clusters (EVERY pair in a family agrees >=85%) |
| 110 | + const A=Array.from({length:n},()=>new Array(n).fill(1)); |
| 111 | + for(let i=0;i<n;i++)for(let j=i+1;j<n;j++){let s=0;for(let k=0;k<m;k++)if(B[k][i]===B[k][j])s++; A[i][j]=A[j][i]=s/m;} |
| 112 | + let clusters=srvs.map((_,i)=>[i]); |
| 113 | + const cdist=(a,b)=>{let mn=1;for(const x of a)for(const y of b)if(A[x][y]<mn)mn=A[x][y];return mn;}; |
| 114 | + for(;;){ let best=-1,bi=-1,bj=-1; |
| 115 | + for(let a=0;a<clusters.length;a++)for(let b=a+1;b<clusters.length;b++){const d=cdist(clusters[a],clusters[b]); if(d>=0.85&&d>best){best=d;bi=a;bj=b;}} |
| 116 | + if(bi<0)break; clusters[bi]=clusters[bi].concat(clusters[bj]); clusters.splice(bj,1); |
115 | 117 | } |
116 | | - const families=new Set(srvs.map((_,i)=>find(i))).size; |
| 118 | + clusters.sort((a,b)=>b.length-a.length); |
| 119 | + const famGroups=clusters.filter(c=>c.length>=2).map(c=>c.map(i=>srvs[i].name)); |
| 120 | + const loners=clusters.filter(c=>c.length===1).map(c=>srvs[c[0]].name); |
| 121 | + const famOf={}; famGroups.forEach((g,idx)=>g.forEach(nm=>famOf[nm]=idx+1)); |
117 | 122 | // per-test stats for the test-name hover |
118 | 123 | const byId={}; |
119 | 124 | for(let i=0;i<m;i++){ let passN=0; for(let j=0;j<n;j++) if(B[i][j]) passN++; |
120 | 125 | byId[vts[i].id]={passN,H:entropy(vts[i],srvs)}; } |
121 | | - return {density,explained,systematicPct,families,n,byId}; |
| 126 | + return {density,explained,systematicPct,families:famGroups.length,famGroups,loners,famOf,n,byId}; |
122 | 127 | } |
123 | 128 | function renderInsight(INS){ |
124 | 129 | const box=document.getElementById("insight"); if(!box) return; |
|
131 | 136 | ]; |
132 | 137 | 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(""); |
133 | 138 | } |
| 139 | + function renderFamilies(INS){ |
| 140 | + const box=document.getElementById("families"); if(!box) return; |
| 141 | + if(!INS||!INS.famGroups){ box.innerHTML=""; return; } |
| 142 | + let html=`<div class="fam-title">Behavioural families <span>· every pair in a family gives the same verdict ≥85% of the time</span></div><div class="fam-grid">`; |
| 143 | + INS.famGroups.forEach((g,i)=>{ |
| 144 | + html+=`<div class="fam-card"><div class="fam-h"><span class="fam-tag">F${i+1}</span> ${g.length} servers</div>`+ |
| 145 | + `<div class="fam-chips">${g.map(nm=>`<span class="fam-chip">${esc(nm)}</span>`).join("")}</div></div>`; |
| 146 | + }); |
| 147 | + if(INS.loners.length) html+=`<div class="fam-card loners"><div class="fam-h">Distinct · ${INS.loners.length}</div>`+ |
| 148 | + `<div class="fam-chips">${INS.loners.map(nm=>`<span class="fam-chip">${esc(nm)}</span>`).join("")}</div></div>`; |
| 149 | + box.innerHTML=html+`</div>`; |
| 150 | + } |
134 | 151 | function visibleTests(srvs){ |
135 | 152 | let ts=tests.filter(t=>state.cats.has(t.cat)&&(!state.scoredOnly||t.scored)); |
136 | 153 | ts=ts.map(t=>({t,d:disagreement(t,srvs),h:entropy(t,srvs)})); |
|
231 | 248 | const head=document.getElementById("mhead"), body=document.getElementById("mbody"); |
232 | 249 | head.innerHTML="";body.innerHTML=""; |
233 | 250 | if(!srvs.length){ document.getElementById("rowcount").textContent="No frameworks selected."; return; } |
| 251 | + const vts=visibleTests(srvs); |
| 252 | + INSIGHT=(CFG.showEntropy && vts.length>=3 && srvs.length>=3)?computeInsight(vts,srvs):null; |
| 253 | + renderInsight(INSIGHT); renderFamilies(INSIGHT); |
234 | 254 | const tr=el("tr"); |
235 | 255 | tr.appendChild(el("th","corner",`test · ${srvs.length} shown →`)); |
236 | 256 | if(CFG.showEntropy) tr.appendChild(el("th","ent-h","entropy<br>bits")); |
237 | 257 | srvs.forEach(s=>{const th=el("th","srv");th.title=`${s.name} — ${s.score}/${s.scored} (${s.tier})`; |
238 | | - th.innerHTML=`<div class="rot">${s.name}</div><div class="sc">${s.score}</div>`;tr.appendChild(th);}); |
| 258 | + const fam=INSIGHT&&INSIGHT.famOf?INSIGHT.famOf[s.name]:null; |
| 259 | + th.innerHTML=`<div class="rot">${s.name}</div><div class="sc">${s.score}</div>`+(fam?`<div class="fam-badge">F${fam}</div>`:""); |
| 260 | + tr.appendChild(th);}); |
239 | 261 | head.appendChild(tr); |
240 | | - const vts=visibleTests(srvs); |
241 | | - INSIGHT=(CFG.showEntropy && vts.length>=3 && srvs.length>=3)?computeInsight(vts,srvs):null; |
242 | | - renderInsight(INSIGHT); |
243 | 262 | const frag=document.createDocumentFragment(); |
244 | 263 | vts.forEach(t=>{ |
245 | 264 | const row=el("tr",t.scored?null:"unscored"); |
|
0 commit comments