Add arch_svg generator source (meta-device introspection + modular diff + SVG render)
Browse files- arch_svg/README.md +170 -0
- arch_svg/__init__.py +23 -0
- arch_svg/__main__.py +7 -0
- arch_svg/cli.py +84 -0
- arch_svg/discover.py +113 -0
- arch_svg/engine.py +71 -0
- arch_svg/gallery.py +250 -0
- arch_svg/introspect.py +1402 -0
- arch_svg/layout.py +1479 -0
- arch_svg/masks.py +149 -0
- arch_svg/modular.py +322 -0
- arch_svg/out/README.md +22 -0
- arch_svg/render.py +389 -0
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)
|