ArthurZ HF Staff commited on
Commit
a9cfb03
·
verified ·
1 Parent(s): 1fc060f

Add arch_svg generator source (meta-device introspection + modular diff + SVG render)

Browse files
arch_svg/README.md ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `arch_svg` — architecture diagrams generated from the `transformers` source
2
+
3
+ Introspect any model in `transformers` and emit its architecture as an **SVG**, in the
4
+ spirit of Sebastian Raschka's [LLM Architecture Gallery](https://sebastianraschka.com/llm-architecture-gallery/)
5
+ — but generated automatically from the library's own source instead of drawn by hand.
6
+
7
+ Output is a **static, self-contained SVG** (CSS only, no JS, no external fetch) — meant to be
8
+ embeddable directly in a model's README on the Hub. Modular models get **two** views
9
+ (`--mode both`, the default for `all`); standalone models get only the full view.
10
+
11
+ - **full** — a detailed, Sebastian-Raschka-style diagram read **top-to-bottom like the code**:
12
+ `input_ids [1, 12]` → token embedding → the **expanded decoder block** (pre-norm →
13
+ **RoPE** → self-attention with Q/K/V/O projections at real meta-tensor dims, GQA/MQA/MLA,
14
+ QK-norm → ⊕ residual → pre-norm → MLP gate/up/down **or** Sparse MoE router→top-k→experts
15
+ (+shared)) → final norm → LM head → softmax → `logits`, with residual skip arrows and
16
+ tensor shapes at each stage. A left **layer-schedule strip** shows the per-layer
17
+ `config.layer_types` pattern (e.g. sliding/full, linear/full), and per-distinct-type
18
+ **attention-mask pattern grids** are drawn on the right. The facts panel (far right) shows
19
+ the example checkpoint id, model_type, dims, heads/kv, experts, etc.
20
+ - **diff** — *only what this model changes* vs. the parent(s) it inherits from in its
21
+ `modular_<name>.py`. A clean modular model produces a tiny, legible diff; a bloated one
22
+ produces a large diff. **The diff size is a direct, automatic measure of how modular each
23
+ model actually is.** A "changes by class" panel lists every overridden / added / deleted
24
+ member.
25
+
26
+ > Compared to interactive viewers like hfviewer.com, this is deliberately **static**: one
27
+ > deterministic `.svg` per model, safe to commit and embed as a model-card fingerprint.
28
+
29
+ The headline feature is **exploiting modular `transformers`**: many models are defined as a
30
+ `modular_<name>.py` that inherits from another model (`class GemmaModel(LlamaModel)`), and
31
+ the diff view shows exactly the override/add/delete payload of that inheritance.
32
+
33
+ No network, no weights. Models are built on `torch.device("meta")` from their **default
34
+ config class** (`Config()` — no `config.json` download required), so the whole zoo renders
35
+ in seconds at zero GPU/memory cost. Anything that can't be built on meta falls back to
36
+ config-only parsing; anything that can't be parsed at all is recorded in `report.json`
37
+ rather than crashing the batch.
38
+
39
+ ## Usage
40
+
41
+ ```bash
42
+ python -m arch_svg one --model llama --mode full --out out/ # detailed Raschka-style block
43
+ python -m arch_svg one --model gemma --mode diff --out out/ # Gemma's diff vs Llama
44
+ python -m arch_svg all --mode both --out out/ --jobs 10 # whole library, BOTH views + index.html
45
+ python -m arch_svg all --mode both --out out/ --limit 30 # quick subset
46
+ python -m arch_svg list # list discovered models
47
+ ```
48
+
49
+ `all` (mode `both`) writes `out/<model>.svg` (full) **and** `out/<model>.diff.svg` (diff) per
50
+ model, an `out/index.html` contact sheet (each card shows both thumbnails, with a live filter
51
+ box and a "largest diffs" leaderboard), and `out/report.json` with per-model status
52
+ (`ok` / `built-from-config-only` / `failed` + traceback), the resolved parent, and the count
53
+ of overridden / added / deleted / new nodes.
54
+
55
+ Open `out/index.html` in a browser — SVGs use CSS variables and honor `prefers-color-scheme`,
56
+ so light/dark is a one-line swap. (Note: `librsvg`/Quick Look don't support CSS custom
57
+ properties, so a *browser* is the intended viewer; opening a single `.svg` in some image
58
+ apps may show it un-themed.)
59
+
60
+ ## How it works
61
+
62
+ ```
63
+ arch_svg/
64
+ discover.py # enumerate models/* (import-free; stat the tree + the config mapping)
65
+ modular.py # ← the interesting part: libcst diff of modular_*.py vs its parent(s)
66
+ introspect.py # default-config + meta-device build → normalized ArchModel (+ detectors)
67
+ layout.py # ArchModel/ArchDiff → positioned boxes (standardized vocabulary)
68
+ render.py # boxes → SVG string (themeable <style>, deterministic output)
69
+ gallery.py # batch driver, index.html, report.json (keeps going on failure)
70
+ cli.py # python -m arch_svg
71
+ ```
72
+
73
+ `modular.py` does **not** re-run `utils/modular_model_converter.py` (that exists to
74
+ *generate* standalone modeling files). Instead it mirrors the converter's inheritance
75
+ semantics with `libcst` to recover the diff payload directly:
76
+
77
+ - a method/attr present in **both** the modular class and its named parent → **overridden**
78
+ - present **only** in the modular class → **added**
79
+ - a deletion sentinel (`attr = AttributeError(...)` or `raise AttributeError`) → **deleted**
80
+
81
+ The parent model is read from the modular file's `from ..<model>.modeling_<model> import …`
82
+ imports — the same import-following the converter performs.
83
+
84
+ ### Standardized visual vocabulary
85
+
86
+ Following the "**standardize, don't abstract**" tenet, every component kind maps to a fixed
87
+ shape/color, so the same block looks identical across all 450+ diagrams:
88
+
89
+ | color | component | | color | component |
90
+ |---|---|---|---|---|
91
+ | 🔵 blue | token embedding | | 🟠 orange | MoE / experts |
92
+ | 🟦 cyan | attention | | 🟣 purple | MLP / dense FFN |
93
+ | 🟢 green | Mamba / SSM | | ⚪ grey | norm (RMS/Layer) |
94
+ | 🔴 red | LM head | | 🟡 yellow | RoPE |
95
+
96
+ In **diff mode** the parent architecture is drawn ghosted, and only changed regions are
97
+ highlighted: **green = added, amber = overridden, red = deleted**. A "changes by class"
98
+ panel enumerates every modular class and exactly which methods/attrs it touches.
99
+
100
+ ## What diff mode revealed about modularity
101
+
102
+ Run over the full library (454 models with modeling code): **249 modular, 205 standalone**;
103
+ 374 built on meta, 72 config-only, 8 unrenderable.
104
+
105
+ **Llama is the spine of the library.** 46 models inherit (transitively, via their dominant
106
+ parent) from `llama` — then `vit` (10), `llava` (9), `mixtral` (9), `clip` (7),
107
+ `wav2vec2` (7). A handful of base models account for most of the zoo.
108
+
109
+ **The cleanest modular models are nearly free.** Diff "size" =
110
+ `overridden + added + deleted + 3·new_classes`. Median modular diff is **39**; the
111
+ exemplars are tiny:
112
+
113
+ | model | parent | diff size |
114
+ |---|---|---|
115
+ | `glm` | llama | 2 |
116
+ | `qwen3` | qwen2 | 4 |
117
+ | `qwen2` | llama | 5 |
118
+ | `granite` | llama | 7 |
119
+ | `ijepa` | vit | 7 |
120
+
121
+ 9 models are **pure-override** (only re-tune existing methods, add *nothing* and create *no*
122
+ new classes) — the gold standard for modular: `bitnet`, `camembert`, `ernie4_5`, `glm`,
123
+ `granite`, `ijepa`, `qwen2`, `qwen3`, `xlm_roberta`.
124
+
125
+ **The largest diffs flag where modularity breaks down (refactor candidates).** 30 models
126
+ have diff size > 100:
127
+
128
+ | model | parent | size | reading |
129
+ |---|---|---|---|
130
+ | `qwen2_5_omni` | qwen2_5_vl | 429 | omni model bolts on audio+talker towers — mostly *new* classes |
131
+ | `qwen3_omni_moe` | qwen3_moe | 310 | same pattern |
132
+ | `gemma3n` | gemma3 | 213 | adds a full audio encoder + per-layer laurel/altup |
133
+ | `rt_detr` | detr | 197 | **0 overridden, 137 added** — inherits the *name* but rebuilds the model |
134
+ | `maskformer` | detr | 143 | **0 overridden, 86 added** — same: near-zero reuse of the parent |
135
+
136
+ Two distinct shapes of "large diff" show up, and the diagram tells them apart at a glance:
137
+
138
+ 1. **Legitimately large** — multimodal/omni models (`qwen2_5_omni`, `phi4_multimodal`,
139
+ `florence2`) that genuinely add vision/audio sub-towers. Big, but the additions are real
140
+ new architecture.
141
+ 2. **Suspicious** — models with **0 overridden + many added** (`rt_detr`, `maskformer`).
142
+ They declare a parent but override none of its behaviour; the inheritance buys almost
143
+ nothing and is closer to copy-paste-with-a-base-class. These are the clearest candidates
144
+ to either lean harder on the parent or stop pretending to inherit from it.
145
+
146
+ ### A note on philosophy (and why there are 8 failures + 72 config-only)
147
+
148
+ This tool deliberately contains **no per-model escapes or special-cases**. The resolvers
149
+ (model-type normalization, class-name→region mapping, sequence-valued config formatting) are
150
+ all generic. When a model *doesn't* render cleanly, that is treated as a **finding, not a bug
151
+ to paper over**:
152
+
153
+ - the **8 failures** are all composite meta-models (`encoder_decoder`, `rag`, `musicgen`,
154
+ `vision_encoder_decoder`, …) that can't be instantiated from defaults;
155
+ - the **72 config-only** models are ones whose default config can't build on meta;
156
+ - a **huge diff** means the modeling code deviates from the standard.
157
+
158
+ The right response to a model that doesn't fit the standard vocabulary is to **standardize the
159
+ model**, not to add a workaround here. In that sense this gallery doubles as a linter for
160
+ modularity and standardization across the library.
161
+
162
+ ## Regenerating the gallery
163
+
164
+ ```bash
165
+ python -m arch_svg all --mode diff --out arch_svg/out --jobs 10
166
+ python -m arch_svg all --mode full --out arch_svg/out_full --jobs 10
167
+ ```
168
+
169
+ Output is deterministic (stable ordering, integer coordinates) so the SVGs are diff-able in
170
+ git.
arch_svg/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """arch_svg - introspect any `transformers` model and emit an architecture diagram as SVG.
2
+
3
+ The headline feature is exploiting *modular* transformers: a model defined as a
4
+ ``modular_<name>.py`` that inherits from another model can be rendered in ``diff`` mode,
5
+ showing only what it changes relative to its parent(s). A clean modular model produces a
6
+ tiny, legible diff -- a direct measure of how modular the library really is.
7
+
8
+ See ``python -m arch_svg --help``.
9
+ """
10
+
11
+ from .introspect import ArchModel, LayerBlock, introspect
12
+ from .modular import ArchDiff, ClassChange, diff, resolve_parent
13
+
14
+
15
+ __all__ = [
16
+ "ArchModel",
17
+ "LayerBlock",
18
+ "introspect",
19
+ "ArchDiff",
20
+ "ClassChange",
21
+ "diff",
22
+ "resolve_parent",
23
+ ]
arch_svg/__main__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
arch_svg/cli.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """`python -m arch_svg ...`
2
+
3
+ python -m arch_svg one --model llama --mode full --out out/
4
+ python -m arch_svg one --model gemma --mode diff --out out/
5
+ python -m arch_svg all --mode diff --out out/ --jobs 8
6
+ python -m arch_svg all --mode diff --out out/ --limit 20 # quick subset
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import os
13
+ import sys
14
+
15
+
16
+ def _cmd_one(args) -> int:
17
+ from .discover import discover_models, model_type_for
18
+ from .gallery import render_one
19
+
20
+ model_type = None
21
+ for e in discover_models():
22
+ if e.name == args.model:
23
+ model_type = model_type_for(e)
24
+ break
25
+ svg, record = render_one(args.model, model_type or args.model, args.mode)
26
+ os.makedirs(args.out, exist_ok=True)
27
+ path = os.path.join(args.out, f"{args.model}.svg")
28
+ with open(path, "w", encoding="utf-8") as f:
29
+ f.write(svg)
30
+ print(f"wrote {path} (status={record['status']})")
31
+ if record.get("build_error"):
32
+ print(f" note: {record['build_error']}")
33
+ if args.mode == "diff" and record.get("is_modular") is not None:
34
+ print(
35
+ f" modular={record.get('is_modular')} parent={record.get('parent_model')} totals={record.get('diff_totals')}"
36
+ )
37
+ return 0 if record["status"] != "failed" else 1
38
+
39
+
40
+ def _cmd_all(args) -> int:
41
+ from .gallery import run_all
42
+
43
+ res = run_all(out=args.out, mode=args.mode, jobs=args.jobs, limit=args.limit)
44
+ s = res["summary"]
45
+ print(f"\ndone → {os.path.join(args.out, 'index.html')}")
46
+ print(f" {s}")
47
+ return 0
48
+
49
+
50
+ def _cmd_list(args) -> int:
51
+ from .discover import discover_models
52
+
53
+ for e in discover_models():
54
+ tag = "modular" if e.has_modular else "standalone"
55
+ print(f"{e.name:32s} {tag}")
56
+ return 0
57
+
58
+
59
+ def main(argv=None) -> int:
60
+ p = argparse.ArgumentParser(prog="arch_svg", description="transformers architecture SVG generator")
61
+ sub = p.add_subparsers(dest="cmd", required=True)
62
+
63
+ one = sub.add_parser("one", help="render a single model")
64
+ one.add_argument("--model", required=True)
65
+ one.add_argument("--mode", choices=["full", "diff"], default="full")
66
+ one.add_argument("--out", default="out")
67
+ one.set_defaults(func=_cmd_one)
68
+
69
+ alle = sub.add_parser("all", help="render the whole model zoo + index.html")
70
+ alle.add_argument("--mode", choices=["full", "diff", "both"], default="both")
71
+ alle.add_argument("--out", default="out")
72
+ alle.add_argument("--jobs", type=int, default=4)
73
+ alle.add_argument("--limit", type=int, default=None)
74
+ alle.set_defaults(func=_cmd_all)
75
+
76
+ lst = sub.add_parser("list", help="list discovered models")
77
+ lst.set_defaults(func=_cmd_list)
78
+
79
+ args = p.parse_args(argv)
80
+ return args.func(args)
81
+
82
+
83
+ if __name__ == "__main__":
84
+ sys.exit(main())
arch_svg/discover.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Enumerate models under ``transformers/models/*`` -- no imports of the models themselves.
2
+
3
+ Discovery is cheap and import-free: we only stat the directory tree and read the
4
+ ``model_type`` from the config mapping. Heavy work (building the model on meta) happens
5
+ later, per-model, so a single broken model can never crash the whole batch.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import os
12
+ from dataclasses import dataclass
13
+ from functools import lru_cache
14
+
15
+
16
+ # Subpackages under models/ that are not actual models (utilities, tokenizers-only, etc.).
17
+ _NON_MODELS = {"auto", "__pycache__"}
18
+
19
+
20
+ @dataclass
21
+ class ModelEntry:
22
+ """A discovered model subpackage. Purely path-derived; nothing is imported yet."""
23
+
24
+ name: str # directory name, e.g. "gemma"
25
+ path: str # absolute path to the model subpackage
26
+ modeling_files: list[str] # modeling_*.py present
27
+ modular_file: str | None # modular_*.py if present
28
+ config_files: list[str] # configuration_*.py present
29
+
30
+ @property
31
+ def has_modular(self) -> bool:
32
+ return self.modular_file is not None
33
+
34
+
35
+ @lru_cache(maxsize=1)
36
+ def models_root() -> str:
37
+ """Locate ``transformers/models`` inside the *installed* source tree."""
38
+ spec = importlib.util.find_spec("transformers")
39
+ if spec is None or not spec.submodule_search_locations:
40
+ raise RuntimeError("Could not locate the installed `transformers` package.")
41
+ return os.path.join(list(spec.submodule_search_locations)[0], "models")
42
+
43
+
44
+ def discover_models(root: str | None = None) -> list[ModelEntry]:
45
+ """Return every model subpackage under ``transformers/models/`` sorted by name."""
46
+ root = root or models_root()
47
+ entries: list[ModelEntry] = []
48
+ for name in sorted(os.listdir(root)):
49
+ if name in _NON_MODELS or name.startswith("_") or name.startswith("."):
50
+ continue
51
+ path = os.path.join(root, name)
52
+ if not os.path.isdir(path):
53
+ continue
54
+ files = os.listdir(path)
55
+ modeling = sorted(f for f in files if f.startswith("modeling_") and f.endswith(".py"))
56
+ configs = sorted(f for f in files if f.startswith("configuration_") and f.endswith(".py"))
57
+ modular = next((f for f in sorted(files) if f.startswith("modular_") and f.endswith(".py")), None)
58
+ if not modeling and not modular and not configs:
59
+ continue # tokenizer/processor-only subpackage
60
+ entries.append(
61
+ ModelEntry(
62
+ name=name,
63
+ path=path,
64
+ modeling_files=modeling,
65
+ modular_file=modular,
66
+ config_files=configs,
67
+ )
68
+ )
69
+ return entries
70
+
71
+
72
+ @lru_cache(maxsize=1)
73
+ def _config_mapping_keys() -> dict[str, str]:
74
+ """Map model directory name heuristics to model_type via the config mapping.
75
+
76
+ Returns a dict {model_type: model_type}; the keys ARE the model_types registered in
77
+ the library. Kept import-light: only imports the auto config module.
78
+ """
79
+ from transformers.models.auto.configuration_auto import CONFIG_MAPPING_NAMES
80
+
81
+ return dict(CONFIG_MAPPING_NAMES)
82
+
83
+
84
+ def model_type_for(entry: ModelEntry) -> str | None:
85
+ """Best-effort resolution of a directory name to a registered ``model_type``.
86
+
87
+ The directory name usually equals the model_type; when it does not (e.g. dir
88
+ ``kosmos2`` -> ``kosmos-2``, ``x_clip`` -> ``xclip``), fall back to normalized matching
89
+ against the config mapping (strip ``-``/``_`` and compare). For dirs that back several
90
+ model_types (``data2vec`` -> ``data2vec-{audio,text,vision}``) the first is chosen.
91
+ """
92
+ keys = list(_config_mapping_keys())
93
+ name = entry.name
94
+ if name in keys:
95
+ return name
96
+ # exact match after hyphen/underscore unification
97
+ for mt in keys:
98
+ if mt.replace("-", "_") == name:
99
+ return mt
100
+
101
+ def norm(s: str) -> str:
102
+ return s.replace("-", "").replace("_", "")
103
+
104
+ target = norm(name)
105
+ candidates = [mt for mt in keys if norm(mt) == target]
106
+ if not candidates:
107
+ # dir name is a prefix of exactly one (or more) model_types, e.g. lasr -> lasr_ctc
108
+ candidates = [mt for mt in keys if norm(mt).startswith(target)]
109
+ if candidates:
110
+ # avoid obviously auxiliary types; prefer the shortest / alphabetically first
111
+ candidates = [c for c in candidates if "privacy" not in c and "filter" not in c] or candidates
112
+ return sorted(candidates, key=lambda c: (len(c), c))[0]
113
+ return None
arch_svg/engine.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tiny self-contained layout primitives — our own engine, no external deps.
2
+
3
+ The point of this module is **text-aware sizing**: every other part of the package can ask
4
+ "how wide is this string at this font size?" and "make this string fit in this width", so
5
+ boxes are sized to their content and text never overflows or runs together. Kept dependency-
6
+ free and deterministic (a fixed proportional-width table) so output stays reproducible.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ # per-character width as a fraction of the font size, for a generic sans-serif.
13
+ # Approximate but stable; good enough to size/ellipsize boxes so text fits.
14
+ _NARROW = set("iIl.,:;'!|()[]{}ftj ")
15
+ _WIDE = set("mMW@%—–→⊙⊕")
16
+ _MID = set("rtszc")
17
+
18
+
19
+ def char_em(ch: str) -> float:
20
+ if ch in _NARROW:
21
+ return 0.30
22
+ if ch in _WIDE:
23
+ return 0.92
24
+ if ch in _MID:
25
+ return 0.48
26
+ if ch.isupper() or ch.isdigit():
27
+ return 0.62
28
+ return 0.54
29
+
30
+
31
+ def text_width(s: str, px: float) -> float:
32
+ """Estimated rendered width (in px) of ``s`` at font size ``px``."""
33
+ return sum(char_em(c) for c in s) * px
34
+
35
+
36
+ def fit_text(s: str, max_px: float, px: float) -> str:
37
+ """Return ``s`` unchanged if it fits in ``max_px``, else truncated with a trailing ellipsis."""
38
+ if max_px <= 0 or text_width(s, px) <= max_px:
39
+ return s
40
+ ell = "…"
41
+ budget = max_px - text_width(ell, px)
42
+ out = []
43
+ w = 0.0
44
+ for c in s:
45
+ cw = char_em(c) * px
46
+ if w + cw > budget:
47
+ break
48
+ out.append(c)
49
+ w += cw
50
+ return ("".join(out).rstrip() + ell) if out else ell
51
+
52
+
53
+ def wrap_text(s: str, max_px: float, px: float, max_lines: int = 2) -> list[str]:
54
+ """Greedily wrap ``s`` to ``max_px`` over up to ``max_lines`` lines (last line ellipsized)."""
55
+ if text_width(s, px) <= max_px:
56
+ return [s]
57
+ words = s.split(" ")
58
+ lines: list[str] = []
59
+ cur = ""
60
+ for w in words:
61
+ trial = (cur + " " + w).strip()
62
+ if text_width(trial, px) <= max_px or not cur:
63
+ cur = trial
64
+ else:
65
+ lines.append(cur)
66
+ cur = w
67
+ if len(lines) == max_lines - 1:
68
+ break
69
+ rest = " ".join(words[sum(len(line.split(" ")) for line in lines) :]) if lines else cur
70
+ lines.append(fit_text(rest or cur, max_px, px))
71
+ return lines[:max_lines]
arch_svg/gallery.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Batch driver: render every model, write per-model SVGs, an index.html contact sheet,
2
+ and a report.json. Keeps going on per-model failure -- errors are collected, never fatal.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import traceback
10
+ from concurrent.futures import ProcessPoolExecutor, as_completed
11
+ from html import escape
12
+
13
+ from .discover import ModelEntry, discover_models, model_type_for
14
+ from .introspect import introspect
15
+ from .layout import build_diff, build_full
16
+ from .modular import diff
17
+ from .render import render
18
+
19
+
20
+ def render_one(model: str, model_type: str | None, mode: str) -> tuple[str, dict]:
21
+ """Render a single model to an SVG string + a report record. Never raises."""
22
+ record: dict = {"model": model, "mode": mode, "status": "ok"}
23
+ try:
24
+ am = introspect(model, model_type)
25
+ record["build_status"] = am.status
26
+ record["decoder_kind"] = am.decoder_kind
27
+ record["layer_summary"] = am.layer_summary
28
+ if am.error:
29
+ record["build_error"] = am.error
30
+ if mode in ("diff", "both"):
31
+ ad = diff(model)
32
+ record["is_modular"] = ad.is_modular
33
+ record["parent_model"] = ad.parent_model
34
+ record["parent_models"] = ad.parent_models
35
+ record["diff_totals"] = ad.totals
36
+ record["note"] = ad.note
37
+ svg = render(build_diff(am, ad))
38
+ else:
39
+ svg = render(build_full(am))
40
+ if am.status == "failed":
41
+ record["status"] = "failed"
42
+ elif am.status == "config-only":
43
+ record["status"] = "built-from-config-only"
44
+ return svg, record
45
+ except Exception:
46
+ record["status"] = "failed"
47
+ record["traceback"] = traceback.format_exc()[-1500:]
48
+ # emit a minimal error placeholder svg so the gallery link still works
49
+ svg = (
50
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="120" viewBox="0 0 480 120">'
51
+ f'<rect width="480" height="120" fill="#fff"/>'
52
+ f'<text x="16" y="40" font-family="sans-serif" font-size="16" fill="#dc2626">{escape(model)} — failed</text>'
53
+ f'<text x="16" y="66" font-family="monospace" font-size="11" fill="#6b7280">{escape(mode)} mode</text>'
54
+ f"</svg>"
55
+ )
56
+ return svg, record
57
+
58
+
59
+ def _write(out, fname, svg):
60
+ path = os.path.join(out, fname)
61
+ with open(path, "w", encoding="utf-8") as f:
62
+ f.write(svg)
63
+ return os.path.basename(path)
64
+
65
+
66
+ def _worker(args):
67
+ model, model_type, mode, out = args
68
+ if mode == "both":
69
+ from .modular import _modular_path
70
+
71
+ # the detailed full view is always emitted
72
+ full_svg, record = render_one(model, model_type, "full")
73
+ record["svg"] = _write(out, f"{model}.svg", full_svg)
74
+ # the diff view is only meaningful for modular models; standalone -> full only
75
+ if _modular_path(model) is not None:
76
+ diff_svg, diff_rec = render_one(model, model_type, "diff")
77
+ record["diff_svg"] = _write(out, f"{model}.diff.svg", diff_svg)
78
+ for key in ("is_modular", "parent_model", "parent_models", "diff_totals", "note"):
79
+ if key in diff_rec:
80
+ record[key] = diff_rec[key]
81
+ if diff_rec.get("status") == "failed" and record.get("status") != "failed":
82
+ record["diff_status"] = "failed"
83
+ else:
84
+ record["is_modular"] = False
85
+ record["parent_model"] = None
86
+ record["note"] = "standalone (no modular parent)"
87
+ return record
88
+ svg, record = render_one(model, model_type, mode)
89
+ record["svg"] = _write(out, f"{model}.svg", svg)
90
+ return record
91
+
92
+
93
+ def run_all(out: str, mode: str = "both", jobs: int = 4, limit: int | None = None) -> dict:
94
+ os.makedirs(out, exist_ok=True)
95
+ entries: list[ModelEntry] = discover_models()
96
+ if limit:
97
+ entries = entries[:limit]
98
+ tasks = [(e.name, model_type_for(e), mode, out) for e in entries]
99
+
100
+ records: list[dict] = []
101
+ if jobs and jobs > 1:
102
+ with ProcessPoolExecutor(max_workers=jobs) as ex:
103
+ futures = {ex.submit(_worker, t): t[0] for t in tasks}
104
+ for i, fut in enumerate(as_completed(futures), 1):
105
+ name = futures[fut]
106
+ try:
107
+ records.append(fut.result())
108
+ except Exception:
109
+ records.append(
110
+ {
111
+ "model": name,
112
+ "mode": mode,
113
+ "status": "failed",
114
+ "traceback": traceback.format_exc()[-800:],
115
+ "svg": f"{name}.svg",
116
+ }
117
+ )
118
+ print(f"[{i}/{len(tasks)}] {name}", flush=True)
119
+ else:
120
+ for i, t in enumerate(tasks, 1):
121
+ records.append(_worker(t))
122
+ print(f"[{i}/{len(tasks)}] {t[0]}", flush=True)
123
+
124
+ records.sort(key=lambda r: r["model"])
125
+ report = _summarize(records, mode)
126
+ with open(os.path.join(out, "report.json"), "w", encoding="utf-8") as f:
127
+ json.dump({"mode": mode, "summary": report, "models": records}, f, indent=2)
128
+ with open(os.path.join(out, "index.html"), "w", encoding="utf-8") as f:
129
+ f.write(_index_html(records, mode, report))
130
+ return {"summary": report, "out": out}
131
+
132
+
133
+ def _summarize(records: list[dict], mode: str) -> dict:
134
+ s = {"total": len(records), "ok": 0, "config_only": 0, "failed": 0}
135
+ if mode in ("diff", "both"):
136
+ s["modular"] = 0
137
+ s["standalone"] = 0
138
+ s["largest_diffs"] = []
139
+ for r in records:
140
+ st = r.get("status")
141
+ if st == "failed":
142
+ s["failed"] += 1
143
+ elif st == "built-from-config-only":
144
+ s["config_only"] += 1
145
+ else:
146
+ s["ok"] += 1
147
+ if mode in ("diff", "both"):
148
+ if r.get("is_modular"):
149
+ s["modular"] += 1
150
+ elif r.get("is_modular") is False:
151
+ s["standalone"] += 1
152
+ if mode in ("diff", "both"):
153
+ scored = []
154
+ for r in records:
155
+ t = r.get("diff_totals")
156
+ if r.get("is_modular") and t:
157
+ size = t["overridden"] + t["added"] + t["deleted"] + 3 * t["new_classes"]
158
+ scored.append((size, r["model"], r.get("parent_model"), t))
159
+ scored.sort(reverse=True)
160
+ s["largest_diffs"] = [{"model": m, "parent": p, "diff_size": sz, "totals": t} for sz, m, p, t in scored[:20]]
161
+ return s
162
+
163
+
164
+ def _index_html(records: list[dict], mode: str, summary: dict) -> str:
165
+ cards = []
166
+ for r in records:
167
+ st = r.get("status", "ok")
168
+ badge = {"ok": "#16a34a", "built-from-config-only": "#d97706", "failed": "#dc2626"}.get(st, "#6b7280")
169
+ sub = ""
170
+ if mode in ("diff", "both"):
171
+ if r.get("is_modular"):
172
+ t = r.get("diff_totals", {})
173
+ sub = f"↳ {escape(str(r.get('parent_model')))} · ovr {t.get('overridden', 0)} / add {t.get('added', 0)} / del {t.get('deleted', 0)} / new {t.get('new_classes', 0)}"
174
+ else:
175
+ sub = "standalone"
176
+ else:
177
+ sub = escape(str(r.get("layer_summary") or r.get("decoder_kind") or ""))
178
+ if mode == "both" and r.get("diff_svg"):
179
+ thumbs = (
180
+ f'<div class="thumbs">'
181
+ f'<a href="{escape(r["svg"])}" target="_blank" class="tw">'
182
+ f'<object data="{escape(r["svg"])}" type="image/svg+xml" class="thumb"></object>'
183
+ f'<span class="tlabel">full</span></a>'
184
+ f'<a href="{escape(r["diff_svg"])}" target="_blank" class="tw">'
185
+ f'<object data="{escape(r["diff_svg"])}" type="image/svg+xml" class="thumb"></object>'
186
+ f'<span class="tlabel">diff</span></a></div>'
187
+ )
188
+ else:
189
+ thumbs = (
190
+ f'<a href="{escape(r["svg"])}" target="_blank" class="tw">'
191
+ f'<object data="{escape(r["svg"])}" type="image/svg+xml" class="thumb"></object></a>'
192
+ )
193
+ cards.append(
194
+ f'<div class="card">'
195
+ f'<div class="card-h"><span class="name">{escape(r["model"])}</span>'
196
+ f'<span class="dot" style="background:{badge}"></span></div>'
197
+ f"{thumbs}"
198
+ f'<div class="sub">{sub}</div></div>'
199
+ )
200
+
201
+ summary_html = f"ok {summary['ok']} · config-only {summary['config_only']} · failed {summary['failed']}"
202
+ if mode in ("diff", "both"):
203
+ summary_html += f" · modular {summary.get('modular', 0)} · standalone {summary.get('standalone', 0)}"
204
+ rows = "".join(
205
+ f"<tr><td>{escape(d['model'])}</td><td>{escape(str(d['parent']))}</td>"
206
+ f"<td>{d['diff_size']}</td><td>{d['totals']['overridden']}</td>"
207
+ f"<td>{d['totals']['added']}</td><td>{d['totals']['deleted']}</td>"
208
+ f"<td>{d['totals']['new_classes']}</td></tr>"
209
+ for d in summary.get("largest_diffs", [])
210
+ )
211
+ big = (
212
+ "<h2>Largest diffs (least modular)</h2>"
213
+ '<table class="lb"><tr><th>model</th><th>parent</th><th>size</th>'
214
+ "<th>ovr</th><th>add</th><th>del</th><th>new</th></tr>"
215
+ f"{rows}</table>"
216
+ )
217
+ else:
218
+ big = ""
219
+
220
+ return f"""<!doctype html>
221
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
222
+ <title>transformers architecture gallery — {escape(mode)}</title>
223
+ <style>
224
+ :root {{ color-scheme: light dark; --bg:#fff; --fg:#111; --muted:#666; --card:#f6f8fa; --bd:#e5e7eb; }}
225
+ @media (prefers-color-scheme: dark) {{ :root {{ --bg:#0d1117; --fg:#e6edf3; --muted:#8b949e; --card:#161b22; --bd:#30363d; }} }}
226
+ body {{ background:var(--bg); color:var(--fg); font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,sans-serif; margin:0; padding:28px; }}
227
+ h1 {{ margin:0 0 4px; }} .meta {{ color:var(--muted); margin-bottom:18px; }}
228
+ .grid {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:16px; }}
229
+ .card {{ background:var(--card); border:1px solid var(--bd); border-radius:12px; padding:12px; color:inherit; display:flex; flex-direction:column; }}
230
+ .card:hover {{ border-color:#3b82f6; }}
231
+ .card-h {{ display:flex; justify-content:space-between; align-items:center; }}
232
+ .name {{ font-weight:700; }} .dot {{ width:10px; height:10px; border-radius:50%; }}
233
+ .thumbs {{ display:flex; gap:8px; }}
234
+ .tw {{ position:relative; flex:1; text-decoration:none; }}
235
+ .tlabel {{ position:absolute; top:10px; left:6px; font-size:10px; font-weight:700; color:var(--muted); background:var(--bg); padding:1px 5px; border-radius:4px; opacity:.85; }}
236
+ .thumb {{ width:100%; height:260px; pointer-events:none; margin:8px 0; background:var(--bg); border-radius:6px; border:1px solid var(--bd); }}
237
+ .sub {{ font-size:12px; color:var(--muted); }}
238
+ table.lb {{ border-collapse:collapse; margin:8px 0 24px; font-size:13px; }}
239
+ table.lb td, table.lb th {{ border:1px solid var(--bd); padding:4px 10px; text-align:left; }}
240
+ input {{ padding:8px 12px; border-radius:8px; border:1px solid var(--bd); background:var(--card); color:var(--fg); width:260px; margin-bottom:16px; }}
241
+ </style></head>
242
+ <body>
243
+ <h1>transformers architecture gallery</h1>
244
+ <div class="meta">mode: <b>{escape(mode)}</b> · {summary_html}</div>
245
+ {big}
246
+ <input id="q" placeholder="filter models…" oninput="for(const c of document.querySelectorAll('.card')){{c.style.display=c.querySelector('.name').textContent.includes(this.value)?'':'none'}}">
247
+ <div class="grid">
248
+ {"".join(cards)}
249
+ </div>
250
+ </body></html>"""
arch_svg/introspect.py ADDED
@@ -0,0 +1,1402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build a normalized ``ArchModel`` from a model's default config + meta-device module tree.
2
+
3
+ No network, no weights: we instantiate the *config class* with its defaults and build the
4
+ model under ``torch.device("meta")`` so allocation is free. If the meta build fails we fall
5
+ back to a config-only ``ArchModel`` (status ``"config-only"``). A registry of detectors maps
6
+ module class names to normalized component kinds so unknown modules degrade to their class
7
+ name rather than crashing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import warnings
14
+ from dataclasses import dataclass, field
15
+
16
+
17
+ warnings.filterwarnings("ignore")
18
+
19
+
20
+ # --------------------------------------------------------------------------------- model
21
+
22
+
23
+ @dataclass
24
+ class LayerBlock:
25
+ """A run of consecutive identical decoder layers, collapsed into one box."""
26
+
27
+ layer_class: str
28
+ count: int
29
+ kind: str # "attention" | "moe" | "mamba" | "linear_attention" | "recurrent" | "other"
30
+ attn_variant: str | None = None # MHA | GQA | MQA | MLA | sliding | None
31
+ mlp_kind: str = "dense" # dense | moe
32
+ norm: str | None = None
33
+
34
+
35
+ # ---- structured "inside the block" specs (for the Raschka-style expanded full view) ----
36
+
37
+
38
+ @dataclass
39
+ class Proj:
40
+ """A single projection (Linear) inside attention / MLP, with meta-tensor shapes."""
41
+
42
+ name: str
43
+ in_f: int | None
44
+ out_f: int | None
45
+ bias: bool = False
46
+
47
+
48
+ @dataclass
49
+ class AttentionSpec:
50
+ cls: str
51
+ variant: str | None = None # MHA | GQA | MQA | MLA
52
+ n_heads: int | None = None
53
+ n_kv: int | None = None
54
+ head_dim: int | None = None
55
+ projs: list[Proj] = field(default_factory=list)
56
+ qk_norm: bool = False
57
+ rope: bool = False
58
+ sliding_window: int | None = None
59
+
60
+
61
+ @dataclass
62
+ class MLPSpec:
63
+ cls: str
64
+ is_moe: bool = False
65
+ act: str = "SiLU"
66
+ gate: Proj | None = None
67
+ up: Proj | None = None
68
+ down: Proj | None = None
69
+ n_experts: int | None = None
70
+ top_k: int | None = None
71
+ expert_dim: int | None = None
72
+ n_shared: int | None = None
73
+ projs: list[Proj] = field(default_factory=list) # generic fallback when gate/up/down naming differs
74
+
75
+
76
+ @dataclass
77
+ class BlockSpec:
78
+ """The canonical pre-norm transformer block, decomposed for detailed rendering."""
79
+
80
+ layer_class: str
81
+ pre_attn_norm: str | None = None
82
+ attention: AttentionSpec | None = None
83
+ mixer: AttentionSpec | None = None # token mixer for non-attention blocks (Mamba/SSM/RWKV/linear-attn)
84
+ post_attn_norm: str | None = None
85
+ mlp: MLPSpec | None = None
86
+ extra: list[str] = field(default_factory=list) # e.g. sandwich-norm note
87
+
88
+
89
+ @dataclass
90
+ class ArchModel:
91
+ model: str
92
+ model_type: str | None = None
93
+ config_class: str | None = None
94
+ status: str = "ok" # ok | config-only | failed
95
+ error: str | None = None
96
+
97
+ # scalar config facts
98
+ hidden_size: int | None = None
99
+ vocab_size: int | None = None
100
+ num_layers: int | None = None
101
+ num_attention_heads: int | None = None
102
+ num_kv_heads: int | None = None
103
+ head_dim: int | None = None
104
+ intermediate_size: int | None = None
105
+ max_position_embeddings: int | None = None
106
+ sliding_window: int | None = None
107
+
108
+ # normalized architecture facts
109
+ norm_type: str | None = None
110
+ positional: str | None = None
111
+ attn_variant: str | None = None
112
+ decoder_kind: str = "dense" # dense | sparse-moe | hybrid | ssm | other
113
+ is_moe: bool = False
114
+ num_experts: int | None = None
115
+ experts_per_token: int | None = None
116
+ tie_word_embeddings: bool | None = None
117
+
118
+ layer_blocks: list[LayerBlock] = field(default_factory=list)
119
+ layer_summary: str | None = None # e.g. "36× DeltaNet + 12× Attention"
120
+ module_kinds: dict[str, int] = field(default_factory=dict) # class name -> count (top kinds)
121
+ top_class: str | None = None # outer model class name
122
+ block: BlockSpec | None = None # decomposed decoder block internals (full view)
123
+ generic_tree: list[dict] = field(default_factory=list) # full module tree for non-transformer models
124
+ flow: list[dict] = field(default_factory=list) # per-stage forward I/O shapes (data-flow view)
125
+ flow_input: str | None = None # input tensor label, e.g. "input_values [1, 1, 8000]"
126
+ flow_output: list[int] | None = None # final output shape
127
+ is_pipeline: bool = False # ≥3 distinct top-level stage sub-networks (e.g. BLT, codecs)
128
+ rope_theta: float | None = None
129
+ hidden_act: str | None = None
130
+ layer_types: list[str] = field(default_factory=list) # config.layer_types schedule (per layer)
131
+ alt_blocks: list[str] = field(default_factory=list) # other layer classes (hybrid) not shown in detail
132
+ checkpoint: str | None = None # example HF checkpoint id (from @auto_docstring), offline
133
+ attn_patterns: dict = field(default_factory=dict) # layer_type -> 0/1 mask grid
134
+ tokens: list[str] = field(default_factory=list) # example tokenized input shown at the top
135
+ # sparse/compressed attention components (e.g. DeepSeek-V4 HCA/CSA), per distinct layer_type
136
+ sparse_components: list[dict] = field(default_factory=list)
137
+ # fully-decomposed module trees (recursive) of each distinct attention variant + the FFN
138
+ attention_variants: list[dict] = field(default_factory=list)
139
+ mlp_tree: dict | None = None
140
+ mlp_variants: list[dict] = field(default_factory=list) # per distinct mlp_layer_type (moe/hash_moe/dense)
141
+ mlp_layer_types: list[str] = field(default_factory=list)
142
+ # multimodal (VLM / audio) structure
143
+ is_multimodal: bool = False
144
+ towers: list[dict] = field(default_factory=list) # encoder towers (vision/audio) + projector
145
+ cross_attention_layers: list | None = None # LLM layer indices that cross-attend to features
146
+ modal_inputs: list[str] = field(default_factory=list) # e.g. ["input_ids", "pixel_values"]
147
+ auto_classes: list[str] = field(default_factory=list) # Auto* classes used to build sub-models
148
+ image_bidirectional: bool = False # VLM: image tokens attend bidirectionally (prefix-LM)
149
+ family: str | None = None # causal_lm | image_classification | audio_classification | seq2seq | ...
150
+ head_class: str | None = None # the task wrapper class, e.g. ViTForImageClassification
151
+ view: str = "decoder" # decoder | encoder | enc_dec | multimodal — which layout to use
152
+ num_labels: int | None = None
153
+
154
+
155
+ # ----------------------------------------------------------------------------- detectors
156
+
157
+ _MOE_RE = re.compile(r"(expert|router|moe|sparsemoe|sparse_moe)", re.IGNORECASE)
158
+ _MAMBA_RE = re.compile(r"(mamba|mixer|ssm|\bs4\b|selectivescan)", re.IGNORECASE)
159
+ _LINEAR_ATTN_RE = re.compile(r"(deltanet|linearattention|lineattn|gateddelta|gla\b|retention|rwkv)", re.IGNORECASE)
160
+ _ATTN_RE = re.compile(r"attention$", re.IGNORECASE)
161
+ _NORM_RE = re.compile(r"norm", re.IGNORECASE)
162
+
163
+
164
+ def _kind_for_layer(class_name: str) -> str:
165
+ n = class_name.lower()
166
+ if _MAMBA_RE.search(n):
167
+ return "mamba"
168
+ if _LINEAR_ATTN_RE.search(n):
169
+ return "linear_attention"
170
+ if "recurrent" in n or "rnn" in n:
171
+ return "recurrent"
172
+ return "attention"
173
+
174
+
175
+ def layer_type_kind(t: str) -> str:
176
+ """Classify a ``config.layer_types`` entry (e.g. 'sliding_attention') into a kind.
177
+
178
+ Sliding/full/compressed are all *attention* (they differ only in the mask); linear /
179
+ delta / gated / mamba / recurrent are structurally different layers.
180
+ """
181
+ n = t.lower()
182
+ if _MAMBA_RE.search(n):
183
+ return "mamba"
184
+ if _LINEAR_ATTN_RE.search(n) or "linear" in n or "delta" in n or "gated" in n:
185
+ return "linear_attention"
186
+ if "recurrent" in n:
187
+ return "recurrent"
188
+ return "attention"
189
+
190
+
191
+ def _cfg(config, *names, default=None):
192
+ for n in names:
193
+ if hasattr(config, n):
194
+ v = getattr(config, n)
195
+ if v is not None:
196
+ return v
197
+ return default
198
+
199
+
200
+ def _attn_variant(config) -> str | None:
201
+ heads = _cfg(config, "num_attention_heads", "n_head", "num_heads")
202
+ kv = _cfg(config, "num_key_value_heads", "num_kv_heads", default=heads)
203
+ # Multi-head Latent Attention (DeepSeek style)
204
+ if _cfg(config, "kv_lora_rank", "q_lora_rank") is not None:
205
+ return "MLA"
206
+ if heads is None:
207
+ return None
208
+ if kv is None:
209
+ return "MHA"
210
+ if kv == 1:
211
+ return "MQA"
212
+ if kv < heads:
213
+ return "GQA"
214
+ return "MHA"
215
+
216
+
217
+ def _positional(config, module_classes: set[str]) -> str:
218
+ if any("rotary" in c.lower() or "rope" in c.lower() for c in module_classes):
219
+ return "RoPE"
220
+ if _cfg(config, "rope_parameters", "rope_theta", "rope_scaling") is not None:
221
+ return "RoPE"
222
+ if _cfg(config, "alibi") or any("alibi" in c.lower() for c in module_classes):
223
+ return "ALiBi"
224
+ if any(c in ("PositionEmbedding", "LearnedPositionalEmbedding") for c in module_classes):
225
+ return "learned-absolute"
226
+ # presence of a position embedding nn.Embedding sized to max_position_embeddings
227
+ return "RoPE" if _cfg(config, "rotary_emb_base") is not None else "n/a"
228
+
229
+
230
+ def _norm_type(module_classes: set[str]) -> str | None:
231
+ norms = sorted({c for c in module_classes if _NORM_RE.search(c)})
232
+ if not norms:
233
+ return None
234
+ # prefer the model-specific norm name; normalize common ones
235
+ for c in norms:
236
+ if "rms" in c.lower():
237
+ return "RMSNorm"
238
+ for c in norms:
239
+ if "layernorm" in c.lower():
240
+ return "LayerNorm"
241
+ return norms[0]
242
+
243
+
244
+ # ----------------------------------------------------------------------- module-tree walk
245
+
246
+
247
+ def _find_layer_list(model, num_layers):
248
+ """Find the nn.ModuleList holding the decoder/encoder layers."""
249
+ import torch.nn as nn
250
+
251
+ best = None
252
+ for name, module in model.named_modules():
253
+ if isinstance(module, nn.ModuleList) and len(module) > 0:
254
+ # prefer one whose length matches num_layers, else the longest
255
+ if num_layers and len(module) == num_layers:
256
+ return name, module
257
+ if best is None or len(module) > len(best[1]):
258
+ best = (name, module)
259
+ return best if best else (None, None)
260
+
261
+
262
+ def _collapse(class_names: list[str]) -> list[tuple[str, int]]:
263
+ out: list[tuple[str, int]] = []
264
+ for name in class_names:
265
+ if out and out[-1][0] == name:
266
+ out[-1] = (name, out[-1][1] + 1)
267
+ else:
268
+ out.append((name, 1))
269
+ return out
270
+
271
+
272
+ def _all_configs(config):
273
+ """Yield the config and every nested sub-config (``*_config``)."""
274
+ yield config
275
+ for k in list(vars(config)):
276
+ if k.endswith("_config"):
277
+ sc = getattr(config, k, None)
278
+ if sc is not None and hasattr(sc, "model_type"):
279
+ yield from _all_configs(sc)
280
+
281
+
282
+ def _sanitize_config(config) -> bool:
283
+ """Fix invalid default-config combos that break a meta build (generic, no per-model code).
284
+
285
+ The common one: ``hidden_size % num_attention_heads != 0`` in a sub-config (e.g. some
286
+ vision towers). We snap ``num_attention_heads`` to the largest divisor ≤ the original and
287
+ make ``num_key_value_heads`` divide it. Returns True if anything changed.
288
+ """
289
+ changed = False
290
+ for c in _all_configs(config):
291
+ h = getattr(c, "hidden_size", None)
292
+ nh = getattr(c, "num_attention_heads", None)
293
+ if isinstance(h, int) and isinstance(nh, int) and nh > 0 and h % nh != 0:
294
+ d = next((x for x in range(nh, 0, -1) if h % x == 0), 1)
295
+ c.num_attention_heads = d
296
+ changed = True
297
+ kv = getattr(c, "num_key_value_heads", None)
298
+ if isinstance(kv, int) and (kv == 0 or d % kv != 0):
299
+ c.num_key_value_heads = d
300
+ return changed
301
+
302
+
303
+ def _build_meta_model(config, device: str = "meta"):
304
+ import torch
305
+
306
+ import transformers as tf
307
+
308
+ # try AutoModel first, then common task factories, then EVERY remaining AutoModelFor*
309
+ # (so depth / segmentation / time-series / retrieval wrappers all build without hardcoding)
310
+ preferred = [
311
+ "AutoModel",
312
+ "AutoModelForImageTextToText",
313
+ "AutoModelForCausalLM",
314
+ "AutoModelForImageClassification",
315
+ "AutoModelForDepthEstimation",
316
+ "AutoModelForSemanticSegmentation",
317
+ "AutoModelForPreTraining",
318
+ "AutoModelForSeq2SeqLM",
319
+ ]
320
+ rest = sorted(n for n in dir(tf) if n.startswith("AutoModel") and n not in preferred)
321
+ factories, seen = [], set()
322
+ for n in preferred + rest:
323
+ f = getattr(tf, n, None)
324
+ if f is not None and hasattr(f, "from_config") and n not in seen:
325
+ seen.add(n)
326
+ factories.append(f)
327
+ with torch.device(device):
328
+ last = None
329
+ for sanitize in (False, True):
330
+ if sanitize and not _sanitize_config(config):
331
+ break
332
+ for fac in factories:
333
+ try:
334
+ return fac.from_config(config)
335
+ except Exception as e:
336
+ last = e
337
+ raise last
338
+
339
+
340
+ _CKPT_RE = re.compile(r"""checkpoint\s*=\s*["']([\w\-./]+/[\w\-.]+)["']""")
341
+
342
+
343
+ def _example_checkpoint(model: str, mt: str) -> str | None:
344
+ """Best-effort example checkpoint id from ``@auto_docstring(checkpoint=...)`` in the
345
+ model's source (offline; we just read the files). Returns e.g. 'google/gemma-7b'."""
346
+ import os
347
+
348
+ from .discover import models_root
349
+
350
+ d = os.path.join(models_root(), model)
351
+ if not os.path.isdir(d):
352
+ return None
353
+ for f in sorted(os.listdir(d)):
354
+ if f.startswith(("modeling_", "configuration_", "modular_")) and f.endswith(".py"):
355
+ try:
356
+ m = _CKPT_RE.search(open(os.path.join(d, f), encoding="utf-8").read())
357
+ except Exception:
358
+ continue
359
+ if m:
360
+ return m.group(1)
361
+ return None
362
+
363
+
364
+ _ACT_NAMES = {"gelu", "relu", "silu", "tanh", "sigmoid", "swish", "mish", "quickgelu", "newgelu", "geglu"}
365
+
366
+
367
+ def _classify_mod(name: str, cls: str) -> str:
368
+ """Map a submodule to a *standardized* semantic kind used for colouring across every view."""
369
+ n = (name + " " + cls).lower()
370
+ c = cls.lower()
371
+ if "rotary" in n or "rope" in n:
372
+ return "rope"
373
+ if "norm" in c:
374
+ return "norm"
375
+ if "conv" in c: # Conv1d/2d/3d, ParametrizedConv*, depthwise conv, ...
376
+ return "conv"
377
+ if any(k in c for k in ("lstm", "gru", "rnn")):
378
+ return "recurrent"
379
+ if "embedding" in c or "embed" in c:
380
+ return "embedding"
381
+ if "pool" in c:
382
+ return "pool"
383
+ if "dropout" in c or c == "identity" or "drop_path" in n:
384
+ return "dropout"
385
+ if "activation" in c or c in _ACT_NAMES or name in ("act", "activation", "act_fn"):
386
+ return "act"
387
+ if "quantiz" in c or "codebook" in c:
388
+ return "quantizer"
389
+ if "indexer" in n:
390
+ return "indexer"
391
+ if "compress" in n:
392
+ return "compressor"
393
+ if name == "gate" or "router" in n:
394
+ return "router"
395
+ if "expert" in n:
396
+ return "experts"
397
+ if any(k in n for k in ("classifier", "lm_head", "score")) or name == "head":
398
+ return "head"
399
+ if "linear" in c or "proj" in name:
400
+ return "linear"
401
+ return "other"
402
+
403
+
404
+ def _decompose(module, depth: int) -> list[dict]:
405
+ """Recursively decompose a module into a tree of ``{name, cls, kind, dim, children}``."""
406
+ import torch.nn as nn
407
+
408
+ nodes = []
409
+ for n, c in module.named_children():
410
+ cls = type(c).__name__
411
+ kind = _classify_mod(n, cls)
412
+ dim = None
413
+ if isinstance(c, nn.Linear):
414
+ dim = f"[{c.in_features}→{c.out_features}]"
415
+ else:
416
+ w = getattr(c, "weight", None)
417
+ if w is not None and hasattr(w, "shape") and 1 <= len(w.shape) <= 3:
418
+ dim = "×".join(str(s) for s in tuple(w.shape))
419
+ is_leaf = isinstance(c, nn.Linear) or kind in ("norm", "rope")
420
+ children = _decompose(c, depth - 1) if (depth > 0 and not is_leaf) else []
421
+ nodes.append({"name": n, "cls": cls, "kind": kind, "dim": dim, "children": children})
422
+ return nodes
423
+
424
+
425
+ def _mod_dim(c) -> str | None:
426
+ """A compact dimension label for any leaf module (Linear / Conv / Embedding / parametrized)."""
427
+ import torch.nn as nn
428
+
429
+ if isinstance(c, nn.Linear):
430
+ return f"[{c.in_features}→{c.out_features}]"
431
+ if isinstance(c, (nn.Conv1d, nn.Conv2d, nn.Conv3d)):
432
+ k = "×".join(str(x) for x in c.kernel_size)
433
+ g = f" g{c.groups}" if c.groups != 1 else ""
434
+ return f"{c.in_channels}→{c.out_channels} k{k}{g}"
435
+ if isinstance(c, nn.Embedding):
436
+ return f"[{c.num_embeddings}×{c.embedding_dim}]"
437
+ w = getattr(c, "weight", None)
438
+ if w is not None and hasattr(w, "shape") and 1 <= len(w.shape) <= 2:
439
+ return "×".join(str(s) for s in tuple(w.shape))
440
+ return None
441
+
442
+
443
+ def _is_leaf_mod(c) -> bool:
444
+ import torch.nn as nn
445
+
446
+ return isinstance(c, (nn.Linear, nn.Conv1d, nn.Conv2d, nn.Conv3d, nn.Embedding)) or bool(
447
+ _NORM_RE.search(type(c).__name__)
448
+ )
449
+
450
+
451
+ def _node_for(name, c, depth) -> dict:
452
+ leaf = _is_leaf_mod(c) or not list(c.named_children())
453
+ return {
454
+ "name": name,
455
+ "cls": type(c).__name__,
456
+ "kind": _classify_mod(name, type(c).__name__),
457
+ "dim": _mod_dim(c),
458
+ "children": _module_tree(c, depth - 1) if (depth > 0 and not leaf) else [],
459
+ }
460
+
461
+
462
+ def _collapse_sequence(kids, depth: int) -> list[dict]:
463
+ """Collapse a sequence of children by detecting the smallest repeating *period*: a run like
464
+ ``(Conv1d, ResnetBlock) ×4`` becomes one ``×4`` group, ``[Stage, Stage, Stage] ×3`` becomes
465
+ ``×3 Stage``. Non-repeating elements (an input stem, a trailing LSTM, ...) stay as singles."""
466
+ sigs = [type(c).__name__ for _, c in kids]
467
+ n = len(kids)
468
+ out: list[dict] = []
469
+ i = 0
470
+ while i < n:
471
+ best = None
472
+ for p in range(1, (n - i) // 2 + 1): # smallest period that repeats ≥ twice wins
473
+ k = 1
474
+ while i + (k + 1) * p <= n and sigs[i + k * p : i + (k + 1) * p] == sigs[i : i + p]:
475
+ k += 1
476
+ if k >= 2:
477
+ best = (p, k)
478
+ break
479
+ if best:
480
+ p, k = best
481
+ members = kids[i : i + p]
482
+ if p == 1: # a homogeneous run → "×k ClassName" with the block's internals
483
+ nm, c = members[0]
484
+ g = _node_for(nm, c, depth)
485
+ g["name"], g["cls"] = f"[{i}:{i + k}]", f"{k}× {type(c).__name__}"
486
+ out.append(g)
487
+ else: # a periodic pattern → "×k (A, B, …)" containing one copy of the pattern
488
+ out.append(
489
+ {
490
+ "name": f"[{i}:{i + p * k}]",
491
+ "cls": f"{k}× (" + ", ".join(sigs[i : i + p]) + ")",
492
+ "kind": "other",
493
+ "dim": None,
494
+ "children": [_node_for(nm, c, depth) for nm, c in members],
495
+ }
496
+ )
497
+ i += p * k
498
+ else:
499
+ out.append(_node_for(*kids[i], depth))
500
+ i += 1
501
+ return out
502
+
503
+
504
+ def _module_tree(module, depth: int) -> list[dict]:
505
+ """Recursive module hierarchy with repeating ``ModuleList``/``Sequential`` runs collapsed to
506
+ ``×N`` (see ``_collapse_sequence``). This renders *any* architecture — conv backbones, codecs,
507
+ FFT mixers, detectors — from its real structure, never a config-only schematic."""
508
+ import torch.nn as nn
509
+
510
+ nodes: list[dict] = []
511
+ for n, c in module.named_children():
512
+ cls = type(c).__name__
513
+ kids = list(c.named_children())
514
+ if isinstance(c, (nn.ModuleList, nn.Sequential)) and len(kids) > 1:
515
+ groups = _collapse_sequence(kids, depth) if depth > 0 else []
516
+ if len(groups) == 1 and groups[0]["cls"].startswith(f"{len(kids)}× "):
517
+ # whole list is one homogeneous repeat → inline it (e.g. "stages · 4× ResNetStage")
518
+ g = groups[0]
519
+ nodes.append({"name": n, "cls": g["cls"], "kind": g["kind"], "dim": None, "children": g["children"]})
520
+ else:
521
+ nodes.append(
522
+ {
523
+ "name": n,
524
+ "cls": f"{cls} [{len(kids)}]",
525
+ "kind": _classify_mod(n, cls),
526
+ "dim": None,
527
+ "children": groups,
528
+ }
529
+ )
530
+ continue
531
+ nodes.append(_node_for(n, c, depth))
532
+ return nodes
533
+
534
+
535
+ def _out_shape(x) -> list[int] | None:
536
+ """Best-effort tensor shape of a module's input/output: tensor, tuple/list, or ModelOutput."""
537
+ import torch
538
+
539
+ if torch.is_tensor(x):
540
+ return list(x.shape)
541
+ if isinstance(x, (list, tuple)):
542
+ for e in x:
543
+ s = _out_shape(e)
544
+ if s:
545
+ return s
546
+ return None
547
+ for attr in ("last_hidden_state", "logits", "sample", "audio_values", "audio_codes", "hidden_states"):
548
+ v = getattr(x, attr, None)
549
+ s = _out_shape(v) if v is not None else None
550
+ if s:
551
+ return s
552
+ return None
553
+
554
+
555
+ def _dummy_inputs(model, config, device: str) -> dict:
556
+ """A minimal forward input matching the model's modality (no tokenizer / processor needed)."""
557
+ import torch
558
+
559
+ mi = getattr(model, "main_input_name", None) or "input_ids"
560
+ if mi in ("input_values", "input_features"):
561
+ return {mi: torch.zeros(1, 1, 8000, device=device)}
562
+ if mi == "pixel_values":
563
+ sz = getattr(config, "image_size", None) or 64
564
+ sz = sz if isinstance(sz, int) else (sz[0] if isinstance(sz, (list, tuple)) else 64)
565
+ ch = getattr(config, "num_channels", None) or 3
566
+ return {mi: torch.zeros(1, ch, sz, sz, device=device)}
567
+ return {"input_ids": torch.ones(1, 8, dtype=torch.long, device=device)}
568
+
569
+
570
+ class _Deadline:
571
+ """Hard wall-clock timeout (SIGALRM) so a slow/looping forward can never hang the build.
572
+ Works in the main thread of each ProcessPoolExecutor worker; a no-op where SIGALRM is absent."""
573
+
574
+ def __init__(self, seconds: float):
575
+ self.seconds = seconds
576
+
577
+ def __enter__(self):
578
+ import signal
579
+
580
+ self._sig = getattr(signal, "SIGALRM", None)
581
+ if self._sig is not None:
582
+
583
+ def _raise(*_):
584
+ raise TimeoutError("forward exceeded time budget")
585
+
586
+ self._old = signal.signal(self._sig, _raise)
587
+ signal.setitimer(signal.ITIMER_REAL, self.seconds)
588
+ return self
589
+
590
+ def __exit__(self, *exc):
591
+ import signal
592
+
593
+ if self._sig is not None:
594
+ signal.setitimer(signal.ITIMER_REAL, 0)
595
+ signal.signal(self._sig, self._old)
596
+ return False
597
+
598
+
599
+ def _run_flow(model, config, device: str) -> tuple[str, list[dict], list[int] | None] | None:
600
+ """Run one forward pass capturing the in→out shape of each top-level stage (in call order),
601
+ via forward hooks. Returns (input_label, stages, output_shape) or None on failure."""
602
+ import torch
603
+
604
+ model.eval()
605
+ inp = _dummy_inputs(model, config, device)
606
+ in_name = next(iter(inp))
607
+ in_shape = list(next(iter(inp.values())).shape)
608
+ flow: list[dict] = []
609
+ hooks = []
610
+ order = {id(m): i for i, (_, m) in enumerate(model.named_children())}
611
+
612
+ def mk(nm):
613
+ def hook(mod, i, o):
614
+ flow.append(
615
+ {
616
+ "name": nm,
617
+ "cls": type(mod).__name__,
618
+ "in_shape": _out_shape(i),
619
+ "out_shape": _out_shape(o),
620
+ "_ord": order.get(id(mod), 999),
621
+ }
622
+ )
623
+
624
+ return hook
625
+
626
+ for nm, mod in model.named_children():
627
+ hooks.append(mod.register_forward_hook(mk(nm)))
628
+ out = None
629
+ try:
630
+ with torch.no_grad(), _Deadline(8):
631
+ out = model(**inp)
632
+ except BaseException: # includes TimeoutError from the deadline
633
+ for h in hooks:
634
+ h.remove()
635
+ return None
636
+ for h in hooks:
637
+ h.remove()
638
+ if not flow:
639
+ return None
640
+ # de-dup (a stage hit more than once keeps its first call) and keep call order
641
+ seen, uniq = set(), []
642
+ for f in flow:
643
+ if f["name"] in seen:
644
+ continue
645
+ seen.add(f["name"])
646
+ uniq.append(f)
647
+ return (f"{in_name} {in_shape}", uniq, _out_shape(out))
648
+
649
+
650
+ def _shrink_config(config):
651
+ """Shrink a config in-place so it can be instantiated cheaply on CPU for a real forward."""
652
+ for k in (
653
+ "num_hidden_layers",
654
+ "num_layers",
655
+ "decoder_layers",
656
+ "encoder_layers",
657
+ "n_layers",
658
+ "num_local_experts",
659
+ "n_routed_experts",
660
+ ):
661
+ if isinstance(getattr(config, k, None), int):
662
+ setattr(config, k, min(2, getattr(config, k)))
663
+ # cap the big allocations (vocab × hidden embeddings, position tables) so a CPU build is cheap
664
+ for k, cap in (("vocab_size", 256), ("max_position_embeddings", 64), ("max_target_positions", 64)):
665
+ if isinstance(getattr(config, k, None), int) and getattr(config, k) > cap:
666
+ setattr(config, k, cap)
667
+ for sub in ("encoder", "decoder", "text_config", "vision_config", "audio_config"):
668
+ c = getattr(config, sub, None)
669
+ if hasattr(c, "__dict__"):
670
+ _shrink_config(c)
671
+ return config
672
+
673
+
674
+ def _stage_children(model_obj) -> list:
675
+ """Top-level children that are substantial *stage* sub-networks (own ModuleList or ≥2 kids),
676
+ i.e. a real pipeline rather than a single backbone + head. Excludes embeddings/norms/heads."""
677
+ import torch.nn as nn
678
+
679
+ stages = []
680
+ for n, c in model_obj.named_children():
681
+ if _is_leaf_mod(c):
682
+ continue
683
+ kids = list(c.named_children())
684
+ if not kids:
685
+ continue
686
+ has_list = any(isinstance(m, (nn.ModuleList, nn.Sequential)) for m in c.modules())
687
+ if has_list or len(kids) >= 2:
688
+ stages.append((n, c))
689
+ return stages
690
+
691
+
692
+ def _capture_flow(model_obj, config, model_type) -> tuple[str, list[dict], list[int] | None] | None:
693
+ """Capture data-flow shapes via a forward on the ALREADY-BUILT *meta* model — meta tensors
694
+ carry shapes but allocate ~no real memory, so this is safe to run for every model in parallel.
695
+ Models whose forward can't run on meta (data-dependent ops) simply don't get the flow view;
696
+ they fall back to the structural module-tree view. We never build a real model here."""
697
+ try:
698
+ f = _run_flow(model_obj, config, "meta")
699
+ if f and len(f[1]) >= 2:
700
+ return f
701
+ except BaseException:
702
+ pass
703
+ return None
704
+
705
+
706
+ _HEAD_PRIORITY = [
707
+ "IMAGE_TEXT_TO_TEXT",
708
+ "SEQ_TO_SEQ_CAUSAL_LM",
709
+ "CAUSAL_LM",
710
+ "IMAGE_CLASSIFICATION",
711
+ "AUDIO_CLASSIFICATION",
712
+ "MASKED_LM",
713
+ "SEQUENCE_CLASSIFICATION",
714
+ "TOKEN_CLASSIFICATION",
715
+ "PRETRAINING",
716
+ ]
717
+
718
+
719
+ def _detect_family(mt: str, config):
720
+ """Return (family, head_class) by scanning the Auto MODEL_FOR_* mappings."""
721
+ try:
722
+ import transformers.models.auto.modeling_auto as ma
723
+ except Exception:
724
+ return None, None
725
+ for key in _HEAD_PRIORITY:
726
+ m = getattr(ma, f"MODEL_FOR_{key}_MAPPING_NAMES", {})
727
+ if mt in m:
728
+ hc = m[mt]
729
+ if isinstance(hc, (tuple, list)): # some types map to several heads; take the first
730
+ hc = hc[0] if hc else None
731
+ return key.lower(), hc
732
+ return None, None
733
+
734
+
735
+ def _attn_child(layer):
736
+ for n, c in layer.named_children():
737
+ if "attention" in type(c).__name__.lower() or n in ("self_attn", "attn", "attention", "self_attention"):
738
+ return c
739
+ return None
740
+
741
+
742
+ def _mixer_child(layer):
743
+ """The token-mixing submodule of a non-attention block (Mamba/SSM/RWKV/linear-attention)."""
744
+ # prefer the conventionally-named mixer child
745
+ for n, c in layer.named_children():
746
+ if n in ("mixer", "ssm", "temporal_mixer", "time_mixer"):
747
+ return c
748
+ # else a non-norm child whose class name names a known mixer family
749
+ for n, c in layer.named_children():
750
+ cls = type(c).__name__
751
+ if _NORM_RE.search(cls):
752
+ continue
753
+ low = cls.lower()
754
+ if any(k in low for k in ("mamba", "mixer", "ssm", "rwkv", "retention", "lineardelta")):
755
+ return c
756
+ return None
757
+
758
+
759
+ def _block_core(layer):
760
+ """The token-mixing module of a block: real attention if present, else an SSM/recurrent mixer."""
761
+ return _attn_child(layer) or _mixer_child(layer)
762
+
763
+
764
+ def _annotate_moe(nodes: list[dict], am, ed) -> None:
765
+ """Fill experts/router counts (fused experts have no per-expert submodules on meta)."""
766
+ for nd in nodes:
767
+ if nd["kind"] == "experts" and not nd.get("dim"):
768
+ nd["dim"] = f"×{am.num_experts or '?'} · dim {ed or '?'}"
769
+ if nd["kind"] == "router":
770
+ nd["dim"] = f"top-{am.experts_per_token or '?'} of {am.num_experts or '?'}"
771
+
772
+
773
+ def _mlp_child(layer):
774
+ for n, c in layer.named_children():
775
+ cl = type(c).__name__.lower()
776
+ if n in ("mlp", "feed_forward", "ffn", "block_sparse_moe", "moe") or cl.endswith(
777
+ ("mlp", "moe", "moeblock", "sparsemoeblock", "feedforward")
778
+ ):
779
+ return c
780
+ return None
781
+
782
+
783
+ def _linear_proj(name, module) -> Proj | None:
784
+ import torch.nn as nn
785
+
786
+ if isinstance(module, nn.Linear):
787
+ return Proj(name=name, in_f=module.in_features, out_f=module.out_features, bias=module.bias is not None)
788
+ # some models use Conv1D (gpt2) with weight shape (in, out)
789
+ w = getattr(module, "weight", None)
790
+ if w is not None and hasattr(w, "shape") and len(w.shape) == 2:
791
+ return Proj(
792
+ name=name, in_f=int(w.shape[1]), out_f=int(w.shape[0]), bias=getattr(module, "bias", None) is not None
793
+ )
794
+ return None
795
+
796
+
797
+ def _extract_block(layer, tcfg, am) -> BlockSpec | None:
798
+ """Decompose one decoder layer into a canonical pre-norm block for detailed rendering."""
799
+
800
+ spec = BlockSpec(layer_class=type(layer).__name__)
801
+ norms: list[tuple[str, str]] = [] # (child_name, class_name)
802
+ attn_mod = mlp_mod = None
803
+ for cname, child in layer.named_children():
804
+ cls = type(child).__name__
805
+ low = (cname + " " + cls).lower()
806
+ if _NORM_RE.search(cls):
807
+ norms.append((cname, cls))
808
+ elif low.find("attention") >= 0 or cname in ("self_attn", "attn", "attention", "self_attention"):
809
+ attn_mod = (cname, child)
810
+ elif cname in ("mlp", "feed_forward", "ffn", "block_sparse_moe", "moe", "mlp_layer") or cls.lower().endswith(
811
+ ("mlp", "moe", "moeblock", "sparsemoeblock", "feedforward")
812
+ ):
813
+ mlp_mod = (cname, child)
814
+
815
+ # ---- attention ----
816
+ if attn_mod is not None:
817
+ _, am_mod = attn_mod
818
+ aspec = AttentionSpec(cls=type(am_mod).__name__, variant=am.attn_variant)
819
+ aspec.n_heads = am.num_attention_heads
820
+ aspec.n_kv = am.num_kv_heads
821
+ aspec.head_dim = am.head_dim
822
+ aspec.sliding_window = am.sliding_window if isinstance(am.sliding_window, int) else None
823
+ for n, c in am_mod.named_children():
824
+ p = _linear_proj(n, c)
825
+ if p is not None: # every Linear/Conv1D in attention is a projection (q/k/v/o, fused qkv, lora)
826
+ aspec.projs.append(p)
827
+ if _NORM_RE.search(type(c).__name__) and ("q_norm" in n or "k_norm" in n or n.endswith("norm")):
828
+ aspec.qk_norm = True
829
+ if "rotary" in type(c).__name__.lower():
830
+ aspec.rope = True
831
+ aspec.rope = aspec.rope or (am.positional == "RoPE")
832
+ spec.attention = aspec
833
+
834
+ # ---- mlp / moe ----
835
+ if mlp_mod is not None:
836
+ mname, mm = mlp_mod
837
+ mcls = type(mm).__name__
838
+ # MoE if this module (or any descendant) is a router/experts/sparse block
839
+ is_moe = bool(_MOE_RE.search(mcls)) or any(_MOE_RE.search(type(c).__name__) for c in mm.modules())
840
+ mspec = MLPSpec(cls=mcls, is_moe=is_moe, act=(am.hidden_act or "SiLU"))
841
+ if is_moe:
842
+ mspec.n_experts = am.num_experts
843
+ mspec.top_k = am.experts_per_token
844
+ mspec.expert_dim = _cfg(tcfg, "moe_intermediate_size", "expert_intermediate_size", "intermediate_size")
845
+ mspec.n_shared = _cfg(tcfg, "n_shared_experts", "num_shared_experts") # counts only
846
+ else:
847
+ projs = {}
848
+ for n, c in mm.named_children():
849
+ p = _linear_proj(n, c)
850
+ if p is not None:
851
+ projs[n.lower()] = p
852
+ mspec.projs = list(projs.values())
853
+ mspec.gate = next((projs[k] for k in projs if "gate" in k and "up" not in k), None)
854
+ mspec.up = next(
855
+ (projs[k] for k in projs if k.startswith("up") or "up_proj" in k or "fc" in k or "c_fc" in k), None
856
+ )
857
+ mspec.down = next(
858
+ (
859
+ projs[k]
860
+ for k in projs
861
+ if "down" in k
862
+ or "c_proj" in k
863
+ or k.endswith(("proj2", "_proj"))
864
+ and "up" not in k
865
+ and "gate" not in k
866
+ ),
867
+ None,
868
+ )
869
+ spec.mlp = mspec
870
+
871
+ # ---- norms (canonical pre-norm: input -> attn, post-attn -> mlp) ----
872
+ if norms:
873
+ pre = next((c for n, c in norms if "input" in n or "pre" in n or n == norms[0][0]), norms[0][1])
874
+ spec.pre_attn_norm = pre
875
+ post = next((c for n, c in norms if "post" in n or "attention" in n), None)
876
+ spec.post_attn_norm = post or (norms[1][1] if len(norms) > 1 else pre)
877
+ if len(norms) > 2:
878
+ spec.extra.append(f"{len(norms)} norms (sandwich/extra)")
879
+
880
+ # ---- token mixer (Mamba/SSM/RWKV) when there is no attention ----
881
+ if spec.attention is None:
882
+ mix = _mixer_child(layer)
883
+ if mix is not None:
884
+ mspec2 = AttentionSpec(cls=type(mix).__name__, variant="SSM")
885
+ for n, c in mix.named_children():
886
+ p = _linear_proj(n, c)
887
+ if p is not None:
888
+ mspec2.projs.append(p)
889
+ spec.mixer = mspec2
890
+
891
+ if spec.attention is None and spec.mixer is None and spec.mlp is None:
892
+ return None
893
+ return spec
894
+
895
+
896
+ _EXAMPLE_PROMPT = "Hey, how are you?"
897
+ _FALLBACK_TOKENS = ["Hey", ",", "␣how", "␣are", "␣you", "?"]
898
+
899
+
900
+ def _example_tokens(checkpoint: str | None) -> list[str]:
901
+ """Tokenize a fixed example prompt with the model's tokenizer (offline / cached only).
902
+
903
+ Falls back to a fixed illustrative token list when no cached tokenizer is available.
904
+ Token strings are cleaned: the SentencePiece ``▁`` and BPE ``Ġ`` space markers become a
905
+ visible ``␣`` so the per-token boxes read naturally.
906
+ """
907
+ if not checkpoint:
908
+ return list(_FALLBACK_TOKENS)
909
+ try:
910
+ from transformers import AutoTokenizer
911
+
912
+ tok = AutoTokenizer.from_pretrained(checkpoint, local_files_only=True)
913
+ ids = tok(_EXAMPLE_PROMPT, add_special_tokens=False)["input_ids"]
914
+ pieces = tok.convert_ids_to_tokens(ids)
915
+ out = []
916
+ for p in pieces[:10]:
917
+ out.append(p.replace("▁", "␣").replace("Ġ", "␣").replace("Ċ", "⏎"))
918
+ return out or list(_FALLBACK_TOKENS)
919
+ except Exception:
920
+ return list(_FALLBACK_TOKENS)
921
+
922
+
923
+ _AUTO_RE = re.compile(r"\b(AutoModel(?:ForCausalLM|ForConditionalGeneration|ForImageTextToText)?|AutoModelFor\w+)\b")
924
+
925
+
926
+ def _auto_classes(model: str) -> list[str]:
927
+ """Which Auto* classes the modeling file uses to instantiate sub-models (offline grep).
928
+
929
+ Composition models build their towers with e.g. ``AutoModel.from_config(vision_config)``;
930
+ surfacing this is important — the vision tower or LLM is often an AutoModel.
931
+ """
932
+ import os
933
+
934
+ from .discover import models_root
935
+
936
+ d = os.path.join(models_root(), model)
937
+ if not os.path.isdir(d):
938
+ return []
939
+ found: set[str] = set()
940
+ for f in sorted(os.listdir(d)):
941
+ if f.startswith(("modeling_", "modular_")) and f.endswith(".py"):
942
+ try:
943
+ src = open(os.path.join(d, f), encoding="utf-8").read()
944
+ except Exception:
945
+ continue
946
+ for m in _AUTO_RE.findall(src):
947
+ found.add(m)
948
+ return sorted(found)
949
+
950
+
951
+ _VISION_RE = re.compile(r"(vision|visual|image|vit|clip|siglip|patch)", re.IGNORECASE)
952
+ _AUDIO_RE = re.compile(r"(audio|speech|wav|mel|acoustic|whisper)", re.IGNORECASE)
953
+ _PROJ_RE = re.compile(r"(projector|connector|multi_modal|mm_proj|adapter|merger|aligner)", re.IGNORECASE)
954
+ _LLM_RE = re.compile(r"(language_model|text_model|^model$|decoder|thinker)", re.IGNORECASE)
955
+
956
+
957
+ def _subconfig(config, *names):
958
+ for n in names:
959
+ sc = getattr(config, n, None)
960
+ if sc is not None and hasattr(sc, "model_type"):
961
+ return sc
962
+ return None
963
+
964
+
965
+ def _tower_summary(child_name, child, config) -> dict:
966
+ """Summarize one top-level modality tower (encoder/projector) for the multi-tower view."""
967
+ cls = type(child).__name__
968
+ role = "other"
969
+ nl = child_name.lower() + " " + cls.lower()
970
+ if _PROJ_RE.search(nl):
971
+ role = "projector"
972
+ elif _VISION_RE.search(nl):
973
+ role = "vision"
974
+ elif _AUDIO_RE.search(nl):
975
+ role = "audio"
976
+ elif _LLM_RE.search(child_name.lower()):
977
+ role = "text"
978
+ sc = None
979
+ if role == "vision":
980
+ sc = _subconfig(config, "vision_config")
981
+ elif role == "audio":
982
+ sc = _subconfig(config, "audio_config")
983
+ layers = hidden = mtype = None
984
+ if sc is not None:
985
+ layers = _cfg(sc, "num_hidden_layers", "depth", "num_layers")
986
+ hidden = _cfg(sc, "hidden_size", "d_model", "embed_dim")
987
+ mtype = getattr(sc, "model_type", None)
988
+ out = {"role": role, "name": child_name, "cls": cls, "model_type": mtype, "layers": layers, "hidden": hidden}
989
+ # decompose small towers (projector / connector) fully so their inside is visible.
990
+ n_sub = sum(1 for _ in child.modules())
991
+ if role == "projector" or (role in ("vision", "audio") and n_sub <= 12):
992
+ out["children"] = _decompose(child, 2)
993
+ elif role in ("vision", "audio"):
994
+ # big encoder stack: show ONE representative layer (decomposed) ×N, like the LLM block
995
+ import contextlib
996
+
997
+ with contextlib.suppress(Exception):
998
+ _, elayers = _find_layer_list(child, layers)
999
+ if elayers is not None and len(elayers) > 0:
1000
+ rep = elayers[0]
1001
+ if _attn_child(rep) is None: # hierarchical (e.g. swin stage) -> descend
1002
+ rep = next((m for m in rep.modules() if _attn_child(m) is not None), rep)
1003
+ out["block_children"] = _decompose(rep, 2)
1004
+ out["block_class"] = type(rep).__name__
1005
+ out["block_n"] = len(elayers)
1006
+ return out
1007
+
1008
+
1009
+ def _detect_towers(model_obj, config) -> list[dict]:
1010
+ """Detect modality towers from the top-level children of a (multimodal) model."""
1011
+ towers = []
1012
+ for n, c in model_obj.named_children():
1013
+ cls = type(c).__name__
1014
+ # skip plain containers like embeddings/heads at the very top
1015
+ if cls in ("Embedding", "Linear") and not _PROJ_RE.search(n.lower()):
1016
+ continue
1017
+ t = _tower_summary(n, c, config)
1018
+ if t["role"] in ("vision", "audio", "projector", "text"):
1019
+ towers.append(t)
1020
+ return towers
1021
+
1022
+
1023
+ def introspect(model: str, model_type: str | None = None) -> ArchModel:
1024
+ """Introspect ``model`` into an ``ArchModel``. Never raises -- failures are recorded."""
1025
+ from transformers.models.auto.configuration_auto import CONFIG_MAPPING
1026
+
1027
+ am = ArchModel(model=model, model_type=model_type)
1028
+ mt = model_type or model
1029
+ try:
1030
+ config_cls = CONFIG_MAPPING[mt]
1031
+ except Exception:
1032
+ # try to recover model_type from the directory name
1033
+ config_cls = None
1034
+ for k, cls in CONFIG_MAPPING.items():
1035
+ if k.replace("-", "_") == model:
1036
+ config_cls, mt = cls, k
1037
+ break
1038
+ if config_cls is None:
1039
+ am.status = "failed"
1040
+ am.error = f"no config class for model_type={mt!r}"
1041
+ return am
1042
+
1043
+ am.model_type = mt
1044
+ am.config_class = config_cls.__name__
1045
+ try:
1046
+ config = config_cls()
1047
+ except Exception as e: # some configs require args
1048
+ am.status = "failed"
1049
+ am.error = f"config instantiation failed: {e!r}"[:300]
1050
+ return am
1051
+
1052
+ # decoder/text config (multimodal configs nest a text_config)
1053
+ tcfg = getattr(config, "get_text_config", lambda: config)()
1054
+ am.cross_attention_layers = _cfg(tcfg, "cross_attention_layers")
1055
+ has_vision_cfg = _subconfig(config, "vision_config") is not None
1056
+ has_audio_cfg = _subconfig(config, "audio_config") is not None
1057
+
1058
+ # task family + which layout view to use (decoder LM / encoder-classifier / enc-dec / VLM)
1059
+ am.family, am.head_class = _detect_family(mt, config)
1060
+ am.num_labels = _cfg(config, "num_labels", "num_classes")
1061
+ if getattr(config, "is_encoder_decoder", False):
1062
+ am.view = "enc_dec"
1063
+ elif am.family in ("image_classification", "audio_classification", "masked_lm"):
1064
+ am.view = "encoder"
1065
+ else:
1066
+ am.view = "decoder" # multimodal is set later once towers are detected
1067
+
1068
+ am.hidden_size = _cfg(tcfg, "hidden_size", "d_model", "n_embd", "dim")
1069
+ am.vocab_size = _cfg(tcfg, "vocab_size")
1070
+ am.num_layers = _cfg(tcfg, "num_hidden_layers", "num_layers", "n_layer", "n_layers")
1071
+ am.num_attention_heads = _cfg(tcfg, "num_attention_heads", "n_head", "num_heads")
1072
+ am.num_kv_heads = _cfg(tcfg, "num_key_value_heads", "num_kv_heads", default=am.num_attention_heads)
1073
+ am.head_dim = _cfg(tcfg, "head_dim")
1074
+ # hierarchical vision models (swin, dinat, hiera, ...) carry per-stage tuples; only the
1075
+ # plain int case has a well-defined single head_dim.
1076
+ if am.head_dim is None and isinstance(am.hidden_size, int) and isinstance(am.num_attention_heads, int):
1077
+ am.head_dim = am.hidden_size // am.num_attention_heads
1078
+ am.intermediate_size = _cfg(tcfg, "intermediate_size", "ffn_dim", "d_ff")
1079
+ am.max_position_embeddings = _cfg(tcfg, "max_position_embeddings", "n_positions")
1080
+ am.sliding_window = _cfg(tcfg, "sliding_window")
1081
+ am.num_experts = _cfg(tcfg, "num_experts", "num_local_experts", "n_routed_experts", "moe_num_experts")
1082
+ am.experts_per_token = _cfg(tcfg, "num_experts_per_tok", "moe_topk", "num_experts_per_token", "top_k")
1083
+ am.tie_word_embeddings = _cfg(config, "tie_word_embeddings")
1084
+ am.attn_variant = _attn_variant(tcfg)
1085
+ am.hidden_act = _cfg(tcfg, "hidden_act", "activation_function", "hidden_activation")
1086
+ rt = _cfg(tcfg, "rope_theta")
1087
+ am.rope_theta = rt if isinstance(rt, (int, float)) else None
1088
+ lts = _cfg(tcfg, "layer_types")
1089
+ if isinstance(lts, (list, tuple)) and lts and all(isinstance(x, str) for x in lts):
1090
+ am.layer_types = list(lts)
1091
+ am.checkpoint = _example_checkpoint(model, mt)
1092
+ am.tokens = _example_tokens(am.checkpoint)
1093
+ # sparse/compressed attention components, keyed by distinct attention layer_type
1094
+ compress_rates = _cfg(tcfg, "compress_rates")
1095
+ if isinstance(compress_rates, dict) and am.layer_types:
1096
+ index_topk = _cfg(tcfg, "index_topk")
1097
+ from .masks import concat_compressed_mask
1098
+
1099
+ for lt in dict.fromkeys(am.layer_types):
1100
+ if lt in compress_rates:
1101
+ comp = {"type": lt, "compress_rate": compress_rates[lt]}
1102
+ if "sparse" in lt and index_topk:
1103
+ comp["index_topk"] = index_topk
1104
+ # the real mask passed to attention: sliding K/V cache ⊕ compressed cache.
1105
+ # display m is capped so the figure is legible (true rate kept in the label).
1106
+ m = min(int(compress_rates[lt]), 8)
1107
+ grid, split, n_comp = concat_compressed_mask(16, m)
1108
+ comp.update(display_m=m, mask=grid, mask_split=split, n_comp=n_comp)
1109
+ am.sparse_components.append(comp)
1110
+
1111
+ module_classes: set[str] = set()
1112
+ layer_classes: list[str] = []
1113
+
1114
+ try:
1115
+ model_obj = _build_meta_model(config)
1116
+ am.top_class = type(model_obj).__name__
1117
+ counts: dict[str, int] = {}
1118
+ for _, mod in model_obj.named_modules():
1119
+ cn = type(mod).__name__
1120
+ counts[cn] = counts.get(cn, 0) + 1
1121
+ module_classes.add(cn)
1122
+ am.module_kinds = dict(sorted(counts.items(), key=lambda kv: -kv[1])[:25])
1123
+
1124
+ name, layers = _find_layer_list(model_obj, am.num_layers)
1125
+
1126
+ # multimodal towers (vision/audio encoders + projector + LLM)
1127
+ towers = _detect_towers(model_obj, config)
1128
+ if (has_vision_cfg or has_audio_cfg) and any(t["role"] in ("vision", "audio") for t in towers):
1129
+ am.is_multimodal = True
1130
+ am.view = "multimodal"
1131
+ am.towers = towers
1132
+ am.modal_inputs = ["input_ids"]
1133
+ if any(t["role"] == "vision" for t in towers):
1134
+ am.modal_inputs.append("pixel_values")
1135
+ if any(t["role"] == "audio" for t in towers):
1136
+ am.modal_inputs.append("input_features")
1137
+ am.auto_classes = _auto_classes(model)
1138
+ # prefix-LM image attention (bidirectional over image tokens, e.g. PaliGemma/Gemma3)
1139
+ import os as _os
1140
+
1141
+ from .discover import models_root as _mr
1142
+
1143
+ _d = _os.path.join(_mr(), model)
1144
+ for _f in sorted(_os.listdir(_d)) if _os.path.isdir(_d) else []:
1145
+ if _f.startswith(("modeling_", "modular_")) and _f.endswith(".py"):
1146
+ try:
1147
+ _src = open(_os.path.join(_d, _f), encoding="utf-8").read()
1148
+ except Exception:
1149
+ continue
1150
+ if "token_type_ids" in _src or "bidirectional" in _src.lower():
1151
+ am.image_bidirectional = True
1152
+ break
1153
+ # for the LLM block, restrict the layer search to the language_model subtree
1154
+ llm = next((getattr(model_obj, t["name"]) for t in towers if t["role"] == "text"), None)
1155
+ if llm is not None:
1156
+ _, llm_layers = _find_layer_list(llm, am.num_layers)
1157
+ if llm_layers is not None:
1158
+ layers = llm_layers
1159
+
1160
+ # norm/positional must be known before block extraction (rope detection uses them)
1161
+ am.norm_type = _norm_type(module_classes)
1162
+ am.positional = _positional(tcfg, module_classes)
1163
+ if layers is not None:
1164
+ layer_classes = [type(layer).__name__ for layer in layers]
1165
+ if not am.num_layers:
1166
+ am.num_layers = len(layers)
1167
+ # decompose a representative decoder layer into block internals (full view).
1168
+ # MoE models often keep the first few layers dense (e.g. DeepSeek); prefer a
1169
+ # layer that actually contains the MoE block so the diagram is representative.
1170
+ try:
1171
+ import torch.nn as nn
1172
+
1173
+ def _score(layer):
1174
+ has_attn = any(
1175
+ "attention" in type(c).__name__.lower() and any(isinstance(g, nn.Linear) for g in c.children())
1176
+ for c in layer.children()
1177
+ )
1178
+ has_moe = any(_MOE_RE.search(type(c).__name__) for c in layer.modules())
1179
+ # prefer a layer with real attention; among those prefer one with MoE
1180
+ return (1 if has_attn else 0, 1 if (has_moe and am.num_experts) else 0)
1181
+
1182
+ rep = layers[0]
1183
+ if len(layers) > 1:
1184
+ rep = max(layers, key=_score)
1185
+ # hierarchical/staged models (Swin/Donut/NAT): the "layer" is a Stage with no
1186
+ # direct attention -> descend to the innermost module that has real attention
1187
+ if _attn_child(rep) is None:
1188
+ deep = next(
1189
+ (m for m in rep.modules() if _attn_child(m) is not None and m is not rep),
1190
+ None,
1191
+ )
1192
+ if deep is None: # search the whole model
1193
+ deep = next((m for m in model_obj.modules() if _attn_child(m) is not None), None)
1194
+ if deep is not None:
1195
+ rep = deep
1196
+ layers = [rep]
1197
+ am.block = _extract_block(rep, tcfg, am)
1198
+ # record any structurally different layer classes (hybrid) not shown in detail
1199
+ kinds = {_kind_for_layer(type(layer).__name__) for layer in layers}
1200
+ am.alt_blocks = sorted(k for k in kinds if k != "attention")
1201
+
1202
+ # FULL recursive decomposition of each DISTINCT attention variant (the real
1203
+ # module tree -- e.g. DeepSeek-V4 HCA vs CSA-with-Indexer), so all blocks show.
1204
+ variants, seen = [], set()
1205
+ for i, layer in enumerate(layers):
1206
+ at = _block_core(layer)
1207
+ if at is None:
1208
+ continue
1209
+ lt = getattr(at, "layer_type", None) or (
1210
+ am.layer_types[i] if i < len(am.layer_types) else type(at).__name__
1211
+ )
1212
+ if lt in seen:
1213
+ continue
1214
+ seen.add(lt)
1215
+ variants.append({"type": lt, "cls": type(at).__name__, "children": _decompose(at, 2)})
1216
+ if len(variants) >= 3:
1217
+ break
1218
+ am.attention_variants = variants
1219
+ # decompose the FFN/MoE of the representative layer too
1220
+ for n, c in rep.named_children():
1221
+ if n in ("mlp", "feed_forward", "ffn", "block_sparse_moe", "moe") or type(
1222
+ c
1223
+ ).__name__.lower().endswith(("mlp", "moe", "moeblock", "sparsemoeblock")):
1224
+ am.mlp_tree = {"cls": type(c).__name__, "children": _decompose(c, 2)}
1225
+ # annotate router/experts nodes with counts (fused experts have no
1226
+ # per-expert submodules on meta, so surface the count + dims here)
1227
+ ed = _cfg(tcfg, "moe_intermediate_size", "expert_intermediate_size", "intermediate_size")
1228
+ _annotate_moe(am.mlp_tree["children"], am, ed)
1229
+ break
1230
+
1231
+ # distinct FFN variants per config.mlp_layer_types (moe / hash_moe / dense)
1232
+ mlts = _cfg(tcfg, "mlp_layer_types")
1233
+ if isinstance(mlts, (list, tuple)) and mlts and all(isinstance(x, str) for x in mlts):
1234
+ am.mlp_layer_types = list(mlts)
1235
+ ed = _cfg(tcfg, "moe_intermediate_size", "expert_intermediate_size", "intermediate_size")
1236
+ mseen = set()
1237
+ for i, layer in enumerate(layers):
1238
+ if i >= len(am.mlp_layer_types):
1239
+ break
1240
+ mt2 = am.mlp_layer_types[i]
1241
+ if mt2 in mseen:
1242
+ continue
1243
+ mc = _mlp_child(layer)
1244
+ if mc is None:
1245
+ continue
1246
+ mseen.add(mt2)
1247
+ ch = _decompose(mc, 2)
1248
+ _annotate_moe(ch, am, ed)
1249
+ am.mlp_variants.append({"type": mt2, "cls": type(mc).__name__, "children": ch})
1250
+ if len(am.mlp_variants) >= 3:
1251
+ break
1252
+ except Exception:
1253
+ am.block = None
1254
+
1255
+ # Universal fallback: any model without a recognized attention/SSM/MLP transformer block
1256
+ # (conv backbones, audio codecs, FFT mixers, detectors, ...) still gets a real top-down
1257
+ # render from its actual (collapsed) module tree instead of a config-only schematic.
1258
+ no_block = am.block is None or (am.block.attention is None and am.block.mixer is None and am.block.mlp is None)
1259
+ generic = no_block and not am.attention_variants
1260
+ try:
1261
+ am.is_pipeline = len(_stage_children(model_obj)) >= 3
1262
+ except Exception:
1263
+ am.is_pipeline = False
1264
+ # A model gets the data-flow view when it has no single transformer block (conv backbone,
1265
+ # codec, ...) OR when it is a multi-stage pipeline (BLT: patcher → encoder → transformer →
1266
+ # decoder), where the data path is the story rather than one attention block.
1267
+ if (generic or am.is_pipeline) and not am.is_multimodal:
1268
+ try:
1269
+ am.generic_tree = am.generic_tree or _module_tree(model_obj, depth=4)
1270
+ except Exception:
1271
+ am.generic_tree = []
1272
+ try:
1273
+ cap = _capture_flow(model_obj, config, model)
1274
+ if cap:
1275
+ am.flow_input, am.flow, am.flow_output = cap
1276
+ except Exception:
1277
+ am.flow = []
1278
+ except Exception as e:
1279
+ am.status = "config-only"
1280
+ am.error = f"meta build failed: {e!r}"[:300]
1281
+
1282
+ # MoE / decoder kind
1283
+ moe_modules = {c for c in module_classes if _MOE_RE.search(c)}
1284
+ am.is_moe = bool(moe_modules) or (am.num_experts is not None and am.num_experts > 1)
1285
+ has_mamba = any(_MAMBA_RE.search(c) for c in module_classes)
1286
+ has_linear = any(_LINEAR_ATTN_RE.search(c) for c in module_classes)
1287
+
1288
+ if am.norm_type is None:
1289
+ am.norm_type = _norm_type(module_classes)
1290
+ if am.positional is None:
1291
+ am.positional = _positional(tcfg, module_classes)
1292
+
1293
+ # layer blocks: prefer config.layer_types (the real per-layer schedule) when present,
1294
+ # since hybrid models (qwen3_next: linear+full, gemma3: sliding+full) reuse ONE layer
1295
+ # class so the module tree alone hides the mix.
1296
+ blocks: list[LayerBlock] = []
1297
+ if am.layer_types:
1298
+ for lt, n in _collapse(am.layer_types):
1299
+ kind = layer_type_kind(lt)
1300
+ blocks.append(
1301
+ LayerBlock(
1302
+ layer_class=lt,
1303
+ count=n,
1304
+ kind=kind,
1305
+ attn_variant=(am.attn_variant if kind == "attention" else None),
1306
+ mlp_kind="moe" if (am.is_moe and kind in ("attention", "linear_attention")) else "dense",
1307
+ norm=am.norm_type,
1308
+ )
1309
+ )
1310
+ elif layer_classes:
1311
+ for cls_name, n in _collapse(layer_classes):
1312
+ kind = _kind_for_layer(cls_name)
1313
+ blocks.append(
1314
+ LayerBlock(
1315
+ layer_class=cls_name,
1316
+ count=n,
1317
+ kind=kind,
1318
+ attn_variant=am.attn_variant if kind == "attention" else None,
1319
+ mlp_kind="moe" if (am.is_moe and kind == "attention") else "dense",
1320
+ norm=am.norm_type,
1321
+ )
1322
+ )
1323
+ elif am.num_layers:
1324
+ # config-only fallback: one synthetic block. With no module tree to walk, infer the
1325
+ # kind from the synthetic layer name (e.g. "MambaDecoderLayer" -> mamba) and config.
1326
+ synth_name = f"{am.config_class.replace('Config', '') if am.config_class else mt}DecoderLayer"
1327
+ kind = "mamba" if has_mamba else ("linear_attention" if has_linear else _kind_for_layer(synth_name))
1328
+ blocks.append(
1329
+ LayerBlock(
1330
+ layer_class=synth_name,
1331
+ count=am.num_layers,
1332
+ kind=kind,
1333
+ attn_variant=am.attn_variant,
1334
+ mlp_kind="moe" if am.is_moe else "dense",
1335
+ norm=am.norm_type,
1336
+ )
1337
+ )
1338
+ am.layer_blocks = blocks
1339
+
1340
+ # decoder kind + summary
1341
+ distinct_kinds = {b.kind for b in blocks}
1342
+ if len(distinct_kinds) > 1:
1343
+ am.decoder_kind = "hybrid"
1344
+ elif has_mamba and not any(b.kind == "attention" for b in blocks):
1345
+ am.decoder_kind = "ssm"
1346
+ elif am.is_moe:
1347
+ am.decoder_kind = "sparse-moe"
1348
+ elif distinct_kinds and distinct_kinds != {"attention"}:
1349
+ am.decoder_kind = "other"
1350
+ else:
1351
+ am.decoder_kind = "dense"
1352
+
1353
+ if blocks:
1354
+ # aggregate by kind-label totals (not per-run) so the summary stays short
1355
+ totals: dict[str, int] = {}
1356
+ for b in blocks:
1357
+ label = _pretty_kind(b)
1358
+ totals[label] = totals.get(label, 0) + b.count
1359
+ am.layer_summary = " + ".join(f"{n}× {label}" for label, n in totals.items())
1360
+
1361
+ # attention-mask patterns per distinct layer type (offline, real masking_utils)
1362
+ if am.layer_types:
1363
+ try:
1364
+ from .masks import attention_pattern_grids
1365
+
1366
+ am.attn_patterns = attention_pattern_grids(tcfg, am.layer_types, seq=24)
1367
+ except Exception:
1368
+ am.attn_patterns = {}
1369
+
1370
+ return am
1371
+
1372
+
1373
+ _LT_SHORT = {
1374
+ "full_attention": "full",
1375
+ "sliding_attention": "sliding",
1376
+ "chunked_attention": "chunked",
1377
+ "compressed_sparse_attention": "compressed-sparse",
1378
+ "heavily_compressed_attention": "heavily-compressed",
1379
+ "linear_attention": "linear",
1380
+ }
1381
+
1382
+
1383
+ def _short_layer_type(name: str) -> str:
1384
+ if name in _LT_SHORT:
1385
+ return _LT_SHORT[name]
1386
+ n = name.lower()
1387
+ for key, short in _LT_SHORT.items():
1388
+ if n == key:
1389
+ return short
1390
+ return name.replace("_attention", "").replace("_", " ")
1391
+
1392
+
1393
+ def _pretty_kind(b: LayerBlock) -> str:
1394
+ # when the layer_class is actually a layer_type (sliding/full/...), show that label
1395
+ is_layer_type = b.layer_class.endswith("attention") or b.layer_class in _LT_SHORT
1396
+ if b.kind == "attention":
1397
+ base = _short_layer_type(b.layer_class) if is_layer_type else (b.attn_variant or "Attention")
1398
+ return base + (" +MoE" if b.mlp_kind == "moe" else "")
1399
+ if b.kind == "linear_attention":
1400
+ base = _short_layer_type(b.layer_class) if is_layer_type else "linear-attn"
1401
+ return base + (" +MoE" if b.mlp_kind == "moe" else "")
1402
+ return {"mamba": "Mamba", "recurrent": "Recurrent"}.get(b.kind, b.layer_class)
arch_svg/layout.py ADDED
@@ -0,0 +1,1479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn an ``ArchModel`` / ``ArchDiff`` into positioned boxes.
2
+
3
+ Layout is deterministic (stable ordering, integer coordinates) so the emitted SVG is
4
+ diff-able in git. The visual vocabulary is *standardized*: a given component kind always
5
+ maps to the same shape class and palette slot, mirroring the library's "standardize, don't
6
+ abstract" tenet -- an attention block looks the same across every model's diagram.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ from .engine import text_width
14
+ from .introspect import ArchModel
15
+ from .modular import ArchDiff, ClassChange
16
+
17
+
18
+ _PARENT_CACHE: dict = {} # model_type -> ArchModel of the modular parent (for diff comparison)
19
+
20
+
21
+ def _parent_arch(model_type):
22
+ if model_type not in _PARENT_CACHE:
23
+ try:
24
+ from .introspect import introspect
25
+
26
+ _PARENT_CACHE[model_type] = introspect(model_type)
27
+ except Exception:
28
+ _PARENT_CACHE[model_type] = None
29
+ return _PARENT_CACHE[model_type]
30
+
31
+
32
+ def _collect_submodule_names(nodes, out: set):
33
+ for nd in nodes:
34
+ out.add(nd["name"])
35
+ if nd.get("children"):
36
+ _collect_submodule_names(nd["children"], out)
37
+
38
+
39
+ # component kind -> css class used by render.py (the standardized vocabulary)
40
+ KIND_CLASS = {
41
+ "embedding": "c-embed",
42
+ "attention": "c-attn",
43
+ "mamba": "c-mamba",
44
+ "linear_attention": "c-linattn",
45
+ "recurrent": "c-recur",
46
+ "moe": "c-moe",
47
+ "mlp": "c-mlp",
48
+ "norm": "c-norm",
49
+ "head": "c-head",
50
+ "config": "c-config",
51
+ "rope": "c-rope",
52
+ "layer": "c-layer",
53
+ }
54
+
55
+ # diff change -> css class
56
+ CHANGE_CLASS = {"added": "ch-added", "overridden": "ch-over", "deleted": "ch-deleted", None: ""}
57
+
58
+
59
+ @dataclass
60
+ class Box:
61
+ x: int
62
+ y: int
63
+ w: int
64
+ h: int
65
+ label: str
66
+ cls: str = ""
67
+ sublabels: list[str] = field(default_factory=list)
68
+ badge: str | None = None # small corner tag, e.g. "×32"
69
+ ghost: bool = False
70
+ change: str | None = None # added | overridden | deleted | None
71
+ title: str | None = None # SVG <title> tooltip
72
+ shape: str = "rect" # rect | circle | container | op | io | cell | grid
73
+ small: bool = False # render with smaller fonts (inner ops, Q/K/V chips)
74
+ glyph: str | None = None # single char drawn centered (e.g. "+", "×")
75
+ grid: list[list[int]] | None = None # for shape == "grid": 0/1 attention-mask matrix
76
+ grid_split: int | None = None # column index dividing sliding K/V | compressed cache
77
+
78
+
79
+ @dataclass
80
+ class Arrow:
81
+ """A poly-line connector. ``points`` are (x, y) pairs; rendered with an arrowhead."""
82
+
83
+ points: list[tuple[int, int]]
84
+ cls: str = "flow" # flow | residual
85
+ label: str | None = None
86
+ dashed: bool = False
87
+
88
+
89
+ @dataclass
90
+ class Diagram:
91
+ width: int
92
+ height: int
93
+ boxes: list[Box]
94
+ title: str
95
+ subtitle: str = ""
96
+ legend: list[tuple[str, str]] = field(default_factory=list) # (css-class, text)
97
+ facts: list[tuple[str, str]] = field(default_factory=list) # side panel key/value
98
+ changes: list[tuple[str, str, str]] = field(default_factory=list) # (type, class, detail)
99
+ arrows: list[Arrow] = field(default_factory=list)
100
+ mode: str = "full"
101
+ spine: bool = True # draw the central connector spine
102
+
103
+
104
+ # geometry constants
105
+ W = 920
106
+ COL_X = 150 # left edge of the central stack
107
+ COL_W = 420 # width of the central stack
108
+ PAD = 28
109
+ ROW_H = 30
110
+
111
+
112
+ def _fmt_val(v) -> str:
113
+ """Compactly format a config value -- including sequence-valued fields.
114
+
115
+ Some (often hybrid) models carry per-layer lists for ``intermediate_size``,
116
+ ``head_dim`` or ``sliding_window``. We collapse those to ``[x]×N`` (all equal) or
117
+ ``[a … z] (N)`` rather than dumping the whole list. No per-model handling.
118
+ """
119
+ if v is None:
120
+ return "—"
121
+ if isinstance(v, (list, tuple)):
122
+ if not v:
123
+ return "[]"
124
+ if len(set(v)) == 1:
125
+ return f"[{_fmt_val(v[0])}]×{len(v)}"
126
+ return f"[{_fmt_val(v[0])} … {_fmt_val(v[-1])}] ({len(v)})"
127
+ if isinstance(v, bool):
128
+ return str(v)
129
+ if isinstance(v, int):
130
+ return f"{v:,}"
131
+ return str(v)
132
+
133
+
134
+ def _facts(am: ArchModel) -> list[tuple[str, str]]:
135
+ fmt = _fmt_val
136
+
137
+ mid = am.checkpoint or am.model
138
+ if len(mid) > 30:
139
+ mid = mid[:29] + "…"
140
+ rows = [
141
+ ("model id", mid),
142
+ ("model_type", am.model_type or "—"),
143
+ ("config", am.config_class or "—"),
144
+ ("decoder", am.decoder_kind),
145
+ ("layers", fmt(am.num_layers)),
146
+ ("hidden", fmt(am.hidden_size)),
147
+ ("intermediate", fmt(am.intermediate_size)),
148
+ ("heads / kv", f"{am.num_attention_heads} / {am.num_kv_heads}"),
149
+ ("attention", am.attn_variant or "—"),
150
+ ("norm", am.norm_type or "—"),
151
+ ("positional", am.positional or "—"),
152
+ ("vocab", fmt(am.vocab_size)),
153
+ ("max_pos", fmt(am.max_position_embeddings)),
154
+ ]
155
+ if am.sliding_window:
156
+ rows.append(("sliding_window", fmt(am.sliding_window)))
157
+ if am.is_moe:
158
+ rows.append(("experts (top-k)", f"{am.num_experts} (top-{am.experts_per_token})"))
159
+ rows.append(("tie_embeddings", str(am.tie_word_embeddings)))
160
+ if am.status != "ok":
161
+ rows.append(("status", am.status))
162
+ return rows
163
+
164
+
165
+ def _reflow(boxes: list[Box], start_y: int = PAD + 70, gap: int = 16) -> int:
166
+ """Re-stack boxes vertically in list order using their (possibly grown) heights.
167
+
168
+ Called after any box height changes so diff annotations never overlap neighbours.
169
+ Returns the y coordinate just below the last box.
170
+ """
171
+ y = start_y
172
+ for b in boxes:
173
+ b.y = y
174
+ y += b.h + gap
175
+ return y
176
+
177
+
178
+ def _build_compact(am: ArchModel) -> Diagram:
179
+ """Top-down view from CONFIG alone — used when the model can't be instantiated on meta (so
180
+ there's no module tree to decompose) or has no transformer block (e.g. conv backbones).
181
+ Shows input → embedding → the per-config layer blocks (collapsed, ×N) → final norm → head,
182
+ family-aware, with the layer-type schedule. Far cleaner than the old bottom-up stack."""
183
+ boxes: list[Box] = []
184
+ x, w = COL_X, COL_W
185
+ y = PAD + 78
186
+ modality = (
187
+ "vision"
188
+ if am.family == "image_classification"
189
+ else ("audio" if am.family == "audio_classification" else "text")
190
+ )
191
+ enc = am.view == "encoder"
192
+
193
+ def add(label, cls, subs=None, h=None, shape="op", badge=None):
194
+ nonlocal y
195
+ b = Box(
196
+ x,
197
+ y,
198
+ w,
199
+ h if h is not None else (30 + 15 * len(subs or [])),
200
+ label,
201
+ cls,
202
+ sublabels=subs or [],
203
+ shape=shape,
204
+ small=True,
205
+ badge=badge,
206
+ )
207
+ boxes.append(b)
208
+ y = y + b.h + 16
209
+ return b
210
+
211
+ # input + embedding (family-aware)
212
+ if modality == "vision":
213
+ add("input image", "c-io", ["pixel_values [1, 3, H, W]"], h=30, shape="io")
214
+ add("Patch Embedding", "c-embed", [f"→ [1, S, {am.hidden_size or '?'}]"], h=40)
215
+ elif modality == "audio":
216
+ add("input audio", "c-io", ["input_features"], h=30, shape="io")
217
+ add("Feature Projection", "c-embed", [f"→ [1, S, {am.hidden_size or '?'}]"], h=40)
218
+ else:
219
+ add("input_ids", "c-io", ["[1, S]"], h=30, shape="io")
220
+ add("Token Embedding", "c-embed", [f"[{am.vocab_size or '?'} × {am.hidden_size or '?'}]"], h=40)
221
+
222
+ # per-config layer blocks (collapsed ×N), coloured by kind
223
+ for b in am.layer_blocks:
224
+ inner = [f"{b.norm or 'Norm'} → {(b.attn_variant or b.kind.replace('_', '-'))}"]
225
+ inner.append(
226
+ f"{b.norm or 'Norm'} → {('MoE ' + str(am.num_experts) + 'E·top-' + str(am.experts_per_token)) if b.mlp_kind == 'moe' else 'MLP'}"
227
+ )
228
+ cls = KIND_CLASS["moe"] if b.mlp_kind == "moe" else KIND_CLASS.get(b.kind, KIND_CLASS["layer"])
229
+ add(b.layer_class, cls, inner, badge=f"×{b.count}", h=30 + 15 * len(inner))
230
+
231
+ add(f"Final {am.norm_type or 'Norm'}", "c-norm", h=28)
232
+ if enc:
233
+ add("Pool + Classifier head", "c-head", [f"→ class logits [1, {am.num_labels or '?'}]"], h=40)
234
+ else:
235
+ add("LM Head", "c-head", [f"→ logits [1, S, {am.vocab_size or '?'}]"], h=40)
236
+
237
+ facts = _facts(am)
238
+ height = max(y + PAD, PAD + 78 + len(facts) * 22 + 120)
239
+ legend = _STD_LEGEND
240
+ note = (
241
+ "config-only — built from config (weights not instantiable on meta)"
242
+ if am.status != "ok"
243
+ else "no transformer block (conv backbone) — shown from config"
244
+ )
245
+ return Diagram(
246
+ width=W,
247
+ height=height,
248
+ boxes=boxes,
249
+ title=am.model,
250
+ subtitle=f"⚠ {note} · {am.decoder_kind} · {am.layer_summary or ''}",
251
+ legend=legend,
252
+ facts=facts,
253
+ mode="full",
254
+ spine=True,
255
+ )
256
+
257
+
258
+ def _build_generic(am: ArchModel) -> Diagram:
259
+ """Universal top-down render from the real (collapsed) module tree — for any model that is
260
+ not a standard attention/SSM transformer: conv backbones (ResNet/ConvNeXt/MobileNet),
261
+ audio codecs (EnCodec/DAC), FFT mixers (FNet), detectors, pose/segmentation heads, ...
262
+ Every ``ModuleList``/``Sequential`` of identical blocks is collapsed to a ``×N`` section."""
263
+ boxes: list[Box] = []
264
+ GX, GW = 40, 850 # tree column (facts panel sits at width-288)
265
+ y = PAD + 78
266
+ PFX = (am.config_class or "").replace("Config", "")
267
+
268
+ def add(label, cls, subs=None, h=30, shape="op"):
269
+ nonlocal y
270
+ b = Box(GX, y, GW, h, label, cls, sublabels=subs or [], shape=shape, small=True)
271
+ boxes.append(b)
272
+ y = b.y + b.h + 14
273
+ return b
274
+
275
+ # input (family-aware) — what the model consumes
276
+ modality = (
277
+ "vision" if am.family == "image_classification" else ("audio" if am.family == "audio_classification" else None)
278
+ )
279
+ if "pixel_values" in (am.modal_inputs or []) or modality == "vision":
280
+ add("input image", "c-io", ["pixel_values [1, 3, H, W]"], shape="io")
281
+ elif "input_features" in (am.modal_inputs or []) or modality == "audio":
282
+ add("input audio", "c-io", ["input_values / input_features"], shape="io")
283
+ else:
284
+ add("inputs", "c-io", ["input_ids / pixel_values / input_values"], shape="io")
285
+
286
+ # the real module hierarchy
287
+ y = _emit_tree(boxes, am.generic_tree, GX, GW, y, gp=8, prefix=PFX)
288
+
289
+ facts = _facts(am)
290
+ height = max(y + PAD, PAD + 78 + len(facts) * 22 + 120)
291
+ legend = _STD_LEGEND
292
+ return Diagram(
293
+ width=WF,
294
+ height=height,
295
+ boxes=boxes,
296
+ title=am.model,
297
+ subtitle=f"{am.top_class or ''} · full module tree (no attention — shown structurally)",
298
+ legend=legend,
299
+ facts=facts,
300
+ mode="full",
301
+ spine=False,
302
+ )
303
+
304
+
305
+ def _struct_hint(node: dict | None) -> str | None:
306
+ """A one-line structural summary of a stage's children, e.g. '4× DacEncoderBlock'."""
307
+ if not node:
308
+ return None
309
+ reps = [c["cls"] for c in node.get("children", []) if "×" in c.get("cls", "")]
310
+ if reps:
311
+ return " · ".join(reps[:2])
312
+ kids = node.get("children", [])
313
+ if kids:
314
+ return f"{len(kids)} submodules"
315
+ return None
316
+
317
+
318
+ def _shape_str(s) -> str:
319
+ return "×".join(str(d) for d in s) if s else "?"
320
+
321
+
322
+ def _build_flow(am: ArchModel) -> Diagram:
323
+ """Clean forward data-flow: the real input tensor flows top-to-bottom through each stage (in
324
+ call order, with the shape it produces) to the output tensor. Shapes come from one dummy
325
+ forward pass (meta, or a tiny CPU model). Used for codecs / conv backbones / detectors where
326
+ the data path — not an attention block — is the story (DAC, EnCodec, BLT, ResNet, ...)."""
327
+ from .introspect import _classify_mod
328
+
329
+ boxes: list[Box] = []
330
+ arrows: list[Arrow] = []
331
+ GX, GW = 300, 580 # flow column (facts panel sits at width-288)
332
+ y = PAD + 80
333
+ PFX = (am.config_class or "").replace("Config", "")
334
+ tree_by = {n["name"]: n for n in am.generic_tree}
335
+
336
+ def place(label, cls, subs, shape="op"):
337
+ nonlocal y
338
+ b = Box(GX, y, GW, 34 + 15 * len(subs), label, cls, sublabels=subs, shape=shape, small=True)
339
+ boxes.append(b)
340
+ y = b.y + b.h
341
+ return b
342
+
343
+ # stage list + shapes: prefer the captured forward (true call order + shapes); otherwise fall
344
+ # back to the structural top-level stages (definition order, no shapes) — always memory-free.
345
+ shapes = {s["name"]: s.get("out_shape") for s in am.flow}
346
+ if am.flow:
347
+ stages = [(s["name"], s["cls"]) for s in am.flow]
348
+ has_shapes = True
349
+ else:
350
+ stages = [(n["name"], n["cls"]) for n in am.generic_tree]
351
+ has_shapes = False
352
+
353
+ # input box
354
+ if am.flow_input:
355
+ in_label, in_sub = am.flow_input, []
356
+ else:
357
+ mods = am.modal_inputs or []
358
+ if "pixel_values" in mods or am.family == "image_classification":
359
+ in_label, in_sub = "input image", ["pixel_values [1, 3, H, W]"]
360
+ elif "input_features" in mods or am.family == "audio_classification":
361
+ in_label, in_sub = "input audio", ["input_values / input_features"]
362
+ else:
363
+ in_label, in_sub = "inputs", ["input_ids / pixel_values / input_values"]
364
+ prev = place(in_label, "c-io", in_sub, shape="io")
365
+
366
+ for name, cls in stages:
367
+ y += 24 # arrow gap
368
+ kind = _classify_mod(name, cls)
369
+ node = tree_by.get(name)
370
+ shp = shapes.get(name)
371
+ shape_lbl = f" → [{_shape_str(shp)}]" if shp else ""
372
+ hdr = f"{name} · {_short_cls(cls, PFX)}{shape_lbl}"
373
+ sec_top = y
374
+ if node and node.get("children"): # expose the stage's internal structure (×N blocks)
375
+ y += 28 # header strip
376
+ y = _emit_tree(boxes, node["children"], GX + 10, GW - 20, y, gp=7, prefix=PFX)
377
+ anchor = Box(GX, sec_top, GW, y - sec_top + 6, hdr, _NODE_CLS.get(kind, "c-layer"), shape="section")
378
+ boxes.append(anchor)
379
+ y += 6
380
+ else:
381
+ anchor = place(hdr, _NODE_CLS.get(kind, "c-layer"), [])
382
+ arrows.append(Arrow([(prev.x + prev.w / 2, prev.y + prev.h), (anchor.x + anchor.w / 2, sec_top)], cls="flow"))
383
+ prev = anchor
384
+ if am.flow_output:
385
+ y += 24
386
+ b = place(f"output [{_shape_str(am.flow_output)}]", "c-io", [], shape="io")
387
+ arrows.append(Arrow([(prev.x + prev.w / 2, prev.y + prev.h), (b.x + b.w / 2, b.y)], cls="flow"))
388
+
389
+ facts = _facts(am)
390
+ height = max(y + PAD, PAD + 80 + len(facts) * 22 + 120)
391
+ sub_note = "forward data flow (shapes from a dummy input pass)" if has_shapes else "staged data flow (structure)"
392
+ return Diagram(
393
+ width=WF,
394
+ height=height,
395
+ boxes=boxes,
396
+ arrows=arrows,
397
+ title=am.model,
398
+ subtitle=f"{am.top_class or ''} · {sub_note}",
399
+ legend=_STD_LEGEND,
400
+ facts=facts,
401
+ mode="full",
402
+ spine=False,
403
+ )
404
+
405
+
406
+ # --------------------------------------------------------------- detailed full (Raschka)
407
+
408
+ # geometry for the detailed view (wider canvas: schedule strip · column · masks · facts)
409
+ WF = 1200
410
+ STRIP_X = 40 # layer-schedule strip
411
+ DCOL_X = 320 # left edge of central op column
412
+ DCOL_W = 330 # width of central op column
413
+ RAIL_X = 286 # residual rail x (left of the column)
414
+ MASK_X = 700 # attention-mask grids column
415
+ SEQ = 12 # dummy batch/seq for displayed tensor shapes: [1, 12]
416
+
417
+ # layer_type -> color class (see render._STYLE)
418
+ _LT_CLASS = {
419
+ "full_attention": "c-lt-full",
420
+ "sliding_attention": "c-lt-sliding",
421
+ "chunked_attention": "c-lt-chunked",
422
+ "compressed_sparse_attention": "c-lt-compressed",
423
+ "heavily_compressed_attention": "c-lt-heavy",
424
+ "linear_attention": "c-lt-linear",
425
+ }
426
+
427
+
428
+ def _lt_class(t: str) -> str:
429
+ if t in _LT_CLASS:
430
+ return _LT_CLASS[t]
431
+ n = t.lower()
432
+ if "linear" in n or "delta" in n or "gated" in n:
433
+ return "c-lt-linear"
434
+ if "mamba" in n or "ssm" in n:
435
+ return "c-lt-mamba"
436
+ if "sliding" in n:
437
+ return "c-lt-sliding"
438
+ if "chunk" in n:
439
+ return "c-lt-chunked"
440
+ if "compress" in n:
441
+ return "c-lt-compressed"
442
+ return "c-lt-full"
443
+
444
+
445
+ def _dim(p) -> str:
446
+ if p is None:
447
+ return ""
448
+ return f"[{p.in_f or '?'}→{p.out_f or '?'}]"
449
+
450
+
451
+ # module-tree node kind -> css class (for the recursive decomposition)
452
+ # standardized node kind -> css class, shared by every view (attention internals, SSM mixer,
453
+ # MoE, and the generic conv/codec/FFT module tree) so a given component is always the same colour.
454
+ _NODE_CLS = {
455
+ "embedding": "c-embed",
456
+ "linear": "c-proj",
457
+ "conv": "c-conv",
458
+ "norm": "c-norm",
459
+ "act": "c-act",
460
+ "pool": "c-pool",
461
+ "dropout": "c-sub",
462
+ "rope": "c-rope",
463
+ "attention": "c-attn",
464
+ "mamba": "c-mamba",
465
+ "recurrent": "c-recur",
466
+ "quantizer": "c-quant",
467
+ "compressor": "c-lt-compressed",
468
+ "indexer": "c-lt-heavy",
469
+ "router": "c-moe",
470
+ "experts": "c-moe",
471
+ "head": "c-head",
472
+ "other": "c-sub",
473
+ }
474
+
475
+ # one standardized legend shared by all full-view layouts (only the colours that can appear)
476
+ _STD_LEGEND = [
477
+ ("c-embed", "embedding"),
478
+ ("c-attn", "attention"),
479
+ ("c-mamba", "Mamba / SSM"),
480
+ ("c-moe", "MoE / experts"),
481
+ ("c-proj", "linear / proj"),
482
+ ("c-conv", "convolution"),
483
+ ("c-norm", "normalization"),
484
+ ("c-act", "activation"),
485
+ ("c-pool", "pooling"),
486
+ ("c-head", "head"),
487
+ ]
488
+
489
+
490
+ def _short_cls(cls: str, prefix: str = "") -> str:
491
+ """Drop the model prefix and the redundant 'Activation' suffix: DeepseekV4RMSNorm→RMSNorm,
492
+ SiLUActivation→SiLU."""
493
+ s = cls
494
+ if prefix and s.startswith(prefix):
495
+ s = s[len(prefix) :]
496
+ if s.endswith("Activation") and len(s) > 10:
497
+ s = s[: -len("Activation")]
498
+ return s or cls
499
+
500
+
501
+ def _leaf_label(nd, prefix: str) -> str:
502
+ """One compact line per leaf: name + dims; class shown only when it isn't a plain Linear."""
503
+ sc = _short_cls(nd["cls"], prefix)
504
+ dim = nd.get("dim")
505
+ if sc == "Linear": # 'Linear' is implied by the [in→out] arrow
506
+ return f"{nd['name']} {dim}" if dim else nd["name"]
507
+ return f"{nd['name']} {sc}" + (f" {dim}" if dim else "")
508
+
509
+
510
+ def _emit_tree(boxes, nodes, x, w, y, gp=7, depth=0, prefix=""):
511
+ """Recursively lay out a module tree: leaves are single-line chips, branches become titled
512
+ sub-sections containing their own children. Returns the y below the laid-out tree."""
513
+ i = 0
514
+ while i < len(nodes):
515
+ nd = nodes[i]
516
+ if nd.get("children"): # a branch -> nested titled sub-section
517
+ sub_top = y
518
+ y += 25 # clear the white header strip
519
+ y = _emit_tree(boxes, nd["children"], x + 8, w - 16, y, gp, depth + 1, prefix)
520
+ label = f"{nd['name']} · {nd['cls']}" + (f" ({nd['dim']})" if nd.get("dim") else "")
521
+ boxes.append(
522
+ Box(x, sub_top, w, y - sub_top + 6, label, _NODE_CLS.get(nd["kind"], "c-sub"), shape="section")
523
+ )
524
+ y += 6 + gp
525
+ i += 1
526
+ else: # a run of leaf chips — content-sized: columns chosen by width, 2-line when needed
527
+ run = []
528
+ while i < len(nodes) and not nodes[i].get("children"):
529
+ run.append(nodes[i])
530
+ i += 1
531
+ # how many columns fit? each chip wants ~its text width; clamp 1..3 by available w
532
+ widest = max((text_width(_leaf_label(nd2, prefix), 12.5) for nd2 in run), default=80) + 18
533
+ per = max(1, min(3, int(w // max(widest, 120))))
534
+ for r in range(0, len(run), per):
535
+ rown = run[r : r + per]
536
+ cw = (w - (len(rown) - 1) * gp) / len(rown)
537
+ # a chip is 2 lines (name / class-dim) when the one-line label doesn't fit its cell
538
+ two = [text_width(_leaf_label(nd2, prefix), 12.5) > cw - 12 for nd2 in rown]
539
+ rh = 34 if any(two) else 22
540
+ for j, nd2 in enumerate(rown):
541
+ ttl = f"{nd2['name']}: {nd2['cls']}" + (f" {nd2['dim']}" if nd2.get("dim") else "")
542
+ if two[j]: # split onto two lines instead of ellipsizing the dims away
543
+ sc = _short_cls(nd2["cls"], prefix)
544
+ sub = (
545
+ (sc + (f" {nd2['dim']}" if nd2.get("dim") else ""))
546
+ if sc != "Linear"
547
+ else (nd2.get("dim") or "")
548
+ )
549
+ boxes.append(
550
+ Box(
551
+ int(x + j * (cw + gp)),
552
+ y,
553
+ int(cw),
554
+ rh,
555
+ nd2["name"],
556
+ _NODE_CLS.get(nd2["kind"], "c-sub"),
557
+ sublabels=[sub] if sub else [],
558
+ shape="op",
559
+ small=True,
560
+ title=ttl,
561
+ )
562
+ )
563
+ else:
564
+ boxes.append(
565
+ Box(
566
+ int(x + j * (cw + gp)),
567
+ y,
568
+ int(cw),
569
+ rh,
570
+ _leaf_label(nd2, prefix),
571
+ _NODE_CLS.get(nd2["kind"], "c-sub"),
572
+ shape="op",
573
+ small=True,
574
+ title=ttl,
575
+ )
576
+ )
577
+ y += rh + gp
578
+ return y
579
+
580
+
581
+ def _node_sig(node):
582
+ """Structural signature of a module node (class + dims + recursive children)."""
583
+ return (node.get("cls"), node.get("dim"), tuple(_node_sig(c) for c in (node.get("children") or [])))
584
+
585
+
586
+ def _emit_merged(boxes, variants, types, x, w, y, gp=7, prefix=""):
587
+ """Render N aligned module trees, sharing identical sub-trees and branching side-by-side
588
+ ONLY where they actually differ. For DeepSeek-V4 attention this shows q/k/v/o once and the
589
+ differing `compressor` (HCA vs CSA+Indexer) side by side -- the real subtlety."""
590
+ base = variants[0]
591
+ names = [n["name"] for n in base]
592
+ if any([n["name"] for n in v] != names for v in variants): # misaligned -> full side-by-side
593
+ nn = len(variants)
594
+ cw = (w - (nn - 1) * 18) / nn
595
+ mb = y
596
+ for k, v in enumerate(variants):
597
+ mb = max(mb, _emit_tree(boxes, v, int(x + k * (cw + 18)), int(cw), y, gp, prefix=prefix))
598
+ return mb
599
+ for idx in range(len(names)):
600
+ nodes = [v[idx] for v in variants]
601
+ if all(_node_sig(nd) == _node_sig(nodes[0]) for nd in nodes): # identical -> render once
602
+ y = _emit_tree(boxes, [nodes[0]], x, w, y, gp, prefix=prefix)
603
+ else: # diverges -> side by side, tagged by layer type
604
+ nn = len(nodes)
605
+ cw = (w - (nn - 1) * gp) / nn
606
+ top = y
607
+ mb = y
608
+ for k, nd in enumerate(nodes):
609
+ cx = int(x + k * (cw + gp))
610
+ boxes.append(Box(cx, top, int(cw), 18, f"▼ {types[k]}", "c-sub", shape="op", small=True))
611
+ mb = max(mb, _emit_tree(boxes, [nd], cx, int(cw), top + 22, gp, prefix=prefix))
612
+ y = mb
613
+ return y
614
+
615
+
616
+ def _short_proj(name: str) -> str:
617
+ return name.replace("_proj", "").replace("_with_mqa", "·mqa").replace("kv_a", "kv↓").replace("kv_b", "kv↑")
618
+
619
+
620
+ def _chips(boxes, items, x, w, y, per_row, cls="c-sub", gp=8, h=34):
621
+ """Place `items` = list of (label, sublabel) as a grid of small boxes; return new y."""
622
+ rows = [items[i : i + per_row] for i in range(0, len(items), per_row)]
623
+ for r in rows:
624
+ cw = (w - (len(r) - 1) * gp) / len(r)
625
+ for j, (lb, sub) in enumerate(r):
626
+ boxes.append(
627
+ Box(
628
+ int(x + j * (cw + gp)),
629
+ y,
630
+ int(cw),
631
+ h,
632
+ lb,
633
+ cls,
634
+ sublabels=([sub] if sub else []),
635
+ shape="op",
636
+ small=True,
637
+ )
638
+ )
639
+ y += h + gp
640
+ return y
641
+
642
+
643
+ def _shape3(h) -> str:
644
+ return f"[1, {SEQ}, {h or '?'}]"
645
+
646
+
647
+ def build_full(am: ArchModel) -> Diagram:
648
+ """Dispatch: multimodal models get the multi-tower view, everything else the LLM column."""
649
+ if am.is_multimodal and am.towers and am.block is not None:
650
+ return build_multimodal(am)
651
+ # multi-stage pipelines (BLT, codecs, ...) read best as a data flow even when they use attention
652
+ if am.is_pipeline and am.generic_tree:
653
+ return _build_flow(am)
654
+ return _build_column(am)
655
+
656
+
657
+ def _build_column(am: ArchModel) -> Diagram:
658
+ """Detailed, Raschka-style view, read **top-to-bottom like the code**: input_ids at the
659
+ top flow down through the embedding, the expanded transformer block (pre-norm →
660
+ self-attention with RoPE/Q/K/V/O and real dims → ⊕ residual → pre-norm → MLP or Sparse
661
+ MoE → ⊕ residual), final norm, LM head and softmax to the logits at the bottom. A
662
+ left-side strip shows the per-layer schedule (config.layer_types); attention-mask grids
663
+ for each distinct layer type are shown on the right. Tensor shapes use a dummy [1, 12]
664
+ input. Falls back to the compact view when block internals can't be extracted."""
665
+ if am.block is None or (am.block.attention is None and am.block.mixer is None and am.block.mlp is None):
666
+ # a real module tree → staged data-flow view (with shapes where a meta forward succeeded);
667
+ # only models that couldn't be built on meta fall back to the config-only schematic.
668
+ return _build_flow(am) if am.generic_tree else _build_compact(am)
669
+
670
+ blk = am.block
671
+ blk_is_ssm = blk.attention is None and blk.mixer is not None # Mamba/SSM token-mixer block
672
+ GAP = 14
673
+ boxes: list[Box] = []
674
+ arrows: list[Arrow] = []
675
+ y = PAD + 80
676
+ H = am.hidden_size
677
+ PFX = (am.config_class or "").replace("Config", "") # model prefix to strip from class names
678
+
679
+ def add(label, cls, subs=None, h=None, shape="op", glyph=None, w=DCOL_W, x=DCOL_X, title=None, grid=None):
680
+ nonlocal y
681
+ b = Box(
682
+ x,
683
+ y,
684
+ w,
685
+ h if h is not None else (30 + 15 * len(subs or [])),
686
+ label,
687
+ cls,
688
+ sublabels=subs or [],
689
+ shape=shape,
690
+ small=True,
691
+ glyph=glyph,
692
+ title=title,
693
+ grid=grid,
694
+ )
695
+ boxes.append(b)
696
+ y = y + b.h + GAP
697
+ return b
698
+
699
+ # which kind of model -> what the input / embedding / head look like
700
+ modality = (
701
+ "vision"
702
+ if am.family == "image_classification"
703
+ else ("audio" if am.family == "audio_classification" else "text")
704
+ )
705
+ enc = am.view == "encoder"
706
+ seq = len(am.tokens or [1] * 6) or 6
707
+
708
+ def _shapeN(h):
709
+ return f"[1, {seq}, {h or '?'}]"
710
+
711
+ # ---------- top: input ----------
712
+ if modality == "vision":
713
+ add("input image", "c-io", ["pixel_values [1, 3, H, W]"], h=32, shape="io")
714
+ add("Patch Embedding", "c-embed", [f"conv patches → {_shapeN(H)}", "+ position embeddings"], h=60)
715
+ elif modality == "audio":
716
+ add("input audio", "c-io", ["input_features [1, n_mels, T]"], h=32, shape="io")
717
+ add("Feature Projection", "c-embed", [f"conv/linear → {_shapeN(H)}", "+ position embeddings"], h=60)
718
+ else:
719
+ toks = am.tokens or ["Hey", ",", "␣how", "␣are", "␣you", "?"]
720
+ seq = len(toks)
721
+ add("input_ids", "c-io", [f'tokenize("Hey, how are you?") → [1, {seq}]'], h=32, shape="io")
722
+ tgp = 5
723
+ tw = (DCOL_W - (seq - 1) * tgp) / seq
724
+ for j, t in enumerate(toks):
725
+ label = t if len(t) <= 6 else t[:6] + "…"
726
+ boxes.append(
727
+ Box(
728
+ int(DCOL_X + j * (tw + tgp)),
729
+ y,
730
+ max(14, int(tw)),
731
+ 26,
732
+ label,
733
+ "c-sub",
734
+ sublabels=[str(j)],
735
+ shape="op",
736
+ small=True,
737
+ title=f"token {j}: {t!r}",
738
+ )
739
+ )
740
+ y += 26 + 18
741
+ pos_note = [f"+ {am.positional} positions"] if (am.positional and am.positional not in ("RoPE", "n/a")) else []
742
+ add(
743
+ "Token Embedding",
744
+ "c-embed",
745
+ [f"weight [{am.vocab_size or '?'} × {H or '?'}] → {_shapeN(H)}"] + pos_note,
746
+ h=30 + 15 * (1 + len(pos_note)),
747
+ )
748
+ body_top = boxes[-1].y # the embedding box top — start of the "<Model>" body wrapper
749
+
750
+ # inner-chip geometry shared by the attention / MoE / MLP sections
751
+ IX, IW, GP = DCOL_X + 12, DCOL_W - 24, 8
752
+
753
+ def section(label, cls, body, badge=None):
754
+ """Draw a filled section panel containing inner chips produced by ``body(y)``."""
755
+ nonlocal y
756
+ sec_top = y
757
+ y += 26 # header room
758
+ y = body(y)
759
+ sec = Box(DCOL_X, sec_top, DCOL_W, y - sec_top + 6, label, cls, shape="section", badge=badge)
760
+ boxes.append(sec)
761
+ y = y + 6 + GAP
762
+ return sec
763
+
764
+ # ---------- transformer block ----------
765
+ y += 22 # headroom for the dashed container's "decoder block ×N" label
766
+ block_top = y
767
+ entry_y = block_top # residual stream entry
768
+ add(
769
+ f"{blk.pre_attn_norm or 'Norm'}",
770
+ "c-norm",
771
+ [("pre-mixer " if blk_is_ssm else "pre-attention ") + _shapeN(H)],
772
+ h=28,
773
+ )
774
+
775
+ # ----- self-attention: ONE section; shared sub-modules rendered once, and only the parts
776
+ # ----- that actually differ across layer types (e.g. the compressor) branch side by side.
777
+ a = blk.attention
778
+ from .introspect import _short_layer_type
779
+
780
+ attn_right = DCOL_X + DCOL_W
781
+ variants = am.attention_variants
782
+ if variants:
783
+ n = min(len(variants), 3)
784
+ types = [_short_layer_type(v["type"]) for v in variants[:n]]
785
+ sc_by_type = {sc["type"]: sc for sc in am.sparse_components}
786
+ diverges = n > 1
787
+ Wsec = DCOL_W if not diverges else max(DCOL_W, n * 300)
788
+ gqa = ""
789
+ if a and a.n_heads and a.n_kv and a.n_kv < a.n_heads:
790
+ gqa = f" · GQA {a.n_heads}:{a.n_kv}"
791
+ elif a and a.n_kv == 1:
792
+ gqa = " · MQA"
793
+ vtop = y
794
+ vy = vtop + 26
795
+ if n == 1:
796
+ vy = _emit_tree(boxes, variants[0]["children"], DCOL_X + 10, Wsec - 20, vy, gp=GP)
797
+ else:
798
+ vy = _emit_merged(boxes, [v["children"] for v in variants[:n]], types, DCOL_X + 10, Wsec - 20, vy, gp=GP)
799
+ # core op footer (shared): SDPA for attention, selective scan for SSM/Mamba mixers
800
+ is_mixer = a is None and blk.mixer is not None
801
+ boxes.append(
802
+ Box(
803
+ DCOL_X + 10,
804
+ vy,
805
+ Wsec - 20,
806
+ 30,
807
+ "selective state-space scan (SSM)" if is_mixer else "scaled dot-product attention",
808
+ "c-sub",
809
+ sublabels=([f"{a.n_heads or '?'} heads · head_dim {a.head_dim or '?'}{gqa}"] if a else []),
810
+ shape="op",
811
+ small=True,
812
+ )
813
+ )
814
+ vy += 30 + GP
815
+ # masks: one per layer type, side by side (this is where the compressors differ in effect)
816
+ mask_items = [
817
+ (v["type"], (sc_by_type.get(v["type"], {}).get("mask") or am.attn_patterns.get(v["type"])))
818
+ for v in variants[:n]
819
+ ]
820
+ mask_items = [(t, g) for t, g in mask_items if g]
821
+ if not mask_items: # encoders attend bidirectionally; decoders are causal
822
+ sq = 14
823
+ if enc:
824
+ grid = [[1] * sq for _ in range(sq)]
825
+ mask_items = [("bidirectional", grid)]
826
+ else:
827
+ grid = [[1 if j <= i else 0 for j in range(sq)] for i in range(sq)]
828
+ mask_items = [("causal", grid)]
829
+ if mask_items:
830
+ mw = (Wsec - 20 - (len(mask_items) - 1) * 16) / len(mask_items)
831
+ mtop = vy + 4
832
+ mb = mtop
833
+ for k, (lt, grid) in enumerate(mask_items):
834
+ sc = sc_by_type.get(lt)
835
+ cols = len(grid[0])
836
+ cellp = max(3, min(10, int((mw - 6) // cols)))
837
+ gx = int(DCOL_X + 10 + k * (mw + 16) + (mw - cellp * cols) / 2)
838
+ cap = (
839
+ f"{_short_layer_type(lt)} mask m={sc['display_m']}·{sc['n_comp']}c"
840
+ if sc
841
+ else f"{_short_layer_type(lt)} mask"
842
+ )
843
+ boxes.append(
844
+ Box(
845
+ gx,
846
+ mtop + 6,
847
+ cellp * cols,
848
+ cellp * len(grid),
849
+ cap,
850
+ _lt_class(lt),
851
+ shape="grid",
852
+ grid=grid,
853
+ grid_split=(sc.get("mask_split") if sc else None),
854
+ title=f"{lt} attention mask (q↓ × k→)",
855
+ )
856
+ )
857
+ mb = max(mb, mtop + 6 + cellp * len(grid))
858
+ vy = mb
859
+ note = ""
860
+ if diverges:
861
+ sigs = [tuple(_node_sig(c) for c in v["children"]) for v in variants[:n]]
862
+ struct_diff = any(s != sigs[0] for s in sigs)
863
+ note = (
864
+ f" ({n} layer types — modules differ ▼)"
865
+ if struct_diff
866
+ else f" ({n} layer types — same modules, mask differs)"
867
+ )
868
+ if is_mixer:
869
+ hdr = f"Token Mixer (SSM) · {blk.mixer.cls}{note}"
870
+ else:
871
+ hdr = f"Self-Attention · {a.cls if a else 'attention'}{note}"
872
+ boxes.append(Box(DCOL_X, vtop, Wsec, vy - vtop + 6, hdr, "c-attn", shape="section"))
873
+ attn_right = DCOL_X + Wsec
874
+ y = vy + 6 + GAP
875
+ if a and a.rope:
876
+ ry = vtop + 30
877
+ rope_label = f"RoPE θ={int(am.rope_theta):,}" if am.rope_theta else "RoPE"
878
+ rnode = Box(
879
+ DCOL_X - 168,
880
+ ry - 4,
881
+ 132,
882
+ 40,
883
+ rope_label,
884
+ "c-rope",
885
+ sublabels=["rotary positions → Q, K"],
886
+ shape="op",
887
+ small=True,
888
+ )
889
+ boxes.append(rnode)
890
+ arrows.append(Arrow([(rnode.x + rnode.w, ry + 14), (DCOL_X, ry + 14)], cls="rope"))
891
+ else:
892
+ add("Token Mixing (linear-attention / SSM)", "c-linattn", [blk.layer_class], h=44)
893
+ plus_attn = add("", "c-add", h=26, shape="circle", glyph="+", title="residual add")
894
+
895
+ # ----- MLP / Sparse MoE: FULL recursive decomposition, variants side by side -----
896
+ # (Mamba/SSM blocks are norm → mixer → residual with NO feed-forward, so skip the FFN entirely)
897
+ mm = blk.mlp
898
+ mvars = am.mlp_variants if len(am.mlp_variants) > 1 else None
899
+ has_mlp = bool(mm or (am.mlp_tree and am.mlp_tree.get("children")) or mvars)
900
+ if has_mlp:
901
+ add(f"{blk.post_attn_norm or 'Norm'}", "c-norm", ["pre-FFN " + _shapeN(H)], h=28)
902
+ if mvars:
903
+ # distinct FFN types (e.g. DeepSeek-V4 moe / hash_moe): shared sub-modules once, only
904
+ # the differing part (the router) branches side by side.
905
+ nm = min(len(mvars), 3)
906
+ Wm = max(DCOL_W, nm * 300)
907
+ mtypes = [mv["type"] for mv in mvars[:nm]]
908
+ vtop = y
909
+ my = vtop + 26
910
+ my = _emit_merged(boxes, [mv["children"] for mv in mvars[:nm]], mtypes, DCOL_X + 10, Wm - 20, my, gp=GP)
911
+ cls0 = mvars[0]["cls"]
912
+ boxes.append(
913
+ Box(
914
+ DCOL_X,
915
+ vtop,
916
+ Wm,
917
+ my - vtop + 6,
918
+ f"Sparse MoE · {cls0} ({nm} FFN types ▼)",
919
+ "c-moe",
920
+ shape="section",
921
+ badge="MoE",
922
+ )
923
+ )
924
+ attn_right = max(attn_right, DCOL_X + Wm)
925
+ y = my + 6 + GAP
926
+ elif am.mlp_tree and am.mlp_tree.get("children"):
927
+ cls = am.mlp_tree["cls"]
928
+ is_moe = bool(mm and mm.is_moe)
929
+ section(
930
+ ("Sparse MoE · " if is_moe else "MLP · ") + cls,
931
+ "c-moe" if is_moe else "c-mlp",
932
+ lambda yy: _emit_tree(boxes, am.mlp_tree["children"], IX, IW, yy, gp=GP),
933
+ badge="MoE" if is_moe else None,
934
+ )
935
+ elif mm:
936
+
937
+ def mlp_body(y):
938
+ items = []
939
+ if mm.gate:
940
+ items.append(("gate_proj", _dim(mm.gate)))
941
+ if mm.up:
942
+ items.append(("up_proj", _dim(mm.up)))
943
+ per = len(items) if items else 1
944
+ if items:
945
+ y = _chips(boxes, items, IX, IW, y, per, gp=GP, h=32)
946
+ boxes.append(
947
+ Box(IX, y, IW, 28, f"{mm.act}(gate) ⊙ up" if mm.gate else mm.act, "c-sub", shape="op", small=True)
948
+ )
949
+ y += 28 + GP
950
+ boxes.append(Box(IX, y, IW, 32, f"down_proj {_dim(mm.down)}", "c-sub", shape="op", small=True))
951
+ y += 32
952
+ return y
953
+
954
+ section(f"MLP · {mm.cls}", "c-mlp", mlp_body)
955
+ elif has_mlp:
956
+ add("MLP", "c-mlp", h=30)
957
+ # second residual add only exists when there is an FFN; SSM blocks have a single residual
958
+ plus_mlp = add("", "c-add", h=26, shape="circle", glyph="+", title="residual add") if has_mlp else plus_attn
959
+ block_bot = y - GAP
960
+
961
+ container_w = max(DCOL_W, attn_right - DCOL_X) + 52
962
+ container = Box(
963
+ DCOL_X - 26,
964
+ block_top - 22,
965
+ container_w,
966
+ block_bot - block_top + 30,
967
+ blk.layer_class, # the real layer class, e.g. LlamaDecoderLayer / ASTLayer
968
+ "c-block",
969
+ shape="container",
970
+ badge=f"× {am.num_layers or '?'}",
971
+ title=f"{am.num_layers} × {blk.layer_class}",
972
+ )
973
+
974
+ # ---------- bottom: final norm, then the task head (depends on family) ----------
975
+ add(f"Final {am.norm_type or 'Norm'}", "c-norm", [_shapeN(H)], h=28)
976
+ body_bot = boxes[-1].y + boxes[-1].h # end of the "<Model>" body (everything above = base model)
977
+ head_title = am.head_class # e.g. ViTForImageClassification / GemmaForCausalLM (the For wrapper)
978
+ if enc: # encoder / classifier head
979
+ nl = am.num_labels or "?"
980
+ add("Pool (CLS / mean)", "c-head", [f"{_shapeN(H)} → [1, {H or '?'}]"], h=40, title=head_title)
981
+ add("Classifier head", "c-head", [f"Linear [{H or '?'}→{nl}]"], h=40, title=head_title)
982
+ add("Softmax", "c-soft", h=26)
983
+ add("class logits", "c-io", [f"[1, {nl}] ({nl} classes)"], h=30, shape="io")
984
+ else: # decoder LM head
985
+ head_sub = [f"Linear [{H or '?'}→{am.vocab_size or '?'}]" + (" · tied" if am.tie_word_embeddings else "")]
986
+ add("LM Head", "c-head", head_sub, h=42, title=head_title)
987
+ add("Softmax", "c-soft", h=26)
988
+ add("logits", "c-io", [f"[1, {seq}, {am.vocab_size or '?'}]"], h=30, shape="io")
989
+
990
+ # ---------- wrapper hierarchy: base <Model> body and <ForXxx> task head, STACKED (adjacent,
991
+ # not nested) so the two labels never overlap ----------
992
+ ww = (attn_right - DCOL_X) + 68
993
+ if am.top_class:
994
+ boxes.append(
995
+ Box(
996
+ DCOL_X - 34,
997
+ body_top - 14,
998
+ ww,
999
+ body_bot - body_top + 20,
1000
+ f"{am.top_class} · base model",
1001
+ "c-block",
1002
+ shape="container",
1003
+ )
1004
+ )
1005
+ if head_title:
1006
+ boxes.append(
1007
+ Box(
1008
+ DCOL_X - 34,
1009
+ body_bot + 6,
1010
+ ww,
1011
+ (y - GAP) - (body_bot + 6) + 8,
1012
+ f"{head_title} · task head",
1013
+ "c-block",
1014
+ shape="container",
1015
+ )
1016
+ )
1017
+
1018
+ # ---------- residual skip arrows (flow downward) ----------
1019
+ def midy(b):
1020
+ return b.y + b.h // 2
1021
+
1022
+ arrows.append(
1023
+ Arrow(
1024
+ [(DCOL_X, entry_y), (RAIL_X, entry_y), (RAIL_X, midy(plus_attn)), (plus_attn.x, midy(plus_attn))],
1025
+ cls="residual",
1026
+ dashed=True,
1027
+ )
1028
+ )
1029
+ if has_mlp:
1030
+ arrows.append(
1031
+ Arrow(
1032
+ [
1033
+ (plus_attn.x, midy(plus_attn)),
1034
+ (RAIL_X - 16, midy(plus_attn)),
1035
+ (RAIL_X - 16, midy(plus_mlp)),
1036
+ (plus_mlp.x, midy(plus_mlp)),
1037
+ ],
1038
+ cls="residual",
1039
+ dashed=True,
1040
+ )
1041
+ )
1042
+
1043
+ boxes.insert(0, container)
1044
+
1045
+ # ---------- layer-schedule strip (config.layer_types) — one labelled cell per layer ----
1046
+ legend = [("c-attn", "self-attention")]
1047
+ strip_bottom = block_bot
1048
+ # only show the per-layer schedule when layers actually differ (≥2 distinct types)
1049
+ if am.layer_types and len(set(am.layer_types)) >= 2:
1050
+ from .introspect import _short_layer_type
1051
+
1052
+ n = len(am.layer_types)
1053
+ SW = 46 # strip width
1054
+ cell_h = max(15, (block_bot - block_top) / n) # at least 15px so the index fits
1055
+ boxes.append(Box(STRIP_X - 4, block_top - 24, SW + 12, 16, "layers (idx)", "c-block", shape="container"))
1056
+ for i, lt in enumerate(am.layer_types):
1057
+ cy = int(block_top + i * cell_h)
1058
+ boxes.append(
1059
+ Box(
1060
+ STRIP_X,
1061
+ cy,
1062
+ SW,
1063
+ max(13, int(cell_h) - 2),
1064
+ str(i),
1065
+ _lt_class(lt),
1066
+ shape="cell",
1067
+ title=f"layer {i}: {lt}",
1068
+ )
1069
+ )
1070
+ strip_bottom = int(block_top + n * cell_h)
1071
+ seen = []
1072
+ for lt in am.layer_types:
1073
+ if lt not in seen:
1074
+ seen.append(lt)
1075
+ for lt in seen:
1076
+ legend.append((_lt_class(lt), _short_layer_type(lt)))
1077
+
1078
+ # (attention-mask grids are now drawn INSIDE each variant card, so no separate column)
1079
+ gy = 0
1080
+ legend += [
1081
+ ("c-proj", "linear / proj"),
1082
+ ("c-moe", "MoE / experts") if am.is_moe else ("c-mlp", "MLP / feed-forward"),
1083
+ ("c-rope", "RoPE"),
1084
+ ("c-norm", "normalization"),
1085
+ ("residual", "residual / skip"),
1086
+ ]
1087
+
1088
+ facts = _facts(am)
1089
+ mask_bottom = gy
1090
+ height = max(y + PAD, strip_bottom + PAD, mask_bottom + PAD, PAD + 80 + len(facts) * 22 + len(legend) * 18 + 120)
1091
+ sub = f"{am.decoder_kind} · {am.layer_summary or ''}"
1092
+ width = max(WF, attn_right + 320) # widen for side-by-side attention variants + facts panel
1093
+ return Diagram(
1094
+ width=width,
1095
+ height=height,
1096
+ boxes=boxes,
1097
+ arrows=arrows,
1098
+ title=am.model,
1099
+ subtitle=sub,
1100
+ legend=legend,
1101
+ facts=facts,
1102
+ mode="full",
1103
+ spine=True,
1104
+ )
1105
+
1106
+
1107
+ # --------------------------------------------------------- multimodal multi-tower full view
1108
+
1109
+
1110
+ def build_multimodal(am: ArchModel) -> Diagram:
1111
+ """VLM / audio view: encoder tower(s) → projector → the LLM (the detailed column).
1112
+
1113
+ The LLM column is built by ``_build_column`` and shifted right; the modality towers are
1114
+ drawn in a left lane, with a fusion arrow into the LLM (merge at modality tokens), or
1115
+ cross-attention edges into specific LLM layers when the text config declares them.
1116
+ """
1117
+ d = _build_column(am)
1118
+ dx = 300
1119
+ for b in d.boxes:
1120
+ b.x += dx
1121
+ for a in d.arrows:
1122
+ a.points = [(x + dx, y) for (x, y) in a.points]
1123
+ d.width += dx
1124
+
1125
+ emb = next((b for b in d.boxes if b.cls == "c-embed"), None)
1126
+ container = next((b for b in d.boxes if b.shape == "container" and b.cls == "c-block"), None)
1127
+
1128
+ TX, TW = 36, 250
1129
+ ty = PAD + 92
1130
+ encoders = [t for t in am.towers if t["role"] in ("vision", "audio")]
1131
+ proj = next((t for t in am.towers if t["role"] == "projector"), None)
1132
+
1133
+ mmpfx = (am.config_class or "").replace("Config", "")
1134
+
1135
+ def tb(label, cls, subs, h, badge=None, shape="op"):
1136
+ nonlocal ty
1137
+ b = Box(TX, ty, TW, h, label, cls, sublabels=subs or [], shape=shape, small=True, badge=badge)
1138
+ d.boxes.append(b)
1139
+ ty += h + 16
1140
+ return b
1141
+
1142
+ def tower(t, label, cls, summary, badge=None):
1143
+ """Render a tower: fully decomposed when small; a representative block ×N for big
1144
+ encoder stacks; else a summary box. So every tower shows what's inside it."""
1145
+ nonlocal ty
1146
+ ch = t.get("children")
1147
+ if not ch and t.get("block_children"):
1148
+ # big encoder: outer section + one representative layer (decomposed) with ×N
1149
+ top = ty
1150
+ inner_top = top + 26
1151
+ blabel = f"{t['block_class']}"
1152
+ by = _emit_tree(d.boxes, t["block_children"], TX + 18, TW - 36, inner_top + 22, gp=6, prefix=mmpfx)
1153
+ d.boxes.append(
1154
+ Box(
1155
+ TX + 10,
1156
+ inner_top,
1157
+ TW - 20,
1158
+ by - inner_top + 6,
1159
+ blabel,
1160
+ cls,
1161
+ shape="section",
1162
+ badge=f"×{t['block_n']}",
1163
+ )
1164
+ )
1165
+ b = Box(TX, top, TW, (by + 6) - top + 6, label, cls, shape="section", badge=badge)
1166
+ d.boxes.append(b)
1167
+ ty = by + 6 + 6 + 16
1168
+ return b
1169
+ if ch:
1170
+ top = ty
1171
+ yy = _emit_tree(d.boxes, ch, TX + 10, TW - 20, top + 26, gp=6, prefix=mmpfx)
1172
+ b = Box(TX, top, TW, yy - top + 6, label, cls, shape="section", badge=badge)
1173
+ d.boxes.append(b)
1174
+ ty = yy + 6 + 16
1175
+ return b
1176
+ return tb(label, cls, summary, 52, badge=badge)
1177
+
1178
+ enc_boxes = []
1179
+ for t in encoders:
1180
+ if t["role"] == "vision":
1181
+ inp, ishape, cls, role = "pixel_values", "[1, 3, 336, 336]", "c-vision", "Vision encoder"
1182
+ img = Box(TX + (TW - 76) // 2, ty, 76, 64, "example image", "c-vision", shape="image")
1183
+ d.boxes.append(img)
1184
+ ty += 64 + 22
1185
+ else:
1186
+ inp, ishape, cls, role = "input_features", "[1, 128, 3000]", "c-audio", "Audio encoder"
1187
+ img = None
1188
+ ib = tb(inp, "c-io", [ishape], 30, shape="io")
1189
+ if img is not None:
1190
+ d.arrows.append(Arrow([(img.x + img.w // 2, img.y + img.h), (ib.x + ib.w // 2, ib.y)], cls="flow"))
1191
+ cls_line = t["cls"] + (" · via AutoModel" if am.auto_classes else "")
1192
+ eb = tower(
1193
+ t,
1194
+ f"{role} · {t['cls']}",
1195
+ cls,
1196
+ [cls_line, f"{t['model_type'] or ''} · {t['layers'] or '?'} layers · h={t['hidden'] or '?'}"],
1197
+ badge=(f"×{t['layers']}" if t["layers"] else None),
1198
+ )
1199
+ d.arrows.append(Arrow([(ib.x + ib.w // 2, ib.y + ib.h), (eb.x + eb.w // 2, eb.y)], cls="flow"))
1200
+ enc_boxes.append(eb)
1201
+
1202
+ pj = tower(
1203
+ proj or {}, f"Projector · {proj['cls'] if proj else 'Linear'}", "c-proj", ["align features → text hidden dim"]
1204
+ )
1205
+ for eb in enc_boxes:
1206
+ d.arrows.append(Arrow([(eb.x + eb.w // 2, eb.y + eb.h), (pj.x + pj.w // 2, pj.y)], cls="flow"))
1207
+
1208
+ # fusion into the LLM
1209
+ px = pj.x + pj.w
1210
+ pmid = pj.y + pj.h // 2
1211
+ if am.cross_attention_layers and container is not None:
1212
+ n = len(am.cross_attention_layers)
1213
+ target_x, target_y = container.x, container.y + container.h // 2
1214
+ d.arrows.append(
1215
+ Arrow(
1216
+ [(px, pmid), ((px + target_x) // 2, pmid), ((px + target_x) // 2, target_y), (target_x, target_y)],
1217
+ cls="xattn",
1218
+ )
1219
+ )
1220
+ d.boxes.append(
1221
+ Box(
1222
+ int((px + target_x) // 2) - 70,
1223
+ target_y - 34,
1224
+ 150,
1225
+ 18,
1226
+ f"cross-attention @ {n} layers",
1227
+ "c-proj",
1228
+ shape="op",
1229
+ small=True,
1230
+ )
1231
+ )
1232
+ elif emb is not None:
1233
+ target_x, target_y = emb.x, emb.y + emb.h // 2
1234
+ d.arrows.append(
1235
+ Arrow(
1236
+ [(px, pmid), ((px + target_x) // 2, pmid), ((px + target_x) // 2, target_y), (target_x, target_y)],
1237
+ cls="flow",
1238
+ )
1239
+ )
1240
+ d.boxes.append(
1241
+ Box(
1242
+ int((px + target_x) // 2) - 78,
1243
+ target_y - 30,
1244
+ 168,
1245
+ 18,
1246
+ "merge at modality tokens",
1247
+ "c-proj",
1248
+ shape="op",
1249
+ small=True,
1250
+ )
1251
+ )
1252
+
1253
+ # image ↔ text attention mask (the cross-modal mask the LLM actually uses)
1254
+ if any(t["role"] == "vision" for t in encoders):
1255
+ from .masks import image_text_mask
1256
+
1257
+ n_img, n_text = 5, 7
1258
+ grid, split = image_text_mask(n_img, n_text, image_bidirectional=am.image_bidirectional)
1259
+ cellp = 13
1260
+ ty += 16 # gap below the projector
1261
+ mx = TX + (TW - cellp * len(grid)) // 2
1262
+ my = ty + 22
1263
+ kind = "prefix-LM: image bidirectional" if am.image_bidirectional else "fully causal"
1264
+ d.boxes.append(Box(TX - 4, ty, TW + 8, 16, "image ⊕ text attention", "c-block", shape="container"))
1265
+ d.boxes.append(
1266
+ Box(
1267
+ mx,
1268
+ my,
1269
+ cellp * len(grid),
1270
+ cellp * len(grid),
1271
+ f"{n_img} img + {n_text} text · {kind}",
1272
+ "c-vision",
1273
+ shape="grid",
1274
+ grid=grid,
1275
+ grid_split=split,
1276
+ title="image↓text query × image|text key",
1277
+ )
1278
+ )
1279
+ ty = my + cellp * len(grid) + 16
1280
+
1281
+ # legend additions
1282
+ if any(t["role"] == "vision" for t in encoders):
1283
+ d.legend.insert(0, ("c-vision", "vision encoder"))
1284
+ if any(t["role"] == "audio" for t in encoders):
1285
+ d.legend.insert(0, ("c-audio", "audio encoder"))
1286
+ d.legend.append(("c-proj", "projector / fusion"))
1287
+ if am.cross_attention_layers:
1288
+ d.legend.append(("xattn", "cross-attention"))
1289
+
1290
+ d.subtitle = f"multimodal · {'+'.join(t['role'] for t in encoders)} → projector → LLM · {am.decoder_kind}"
1291
+ d.facts.insert(1, ("inputs", ", ".join(am.modal_inputs)))
1292
+ if am.auto_classes:
1293
+ d.facts.insert(2, ("built via", ", ".join(am.auto_classes[:2])))
1294
+ d.height = max(d.height, ty + PAD)
1295
+ return d
1296
+
1297
+
1298
+ # ----------------------------------------------------------------------------- diff mode
1299
+
1300
+
1301
+ # map a modular class name to which architectural slot it touches.
1302
+ # We match on SUFFIXES, not substrings: model names themselves often contain component
1303
+ # keywords (e.g. "Qwen2Moe...") which would otherwise mis-route every class to "moe".
1304
+ def _slot_for_class(name: str) -> str:
1305
+ n = name.lower()
1306
+ if n.endswith("config"):
1307
+ return "config"
1308
+ if n.endswith("rotaryembedding") or n.endswith("rotaryembeddings"):
1309
+ return "rope"
1310
+ if n.endswith(("router", "experts", "expert", "moeblock", "sparsemoeblock", "moe", "moemlp")):
1311
+ return "moe"
1312
+ if n.endswith(("mlp", "feedforward", "ffn")):
1313
+ return "mlp"
1314
+ if n.endswith(("attention", "attn", "sdpaattention", "flashattention2")):
1315
+ return "attention"
1316
+ if n.endswith(("rmsnorm", "layernorm", "norm")):
1317
+ return "norm"
1318
+ if (
1319
+ n.endswith(("pretrainedmodel",))
1320
+ or "forcausallm" in n
1321
+ or "forsequence" in n
1322
+ or "fortoken" in n
1323
+ or "forquestion" in n
1324
+ or "forconditional" in n
1325
+ or "forretrieval" in n
1326
+ or "withlmhead" in n
1327
+ ):
1328
+ return "head"
1329
+ if n.endswith(("decoderlayer", "encoderlayer", "layer", "block", "mixer")):
1330
+ return "layer"
1331
+ if n.endswith(("embedding", "embeddings")):
1332
+ return "embedding"
1333
+ if n.endswith("model"):
1334
+ return "layer" # the decoder/model wrapper -> the stack
1335
+ return "layer"
1336
+
1337
+
1338
+ def _change_for(cc: ClassChange) -> str | None:
1339
+ if cc.relation == "new":
1340
+ return "added"
1341
+ if cc.deleted_methods or cc.deleted_attrs:
1342
+ return "deleted"
1343
+ if cc.n_changes == 0:
1344
+ return None # trivial / unchanged
1345
+ if cc.added_methods or cc.added_attrs:
1346
+ return "added"
1347
+ return "overridden"
1348
+
1349
+
1350
+ def _change_detail(cc: ClassChange) -> str:
1351
+ bits = []
1352
+ if cc.overridden_methods:
1353
+ bits.append("ovr " + ",".join(cc.overridden_methods))
1354
+ if cc.added_methods:
1355
+ bits.append("add " + ",".join(cc.added_methods))
1356
+ if cc.deleted_methods:
1357
+ bits.append("del " + ",".join(cc.deleted_methods))
1358
+ na = len(cc.added_attrs) + len(cc.overridden_attrs)
1359
+ if na:
1360
+ bits.append(f"{na} attr")
1361
+ return "; ".join(bits) if bits else "rename only"
1362
+
1363
+
1364
+ def build_diff(am: ArchModel, ad: ArchDiff) -> Diagram:
1365
+ """Diff over the *real architecture*: render the full detailed view, then colour each
1366
+ class-box by how it relates to the parent it inherits from -- added (green), overridden
1367
+ (amber), deleted (red). Everything inherited unchanged / copy-pasted is greyed (ghosted),
1368
+ so at a glance you see what the model is made of AND exactly what it changed."""
1369
+ d = build_full(am)
1370
+ d.mode = "diff"
1371
+ d.title = am.model
1372
+
1373
+ if not ad.is_modular:
1374
+ # standalone: nothing inherited -> show the full architecture, note it
1375
+ d.subtitle = (ad.note or "standalone (no modular parent)") + " · shown in full"
1376
+ return d
1377
+
1378
+ # change type + detail per modular class (skip trivial → treated as inherited/ghost).
1379
+ # Box colour: a class redefined in the modular file (even a "new" subclass like
1380
+ # GemmaRMSNorm, which reimplements the inherited norm) is a CHANGE -> amber, not green.
1381
+ # Green is reserved for genuinely net-new *submodules* (added below from the parent diff).
1382
+ by_cls: dict[str, str] = {}
1383
+ detail: dict[str, ClassChange] = {}
1384
+ changes_list: list[tuple[str, str, str]] = []
1385
+ rank = {"deleted": 0, "overridden": 1, "added": 2}
1386
+ for cc in ad.changes:
1387
+ ch = _change_for(cc)
1388
+ detail[cc.name] = cc
1389
+ if ch is not None:
1390
+ by_cls[cc.name] = "deleted" if ch == "deleted" else "overridden"
1391
+ changes_list.append((ch, cc.name, _change_detail(cc)))
1392
+ changes_list.sort(key=lambda t: (rank.get(t[0], 9), t[1]))
1393
+
1394
+ # longest class names first so we match e.g. GemmaAttention before Gemma
1395
+ changed_names = sorted(by_cls, key=len, reverse=True)
1396
+
1397
+ def match(text: str) -> str | None:
1398
+ for nm in changed_names:
1399
+ if nm in text:
1400
+ return by_cls[nm]
1401
+ return None
1402
+
1403
+ def by_keyword(*kws: str) -> str | None:
1404
+ # the embedding/head boxes have generic labels; match their changed class by keyword
1405
+ for nm in changed_names:
1406
+ low = nm.lower()
1407
+ if any(k in low for k in kws):
1408
+ return by_cls[nm]
1409
+ return None
1410
+
1411
+ # colour each box; ghost everything that didn't change (inherited / copy-pasted)
1412
+ for b in d.boxes:
1413
+ if b.shape in ("container", "io", "circle", "cell", "grid"):
1414
+ continue
1415
+ ch = match(f"{b.label or ''} {b.title or ''}")
1416
+ if ch is None and b.cls == "c-embed":
1417
+ ch = by_keyword("embedding", "embed")
1418
+ if ch is None and b.cls == "c-head":
1419
+ ch = by_keyword("for", "lmhead", "classifier", "classification", "head")
1420
+ if ch:
1421
+ b.change = ch
1422
+ b.ghost = False
1423
+ else:
1424
+ b.ghost = True # inherited unchanged / not a (changed) modular class
1425
+
1426
+ # compare against the PARENT architecture itself, so overridden blocks reveal WHAT changed
1427
+ pam = _parent_arch(ad.parent_model) if ad.parent_model else None
1428
+ if pam is not None:
1429
+ pnames: set = set()
1430
+ for v in pam.attention_variants:
1431
+ _collect_submodule_names(v["children"], pnames)
1432
+ if pam.mlp_tree:
1433
+ _collect_submodule_names(pam.mlp_tree.get("children") or [], pnames)
1434
+ # a submodule present in the child but NOT in the parent is an addition (e.g. qwen3's
1435
+ # q_norm / k_norm vs qwen2) -> light it green even inside an "overridden" block
1436
+ if pnames:
1437
+ for b in d.boxes:
1438
+ if b.shape == "op" and b.title and ": " in b.title and not b.change:
1439
+ nm, _, rest = b.title.partition(": ")
1440
+ # only real submodule chips (title "name: ClassName"), not token/io chips
1441
+ if not rest[:1].isupper():
1442
+ continue
1443
+ if nm and nm not in pnames:
1444
+ b.change = "added"
1445
+ b.ghost = False
1446
+ # inherited (ghosted) section headers: show the PARENT class they came from
1447
+ cpfx = (am.config_class or "").replace("Config", "")
1448
+ ppfx = (pam.config_class or "").replace("Config", "")
1449
+ if cpfx and ppfx and cpfx != ppfx:
1450
+ for b in d.boxes:
1451
+ if b.ghost and b.shape == "section" and cpfx in (b.label or ""):
1452
+ b.label = b.label.replace(cpfx, ppfx) + " ↩ inherited"
1453
+
1454
+ t = ad.totals
1455
+ extra = f" (+{','.join(p for p in ad.parent_models if p != ad.parent_model)})" if len(ad.parent_models) > 1 else ""
1456
+ d.subtitle = (
1457
+ f"diff vs {ad.parent_model}{extra} · {t['overridden']} overridden · {t['added']} added · "
1458
+ f"{t['deleted']} deleted · {t['new_classes']} new · {t['trivial']} inherited-as-is"
1459
+ )
1460
+ d.changes = changes_list
1461
+ d.legend = [
1462
+ ("ch-added", "new submodule (vs parent)"),
1463
+ ("ch-over", "changed / redefined"),
1464
+ ("ch-deleted", "deleted"),
1465
+ ("ghost", "inherited / copy-pasted"),
1466
+ ]
1467
+ d.facts = [
1468
+ ("model id", (am.checkpoint or am.model)[:30]),
1469
+ ("parent", ad.parent_model or "—"),
1470
+ ("classes", str(len(ad.changes))),
1471
+ ("overridden", str(t["overridden"])),
1472
+ ("added", str(t["added"])),
1473
+ ("new classes", str(t["new_classes"])),
1474
+ ("inherited as-is", str(t["trivial"])),
1475
+ ]
1476
+ # ensure height fits the changes panel
1477
+ panel_h = len(d.facts) * 22 + len(d.legend) * 18 + len(d.changes) * 28 + 200
1478
+ d.height = max(d.height, PAD + 80 + panel_h)
1479
+ return d
arch_svg/masks.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Attention-mask *pattern* per layer type, generated offline.
2
+
3
+ The goal is to make the per-layer attention pattern **visible** (full-causal vs sliding vs
4
+ chunked vs compressed vs bidirectional). Real masks built from config are technically exact,
5
+ but production sliding windows (128, 4096, ...) are far larger than any sequence we can draw,
6
+ so they collapse to a plain causal triangle and become indistinguishable. We therefore draw
7
+ **schematic** patterns on a small ``seq`` grid using an illustrative window/chunk, and label
8
+ the *true* window size elsewhere. Where a real pattern is small enough to be faithful (window
9
+ < seq) the schematic coincides with it. Never raises.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+
15
+ _WIN = 6 # illustrative sliding window / chunk size on the schematic grid
16
+
17
+
18
+ def _causal(seq: int) -> list[list[int]]:
19
+ return [[1 if j <= i else 0 for j in range(seq)] for i in range(seq)]
20
+
21
+
22
+ def _full(seq: int) -> list[list[int]]:
23
+ return [[1] * seq for _ in range(seq)]
24
+
25
+
26
+ def _sliding(seq: int, win: int) -> list[list[int]]:
27
+ return [[1 if (0 <= i - j < win) else 0 for j in range(seq)] for i in range(seq)]
28
+
29
+
30
+ def _chunked(seq: int, chunk: int) -> list[list[int]]:
31
+ # causal within the current chunk only
32
+ return [[1 if (j <= i and j // chunk == i // chunk) else 0 for j in range(seq)] for i in range(seq)]
33
+
34
+
35
+ def _compressed(seq: int, win: int, stride: int = 2) -> list[list[int]]:
36
+ # local sliding window + sparse/strided long-range keys (schematic of compressed attention)
37
+ g = _sliding(seq, win)
38
+ for i in range(seq):
39
+ for j in range(0, i - win + 1, stride):
40
+ g[i][j] = 1
41
+ return g
42
+
43
+
44
+ def _compressed_kv(seq: int, real_rate: int, topk: int | None = None) -> list[list[int]]:
45
+ """Schematic of attention over a *compressed KV cache*: queries (rows) attend to a
46
+ compressed key sequence (cols). Columns = seq / display-rate (the real rate is usually too
47
+ large to draw, so we cap it for visibility and label the true rate elsewhere). Causal over
48
+ the compressed positions; for the sparse variant only the most-recent ``topk`` compressed
49
+ keys are kept (a band)."""
50
+ disp = max(2, min(real_rate, 6))
51
+ cols = max(2, -(-seq // disp)) # ceil
52
+ grid = []
53
+ for i in range(seq):
54
+ ci = i // disp # compressed position the query has reached
55
+ row = [1 if c <= ci else 0 for c in range(cols)]
56
+ if topk: # sparse: keep only the most-recent compressed keys (schematic band/top-k)
57
+ for c in range(cols):
58
+ if row[c] and (ci - c) >= max(2, cols // 2):
59
+ row[c] = 0
60
+ grid.append(row)
61
+ return grid
62
+
63
+
64
+ # a fixed example sentence so mask figures read like the reference (token-labelled axes)
65
+ EXAMPLE_WORDS = [
66
+ "The",
67
+ "quick",
68
+ "brown",
69
+ "fox",
70
+ "jumps",
71
+ "over",
72
+ "the",
73
+ "lazy",
74
+ "dog",
75
+ "ate",
76
+ "a",
77
+ "small",
78
+ "red",
79
+ "fish",
80
+ "this",
81
+ "morning",
82
+ ]
83
+
84
+
85
+ def concat_compressed_mask(seq: int, m: int, topk: int | None = None):
86
+ """Reproduce the *actual* mask DeepSeek-V4 passes to attention: the sliding K/V cache
87
+ causal mask (seq×seq) concatenated with the compressed-cache block bias (seq×n_comp).
88
+
89
+ Mirrors ``DeepseekV4HCACompressor.forward`` (block_bias) + the local sliding window:
90
+ query t attends key j iff ``0 ≤ t-j < m`` (recent window), and compressed entry w iff
91
+ ``w < (t+1)//m`` (the window is closed/ready). Returns ``(grid, split_col, n_comp)`` where
92
+ columns ``[0:seq)`` are the sliding cache and ``[seq:seq+n_comp)`` the compressed cache."""
93
+ n_comp = max(seq // m, 0)
94
+ grid = []
95
+ for t in range(seq):
96
+ left = [1 if (j <= t and t - j < m) else 0 for j in range(seq)]
97
+ thr = (t + 1) // m
98
+ right = [1 if w < thr else 0 for w in range(n_comp)]
99
+ grid.append(left + right)
100
+ return grid, seq, n_comp
101
+
102
+
103
+ def image_text_mask(n_img: int = 6, n_text: int = 8, image_bidirectional: bool = True):
104
+ """The cross-modal attention mask for a VLM: ``n_img`` image (prefix) tokens followed by
105
+ ``n_text`` text tokens. Text attends to ALL image tokens + causal text; image tokens are
106
+ bidirectional among themselves (prefix-LM, e.g. PaliGemma) or causal if not. Returns
107
+ ``(grid, split)`` with ``split = n_img`` (the image|text divider)."""
108
+ n = n_img + n_text
109
+ grid = []
110
+ for i in range(n):
111
+ row = []
112
+ for j in range(n):
113
+ if i < n_img: # image query
114
+ attend = (j < n_img) if image_bidirectional else (j <= i)
115
+ else: # text query: all image (prefix) + causal text
116
+ attend = (j < n_img) or (n_img <= j <= i)
117
+ row.append(1 if attend else 0)
118
+ grid.append(row)
119
+ return grid, n_img
120
+
121
+
122
+ def attention_pattern_grids(tcfg, layer_types: list[str], seq: int = 24) -> dict[str, list[list[int]]]:
123
+ """Return ``{layer_type: grid}`` (schematic). grid[i][j]==1 ⇒ query i attends to key j.
124
+
125
+ Sliding/chunked are square (q×q). Compressed/heavily-compressed are RECTANGULAR (q ×
126
+ compressed-kv) to show that the KV cache is compressed -- the key point for DeepSeek-V4."""
127
+ types = list(dict.fromkeys(layer_types or ["full_attention"]))
128
+ rates = getattr(tcfg, "compress_rates", None) or {}
129
+ topk = getattr(tcfg, "index_topk", None)
130
+ out: dict[str, list[list[int]]] = {}
131
+ for lt in types:
132
+ n = lt.lower()
133
+ try:
134
+ if "compress" in n: # compressed / heavily_compressed -> compressed KV cache
135
+ r = rates.get(lt, 4) if isinstance(rates, dict) else 4
136
+ out[lt] = _compressed_kv(seq, int(r), topk if "sparse" in n else None)
137
+ elif "chunk" in n:
138
+ out[lt] = _chunked(seq, _WIN)
139
+ elif "sliding" in n:
140
+ out[lt] = _sliding(seq, _WIN)
141
+ elif "linear" in n or "delta" in n or "mamba" in n or "recurrent" in n:
142
+ out[lt] = _causal(seq)
143
+ elif "bidirectional" in n:
144
+ out[lt] = _full(seq)
145
+ else:
146
+ out[lt] = _causal(seq)
147
+ except Exception:
148
+ continue
149
+ return out
arch_svg/modular.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The interesting part: extract *what a modular model changes* relative to its parent(s).
2
+
3
+ We do not re-run the full ``utils/modular_model_converter.py`` (it exists to *generate*
4
+ standalone modeling files). Instead we mirror its inheritance semantics with libcst to
5
+ recover the diff payload directly:
6
+
7
+ - a method/attr present in BOTH the modular class and the named parent class -> overridden
8
+ - present ONLY in the modular class -> added
9
+ - a deletion sentinel (``attr = AttributeError(...)`` or ``raise AttributeError``) -> deleted
10
+
11
+ These are exactly the rules ``replace_class_node`` applies when it merges a child class
12
+ onto its parent. The parent model a class inherits from is read from the modular file's
13
+ ``from ..<model>.modeling_<model> import <Class>`` imports -- the same import-following the
14
+ converter does. A clean modular model yields a tiny ``ArchDiff``; a bloated one yields a
15
+ large diff, which is the signal we want to surface.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from dataclasses import dataclass, field
22
+ from functools import lru_cache
23
+
24
+ import libcst as cst
25
+
26
+ from .discover import models_root
27
+
28
+
29
+ # ---------------------------------------------------------------------------- data model
30
+
31
+
32
+ @dataclass
33
+ class ClassChange:
34
+ """The change set for a single class defined in a modular file."""
35
+
36
+ name: str
37
+ relation: str # "inherits" | "new"
38
+ parent: str | None = None
39
+ parent_model: str | None = None
40
+ overridden_methods: list[str] = field(default_factory=list)
41
+ added_methods: list[str] = field(default_factory=list)
42
+ deleted_methods: list[str] = field(default_factory=list)
43
+ overridden_attrs: list[str] = field(default_factory=list)
44
+ added_attrs: list[str] = field(default_factory=list)
45
+ deleted_attrs: list[str] = field(default_factory=list)
46
+
47
+ @property
48
+ def n_changes(self) -> int:
49
+ return (
50
+ len(self.overridden_methods)
51
+ + len(self.added_methods)
52
+ + len(self.deleted_methods)
53
+ + len(self.overridden_attrs)
54
+ + len(self.added_attrs)
55
+ + len(self.deleted_attrs)
56
+ )
57
+
58
+ @property
59
+ def is_trivial(self) -> bool:
60
+ """A pure ``class Foo(Bar): pass`` rename -- inherits everything, changes nothing."""
61
+ return self.relation == "inherits" and self.n_changes == 0
62
+
63
+
64
+ @dataclass
65
+ class ArchDiff:
66
+ model: str
67
+ is_modular: bool
68
+ parent_model: str | None
69
+ parent_models: list[str]
70
+ changes: list[ClassChange]
71
+ note: str | None = None
72
+
73
+ @property
74
+ def totals(self) -> dict[str, int]:
75
+ t = {"overridden": 0, "added": 0, "deleted": 0, "new_classes": 0, "trivial": 0}
76
+ for c in self.changes:
77
+ t["overridden"] += len(c.overridden_methods) + len(c.overridden_attrs)
78
+ t["added"] += len(c.added_methods) + len(c.added_attrs)
79
+ t["deleted"] += len(c.deleted_methods) + len(c.deleted_attrs)
80
+ if c.relation == "new":
81
+ t["new_classes"] += 1
82
+ if c.is_trivial:
83
+ t["trivial"] += 1
84
+ return t
85
+
86
+
87
+ # ---------------------------------------------------------------------------- libcst bits
88
+
89
+
90
+ def _dotted(node: cst.BaseExpression) -> str:
91
+ if isinstance(node, cst.Attribute):
92
+ return _dotted(node.value) + "." + node.attr.value
93
+ if isinstance(node, cst.Name):
94
+ return node.value
95
+ return ""
96
+
97
+
98
+ def _base_name(base: cst.Arg) -> str:
99
+ v = base.value
100
+ if isinstance(v, cst.Name):
101
+ return v.value
102
+ if isinstance(v, cst.Attribute):
103
+ return v.attr.value
104
+ return _dotted(v)
105
+
106
+
107
+ class _Collector(cst.CSTVisitor):
108
+ """Collect imports and top-level class definitions from a parsed module."""
109
+
110
+ def __init__(self) -> None:
111
+ # imported name -> source model dir (parsed from `..<model>.modeling_<model>`)
112
+ self.import_model: dict[str, str] = {}
113
+ self.classes: dict[str, cst.ClassDef] = {}
114
+ self._depth = 0
115
+
116
+ def visit_ImportFrom(self, node: cst.ImportFrom) -> None:
117
+ module = _dotted(node.module) if node.module is not None else ""
118
+ if ".modeling_" not in module:
119
+ return
120
+ parent_model = module.split(".modeling_")[0].rsplit(".", 1)[-1]
121
+ if isinstance(node.names, cst.ImportStar):
122
+ return
123
+ for alias in node.names:
124
+ self.import_model[alias.name.value] = parent_model
125
+
126
+ def visit_ClassDef(self, node: cst.ClassDef) -> bool:
127
+ if self._depth == 0:
128
+ self.classes[node.name.value] = node
129
+ self._depth += 1
130
+ return True
131
+
132
+ def leave_ClassDef(self, node: cst.ClassDef) -> None:
133
+ self._depth -= 1
134
+
135
+
136
+ def _methods_and_attrs(klass: cst.ClassDef) -> tuple[dict[str, cst.FunctionDef], dict[str, cst.CSTNode]]:
137
+ methods: dict[str, cst.FunctionDef] = {}
138
+ attrs: dict[str, cst.CSTNode] = {}
139
+ for node in klass.body.body:
140
+ if isinstance(node, cst.FunctionDef):
141
+ methods[node.name.value] = node
142
+ elif isinstance(node, cst.SimpleStatementLine) and node.body:
143
+ stmt = node.body[0]
144
+ if isinstance(stmt, cst.Assign) and stmt.targets:
145
+ target = stmt.targets[0].target
146
+ if isinstance(target, cst.Name):
147
+ attrs[target.value] = stmt
148
+ elif isinstance(stmt, cst.AnnAssign) and isinstance(stmt.target, cst.Name):
149
+ attrs[stmt.target.value] = stmt
150
+ return methods, attrs
151
+
152
+
153
+ def _is_attr_deletion(stmt: cst.CSTNode) -> bool:
154
+ # `foo = AttributeError(...)` is the converter's sentinel for deleting an inherited attr.
155
+ if isinstance(stmt, cst.Assign) and isinstance(stmt.value, cst.Call):
156
+ return isinstance(stmt.value.func, cst.Name) and stmt.value.func.value == "AttributeError"
157
+ return False
158
+
159
+
160
+ def _is_method_deletion(fn: cst.FunctionDef) -> bool:
161
+ # `def foo(self): raise AttributeError(...)` (or a bare `...`/del) signals removal.
162
+ body = fn.body.body if isinstance(fn.body, cst.IndentedBlock) else []
163
+ for node in body:
164
+ if isinstance(node, cst.SimpleStatementLine):
165
+ for s in node.body:
166
+ if isinstance(s, cst.Raise) and isinstance(s.exc, cst.Call):
167
+ if isinstance(s.exc.func, cst.Name) and s.exc.func.value == "AttributeError":
168
+ return True
169
+ return False
170
+
171
+
172
+ @lru_cache(maxsize=512)
173
+ def _parse_module_file(path: str) -> cst.Module | None:
174
+ try:
175
+ return cst.parse_module(open(path, encoding="utf-8").read())
176
+ except Exception:
177
+ return None
178
+
179
+
180
+ @lru_cache(maxsize=512)
181
+ def _parent_classes(parent_model: str) -> dict[str, cst.ClassDef]:
182
+ """Top-level classes defined in ``models/<parent_model>/modeling_<parent_model>.py``."""
183
+ path = os.path.join(models_root(), parent_model, f"modeling_{parent_model}.py")
184
+ if not os.path.exists(path):
185
+ # some models ship multiple modeling_*.py; scan them all
186
+ d = os.path.join(models_root(), parent_model)
187
+ if not os.path.isdir(d):
188
+ return {}
189
+ collector = _Collector()
190
+ for f in sorted(os.listdir(d)):
191
+ if f.startswith("modeling_") and f.endswith(".py"):
192
+ mod = _parse_module_file(os.path.join(d, f))
193
+ if mod is not None:
194
+ mod.visit(collector)
195
+ return collector.classes
196
+ mod = _parse_module_file(path)
197
+ if mod is None:
198
+ return {}
199
+ collector = _Collector()
200
+ mod.visit(collector)
201
+ return collector.classes
202
+
203
+
204
+ # ---------------------------------------------------------------------------- public API
205
+
206
+
207
+ def _modular_path(model: str) -> str | None:
208
+ d = os.path.join(models_root(), model)
209
+ if not os.path.isdir(d):
210
+ return None
211
+ for f in sorted(os.listdir(d)):
212
+ if f.startswith("modular_") and f.endswith(".py"):
213
+ return os.path.join(d, f)
214
+ return None
215
+
216
+
217
+ def resolve_parent(model: str) -> str | None:
218
+ """The dominant parent model of ``model`` (the one most classes inherit from), or None."""
219
+ d = diff(model)
220
+ return d.parent_model
221
+
222
+
223
+ def diff(model: str) -> ArchDiff:
224
+ """Compute the modular diff for ``model``.
225
+
226
+ For a standalone model (no ``modular_*.py``), returns ``is_modular=False`` with a note.
227
+ """
228
+ path = _modular_path(model)
229
+ if path is None:
230
+ return ArchDiff(
231
+ model=model,
232
+ is_modular=False,
233
+ parent_model=None,
234
+ parent_models=[],
235
+ changes=[],
236
+ note="standalone model (no modular parent)",
237
+ )
238
+
239
+ mod = _parse_module_file(path)
240
+ if mod is None:
241
+ return ArchDiff(model, True, None, [], [], note="could not parse modular file")
242
+
243
+ collector = _Collector()
244
+ mod.visit(collector)
245
+
246
+ changes: list[ClassChange] = []
247
+ parent_vote: dict[str, int] = {}
248
+
249
+ for cls_name, node in collector.classes.items():
250
+ bases = [_base_name(b) for b in node.bases]
251
+ # find a base that maps to another transformers model
252
+ inherited_parent = next((b for b in bases if b in collector.import_model), None)
253
+
254
+ if inherited_parent is None:
255
+ # brand-new class (inherits nn.Module / PreTrainedConfig / a local class)
256
+ methods, attrs = _methods_and_attrs(node)
257
+ changes.append(
258
+ ClassChange(
259
+ name=cls_name,
260
+ relation="new",
261
+ parent=bases[0] if bases else None,
262
+ added_methods=sorted(methods),
263
+ added_attrs=sorted(attrs),
264
+ )
265
+ )
266
+ continue
267
+
268
+ parent_model = collector.import_model[inherited_parent]
269
+ parent_vote[parent_model] = parent_vote.get(parent_model, 0) + 1
270
+ parent_cls = _parent_classes(parent_model).get(inherited_parent)
271
+
272
+ child_methods, child_attrs = _methods_and_attrs(node)
273
+ if parent_cls is None:
274
+ # could not locate parent source: treat all child members as overrides we cannot resolve
275
+ changes.append(
276
+ ClassChange(
277
+ name=cls_name,
278
+ relation="inherits",
279
+ parent=inherited_parent,
280
+ parent_model=parent_model,
281
+ overridden_methods=sorted(child_methods),
282
+ overridden_attrs=sorted(child_attrs),
283
+ )
284
+ )
285
+ continue
286
+
287
+ parent_methods, parent_attrs = _methods_and_attrs(parent_cls)
288
+ cc = ClassChange(name=cls_name, relation="inherits", parent=inherited_parent, parent_model=parent_model)
289
+ for name, fn in child_methods.items():
290
+ if _is_method_deletion(fn):
291
+ cc.deleted_methods.append(name)
292
+ elif name in parent_methods:
293
+ cc.overridden_methods.append(name)
294
+ else:
295
+ cc.added_methods.append(name)
296
+ for name, stmt in child_attrs.items():
297
+ if _is_attr_deletion(stmt):
298
+ cc.deleted_attrs.append(name)
299
+ elif name in parent_attrs:
300
+ cc.overridden_attrs.append(name)
301
+ else:
302
+ cc.added_attrs.append(name)
303
+ for lst in (
304
+ cc.overridden_methods,
305
+ cc.added_methods,
306
+ cc.deleted_methods,
307
+ cc.overridden_attrs,
308
+ cc.added_attrs,
309
+ cc.deleted_attrs,
310
+ ):
311
+ lst.sort()
312
+ changes.append(cc)
313
+
314
+ parent_model = max(parent_vote, key=parent_vote.get) if parent_vote else None
315
+ parent_models = sorted(parent_vote, key=lambda m: (-parent_vote[m], m))
316
+ return ArchDiff(
317
+ model=model,
318
+ is_modular=True,
319
+ parent_model=parent_model,
320
+ parent_models=parent_models,
321
+ changes=changes,
322
+ )
arch_svg/out/README.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Transformers Model Architectures
3
+ emoji: 📐
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: static
7
+ pinned: false
8
+ license: apache-2.0
9
+ ---
10
+
11
+ # 🤗 Transformers — Model Architecture Gallery
12
+
13
+ Static, dependency-free architecture diagrams for every model in
14
+ [`huggingface/transformers`](https://github.com/huggingface/transformers), generated by
15
+ introspecting each model built on the `meta` device (no weights, no network).
16
+
17
+ - **full** view — the decoder/encoder block decomposed top-to-bottom (attention or SSM mixer,
18
+ Q/K/V/O projections with real dims, norms, MLP / Sparse MoE, residual stream, mask grids).
19
+ - **diff** view — for models with a `modular_*.py`, what each model overrides / adds / removes
20
+ versus the parent it inherits from.
21
+
22
+ Open `index.html` for the browsable gallery.
arch_svg/render.py ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Positioned boxes + arrows -> SVG string.
2
+
3
+ Theming is a one-line swap: all colors are CSS variables on ``:root``; flipping to the
4
+ ``.dark`` block (or honoring ``prefers-color-scheme``) reskins the whole gallery. Output is
5
+ deterministic given a ``Diagram``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from html import escape
11
+
12
+ from .engine import fit_text
13
+ from .layout import Diagram
14
+
15
+
16
+ # palette: each component kind gets a stable fill/stroke variable pair
17
+ _STYLE = """
18
+ :root {
19
+ --bg: #ffffff; --fg: #1b1f24; --muted: #6b7280; --panel: #f6f8fa; --grid: #e5e7eb;
20
+ --embed: #dbeafe; --embed-s: #3b82f6;
21
+ --attn: #cffafe; --attn-s: #06b6d4;
22
+ --mamba: #dcfce7; --mamba-s: #22c55e;
23
+ --linattn: #fce7f3; --linattn-s: #ec4899;
24
+ --recur: #ede9fe; --recur-s: #8b5cf6;
25
+ --moe: #ffedd5; --moe-s: #f97316;
26
+ --mlp: #ede9fe; --mlp-s: #8b5cf6;
27
+ --norm: #e5e7eb; --norm-s: #9ca3af;
28
+ --head: #fee2e2; --head-s: #ef4444;
29
+ --config: #f1f5f9; --config-s: #64748b;
30
+ --rope: #fef9c3; --rope-s: #eab308;
31
+ --layer: #f8fafc; --layer-s: #cbd5e1;
32
+ --io: #f1f5f9; --io-s: #94a3b8;
33
+ --soft: #fae8ff; --soft-s: #c026d3;
34
+ --add: #ffffff; --add-s: #475569;
35
+ --block-s: #94a3b8;
36
+ --residual: #f59e0b;
37
+ --added: #16a34a; --over: #d97706; --deleted: #dc2626;
38
+ --lt-full: #06b6d4; --lt-sliding: #3b82f6; --lt-chunked: #8b5cf6;
39
+ --lt-compressed: #f97316; --lt-heavy: #dc2626; --lt-linear: #ec4899; --lt-mamba: #22c55e;
40
+ --cell-on: #0ea5e9; --cell-off: #e5e7eb;
41
+ --vision: #dcfce7; --vision-s: #16a34a; --audio: #fae8ff; --audio-s: #c026d3;
42
+ --proj: #fef3c7; --proj-s: #d97706; --xattn: #db2777;
43
+ --conv: #d1fae5; --conv-s: #10b981; --act: #ecfccb; --act-s: #65a30d;
44
+ --pool: #e0f2fe; --pool-s: #0284c7; --quant: #fae8ff; --quant-s: #c026d3;
45
+ }
46
+ @media (prefers-color-scheme: dark) {
47
+ :root {
48
+ --bg: #0d1117; --fg: #e6edf3; --muted: #8b949e; --panel: #161b22; --grid: #30363d;
49
+ --embed: #172554; --attn: #083344; --mamba: #052e16; --linattn: #500724;
50
+ --recur: #2e1065; --moe: #431407; --mlp: #2e1065; --norm: #21262d; --head: #450a0a;
51
+ --config: #1e293b; --rope: #422006; --layer: #161b22;
52
+ --conv: #022c22; --act: #1a2e05; --pool: #082f49; --quant: #3b0764; --proj: #422006;
53
+ --io: #1e293b; --soft: #3b0764; --add: #0d1117; --cell-off: #21262d;
54
+ }
55
+ }
56
+ .bg { fill: var(--bg); }
57
+ text { font-family: ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif; fill: var(--fg); }
58
+ .title { font-size: 22px; font-weight: 700; }
59
+ .subtitle { font-size: 13px; fill: var(--muted); }
60
+ .box-label { font-size: 14px; font-weight: 600; }
61
+ .box-label.sm { font-size: 12.5px; }
62
+ .box-sub { font-size: 11px; fill: var(--muted); }
63
+ .glyph { font-size: 18px; font-weight: 700; fill: var(--add-s); }
64
+ .badge { font-size: 12px; font-weight: 700; fill: var(--fg); }
65
+ .facts-k { font-size: 11.5px; fill: var(--muted); }
66
+ .facts-v { font-size: 11.5px; font-weight: 600; }
67
+ .legend-t { font-size: 11.5px; fill: var(--fg); }
68
+ .panel { fill: var(--panel); stroke: var(--grid); }
69
+ rect.b { rx: 9; stroke-width: 1.6; }
70
+ .c-embed { fill: var(--embed); stroke: var(--embed-s); }
71
+ .c-attn { fill: var(--attn); stroke: var(--attn-s); }
72
+ .c-mamba { fill: var(--mamba); stroke: var(--mamba-s); }
73
+ .c-linattn { fill: var(--linattn); stroke: var(--linattn-s); }
74
+ .c-recur { fill: var(--recur); stroke: var(--recur-s); }
75
+ .c-moe { fill: var(--moe); stroke: var(--moe-s); }
76
+ .c-mlp { fill: var(--mlp); stroke: var(--mlp-s); }
77
+ .c-norm { fill: var(--norm); stroke: var(--norm-s); }
78
+ .c-head { fill: var(--head); stroke: var(--head-s); }
79
+ .c-config{ fill: var(--config);stroke: var(--config-s); }
80
+ .c-rope { fill: var(--rope); stroke: var(--rope-s); }
81
+ .c-proj { fill: var(--proj); stroke: var(--proj-s); }
82
+ .c-conv { fill: var(--conv); stroke: var(--conv-s); }
83
+ .c-act { fill: var(--act); stroke: var(--act-s); }
84
+ .c-pool { fill: var(--pool); stroke: var(--pool-s); }
85
+ .c-quant { fill: var(--quant); stroke: var(--quant-s); }
86
+ .c-layer { fill: var(--layer); stroke: var(--layer-s); }
87
+ .c-io { fill: var(--io); stroke: var(--io-s); }
88
+ .c-soft { fill: var(--soft); stroke: var(--soft-s); }
89
+ .c-add { fill: var(--add); stroke: var(--add-s); }
90
+ .c-block { fill: none; stroke: var(--block-s); stroke-width: 1.6; stroke-dasharray: 7 5; }
91
+ .c-lt-full { fill: var(--lt-full); stroke: var(--lt-full); }
92
+ .c-lt-sliding { fill: var(--lt-sliding); stroke: var(--lt-sliding); }
93
+ .c-lt-chunked { fill: var(--lt-chunked); stroke: var(--lt-chunked); }
94
+ .c-lt-compressed { fill: var(--lt-compressed); stroke: var(--lt-compressed); }
95
+ .c-lt-heavy { fill: var(--lt-heavy); stroke: var(--lt-heavy); }
96
+ .c-lt-linear { fill: var(--lt-linear); stroke: var(--lt-linear); }
97
+ .c-lt-mamba { fill: var(--lt-mamba); stroke: var(--lt-mamba); }
98
+ .cell-on { fill: var(--cell-on); }
99
+ .cell-off { fill: var(--cell-off); }
100
+ .grid-frame { fill: none; stroke: var(--grid); stroke-width: 1; }
101
+ .mask-bg { fill: var(--cell-off); }
102
+ .mask-on { fill: #22c55e; }
103
+ .mask-div { stroke: var(--fg); stroke-width: 1.5; stroke-dasharray: 3 2; }
104
+ .c-vision { fill: var(--vision); stroke: var(--vision-s); }
105
+ .c-audio { fill: var(--audio); stroke: var(--audio-s); }
106
+ .c-proj { fill: var(--proj); stroke: var(--proj-s); }
107
+ .c-sub { fill: var(--bg); stroke: var(--block-s); stroke-width: 1.2; }
108
+ .sec-h { font-size: 12px; font-weight: 700; }
109
+ .sec-hbar { fill: var(--bg); opacity: 0.82; }
110
+ .residual.xattn { stroke: var(--xattn); stroke-width: 2.4; }
111
+ .ghost { opacity: 0.32; stroke-dasharray: 4 3; }
112
+ .ch-added rect.b, rect.b.ch-added { stroke: var(--added); stroke-width: 3.2; }
113
+ .ch-over rect.b, rect.b.ch-over { stroke: var(--over); stroke-width: 3.2; }
114
+ .ch-deleted rect.b, rect.b.ch-deleted { stroke: var(--deleted); stroke-width: 3.2; }
115
+ .edge { stroke: var(--grid); stroke-width: 2; }
116
+ .flow { stroke: var(--grid); stroke-width: 2; fill: none; }
117
+ .residual { stroke: var(--residual); stroke-width: 2; fill: none; }
118
+ .rope { stroke: var(--rope-s); stroke-width: 2.2; fill: none; }
119
+ .xattn { stroke: var(--xattn); stroke-width: 2.4; fill: none; }
120
+ .cell-idx { font-size: 9px; fill: #ffffff; font-weight: 600; }
121
+ .sky { fill: #bae6fd; } .sun { fill: #fde047; } .hill { fill: #4ade80; }
122
+ """
123
+
124
+ _CHANGE_SWATCH = {"added": "var(--added)", "overridden": "var(--over)", "deleted": "var(--deleted)"}
125
+ _LEGEND_SWATCH = {
126
+ "c-attn": "var(--attn-s)",
127
+ "c-moe": "var(--moe-s)",
128
+ "c-mlp": "var(--mlp-s)",
129
+ "c-mamba": "var(--mamba-s)",
130
+ "c-norm": "var(--norm-s)",
131
+ "c-embed": "var(--embed-s)",
132
+ "c-head": "var(--head-s)",
133
+ "c-rope": "var(--rope-s)",
134
+ "c-vision": "var(--vision-s)",
135
+ "c-audio": "var(--audio-s)",
136
+ "c-proj": "var(--proj-s)",
137
+ "xattn": "var(--xattn)",
138
+ "c-lt-full": "var(--lt-full)",
139
+ "c-lt-sliding": "var(--lt-sliding)",
140
+ "c-lt-chunked": "var(--lt-chunked)",
141
+ "c-lt-compressed": "var(--lt-compressed)",
142
+ "c-lt-heavy": "var(--lt-heavy)",
143
+ "c-lt-linear": "var(--lt-linear)",
144
+ "c-lt-mamba": "var(--lt-mamba)",
145
+ "residual": "var(--residual)",
146
+ "ch-added": "var(--added)",
147
+ "ch-over": "var(--over)",
148
+ "ch-deleted": "var(--deleted)",
149
+ "ghost": "var(--grid)",
150
+ }
151
+
152
+
153
+ def _box_svg(b) -> str:
154
+ change_cls = {"added": "ch-added", "overridden": "ch-over", "deleted": "ch-deleted"}.get(b.change, "")
155
+ group_cls = "ghost" if b.ghost else ""
156
+ parts = [f'<g class="{group_cls}">']
157
+ if b.title:
158
+ parts.append(f"<title>{escape(str(b.title))}</title>")
159
+ cx = b.x + b.w / 2
160
+
161
+ if b.shape == "cell": # a single layer-schedule tick with its index
162
+ parts.append(f'<rect class="{b.cls}" x="{b.x}" y="{b.y}" width="{b.w}" height="{b.h}" rx="2"/>')
163
+ if b.label and b.h >= 11:
164
+ parts.append(
165
+ f'<text class="cell-idx" x="{b.x + b.w / 2}" y="{b.y + b.h / 2 + 3.5}" text-anchor="middle">{escape(b.label)}</text>'
166
+ )
167
+ parts.append("</g>")
168
+ return "".join(parts)
169
+
170
+ if b.shape == "grid" and b.grid:
171
+ rows = len(b.grid)
172
+ cols = len(b.grid[0]) if rows else 0
173
+ cell = b.w / max(cols, 1) # square cells; width fixed, height follows rows
174
+ parts.append(
175
+ f'<text class="box-sub" x="{b.x}" y="{b.y - 6}">{escape(fit_text(b.label, max(b.w, 130), 11))}</text>'
176
+ )
177
+ parts.append(
178
+ f'<rect class="mask-bg" x="{b.x}" y="{b.y}" width="{cell * cols:.1f}" height="{cell * rows:.1f}"/>'
179
+ )
180
+ for i, row in enumerate(b.grid):
181
+ for j, c in enumerate(row):
182
+ if c:
183
+ parts.append(
184
+ f'<rect class="mask-on" x="{b.x + j * cell:.1f}" y="{b.y + i * cell:.1f}" '
185
+ f'width="{cell:.1f}" height="{cell:.1f}"/>'
186
+ )
187
+ if b.grid_split is not None and 0 < b.grid_split < cols: # sliding | compressed divider
188
+ dx = b.x + b.grid_split * cell
189
+ parts.append(
190
+ f'<line class="mask-div" x1="{dx:.1f}" y1="{b.y}" x2="{dx:.1f}" y2="{b.y + cell * rows:.1f}"/>'
191
+ )
192
+ parts.append(
193
+ f'<rect class="grid-frame" x="{b.x}" y="{b.y}" width="{cell * cols:.1f}" height="{cell * rows:.1f}"/>'
194
+ )
195
+ parts.append("</g>")
196
+ return "".join(parts)
197
+
198
+ if b.shape == "circle":
199
+ r = b.h // 2
200
+ parts.append(f'<circle class="b {b.cls} {change_cls}" cx="{cx}" cy="{b.y + r}" r="{r}"/>')
201
+ if b.glyph:
202
+ parts.append(
203
+ f'<text class="glyph" x="{cx}" y="{b.y + r + 6}" text-anchor="middle">{escape(b.glyph)}</text>'
204
+ )
205
+ parts.append("</g>")
206
+ return "".join(parts)
207
+
208
+ if b.shape == "container":
209
+ parts.append(f'<rect class="{b.cls}" x="{b.x}" y="{b.y}" width="{b.w}" height="{b.h}" rx="14"/>')
210
+ parts.append(f'<text class="box-sub" x="{b.x + 12}" y="{b.y + 18}">{escape(b.label)}</text>')
211
+ if b.badge:
212
+ parts.append(
213
+ f'<text class="badge" x="{b.x + b.w - 12}" y="{b.y + 20}" text-anchor="end">{escape(b.badge)}</text>'
214
+ )
215
+ parts.append("</g>")
216
+ return "".join(parts)
217
+
218
+ if b.shape == "section": # filled rounded panel with a header; inner chips drawn on top
219
+ change_cls = {"added": "ch-added", "overridden": "ch-over", "deleted": "ch-deleted"}.get(b.change, "")
220
+ parts.append(
221
+ f'<rect class="b {b.cls} {change_cls}" x="{b.x}" y="{b.y}" width="{b.w}" height="{b.h}" rx="11"/>'
222
+ )
223
+ # white header strip so the class name stays readable on saturated section fills
224
+ parts.append(f'<rect class="sec-hbar" x="{b.x + 5}" y="{b.y + 4}" width="{b.w - 10}" height="18" rx="5"/>')
225
+ hdr = fit_text(b.label, b.w - 24 - (40 if b.badge else 0), 12)
226
+ parts.append(f'<text class="sec-h" x="{b.x + 12}" y="{b.y + 17}">{escape(hdr)}</text>')
227
+ if b.badge:
228
+ parts.append(
229
+ f'<text class="badge" x="{b.x + b.w - 12}" y="{b.y + 17}" text-anchor="end">{escape(b.badge)}</text>'
230
+ )
231
+ parts.append("</g>")
232
+ return "".join(parts)
233
+
234
+ if b.shape == "image": # tiny schematic example image (for pixel_values)
235
+ parts.append(f'<rect class="b {b.cls}" x="{b.x}" y="{b.y}" width="{b.w}" height="{b.h}" rx="6"/>')
236
+ ix, iy, iw, ih = b.x + 6, b.y + 6, b.w - 12, b.h - 12
237
+ parts.append(
238
+ f'<clipPath id="ic{b.x}{b.y}"><rect x="{ix}" y="{iy}" width="{iw}" height="{ih}" rx="3"/></clipPath>'
239
+ )
240
+ g = f'<g clip-path="url(#ic{b.x}{b.y})">'
241
+ g += f'<rect class="sky" x="{ix}" y="{iy}" width="{iw}" height="{ih}"/>'
242
+ g += f'<circle class="sun" cx="{ix + iw * 0.72:.0f}" cy="{iy + ih * 0.32:.0f}" r="{ih * 0.16:.0f}"/>'
243
+ g += f'<path class="hill" d="M{ix},{iy + ih} L{ix + iw * 0.35:.0f},{iy + ih * 0.5:.0f} L{ix + iw * 0.6:.0f},{iy + ih:.0f} Z"/>'
244
+ g += f'<path class="hill" d="M{ix + iw * 0.45:.0f},{iy + ih} L{ix + iw * 0.75:.0f},{iy + ih * 0.55:.0f} L{ix + iw:.0f},{iy + ih:.0f} Z"/>'
245
+ g += "</g>"
246
+ parts.append(g)
247
+ parts.append(f'<rect class="grid-frame" x="{ix}" y="{iy}" width="{iw}" height="{ih}" rx="3"/>')
248
+ if b.label:
249
+ parts.append(
250
+ f'<text class="box-sub" x="{cx}" y="{b.y + b.h + 12}" text-anchor="middle">{escape(b.label)}</text>'
251
+ )
252
+ parts.append("</g>")
253
+ return "".join(parts)
254
+
255
+ rx = b.h // 2 if b.shape == "io" else 9
256
+ rect_cls = f"b {b.cls} {change_cls}".strip()
257
+ parts.append(f'<rect class="{rect_cls}" x="{b.x}" y="{b.y}" width="{b.w}" height="{b.h}" rx="{rx}"/>')
258
+ label_cls = "box-label sm" if b.small else "box-label"
259
+ lpx = 12.5 if b.small else 14
260
+ inner = b.w - 14 # text must fit inside the box with a little padding
261
+ # vertically centre the label + sublabels block within the box
262
+ subs = b.sublabels or []
263
+ n_sub = len(subs)
264
+ if n_sub == 0:
265
+ ty = b.y + b.h / 2 + 4.5
266
+ else:
267
+ block = 13 + 15 * n_sub
268
+ ty = b.y + (b.h - block) / 2 + 12
269
+ lab = fit_text(b.label, inner, lpx)
270
+ parts.append(f'<text class="{label_cls}" x="{cx}" y="{ty:.1f}" text-anchor="middle">{escape(lab)}</text>')
271
+ sy = ty + 15
272
+ for s in subs:
273
+ parts.append(
274
+ f'<text class="box-sub" x="{cx}" y="{sy:.1f}" text-anchor="middle">{escape(fit_text(s, inner, 11))}</text>'
275
+ )
276
+ sy += 15
277
+ if b.badge:
278
+ parts.append(
279
+ f'<text class="badge" x="{b.x + b.w - 12}" y="{b.y + 20}" text-anchor="end">{escape(b.badge)}</text>'
280
+ )
281
+ if b.change:
282
+ sw = _CHANGE_SWATCH.get(b.change, "")
283
+ parts.append(f'<circle cx="{b.x + 12}" cy="{b.y + 12}" r="5" fill="{sw}"/>')
284
+ parts.append("</g>")
285
+ return "".join(parts)
286
+
287
+
288
+ def _arrow_svg(a) -> str:
289
+ pts = " ".join(f"{x},{y}" for x, y in a.points)
290
+ dash = ' stroke-dasharray="6 4"' if a.dashed else ""
291
+ return f'<polyline class="{a.cls}" points="{pts}"{dash} marker-end="url(#ah-{a.cls})"/>'
292
+
293
+
294
+ def _marker(name: str, color: str) -> str:
295
+ return (
296
+ f'<marker id="ah-{name}" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" '
297
+ f'orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="{color}"/></marker>'
298
+ )
299
+
300
+
301
+ def render(diagram: Diagram) -> str:
302
+ d = diagram
303
+ panel_w = 264
304
+ panel_x = d.width - panel_w - 24
305
+ parts = [
306
+ f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {d.width} {d.height}" '
307
+ f'width="{d.width}" height="{d.height}" font-size="14">',
308
+ f"<style>{_STYLE}</style>",
309
+ f"<defs>{_marker('flow', 'var(--grid)')}{_marker('residual', 'var(--residual)')}"
310
+ f"{_marker('rope', 'var(--rope-s)')}{_marker('xattn', 'var(--xattn)')}</defs>",
311
+ f'<rect class="bg" x="0" y="0" width="{d.width}" height="{d.height}"/>',
312
+ f'<text class="title" x="24" y="34">{escape(d.title)}</text>',
313
+ f'<text class="subtitle" x="24" y="54">{escape(d.subtitle)}</text>',
314
+ ]
315
+
316
+ # connecting spine behind the central column only (use the modal box-center x)
317
+ if d.spine:
318
+ col = [b for b in d.boxes if b.shape not in ("container", "cell", "grid")]
319
+ if col:
320
+ from collections import Counter
321
+
322
+ centers = Counter(round(b.x + b.w / 2) for b in col)
323
+ xs = centers.most_common(1)[0][0]
324
+ on_axis = [b for b in col if abs((b.x + b.w / 2) - xs) < 6]
325
+ top = min(b.y for b in on_axis)
326
+ bot = max(b.y + b.h for b in on_axis)
327
+ parts.append(f'<line class="edge" x1="{xs}" y1="{top}" x2="{xs}" y2="{bot}"/>')
328
+
329
+ # containers + sections first (behind), then arrows, then boxes on top.
330
+ # sections are drawn LARGEST-first so a nested (inner) section paints on top of its
331
+ # parent instead of being hidden by the parent's opaque fill.
332
+ for b in sorted((b for b in d.boxes if b.shape == "container"), key=lambda b: -b.w * b.h):
333
+ parts.append(_box_svg(b))
334
+ for b in sorted((b for b in d.boxes if b.shape == "section"), key=lambda b: -b.w * b.h):
335
+ parts.append(_box_svg(b))
336
+ for a in d.arrows:
337
+ parts.append(_arrow_svg(a))
338
+ for b in d.boxes:
339
+ if b.shape not in ("container", "section"):
340
+ parts.append(_box_svg(b))
341
+
342
+ # side panel: facts
343
+ py = 88
344
+ parts.append(
345
+ f'<rect class="panel" x="{panel_x}" y="{py}" width="{panel_w}" height="{len(d.facts) * 22 + 20}" rx="8"/>'
346
+ )
347
+ fy = py + 24
348
+ for k, v in d.facts:
349
+ parts.append(f'<text class="facts-k" x="{panel_x + 14}" y="{fy}">{escape(k)}</text>')
350
+ parts.append(
351
+ f'<text class="facts-v" x="{panel_x + panel_w - 14}" y="{fy}" text-anchor="end">{escape(str(v))}</text>'
352
+ )
353
+ fy += 22
354
+ fy += 16
355
+
356
+ # side panel: legend
357
+ parts.append(f'<text class="legend-t" x="{panel_x + 2}" y="{fy}" font-weight="700">legend</text>')
358
+ fy += 12
359
+ _seen_leg = set()
360
+ legend = [(c, l) for c, l in d.legend if not ((c, l) in _seen_leg or _seen_leg.add((c, l)))]
361
+ for cls, label in legend:
362
+ sw = _LEGEND_SWATCH.get(cls, "var(--grid)")
363
+ is_change = cls.startswith("ch-") or cls in ("ghost", "residual")
364
+ if is_change:
365
+ parts.append(
366
+ f'<rect x="{panel_x + 4}" y="{fy - 1}" width="16" height="12" rx="2" fill="none" stroke="{sw}" stroke-width="3"/>'
367
+ )
368
+ else:
369
+ parts.append(f'<rect x="{panel_x + 4}" y="{fy - 1}" width="16" height="12" rx="2" fill="{sw}"/>')
370
+ parts.append(f'<text class="legend-t" x="{panel_x + 28}" y="{fy + 9}">{escape(label)}</text>')
371
+ fy += 18
372
+
373
+ # side panel: per-class changes (diff mode)
374
+ if d.changes:
375
+ fy += 14
376
+ parts.append(f'<text class="legend-t" x="{panel_x + 2}" y="{fy}" font-weight="700">changes by class</text>')
377
+ fy += 16
378
+ for ch, name, detail in d.changes:
379
+ sw = _CHANGE_SWATCH.get(ch, "var(--grid)")
380
+ parts.append(f'<circle cx="{panel_x + 8}" cy="{fy - 3}" r="4" fill="{sw}"/>')
381
+ parts.append(f'<text class="facts-v" x="{panel_x + 20}" y="{fy}">{escape(name)}</text>')
382
+ fy += 13
383
+ if detail:
384
+ d_short = detail if len(detail) <= 46 else detail[:44] + "…"
385
+ parts.append(f'<text class="box-sub" x="{panel_x + 20}" y="{fy}">{escape(d_short)}</text>')
386
+ fy += 15
387
+
388
+ parts.append("</svg>")
389
+ return "\n".join(parts)