"""Turn an ``ArchModel`` / ``ArchDiff`` into positioned boxes. Layout is deterministic (stable ordering, integer coordinates) so the emitted SVG is diff-able in git. The visual vocabulary is *standardized*: a given component kind always maps to the same shape class and palette slot, mirroring the library's "standardize, don't abstract" tenet -- an attention block looks the same across every model's diagram. """ from __future__ import annotations from dataclasses import dataclass, field from .engine import text_width from .introspect import ArchModel from .modular import ArchDiff, ClassChange _PARENT_CACHE: dict = {} # model_type -> ArchModel of the modular parent (for diff comparison) def _parent_arch(model_type): if model_type not in _PARENT_CACHE: try: from .introspect import introspect _PARENT_CACHE[model_type] = introspect(model_type) except Exception: _PARENT_CACHE[model_type] = None return _PARENT_CACHE[model_type] def _collect_submodule_names(nodes, out: set): for nd in nodes: out.add(nd["name"]) if nd.get("children"): _collect_submodule_names(nd["children"], out) # component kind -> css class used by render.py (the standardized vocabulary) KIND_CLASS = { "embedding": "c-embed", "attention": "c-attn", "mamba": "c-mamba", "linear_attention": "c-linattn", "recurrent": "c-recur", "moe": "c-moe", "mlp": "c-mlp", "norm": "c-norm", "head": "c-head", "config": "c-config", "rope": "c-rope", "layer": "c-layer", } # diff change -> css class CHANGE_CLASS = {"added": "ch-added", "overridden": "ch-over", "deleted": "ch-deleted", None: ""} @dataclass class Box: x: int y: int w: int h: int label: str cls: str = "" sublabels: list[str] = field(default_factory=list) badge: str | None = None # small corner tag, e.g. "×32" ghost: bool = False change: str | None = None # added | overridden | deleted | None title: str | None = None # SVG tooltip shape: str = "rect" # rect | circle | container | op | io | cell | grid small: bool = False # render with smaller fonts (inner ops, Q/K/V chips) glyph: str | None = None # single char drawn centered (e.g. "+", "×") grid: list[list[int]] | None = None # for shape == "grid": 0/1 attention-mask matrix grid_split: int | None = None # column index dividing sliding K/V | compressed cache @dataclass class Arrow: """A poly-line connector. ``points`` are (x, y) pairs; rendered with an arrowhead.""" points: list[tuple[int, int]] cls: str = "flow" # flow | residual label: str | None = None dashed: bool = False @dataclass class Diagram: width: int height: int boxes: list[Box] title: str subtitle: str = "" legend: list[tuple[str, str]] = field(default_factory=list) # (css-class, text) facts: list[tuple[str, str]] = field(default_factory=list) # side panel key/value changes: list[tuple[str, str, str]] = field(default_factory=list) # (type, class, detail) arrows: list[Arrow] = field(default_factory=list) mode: str = "full" spine: bool = True # draw the central connector spine # geometry constants W = 920 COL_X = 150 # left edge of the central stack COL_W = 420 # width of the central stack PAD = 28 ROW_H = 30 def _fmt_val(v) -> str: """Compactly format a config value -- including sequence-valued fields. Some (often hybrid) models carry per-layer lists for ``intermediate_size``, ``head_dim`` or ``sliding_window``. We collapse those to ``[x]×N`` (all equal) or ``[a … z] (N)`` rather than dumping the whole list. No per-model handling. """ if v is None: return "—" if isinstance(v, (list, tuple)): if not v: return "[]" if len(set(v)) == 1: return f"[{_fmt_val(v[0])}]×{len(v)}" return f"[{_fmt_val(v[0])} … {_fmt_val(v[-1])}] ({len(v)})" if isinstance(v, bool): return str(v) if isinstance(v, int): return f"{v:,}" return str(v) def _facts(am: ArchModel) -> list[tuple[str, str]]: fmt = _fmt_val mid = am.checkpoint or am.model if len(mid) > 30: mid = mid[:29] + "…" rows = [ ("model id", mid), ("model_type", am.model_type or "—"), ("config", am.config_class or "—"), ("decoder", am.decoder_kind), ("layers", fmt(am.num_layers)), ("hidden", fmt(am.hidden_size)), ("intermediate", fmt(am.intermediate_size)), ("heads / kv", f"{am.num_attention_heads} / {am.num_kv_heads}"), ("attention", am.attn_variant or "—"), ("norm", am.norm_type or "—"), ("positional", am.positional or "—"), ("vocab", fmt(am.vocab_size)), ("max_pos", fmt(am.max_position_embeddings)), ] if am.sliding_window: rows.append(("sliding_window", fmt(am.sliding_window))) if am.is_moe: rows.append(("experts (top-k)", f"{am.num_experts} (top-{am.experts_per_token})")) rows.append(("tie_embeddings", str(am.tie_word_embeddings))) if am.status != "ok": rows.append(("status", am.status)) return rows def _reflow(boxes: list[Box], start_y: int = PAD + 70, gap: int = 16) -> int: """Re-stack boxes vertically in list order using their (possibly grown) heights. Called after any box height changes so diff annotations never overlap neighbours. Returns the y coordinate just below the last box. """ y = start_y for b in boxes: b.y = y y += b.h + gap return y def _build_compact(am: ArchModel) -> Diagram: """Top-down view from CONFIG alone — used when the model can't be instantiated on meta (so there's no module tree to decompose) or has no transformer block (e.g. conv backbones). Shows input → embedding → the per-config layer blocks (collapsed, ×N) → final norm → head, family-aware, with the layer-type schedule. Far cleaner than the old bottom-up stack.""" boxes: list[Box] = [] x, w = COL_X, COL_W y = PAD + 78 modality = ( "vision" if am.family == "image_classification" else ("audio" if am.family == "audio_classification" else "text") ) enc = am.view == "encoder" def add(label, cls, subs=None, h=None, shape="op", badge=None): nonlocal y b = Box( x, y, w, h if h is not None else (30 + 15 * len(subs or [])), label, cls, sublabels=subs or [], shape=shape, small=True, badge=badge, ) boxes.append(b) y = y + b.h + 16 return b # input + embedding (family-aware) if modality == "vision": add("input image", "c-io", ["pixel_values [1, 3, H, W]"], h=30, shape="io") add("Patch Embedding", "c-embed", [f"→ [1, S, {am.hidden_size or '?'}]"], h=40) elif modality == "audio": add("input audio", "c-io", ["input_features"], h=30, shape="io") add("Feature Projection", "c-embed", [f"→ [1, S, {am.hidden_size or '?'}]"], h=40) else: add("input_ids", "c-io", ["[1, S]"], h=30, shape="io") add("Token Embedding", "c-embed", [f"[{am.vocab_size or '?'} × {am.hidden_size or '?'}]"], h=40) # per-config layer blocks (collapsed ×N), coloured by kind for b in am.layer_blocks: inner = [f"{b.norm or 'Norm'} → {(b.attn_variant or b.kind.replace('_', '-'))}"] inner.append( f"{b.norm or 'Norm'} → {('MoE ' + str(am.num_experts) + 'E·top-' + str(am.experts_per_token)) if b.mlp_kind == 'moe' else 'MLP'}" ) cls = KIND_CLASS["moe"] if b.mlp_kind == "moe" else KIND_CLASS.get(b.kind, KIND_CLASS["layer"]) add(b.layer_class, cls, inner, badge=f"×{b.count}", h=30 + 15 * len(inner)) add(f"Final {am.norm_type or 'Norm'}", "c-norm", h=28) if enc: add("Pool + Classifier head", "c-head", [f"→ class logits [1, {am.num_labels or '?'}]"], h=40) else: add("LM Head", "c-head", [f"→ logits [1, S, {am.vocab_size or '?'}]"], h=40) facts = _facts(am) height = max(y + PAD, PAD + 78 + len(facts) * 22 + 120) legend = _STD_LEGEND note = ( "config-only — built from config (weights not instantiable on meta)" if am.status != "ok" else "no transformer block (conv backbone) — shown from config" ) return Diagram( width=W, height=height, boxes=boxes, title=am.model, subtitle=f"⚠ {note} · {am.decoder_kind} · {am.layer_summary or ''}", legend=legend, facts=facts, mode="full", spine=True, ) def _build_generic(am: ArchModel) -> Diagram: """Universal top-down render from the real (collapsed) module tree — for any model that is not a standard attention/SSM transformer: conv backbones (ResNet/ConvNeXt/MobileNet), audio codecs (EnCodec/DAC), FFT mixers (FNet), detectors, pose/segmentation heads, ... Every ``ModuleList``/``Sequential`` of identical blocks is collapsed to a ``×N`` section.""" boxes: list[Box] = [] GX, GW = 40, 850 # tree column (facts panel sits at width-288) y = PAD + 78 PFX = (am.config_class or "").replace("Config", "") def add(label, cls, subs=None, h=30, shape="op"): nonlocal y b = Box(GX, y, GW, h, label, cls, sublabels=subs or [], shape=shape, small=True) boxes.append(b) y = b.y + b.h + 14 return b # input (family-aware) — what the model consumes modality = ( "vision" if am.family == "image_classification" else ("audio" if am.family == "audio_classification" else None) ) if "pixel_values" in (am.modal_inputs or []) or modality == "vision": add("input image", "c-io", ["pixel_values [1, 3, H, W]"], shape="io") elif "input_features" in (am.modal_inputs or []) or modality == "audio": add("input audio", "c-io", ["input_values / input_features"], shape="io") else: add("inputs", "c-io", ["input_ids / pixel_values / input_values"], shape="io") # the real module hierarchy y = _emit_tree(boxes, am.generic_tree, GX, GW, y, gp=8, prefix=PFX) facts = _facts(am) height = max(y + PAD, PAD + 78 + len(facts) * 22 + 120) legend = _STD_LEGEND return Diagram( width=WF, height=height, boxes=boxes, title=am.model, subtitle=f"{am.top_class or ''} · full module tree (no attention — shown structurally)", legend=legend, facts=facts, mode="full", spine=False, ) def _struct_hint(node: dict | None) -> str | None: """A one-line structural summary of a stage's children, e.g. '4× DacEncoderBlock'.""" if not node: return None reps = [c["cls"] for c in node.get("children", []) if "×" in c.get("cls", "")] if reps: return " · ".join(reps[:2]) kids = node.get("children", []) if kids: return f"{len(kids)} submodules" return None def _shape_str(s) -> str: return "×".join(str(d) for d in s) if s else "?" def _build_flow(am: ArchModel) -> Diagram: """Clean forward data-flow: the real input tensor flows top-to-bottom through each stage (in call order, with the shape it produces) to the output tensor. Shapes come from one dummy forward pass (meta, or a tiny CPU model). Used for codecs / conv backbones / detectors where the data path — not an attention block — is the story (DAC, EnCodec, BLT, ResNet, ...).""" from .introspect import _classify_mod boxes: list[Box] = [] arrows: list[Arrow] = [] GX, GW = 300, 580 # flow column (facts panel sits at width-288) y = PAD + 80 PFX = (am.config_class or "").replace("Config", "") tree_by = {n["name"]: n for n in am.generic_tree} def place(label, cls, subs, shape="op"): nonlocal y b = Box(GX, y, GW, 34 + 15 * len(subs), label, cls, sublabels=subs, shape=shape, small=True) boxes.append(b) y = b.y + b.h return b # stage list + shapes: prefer the captured forward (true call order + shapes); otherwise fall # back to the structural top-level stages (definition order, no shapes) — always memory-free. shapes = {s["name"]: s.get("out_shape") for s in am.flow} if am.flow: stages = [(s["name"], s["cls"]) for s in am.flow] has_shapes = True else: stages = [(n["name"], n["cls"]) for n in am.generic_tree] has_shapes = False # input box if am.flow_input: in_label, in_sub = am.flow_input, [] else: mods = am.modal_inputs or [] if "pixel_values" in mods or am.family == "image_classification": in_label, in_sub = "input image", ["pixel_values [1, 3, H, W]"] elif "input_features" in mods or am.family == "audio_classification": in_label, in_sub = "input audio", ["input_values / input_features"] else: in_label, in_sub = "inputs", ["input_ids / pixel_values / input_values"] prev = place(in_label, "c-io", in_sub, shape="io") for name, cls in stages: y += 24 # arrow gap kind = _classify_mod(name, cls) node = tree_by.get(name) shp = shapes.get(name) shape_lbl = f" → [{_shape_str(shp)}]" if shp else "" hdr = f"{name} · {_short_cls(cls, PFX)}{shape_lbl}" sec_top = y if node and node.get("children"): # expose the stage's internal structure (×N blocks) y += 28 # header strip y = _emit_tree(boxes, node["children"], GX + 10, GW - 20, y, gp=7, prefix=PFX) anchor = Box(GX, sec_top, GW, y - sec_top + 6, hdr, _NODE_CLS.get(kind, "c-layer"), shape="section") boxes.append(anchor) y += 6 else: anchor = place(hdr, _NODE_CLS.get(kind, "c-layer"), []) arrows.append(Arrow([(prev.x + prev.w / 2, prev.y + prev.h), (anchor.x + anchor.w / 2, sec_top)], cls="flow")) prev = anchor if am.flow_output: y += 24 b = place(f"output [{_shape_str(am.flow_output)}]", "c-io", [], shape="io") arrows.append(Arrow([(prev.x + prev.w / 2, prev.y + prev.h), (b.x + b.w / 2, b.y)], cls="flow")) facts = _facts(am) height = max(y + PAD, PAD + 80 + len(facts) * 22 + 120) sub_note = "forward data flow (shapes from a dummy input pass)" if has_shapes else "staged data flow (structure)" return Diagram( width=WF, height=height, boxes=boxes, arrows=arrows, title=am.model, subtitle=f"{am.top_class or ''} · {sub_note}", legend=_STD_LEGEND, facts=facts, mode="full", spine=False, ) # --------------------------------------------------------------- detailed full (Raschka) # geometry for the detailed view (wider canvas: schedule strip · column · masks · facts) WF = 1200 STRIP_X = 40 # layer-schedule strip DCOL_X = 320 # left edge of central op column DCOL_W = 330 # width of central op column RAIL_X = 286 # residual rail x (left of the column) MASK_X = 700 # attention-mask grids column SEQ = 12 # dummy batch/seq for displayed tensor shapes: [1, 12] # layer_type -> color class (see render._STYLE) _LT_CLASS = { "full_attention": "c-lt-full", "sliding_attention": "c-lt-sliding", "chunked_attention": "c-lt-chunked", "compressed_sparse_attention": "c-lt-compressed", "heavily_compressed_attention": "c-lt-heavy", "linear_attention": "c-lt-linear", } def _lt_class(t: str) -> str: if t in _LT_CLASS: return _LT_CLASS[t] n = t.lower() if "linear" in n or "delta" in n or "gated" in n: return "c-lt-linear" if "mamba" in n or "ssm" in n: return "c-lt-mamba" if "sliding" in n: return "c-lt-sliding" if "chunk" in n: return "c-lt-chunked" if "compress" in n: return "c-lt-compressed" return "c-lt-full" def _dim(p) -> str: if p is None: return "" return f"[{p.in_f or '?'}→{p.out_f or '?'}]" # module-tree node kind -> css class (for the recursive decomposition) # standardized node kind -> css class, shared by every view (attention internals, SSM mixer, # MoE, and the generic conv/codec/FFT module tree) so a given component is always the same colour. _NODE_CLS = { "embedding": "c-embed", "linear": "c-proj", "conv": "c-conv", "norm": "c-norm", "act": "c-act", "pool": "c-pool", "dropout": "c-sub", "rope": "c-rope", "attention": "c-attn", "mamba": "c-mamba", "recurrent": "c-recur", "quantizer": "c-quant", "compressor": "c-lt-compressed", "indexer": "c-lt-heavy", "router": "c-moe", "experts": "c-moe", "head": "c-head", "other": "c-sub", } # one standardized legend shared by all full-view layouts (only the colours that can appear) _STD_LEGEND = [ ("c-embed", "embedding"), ("c-attn", "attention"), ("c-mamba", "Mamba / SSM"), ("c-moe", "MoE / experts"), ("c-proj", "linear / proj"), ("c-conv", "convolution"), ("c-norm", "normalization"), ("c-act", "activation"), ("c-pool", "pooling"), ("c-head", "head"), ] def _short_cls(cls: str, prefix: str = "") -> str: """Drop the model prefix and the redundant 'Activation' suffix: DeepseekV4RMSNorm→RMSNorm, SiLUActivation→SiLU.""" s = cls if prefix and s.startswith(prefix): s = s[len(prefix) :] if s.endswith("Activation") and len(s) > 10: s = s[: -len("Activation")] return s or cls def _leaf_label(nd, prefix: str) -> str: """One compact line per leaf: name + dims; class shown only when it isn't a plain Linear.""" sc = _short_cls(nd["cls"], prefix) dim = nd.get("dim") if sc == "Linear": # 'Linear' is implied by the [in→out] arrow return f"{nd['name']} {dim}" if dim else nd["name"] return f"{nd['name']} {sc}" + (f" {dim}" if dim else "") def _emit_tree(boxes, nodes, x, w, y, gp=7, depth=0, prefix=""): """Recursively lay out a module tree: leaves are single-line chips, branches become titled sub-sections containing their own children. Returns the y below the laid-out tree.""" i = 0 while i < len(nodes): nd = nodes[i] if nd.get("children"): # a branch -> nested titled sub-section sub_top = y y += 25 # clear the white header strip y = _emit_tree(boxes, nd["children"], x + 8, w - 16, y, gp, depth + 1, prefix) label = f"{nd['name']} · {nd['cls']}" + (f" ({nd['dim']})" if nd.get("dim") else "") boxes.append( Box(x, sub_top, w, y - sub_top + 6, label, _NODE_CLS.get(nd["kind"], "c-sub"), shape="section") ) y += 6 + gp i += 1 else: # a run of leaf chips — content-sized: columns chosen by width, 2-line when needed run = [] while i < len(nodes) and not nodes[i].get("children"): run.append(nodes[i]) i += 1 # how many columns fit? each chip wants ~its text width; clamp 1..3 by available w widest = max((text_width(_leaf_label(nd2, prefix), 12.5) for nd2 in run), default=80) + 18 per = max(1, min(3, int(w // max(widest, 120)))) for r in range(0, len(run), per): rown = run[r : r + per] cw = (w - (len(rown) - 1) * gp) / len(rown) # a chip is 2 lines (name / class-dim) when the one-line label doesn't fit its cell two = [text_width(_leaf_label(nd2, prefix), 12.5) > cw - 12 for nd2 in rown] rh = 34 if any(two) else 22 for j, nd2 in enumerate(rown): ttl = f"{nd2['name']}: {nd2['cls']}" + (f" {nd2['dim']}" if nd2.get("dim") else "") if two[j]: # split onto two lines instead of ellipsizing the dims away sc = _short_cls(nd2["cls"], prefix) sub = ( (sc + (f" {nd2['dim']}" if nd2.get("dim") else "")) if sc != "Linear" else (nd2.get("dim") or "") ) boxes.append( Box( int(x + j * (cw + gp)), y, int(cw), rh, nd2["name"], _NODE_CLS.get(nd2["kind"], "c-sub"), sublabels=[sub] if sub else [], shape="op", small=True, title=ttl, ) ) else: boxes.append( Box( int(x + j * (cw + gp)), y, int(cw), rh, _leaf_label(nd2, prefix), _NODE_CLS.get(nd2["kind"], "c-sub"), shape="op", small=True, title=ttl, ) ) y += rh + gp return y def _node_sig(node): """Structural signature of a module node (class + dims + recursive children).""" return (node.get("cls"), node.get("dim"), tuple(_node_sig(c) for c in (node.get("children") or []))) def _emit_merged(boxes, variants, types, x, w, y, gp=7, prefix=""): """Render N aligned module trees, sharing identical sub-trees and branching side-by-side ONLY where they actually differ. For DeepSeek-V4 attention this shows q/k/v/o once and the differing `compressor` (HCA vs CSA+Indexer) side by side -- the real subtlety.""" base = variants[0] names = [n["name"] for n in base] if any([n["name"] for n in v] != names for v in variants): # misaligned -> full side-by-side nn = len(variants) cw = (w - (nn - 1) * 18) / nn mb = y for k, v in enumerate(variants): mb = max(mb, _emit_tree(boxes, v, int(x + k * (cw + 18)), int(cw), y, gp, prefix=prefix)) return mb for idx in range(len(names)): nodes = [v[idx] for v in variants] if all(_node_sig(nd) == _node_sig(nodes[0]) for nd in nodes): # identical -> render once y = _emit_tree(boxes, [nodes[0]], x, w, y, gp, prefix=prefix) else: # diverges -> side by side, tagged by layer type nn = len(nodes) cw = (w - (nn - 1) * gp) / nn top = y mb = y for k, nd in enumerate(nodes): cx = int(x + k * (cw + gp)) boxes.append(Box(cx, top, int(cw), 18, f"▼ {types[k]}", "c-sub", shape="op", small=True)) mb = max(mb, _emit_tree(boxes, [nd], cx, int(cw), top + 22, gp, prefix=prefix)) y = mb return y def _short_proj(name: str) -> str: return name.replace("_proj", "").replace("_with_mqa", "·mqa").replace("kv_a", "kv↓").replace("kv_b", "kv↑") def _chips(boxes, items, x, w, y, per_row, cls="c-sub", gp=8, h=34): """Place `items` = list of (label, sublabel) as a grid of small boxes; return new y.""" rows = [items[i : i + per_row] for i in range(0, len(items), per_row)] for r in rows: cw = (w - (len(r) - 1) * gp) / len(r) for j, (lb, sub) in enumerate(r): boxes.append( Box( int(x + j * (cw + gp)), y, int(cw), h, lb, cls, sublabels=([sub] if sub else []), shape="op", small=True, ) ) y += h + gp return y def _shape3(h) -> str: return f"[1, {SEQ}, {h or '?'}]" def build_full(am: ArchModel) -> Diagram: """Dispatch: multimodal models get the multi-tower view, everything else the LLM column.""" if am.is_multimodal and am.towers and am.block is not None: return build_multimodal(am) # multi-stage pipelines (BLT, codecs, ...) read best as a data flow even when they use attention if am.is_pipeline and am.generic_tree: return _build_flow(am) return _build_column(am) def _build_column(am: ArchModel) -> Diagram: """Detailed, Raschka-style view, read **top-to-bottom like the code**: input_ids at the top flow down through the embedding, the expanded transformer block (pre-norm → self-attention with RoPE/Q/K/V/O and real dims → ⊕ residual → pre-norm → MLP or Sparse MoE → ⊕ residual), final norm, LM head and softmax to the logits at the bottom. A left-side strip shows the per-layer schedule (config.layer_types); attention-mask grids for each distinct layer type are shown on the right. Tensor shapes use a dummy [1, 12] input. Falls back to the compact view when block internals can't be extracted.""" if am.block is None or (am.block.attention is None and am.block.mixer is None and am.block.mlp is None): # a real module tree → staged data-flow view (with shapes where a meta forward succeeded); # only models that couldn't be built on meta fall back to the config-only schematic. return _build_flow(am) if am.generic_tree else _build_compact(am) blk = am.block blk_is_ssm = blk.attention is None and blk.mixer is not None # Mamba/SSM token-mixer block GAP = 14 boxes: list[Box] = [] arrows: list[Arrow] = [] y = PAD + 80 H = am.hidden_size PFX = (am.config_class or "").replace("Config", "") # model prefix to strip from class names def add(label, cls, subs=None, h=None, shape="op", glyph=None, w=DCOL_W, x=DCOL_X, title=None, grid=None): nonlocal y b = Box( x, y, w, h if h is not None else (30 + 15 * len(subs or [])), label, cls, sublabels=subs or [], shape=shape, small=True, glyph=glyph, title=title, grid=grid, ) boxes.append(b) y = y + b.h + GAP return b # which kind of model -> what the input / embedding / head look like modality = ( "vision" if am.family == "image_classification" else ("audio" if am.family == "audio_classification" else "text") ) enc = am.view == "encoder" seq = len(am.tokens or [1] * 6) or 6 def _shapeN(h): return f"[1, {seq}, {h or '?'}]" # ---------- top: input ---------- if modality == "vision": add("input image", "c-io", ["pixel_values [1, 3, H, W]"], h=32, shape="io") add("Patch Embedding", "c-embed", [f"conv patches → {_shapeN(H)}", "+ position embeddings"], h=60) elif modality == "audio": add("input audio", "c-io", ["input_features [1, n_mels, T]"], h=32, shape="io") add("Feature Projection", "c-embed", [f"conv/linear → {_shapeN(H)}", "+ position embeddings"], h=60) else: toks = am.tokens or ["Hey", ",", "␣how", "␣are", "␣you", "?"] seq = len(toks) add("input_ids", "c-io", [f'tokenize("Hey, how are you?") → [1, {seq}]'], h=32, shape="io") tgp = 5 tw = (DCOL_W - (seq - 1) * tgp) / seq for j, t in enumerate(toks): label = t if len(t) <= 6 else t[:6] + "…" boxes.append( Box( int(DCOL_X + j * (tw + tgp)), y, max(14, int(tw)), 26, label, "c-sub", sublabels=[str(j)], shape="op", small=True, title=f"token {j}: {t!r}", ) ) y += 26 + 18 pos_note = [f"+ {am.positional} positions"] if (am.positional and am.positional not in ("RoPE", "n/a")) else [] add( "Token Embedding", "c-embed", [f"weight [{am.vocab_size or '?'} × {H or '?'}] → {_shapeN(H)}"] + pos_note, h=30 + 15 * (1 + len(pos_note)), ) body_top = boxes[-1].y # the embedding box top — start of the "<Model>" body wrapper # inner-chip geometry shared by the attention / MoE / MLP sections IX, IW, GP = DCOL_X + 12, DCOL_W - 24, 8 def section(label, cls, body, badge=None): """Draw a filled section panel containing inner chips produced by ``body(y)``.""" nonlocal y sec_top = y y += 26 # header room y = body(y) sec = Box(DCOL_X, sec_top, DCOL_W, y - sec_top + 6, label, cls, shape="section", badge=badge) boxes.append(sec) y = y + 6 + GAP return sec # ---------- transformer block ---------- y += 22 # headroom for the dashed container's "decoder block ×N" label block_top = y entry_y = block_top # residual stream entry add( f"{blk.pre_attn_norm or 'Norm'}", "c-norm", [("pre-mixer " if blk_is_ssm else "pre-attention ") + _shapeN(H)], h=28, ) # ----- self-attention: ONE section; shared sub-modules rendered once, and only the parts # ----- that actually differ across layer types (e.g. the compressor) branch side by side. a = blk.attention from .introspect import _short_layer_type attn_right = DCOL_X + DCOL_W variants = am.attention_variants if variants: n = min(len(variants), 3) types = [_short_layer_type(v["type"]) for v in variants[:n]] sc_by_type = {sc["type"]: sc for sc in am.sparse_components} diverges = n > 1 Wsec = DCOL_W if not diverges else max(DCOL_W, n * 300) gqa = "" if a and a.n_heads and a.n_kv and a.n_kv < a.n_heads: gqa = f" · GQA {a.n_heads}:{a.n_kv}" elif a and a.n_kv == 1: gqa = " · MQA" vtop = y vy = vtop + 26 if n == 1: vy = _emit_tree(boxes, variants[0]["children"], DCOL_X + 10, Wsec - 20, vy, gp=GP) else: vy = _emit_merged(boxes, [v["children"] for v in variants[:n]], types, DCOL_X + 10, Wsec - 20, vy, gp=GP) # core op footer (shared): SDPA for attention, selective scan for SSM/Mamba mixers is_mixer = a is None and blk.mixer is not None boxes.append( Box( DCOL_X + 10, vy, Wsec - 20, 30, "selective state-space scan (SSM)" if is_mixer else "scaled dot-product attention", "c-sub", sublabels=([f"{a.n_heads or '?'} heads · head_dim {a.head_dim or '?'}{gqa}"] if a else []), shape="op", small=True, ) ) vy += 30 + GP # masks: one per layer type, side by side (this is where the compressors differ in effect) mask_items = [ (v["type"], (sc_by_type.get(v["type"], {}).get("mask") or am.attn_patterns.get(v["type"]))) for v in variants[:n] ] mask_items = [(t, g) for t, g in mask_items if g] if not mask_items: # encoders attend bidirectionally; decoders are causal sq = 14 if enc: grid = [[1] * sq for _ in range(sq)] mask_items = [("bidirectional", grid)] else: grid = [[1 if j <= i else 0 for j in range(sq)] for i in range(sq)] mask_items = [("causal", grid)] if mask_items: mw = (Wsec - 20 - (len(mask_items) - 1) * 16) / len(mask_items) mtop = vy + 4 mb = mtop for k, (lt, grid) in enumerate(mask_items): sc = sc_by_type.get(lt) cols = len(grid[0]) cellp = max(3, min(10, int((mw - 6) // cols))) gx = int(DCOL_X + 10 + k * (mw + 16) + (mw - cellp * cols) / 2) cap = ( f"{_short_layer_type(lt)} mask m={sc['display_m']}·{sc['n_comp']}c" if sc else f"{_short_layer_type(lt)} mask" ) boxes.append( Box( gx, mtop + 6, cellp * cols, cellp * len(grid), cap, _lt_class(lt), shape="grid", grid=grid, grid_split=(sc.get("mask_split") if sc else None), title=f"{lt} attention mask (q↓ × k→)", ) ) mb = max(mb, mtop + 6 + cellp * len(grid)) vy = mb note = "" if diverges: sigs = [tuple(_node_sig(c) for c in v["children"]) for v in variants[:n]] struct_diff = any(s != sigs[0] for s in sigs) note = ( f" ({n} layer types — modules differ ▼)" if struct_diff else f" ({n} layer types — same modules, mask differs)" ) if is_mixer: hdr = f"Token Mixer (SSM) · {blk.mixer.cls}{note}" else: hdr = f"Self-Attention · {a.cls if a else 'attention'}{note}" boxes.append(Box(DCOL_X, vtop, Wsec, vy - vtop + 6, hdr, "c-attn", shape="section")) attn_right = DCOL_X + Wsec y = vy + 6 + GAP if a and a.rope: ry = vtop + 30 rope_label = f"RoPE θ={int(am.rope_theta):,}" if am.rope_theta else "RoPE" rnode = Box( DCOL_X - 168, ry - 4, 132, 40, rope_label, "c-rope", sublabels=["rotary positions → Q, K"], shape="op", small=True, ) boxes.append(rnode) arrows.append(Arrow([(rnode.x + rnode.w, ry + 14), (DCOL_X, ry + 14)], cls="rope")) else: add("Token Mixing (linear-attention / SSM)", "c-linattn", [blk.layer_class], h=44) plus_attn = add("", "c-add", h=26, shape="circle", glyph="+", title="residual add") # ----- MLP / Sparse MoE: FULL recursive decomposition, variants side by side ----- # (Mamba/SSM blocks are norm → mixer → residual with NO feed-forward, so skip the FFN entirely) mm = blk.mlp mvars = am.mlp_variants if len(am.mlp_variants) > 1 else None has_mlp = bool(mm or (am.mlp_tree and am.mlp_tree.get("children")) or mvars) if has_mlp: add(f"{blk.post_attn_norm or 'Norm'}", "c-norm", ["pre-FFN " + _shapeN(H)], h=28) if mvars: # distinct FFN types (e.g. DeepSeek-V4 moe / hash_moe): shared sub-modules once, only # the differing part (the router) branches side by side. nm = min(len(mvars), 3) Wm = max(DCOL_W, nm * 300) mtypes = [mv["type"] for mv in mvars[:nm]] vtop = y my = vtop + 26 my = _emit_merged(boxes, [mv["children"] for mv in mvars[:nm]], mtypes, DCOL_X + 10, Wm - 20, my, gp=GP) cls0 = mvars[0]["cls"] boxes.append( Box( DCOL_X, vtop, Wm, my - vtop + 6, f"Sparse MoE · {cls0} ({nm} FFN types ▼)", "c-moe", shape="section", badge="MoE", ) ) attn_right = max(attn_right, DCOL_X + Wm) y = my + 6 + GAP elif am.mlp_tree and am.mlp_tree.get("children"): cls = am.mlp_tree["cls"] is_moe = bool(mm and mm.is_moe) section( ("Sparse MoE · " if is_moe else "MLP · ") + cls, "c-moe" if is_moe else "c-mlp", lambda yy: _emit_tree(boxes, am.mlp_tree["children"], IX, IW, yy, gp=GP), badge="MoE" if is_moe else None, ) elif mm: def mlp_body(y): items = [] if mm.gate: items.append(("gate_proj", _dim(mm.gate))) if mm.up: items.append(("up_proj", _dim(mm.up))) per = len(items) if items else 1 if items: y = _chips(boxes, items, IX, IW, y, per, gp=GP, h=32) boxes.append( Box(IX, y, IW, 28, f"{mm.act}(gate) ⊙ up" if mm.gate else mm.act, "c-sub", shape="op", small=True) ) y += 28 + GP boxes.append(Box(IX, y, IW, 32, f"down_proj {_dim(mm.down)}", "c-sub", shape="op", small=True)) y += 32 return y section(f"MLP · {mm.cls}", "c-mlp", mlp_body) elif has_mlp: add("MLP", "c-mlp", h=30) # second residual add only exists when there is an FFN; SSM blocks have a single residual plus_mlp = add("", "c-add", h=26, shape="circle", glyph="+", title="residual add") if has_mlp else plus_attn block_bot = y - GAP container_w = max(DCOL_W, attn_right - DCOL_X) + 52 container = Box( DCOL_X - 26, block_top - 22, container_w, block_bot - block_top + 30, blk.layer_class, # the real layer class, e.g. LlamaDecoderLayer / ASTLayer "c-block", shape="container", badge=f"× {am.num_layers or '?'}", title=f"{am.num_layers} × {blk.layer_class}", ) # ---------- bottom: final norm, then the task head (depends on family) ---------- add(f"Final {am.norm_type or 'Norm'}", "c-norm", [_shapeN(H)], h=28) body_bot = boxes[-1].y + boxes[-1].h # end of the "<Model>" body (everything above = base model) head_title = am.head_class # e.g. ViTForImageClassification / GemmaForCausalLM (the For wrapper) if enc: # encoder / classifier head nl = am.num_labels or "?" add("Pool (CLS / mean)", "c-head", [f"{_shapeN(H)} → [1, {H or '?'}]"], h=40, title=head_title) add("Classifier head", "c-head", [f"Linear [{H or '?'}→{nl}]"], h=40, title=head_title) add("Softmax", "c-soft", h=26) add("class logits", "c-io", [f"[1, {nl}] ({nl} classes)"], h=30, shape="io") else: # decoder LM head head_sub = [f"Linear [{H or '?'}→{am.vocab_size or '?'}]" + (" · tied" if am.tie_word_embeddings else "")] add("LM Head", "c-head", head_sub, h=42, title=head_title) add("Softmax", "c-soft", h=26) add("logits", "c-io", [f"[1, {seq}, {am.vocab_size or '?'}]"], h=30, shape="io") # ---------- wrapper hierarchy: base <Model> body and <ForXxx> task head, STACKED (adjacent, # not nested) so the two labels never overlap ---------- ww = (attn_right - DCOL_X) + 68 if am.top_class: boxes.append( Box( DCOL_X - 34, body_top - 14, ww, body_bot - body_top + 20, f"{am.top_class} · base model", "c-block", shape="container", ) ) if head_title: boxes.append( Box( DCOL_X - 34, body_bot + 6, ww, (y - GAP) - (body_bot + 6) + 8, f"{head_title} · task head", "c-block", shape="container", ) ) # ---------- residual skip arrows (flow downward) ---------- def midy(b): return b.y + b.h // 2 arrows.append( Arrow( [(DCOL_X, entry_y), (RAIL_X, entry_y), (RAIL_X, midy(plus_attn)), (plus_attn.x, midy(plus_attn))], cls="residual", dashed=True, ) ) if has_mlp: arrows.append( Arrow( [ (plus_attn.x, midy(plus_attn)), (RAIL_X - 16, midy(plus_attn)), (RAIL_X - 16, midy(plus_mlp)), (plus_mlp.x, midy(plus_mlp)), ], cls="residual", dashed=True, ) ) boxes.insert(0, container) # ---------- layer-schedule strip (config.layer_types) — one labelled cell per layer ---- legend = [("c-attn", "self-attention")] strip_bottom = block_bot # only show the per-layer schedule when layers actually differ (≥2 distinct types) if am.layer_types and len(set(am.layer_types)) >= 2: from .introspect import _short_layer_type n = len(am.layer_types) SW = 46 # strip width cell_h = max(15, (block_bot - block_top) / n) # at least 15px so the index fits boxes.append(Box(STRIP_X - 4, block_top - 24, SW + 12, 16, "layers (idx)", "c-block", shape="container")) for i, lt in enumerate(am.layer_types): cy = int(block_top + i * cell_h) boxes.append( Box( STRIP_X, cy, SW, max(13, int(cell_h) - 2), str(i), _lt_class(lt), shape="cell", title=f"layer {i}: {lt}", ) ) strip_bottom = int(block_top + n * cell_h) seen = [] for lt in am.layer_types: if lt not in seen: seen.append(lt) for lt in seen: legend.append((_lt_class(lt), _short_layer_type(lt))) # (attention-mask grids are now drawn INSIDE each variant card, so no separate column) gy = 0 legend += [ ("c-proj", "linear / proj"), ("c-moe", "MoE / experts") if am.is_moe else ("c-mlp", "MLP / feed-forward"), ("c-rope", "RoPE"), ("c-norm", "normalization"), ("residual", "residual / skip"), ] facts = _facts(am) mask_bottom = gy height = max(y + PAD, strip_bottom + PAD, mask_bottom + PAD, PAD + 80 + len(facts) * 22 + len(legend) * 18 + 120) sub = f"{am.decoder_kind} · {am.layer_summary or ''}" width = max(WF, attn_right + 320) # widen for side-by-side attention variants + facts panel return Diagram( width=width, height=height, boxes=boxes, arrows=arrows, title=am.model, subtitle=sub, legend=legend, facts=facts, mode="full", spine=True, ) # --------------------------------------------------------- multimodal multi-tower full view def build_multimodal(am: ArchModel) -> Diagram: """VLM / audio view: encoder tower(s) → projector → the LLM (the detailed column). The LLM column is built by ``_build_column`` and shifted right; the modality towers are drawn in a left lane, with a fusion arrow into the LLM (merge at modality tokens), or cross-attention edges into specific LLM layers when the text config declares them. """ d = _build_column(am) dx = 300 for b in d.boxes: b.x += dx for a in d.arrows: a.points = [(x + dx, y) for (x, y) in a.points] d.width += dx emb = next((b for b in d.boxes if b.cls == "c-embed"), None) container = next((b for b in d.boxes if b.shape == "container" and b.cls == "c-block"), None) TX, TW = 36, 250 ty = PAD + 92 encoders = [t for t in am.towers if t["role"] in ("vision", "audio")] proj = next((t for t in am.towers if t["role"] == "projector"), None) mmpfx = (am.config_class or "").replace("Config", "") def tb(label, cls, subs, h, badge=None, shape="op"): nonlocal ty b = Box(TX, ty, TW, h, label, cls, sublabels=subs or [], shape=shape, small=True, badge=badge) d.boxes.append(b) ty += h + 16 return b def tower(t, label, cls, summary, badge=None): """Render a tower: fully decomposed when small; a representative block ×N for big encoder stacks; else a summary box. So every tower shows what's inside it.""" nonlocal ty ch = t.get("children") if not ch and t.get("block_children"): # big encoder: outer section + one representative layer (decomposed) with ×N top = ty inner_top = top + 26 blabel = f"{t['block_class']}" by = _emit_tree(d.boxes, t["block_children"], TX + 18, TW - 36, inner_top + 22, gp=6, prefix=mmpfx) d.boxes.append( Box( TX + 10, inner_top, TW - 20, by - inner_top + 6, blabel, cls, shape="section", badge=f"×{t['block_n']}", ) ) b = Box(TX, top, TW, (by + 6) - top + 6, label, cls, shape="section", badge=badge) d.boxes.append(b) ty = by + 6 + 6 + 16 return b if ch: top = ty yy = _emit_tree(d.boxes, ch, TX + 10, TW - 20, top + 26, gp=6, prefix=mmpfx) b = Box(TX, top, TW, yy - top + 6, label, cls, shape="section", badge=badge) d.boxes.append(b) ty = yy + 6 + 16 return b return tb(label, cls, summary, 52, badge=badge) enc_boxes = [] for t in encoders: if t["role"] == "vision": inp, ishape, cls, role = "pixel_values", "[1, 3, 336, 336]", "c-vision", "Vision encoder" img = Box(TX + (TW - 76) // 2, ty, 76, 64, "example image", "c-vision", shape="image") d.boxes.append(img) ty += 64 + 22 else: inp, ishape, cls, role = "input_features", "[1, 128, 3000]", "c-audio", "Audio encoder" img = None ib = tb(inp, "c-io", [ishape], 30, shape="io") if img is not None: d.arrows.append(Arrow([(img.x + img.w // 2, img.y + img.h), (ib.x + ib.w // 2, ib.y)], cls="flow")) cls_line = t["cls"] + (" · via AutoModel" if am.auto_classes else "") eb = tower( t, f"{role} · {t['cls']}", cls, [cls_line, f"{t['model_type'] or ''} · {t['layers'] or '?'} layers · h={t['hidden'] or '?'}"], badge=(f"×{t['layers']}" if t["layers"] else None), ) d.arrows.append(Arrow([(ib.x + ib.w // 2, ib.y + ib.h), (eb.x + eb.w // 2, eb.y)], cls="flow")) enc_boxes.append(eb) pj = tower( proj or {}, f"Projector · {proj['cls'] if proj else 'Linear'}", "c-proj", ["align features → text hidden dim"] ) for eb in enc_boxes: d.arrows.append(Arrow([(eb.x + eb.w // 2, eb.y + eb.h), (pj.x + pj.w // 2, pj.y)], cls="flow")) # fusion into the LLM px = pj.x + pj.w pmid = pj.y + pj.h // 2 if am.cross_attention_layers and container is not None: n = len(am.cross_attention_layers) target_x, target_y = container.x, container.y + container.h // 2 d.arrows.append( Arrow( [(px, pmid), ((px + target_x) // 2, pmid), ((px + target_x) // 2, target_y), (target_x, target_y)], cls="xattn", ) ) d.boxes.append( Box( int((px + target_x) // 2) - 70, target_y - 34, 150, 18, f"cross-attention @ {n} layers", "c-proj", shape="op", small=True, ) ) elif emb is not None: target_x, target_y = emb.x, emb.y + emb.h // 2 d.arrows.append( Arrow( [(px, pmid), ((px + target_x) // 2, pmid), ((px + target_x) // 2, target_y), (target_x, target_y)], cls="flow", ) ) d.boxes.append( Box( int((px + target_x) // 2) - 78, target_y - 30, 168, 18, "merge at modality tokens", "c-proj", shape="op", small=True, ) ) # image ↔ text attention mask (the cross-modal mask the LLM actually uses) if any(t["role"] == "vision" for t in encoders): from .masks import image_text_mask n_img, n_text = 5, 7 grid, split = image_text_mask(n_img, n_text, image_bidirectional=am.image_bidirectional) cellp = 13 ty += 16 # gap below the projector mx = TX + (TW - cellp * len(grid)) // 2 my = ty + 22 kind = "prefix-LM: image bidirectional" if am.image_bidirectional else "fully causal" d.boxes.append(Box(TX - 4, ty, TW + 8, 16, "image ⊕ text attention", "c-block", shape="container")) d.boxes.append( Box( mx, my, cellp * len(grid), cellp * len(grid), f"{n_img} img + {n_text} text · {kind}", "c-vision", shape="grid", grid=grid, grid_split=split, title="image↓text query × image|text key", ) ) ty = my + cellp * len(grid) + 16 # legend additions if any(t["role"] == "vision" for t in encoders): d.legend.insert(0, ("c-vision", "vision encoder")) if any(t["role"] == "audio" for t in encoders): d.legend.insert(0, ("c-audio", "audio encoder")) d.legend.append(("c-proj", "projector / fusion")) if am.cross_attention_layers: d.legend.append(("xattn", "cross-attention")) d.subtitle = f"multimodal · {'+'.join(t['role'] for t in encoders)} → projector → LLM · {am.decoder_kind}" d.facts.insert(1, ("inputs", ", ".join(am.modal_inputs))) if am.auto_classes: d.facts.insert(2, ("built via", ", ".join(am.auto_classes[:2]))) d.height = max(d.height, ty + PAD) return d # ----------------------------------------------------------------------------- diff mode # map a modular class name to which architectural slot it touches. # We match on SUFFIXES, not substrings: model names themselves often contain component # keywords (e.g. "Qwen2Moe...") which would otherwise mis-route every class to "moe". def _slot_for_class(name: str) -> str: n = name.lower() if n.endswith("config"): return "config" if n.endswith("rotaryembedding") or n.endswith("rotaryembeddings"): return "rope" if n.endswith(("router", "experts", "expert", "moeblock", "sparsemoeblock", "moe", "moemlp")): return "moe" if n.endswith(("mlp", "feedforward", "ffn")): return "mlp" if n.endswith(("attention", "attn", "sdpaattention", "flashattention2")): return "attention" if n.endswith(("rmsnorm", "layernorm", "norm")): return "norm" if ( n.endswith(("pretrainedmodel",)) or "forcausallm" in n or "forsequence" in n or "fortoken" in n or "forquestion" in n or "forconditional" in n or "forretrieval" in n or "withlmhead" in n ): return "head" if n.endswith(("decoderlayer", "encoderlayer", "layer", "block", "mixer")): return "layer" if n.endswith(("embedding", "embeddings")): return "embedding" if n.endswith("model"): return "layer" # the decoder/model wrapper -> the stack return "layer" def _change_for(cc: ClassChange) -> str | None: if cc.relation == "new": return "added" if cc.deleted_methods or cc.deleted_attrs: return "deleted" if cc.n_changes == 0: return None # trivial / unchanged if cc.added_methods or cc.added_attrs: return "added" return "overridden" def _change_detail(cc: ClassChange) -> str: bits = [] if cc.overridden_methods: bits.append("ovr " + ",".join(cc.overridden_methods)) if cc.added_methods: bits.append("add " + ",".join(cc.added_methods)) if cc.deleted_methods: bits.append("del " + ",".join(cc.deleted_methods)) na = len(cc.added_attrs) + len(cc.overridden_attrs) if na: bits.append(f"{na} attr") return "; ".join(bits) if bits else "rename only" def build_diff(am: ArchModel, ad: ArchDiff) -> Diagram: """Diff over the *real architecture*: render the full detailed view, then colour each class-box by how it relates to the parent it inherits from -- added (green), overridden (amber), deleted (red). Everything inherited unchanged / copy-pasted is greyed (ghosted), so at a glance you see what the model is made of AND exactly what it changed.""" d = build_full(am) d.mode = "diff" d.title = am.model if not ad.is_modular: # standalone: nothing inherited -> show the full architecture, note it d.subtitle = (ad.note or "standalone (no modular parent)") + " · shown in full" return d # change type + detail per modular class (skip trivial → treated as inherited/ghost). # Box colour: a class redefined in the modular file (even a "new" subclass like # GemmaRMSNorm, which reimplements the inherited norm) is a CHANGE -> amber, not green. # Green is reserved for genuinely net-new *submodules* (added below from the parent diff). by_cls: dict[str, str] = {} detail: dict[str, ClassChange] = {} changes_list: list[tuple[str, str, str]] = [] rank = {"deleted": 0, "overridden": 1, "added": 2} for cc in ad.changes: ch = _change_for(cc) detail[cc.name] = cc if ch is not None: by_cls[cc.name] = "deleted" if ch == "deleted" else "overridden" changes_list.append((ch, cc.name, _change_detail(cc))) changes_list.sort(key=lambda t: (rank.get(t[0], 9), t[1])) # longest class names first so we match e.g. GemmaAttention before Gemma changed_names = sorted(by_cls, key=len, reverse=True) def match(text: str) -> str | None: for nm in changed_names: if nm in text: return by_cls[nm] return None def by_keyword(*kws: str) -> str | None: # the embedding/head boxes have generic labels; match their changed class by keyword for nm in changed_names: low = nm.lower() if any(k in low for k in kws): return by_cls[nm] return None # colour each box; ghost everything that didn't change (inherited / copy-pasted) for b in d.boxes: if b.shape in ("container", "io", "circle", "cell", "grid"): continue ch = match(f"{b.label or ''} {b.title or ''}") if ch is None and b.cls == "c-embed": ch = by_keyword("embedding", "embed") if ch is None and b.cls == "c-head": ch = by_keyword("for", "lmhead", "classifier", "classification", "head") if ch: b.change = ch b.ghost = False else: b.ghost = True # inherited unchanged / not a (changed) modular class # compare against the PARENT architecture itself, so overridden blocks reveal WHAT changed pam = _parent_arch(ad.parent_model) if ad.parent_model else None if pam is not None: pnames: set = set() for v in pam.attention_variants: _collect_submodule_names(v["children"], pnames) if pam.mlp_tree: _collect_submodule_names(pam.mlp_tree.get("children") or [], pnames) # a submodule present in the child but NOT in the parent is an addition (e.g. qwen3's # q_norm / k_norm vs qwen2) -> light it green even inside an "overridden" block if pnames: for b in d.boxes: if b.shape == "op" and b.title and ": " in b.title and not b.change: nm, _, rest = b.title.partition(": ") # only real submodule chips (title "name: ClassName"), not token/io chips if not rest[:1].isupper(): continue if nm and nm not in pnames: b.change = "added" b.ghost = False # inherited (ghosted) section headers: show the PARENT class they came from cpfx = (am.config_class or "").replace("Config", "") ppfx = (pam.config_class or "").replace("Config", "") if cpfx and ppfx and cpfx != ppfx: for b in d.boxes: if b.ghost and b.shape == "section" and cpfx in (b.label or ""): b.label = b.label.replace(cpfx, ppfx) + " ↩ inherited" t = ad.totals extra = f" (+{','.join(p for p in ad.parent_models if p != ad.parent_model)})" if len(ad.parent_models) > 1 else "" d.subtitle = ( f"diff vs {ad.parent_model}{extra} · {t['overridden']} overridden · {t['added']} added · " f"{t['deleted']} deleted · {t['new_classes']} new · {t['trivial']} inherited-as-is" ) d.changes = changes_list d.legend = [ ("ch-added", "new submodule (vs parent)"), ("ch-over", "changed / redefined"), ("ch-deleted", "deleted"), ("ghost", "inherited / copy-pasted"), ] d.facts = [ ("model id", (am.checkpoint or am.model)[:30]), ("parent", ad.parent_model or "—"), ("classes", str(len(ad.changes))), ("overridden", str(t["overridden"])), ("added", str(t["added"])), ("new classes", str(t["new_classes"])), ("inherited as-is", str(t["trivial"])), ] # ensure height fits the changes panel panel_h = len(d.facts) * 22 + len(d.legend) * 18 + len(d.changes) * 28 + 200 d.height = max(d.height, PAD + 80 + panel_h) return d