File size: 11,670 Bytes
1acbf39 ab5e03a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 | // compare.js β architecture comparison engine.
//
// Consumes two IRModels and produces a structured diff. The viewer owns all
// alignment + delta math (there is no shipped pairwise-diff artifact); it works
// purely from the exports: lineage (extends/modularity), the architecture facts
// block, components/templates + stable ids + attributes, repeats + config, and
// edges.
//
// Regime decision (Step 0):
// - parent/child via extends / modularity.parent_model β lineage (align by id)
// - otherwise measure stable-id overlap:
// high overlap β shared ids (align by id)
// low overlap β cross-lineage (align by semantic kind "lanes")
const HEADLINE_FIELDS = [
"family",
"view",
"attention_variant",
"positional",
"is_moe",
"moe",
"sliding_window",
"tie_word_embeddings",
];
// Resolve a possibly-symbolic scalar ("config.hidden_size") against a config.
function resolveVal(v, fields) {
if (typeof v === "string" && v.startsWith("config.")) {
const rv = fields ? fields[v.slice("config.".length)] : undefined;
return rv != null ? rv : v.slice("config.".length);
}
return v;
}
function fmt(v) {
if (v == null) return "β";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}
// Real (non-pseudo) nodes: components + templates + repeats.
function realNodes(ir) {
const out = new Map();
for (const [id, n] of ir.nodes) out.set(id, n);
return out;
}
function diffAttrs(aAttrs, bAttrs, aFields, bFields) {
aAttrs = aAttrs || {};
bAttrs = bAttrs || {};
const keys = [...new Set([...Object.keys(aAttrs), ...Object.keys(bAttrs)])].sort();
return keys.map((k) => {
const av = resolveVal(aAttrs[k], aFields);
const bv = resolveVal(bAttrs[k], bFields);
const same = k in aAttrs && k in bAttrs && String(av) === String(bv);
return { key: k, a: fmt(av), b: fmt(bv), same };
});
}
function decideRegime(a, b) {
const aChild = a.extends === b.modelType;
const bChild = b.extends === a.modelType;
if (aChild || bChild) {
const parent = aChild ? b : a;
return { kind: "lineage", basis: "id", note: `Parent/child β ${parent.modelType} is the base; diff comes from modular patches.` };
}
// Measure stable-id overlap.
const idsA = new Set(a.nodes.keys());
const idsB = new Set(b.nodes.keys());
let shared = 0;
for (const id of idsA) if (idsB.has(id)) shared++;
const overlap = shared / Math.max(1, Math.min(idsA.size, idsB.size));
if (overlap >= 0.5) {
return { kind: "shared_ids", basis: "id", note: `Stable ids align (${Math.round(overlap * 100)}% overlap).`, overlap };
}
return {
kind: "cross_lineage",
basis: "kind",
note: `Different lineages (${Math.round(overlap * 100)}% id overlap) β aligned by semantic kind.`,
overlap,
};
}
function diffHeadline(a, b) {
const fa = a.architecture || {};
const fb = b.architecture || {};
const fields = HEADLINE_FIELDS.filter((k) => fa[k] !== undefined || fb[k] !== undefined);
return fields.map((k) => ({
field: k,
a: fmt(fa[k]),
b: fmt(fb[k]),
same: fmt(fa[k]) === fmt(fb[k]),
}));
}
function pick(fields, keys) {
for (const k of keys) if (fields[k] != null) return { key: k, val: fields[k] };
return null;
}
function totalDepth(ir) {
let n = 0;
for (const r of ir.repeatsById.values()) n += ir.resolveCount(r, ir.configFields);
return n;
}
function diffScale(a, b) {
const rows = [];
const add = (label, av, bv) => {
if (av == null && bv == null) return;
rows.push({ field: label, a: fmt(av), b: fmt(bv), same: fmt(av) === fmt(bv) });
};
const hidA = pick(a.configFields, ["hidden_size", "d_model"]);
const hidB = pick(b.configFields, ["hidden_size", "d_model"]);
add("hidden size", hidA && hidA.val, hidB && hidB.val);
const ffA = pick(a.configFields, ["intermediate_size", "d_ff"]);
const ffB = pick(b.configFields, ["intermediate_size", "d_ff"]);
add("intermediate", ffA && ffA.val, ffB && ffB.val);
const hA = pick(a.configFields, ["num_attention_heads", "num_heads"]);
const hB = pick(b.configFields, ["num_attention_heads", "num_heads"]);
add("attention heads", hA && hA.val, hB && hB.val);
add("total layers (resolved)", totalDepth(a), totalDepth(b));
add("vocab size", a.configFields.vocab_size, b.configFields.vocab_size);
return rows;
}
function diffTopology(a, b) {
const setA = new Set(a.edges.map((e) => e.kind));
const setB = new Set(b.edges.map((e) => e.kind));
const kinds = [...new Set([...setA, ...setB])].sort();
return kinds.map((k) => ({ kind: k, inA: setA.has(k), inB: setB.has(k), same: setA.has(k) === setB.has(k) }));
}
function kindCounts(ir) {
const m = new Map();
for (const n of ir.nodes.values()) m.set(n.kind, (m.get(n.kind) || 0) + 1);
return m;
}
function diffKindCounts(a, b) {
const ca = kindCounts(a);
const cb = kindCounts(b);
const kinds = [...new Set([...ca.keys(), ...cb.keys()])].sort();
return kinds.map((k) => {
const av = ca.get(k) || 0;
const bv = cb.get(k) || 0;
return { kind: k, a: av, b: bv, same: av === bv };
});
}
// Align matched nodes by stable id (lineage / shared-id regimes).
function alignById(a, b) {
const na = realNodes(a);
const nb = realNodes(b);
const matched = [];
const removed = [];
const added = [];
for (const [id, node] of na) {
if (nb.has(id)) {
const other = nb.get(id);
const attrDeltas = diffAttrs(node.attributes, other.attributes, a.configFields, b.configFields);
const kindChanged = node.kind !== other.kind;
const changed = kindChanged || attrDeltas.some((d) => !d.same);
matched.push({
id,
label: a.label(id, a.configFields),
kindA: node.kind,
kindB: other.kind,
kindChanged,
changed,
attrDeltas: attrDeltas.filter((d) => !d.same),
});
} else {
removed.push({ id, label: a.label(id, a.configFields), kind: node.kind });
}
}
for (const [id, node] of nb) {
if (!na.has(id)) added.push({ id, label: b.label(id, b.configFields), kind: node.kind });
}
// Most-changed first.
matched.sort((x, y) => Number(y.changed) - Number(x.changed) || y.attrDeltas.length - x.attrDeltas.length);
return { basis: "id", matched, added, removed };
}
// Align by semantic kind "lanes" (cross-lineage regime).
function alignByKind(a, b) {
const byKind = (ir) => {
const m = new Map();
for (const [id, n] of ir.nodes) {
if (!m.has(n.kind)) m.set(n.kind, []);
m.get(n.kind).push({ id, node: n });
}
return m;
};
const ka = byKind(a);
const kb = byKind(b);
const kinds = [...new Set([...ka.keys(), ...kb.keys()])].sort();
const sample = (list) => (list || []).find((x) => x.node.attributes) || (list || [])[0];
const lanes = kinds.map((k) => {
const la = ka.get(k) || [];
const lb = kb.get(k) || [];
const sa = sample(la);
const sb = sample(lb);
const attrDeltas = diffAttrs(
sa && sa.node.attributes,
sb && sb.node.attributes,
a.configFields,
b.configFields
).filter((d) => !d.same);
return {
kind: k,
countA: la.length,
countB: lb.length,
onlyA: la.length > 0 && lb.length === 0,
onlyB: lb.length > 0 && la.length === 0,
exampleA: sa ? a.label(sa.id, a.configFields) : null,
exampleB: sb ? b.label(sb.id, b.configFields) : null,
attrDeltas,
};
});
return { basis: "kind", lanes };
}
// Presence-set diff (like topology) for two string lists.
function diffSet(listA, listB) {
const sa = new Set(listA || []);
const sb = new Set(listB || []);
const items = [...new Set([...sa, ...sb])].sort();
return items.map((item) => ({ item, inA: sa.has(item), inB: sb.has(item), same: sa.has(item) === sb.has(item) }));
}
function diffCapabilities(a, b) {
const ca = a.capabilities;
const cb = b.capabilities;
if (!ca && !cb) return null;
const schedStr = (ir, c) => {
const s = c && c.attention_schedule;
return Array.isArray(s) && s.length ? ir.scheduleSummary(s) : "uniform";
};
return {
backends: diffSet(ca && ca.attention_backends, cb && cb.attention_backends),
taskHeads: diffSet(ca && ca.task_heads, cb && cb.task_heads),
rows: [
{
field: "tensor parallel",
a: ca ? String(!!ca.tensor_parallel) : "β",
b: cb ? String(!!cb.tensor_parallel) : "β",
same: !!(ca && ca.tensor_parallel) === !!(cb && cb.tensor_parallel),
},
{
field: "attention patterns",
a: (ca && ca.attention_patterns || []).join(", ") || "β",
b: (cb && cb.attention_patterns || []).join(", ") || "β",
same: JSON.stringify(ca && ca.attention_patterns) === JSON.stringify(cb && cb.attention_patterns),
},
{
field: "attention schedule",
a: schedStr(a, ca),
b: schedStr(b, cb),
same: schedStr(a, ca) === schedStr(b, cb),
},
],
};
}
function compareIR(a, b) {
const regime = decideRegime(a, b);
return {
a: { modelType: a.modelType, modelClass: (a.provenance && a.provenance.model_class) || a.modelType },
b: { modelType: b.modelType, modelClass: (b.provenance && b.provenance.model_class) || b.modelType },
regime,
headline: diffHeadline(a, b),
scale: diffScale(a, b),
capabilities: diffCapabilities(a, b),
topology: diffTopology(a, b),
kinds: diffKindCounts(a, b),
nodes: regime.basis === "id" ? alignById(a, b) : alignByKind(a, b),
};
}
// Build a single union artifact from two models (for the merged diff view):
// nodes/edges present in both are kept once; the rest are tagged by origin so
// the renderer can colour them (both = neutral, a-only, b-only). Meaningful when
// the two share stable ids (same lineage); for cross-lineage it degenerates to
// two disjoint colourings, so callers should only use it in the id regime.
function mergeIR(a, b) {
const clone = (o) => JSON.parse(JSON.stringify(o));
const unionArr = (x, y) => [...(x || []), ...(y || []).filter((v) => !(x || []).includes(v))];
const A = a.raw;
const B = b.raw;
const raw = { ...A };
const mergeList = (key) => {
const by = new Map((A[key] || []).map((n) => [n.id, clone(n)]));
for (const n of B[key] || []) {
if (by.has(n.id)) by.get(n.id).children = unionArr(by.get(n.id).children, n.children);
else by.set(n.id, clone(n));
}
return [...by.values()];
};
raw.components = mergeList("components");
raw.templates = mergeList("templates");
const repById = new Map((A.repeats || []).map((r) => [r.id, r]));
for (const r of B.repeats || []) if (!repById.has(r.id)) repById.set(r.id, r);
raw.repeats = [...repById.values()];
const eKey = (e) => `${e.source}|${e.target}|${e.kind}`;
const edgeMap = new Map();
for (const e of A.edges || []) edgeMap.set(eKey(e), e);
for (const e of B.edges || []) if (!edgeMap.has(eKey(e))) edgeMap.set(eKey(e), e);
raw.edges = [...edgeMap.values()];
// Node origin.
const has = (ir, id) => ir.nodes.has(id) || ir.pseudoIds.has(id);
const ids = new Set([...a.nodes.keys(), ...b.nodes.keys(), ...a.pseudoIds, ...b.pseudoIds]);
const origin = new Map();
for (const id of ids) {
const inA = has(a, id);
const inB = has(b, id);
origin.set(id, inA && inB ? "both" : inA ? "a" : "b");
}
// Edge origin.
const aE = new Set((A.edges || []).map(eKey));
const bE = new Set((B.edges || []).map(eKey));
const edgeOrigin = new Map();
for (const k of edgeMap.keys()) edgeOrigin.set(k, aE.has(k) && bE.has(k) ? "both" : aE.has(k) ? "a" : "b");
return { raw, origin, edgeOrigin };
}
export { compareIR, mergeIR };
|