forwardfig 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kenichiro Goto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,163 @@
1
- # Temporary Holding Version
1
+ # forwardfig
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Draw ML architecture figures in TypeScript, written the way you write the model. It has zero dependencies, is about 19 KB gzipped, and outputs SVG (it also works in Node).
4
+
5
+ Docs: https://frinfo702.github.io/forwardfig/docs/
6
+
7
+ Demo (paper figure gallery and playground): https://frinfo702.github.io/forwardfig/demo/
8
+
9
+ ```ts
10
+ import { Figure, Module, Tensor, mha, norm, ffn, text } from 'forwardfig'
11
+
12
+ class EncoderLayer extends Module { // like nn.Module
13
+ attn = mha()
14
+ n1 = norm()
15
+ ff = ffn()
16
+ n2 = norm()
17
+ forward(x: Tensor) {
18
+ const h = this.n1(this.attn(x), x) // Add & Norm with a residual
19
+ return this.n2(this.ff(h), h)
20
+ }
21
+ }
22
+
23
+ const fig = new Figure({ flow: 'BT' }) // drawing side, like matplotlib
24
+ const y = new EncoderLayer({ repeat: 'N×' }).freeze()(fig.input('$\\mathbf{x}$'))
25
+ text('$\\mathbf{h}$')(y)
26
+ fig.mount('#figure') // or fig.svg() for a string
27
+ ```
28
+
29
+ ## Model
30
+
31
+ | | Role | Analogy |
32
+ |---|---|---|
33
+ | `Layer` | One block (one node). Calling it as `layer(x, y)` draws edges into it and returns a Tensor. Calling the same layer again adds edges to the same node, so shared weights stay one block. | `nn.Linear` / `keras.layers.Dense` |
34
+ | `Module` | A subclass that implements `forward()`. Instances are callable. Pass `label` / `repeat` / `box` to draw a group box. `repeatSide: 'auto' \| 'top' \| 'bottom' \| 'left' \| 'right'` sets where `repeat` (e.g. ×N) goes. `auto` (the default) picks the first spot that doesn't collide with blocks, edges, labels or other groups. | `nn.Module` |
35
+ | `Tensor` | The value flowing between blocks. `x.edge('$r$')` and `x.edge({ dashed: true })` style the next edge it enters. | KerasTensor |
36
+ | `Figure` | The drawing side: `input()` (a label, or a layer plus extra options), `edge()` (edges outside forward), `rank()`, `svg()` / `mount()`. | `plt.figure` |
37
+
38
+ There are two ways to write a figure. Use whichever suits the figure, or mix them.
39
+
40
+ - **All in the figure**: `figure(fig => { ... })` or `figure({ flow: 'LR' }, fig => { ... })` builds everything in one place.
41
+ - **By module**: define each `Module` alongside your model code, then call them in the figure.
42
+
43
+ ```ts
44
+ // functional style
45
+ const fig = new Figure({ flow: 'BT' })
46
+ const x = fig.input('$\\mathbf{x}$')
47
+ const W = block('Pretrained $W$').freeze() // 🧊
48
+ const A = thin('$A$').trainable(), B = thin('$B$').trainable() // 🔥
49
+ add(W(x), B(A(x).edge('$r$')))
50
+ ```
51
+
52
+ ## Layers
53
+
54
+ All layers take `(label?, options?)` or `(options?)`; `image(src, …)` and `vector(values, …)` take their data first.
55
+
56
+ - Basic: `block` `thin` `text` `circle` `op('+' | '*' | '~')` `enc` `dec` `tensor({ size: [w,h,d] })` `stack({ n, size, offset })` `neurons({ n })` `vector(values, label?, options?)`
57
+ - ML presets: `mha` `attention` `ffn` `mlp` `norm` `embed` `linear` `softmax` `matmul` `concat` `conv` `pool` `act` `dropout` `loss`
58
+ - Functional: `add(...xs)` (⊕) and `mul(...xs)` (⊗) create a new node on each call.
59
+ - Grids: `matrix({ rows, cols, cells, cellSize })` (2D) and `voxels({ rows, cols, depth, cells, cellSize })` (3D, the front, top and right faces as cells). `cells` is a 2D array or `(r, c) => cell` (`(r, c, d) => cell` for voxels, with d = 0 in front). A cell is a number in 0–1 (a shade of the block's colour), a colour string, or `{ value | color, texture, label, crossed }`. When `cells` is omitted, varied shades are used. Textures inside cells use the lightweight vector looks. `axes: { rows, cols, depth }` names the extent along each axis (TeX works). `cols` goes under the bottom edge, `rows` to the left and `depth` beside the receding edge; any that would sit on an arrow are moved off its line.
60
+ - Magnify: `magnify({ patch: [col, row] | region: [x, y, w, h], lens: 'circle' | 'square' })(img)`. Enlarges part of the image it's called on with a magnifying glass (`circle`, the default) or an inset (`square`). `patch` is one cell of the source's `grid`, and `region` is a range given as 0–1 fractions. It draws the range's outline and zoom lines instead of an arrow, and the enlarged part can feed the next block (e.g. Linear Projection). The lens shows the source range cropped by default; `src` (URL, `<img>`, `<canvas>`, with `fit`) puts any other image in it, such as a high-resolution crop.
61
+ - 3D:
62
+ - `gaussian({ peaks: [[x, y, amp?, sigma?]], mesh })`: a Gaussian or mixture surface.
63
+ - `space({ points: [[x, y, z, class?]], axes: { x, y, z } })`: a 3D scatter on axis panes.
64
+ - `sphere({ points })`: points on a hypersphere.
65
+ - `pointcloud({ points: [[x, y, z, class?]], shape: 'torus' | 'sphere' | 'cube', n })`: a point cloud. Closer points are drawn larger and darker. When `points` is omitted, they're sampled from `shape`.
66
+ - Signals: `waveform({ samples })`. `samples` takes raw audio in [-1, 1] (`number[]` or `Float32Array`). Long signals are reduced to at most 256 min/max columns, so the SVG stays light. When omitted, a synthetic speech-like waveform is drawn.
67
+ - Images: `image(src, label?, { size, fit: 'cover' | 'contain', grid, n, tilt, rotate })`. `src` can be a URL, a data URL, an `<img>` or a `<canvas>`. Passing an array (`image([t0, t1, t2])`) draws a stack of frames, each its own image, with the first one in front. `grid` draws a ViT-style patch grid, and `n` draws a batch of frames. `tilt` (degrees) turns the image around its vertical axis and draws it obliquely; a positive value sends the right edge back (up), a negative one the left; combined with `n`, it reads as a video volume. `rotate` (degrees) rotates it in-plane. The layout uses the transformed frame's bounding box.
68
+
69
+ Common options: `color` `texture` `frozen` `trainable` `crossed` `dashed` `bold` `w` `h` `ghost` `caption` `id`
70
+
71
+ - `color`: a palette name (`pink orange yellow green blue purple teal red gray white`) or `#hex`. If it's omitted, the block is a neutral grey that reads on both light and dark pages.
72
+ - `crossed: true` dims the block and lays a red ✕ over it, for removed or ablated parts. On an edge it puts a ✕ in the middle, for a cut connection.
73
+ - `texture` sets a block's surface. Set it on a module to style its group box.
74
+
75
+ | texture | Look | Example use |
76
+ |---|---|---|
77
+ | `ice` | Clear ice (WebGL) | Frozen weights. This is what `freeze()` gives |
78
+ | `magma` | Cracked magma (WebGL) | Trainable weights. This is what `trainable()` gives |
79
+ | `glass` | Frosted glass that keeps the block's colour (WebGL) | Soft prompts, adapters |
80
+ | `metal` | Brushed metal (WebGL) | Fixed ops, hardware |
81
+ | `noise` | Gaussian grain (WebGL) | Noise injection, diffusion |
82
+ | `quantized` | A 4-level mosaic of the block's colour (SVG) | Low-bit weights |
83
+ | `sparse` | A dot grid with gaps (SVG) | Pruning, sparsity |
84
+ | `hatch` | Diagonal lines (SVG) | Masked, stop-gradient |
85
+
86
+ When both are set, `texture` decides the surface, and the ❄/🔥 badge follows `freeze()` / `trainable()` (for example `block({ texture: 'quantized' }).freeze()` in QLoRA). Without WebGL, glass, metal and noise fall back to lightweight vector versions.
87
+
88
+ - `new Figure({ legend: true })` adds a key under the figure for the looks it uses (❄ frozen, 🔥 trainable, each texture, ✕ crossed). `legend: { position: 'right', labels: { quantized: '4-bit' } }` moves it beside the figure and renames entries.
89
+ - Placement is automatic by default. To override it, use `lane` (fractions allowed), `align: ref`, `leftOf` / `rightOf` / `above` / `below: ref` (with `dist`), `at: [x, y]` or `nudge: [dx, dy]`.
90
+ - `freeze()` / `trainable()` can be called on a layer or on a module. On a module they apply to every block inside it, unless a layer is marked explicitly.
91
+ - A 🧊 or 🔥 in a label works the same way.
92
+
93
+ Edges (`x.edge({...})` / `fig.edge(a, b, {...})`): `label` `crossed` `join: 'side'` (comes down the source's line and turns into the target's side, an L; for conditioning taps. It doesn't affect column assignment) `dashed` `arrow: 'end' | 'both' | 'none'` `thick` `dense` (fully connected between `neurons`) `skip` `curve` `side` (where a detour runs: `left` / `right` in TB / BT, `top` / `bottom` in LR / RL) `color`
94
+
95
+ Labels: a newline (`\n`) starts a new line, and `$...$` is TeX.
96
+
97
+ - **When the page loads MathJax 3**, math is typeset by it automatically. It's embedded in the SVG as paths drawn with real TeX metrics and glyphs. Use `tex-svg-full.js`, because the other builds load some extensions asynchronously.
98
+
99
+ ```html
100
+ <script>window.MathJax = { startup: { typeset: false }, svg: { fontCache: 'local' } }</script>
101
+ <script defer src="https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg-full.js"></script>
102
+ <script type="module">await MathJax.startup.promise; /* draw figures */</script>
103
+ ```
104
+
105
+ - **In Node, or with any other engine**, pass a function that returns an `<svg viewBox>` (1000 units per em) via `configure({ tex: src => ... })`. With `mathjax-full`, `tex: t => adaptor.outerHTML(html.convert(t, { display: false }))` works.
106
+ - **Without MathJax**, a built-in lightweight engine handles it. It covers `^ _ \mathbf \mathrm \text \boldsymbol \mathcal \mathbb`, Greek letters, common symbols, `\hat \tilde \bar \vec` and `\frac` (rendered as a/b), with TeX-like spacing. Force it with `configure({ tex: null })`.
107
+
108
+ ## Custom presets
109
+
110
+ The built-in presets (`mha`, `norm` and so on) are just a shape plus a default label and colour. You can define your own the same way.
111
+
112
+ ```ts
113
+ const rmsnorm = preset('thin', 'RMSNorm', 'yellow')
114
+ const moe = preset('block', 'MoE', { color: 'orange', ghost: 3, kind: 'moe' })
115
+ rmsnorm()(x) // default label and colour
116
+ rmsnorm('Pre-Norm', { color: 'blue' })(x) // override them
117
+ ```
118
+
119
+ `kind` is the key that `kinds` (below) matches on. It defaults to the shape name.
120
+
121
+ ## Style config
122
+
123
+ Like matplotlib's rcParams, you can set defaults globally and narrow them down by scope. A narrower scope always wins, so a value applies unless a lower level sets its own.
124
+
125
+ ```ts
126
+ configure({ style: { fontSize: 14 }, kinds: { mha: { color: 'purple' } } }) // whole library
127
+ new Figure({ style: { color: 'gray', fontFamily: 'Times New Roman, serif' } }) // this figure
128
+ new Encoder({ style: { fontSize: 11, texture: 'glass' } }) // blocks made inside this module
129
+ block('x', { fontSize: 20 }) // the block itself wins
130
+ configure(null) // reset
131
+ ```
132
+
133
+ - Precedence, strongest first: the block's own options > inner Module > outer Module > Figure > `configure()` > the preset's defaults.
134
+ - At each level, `kinds[kind]` (for one preset) beats `style` (for all blocks).
135
+ - `style` can set `color` `texture` `fontSize` `fontFamily` `dashed` `bold`. At the Figure / `configure` level, `fontSize` also sizes edge labels, group titles and captions, and `fontFamily` (any CSS font family) sets the font of the whole figure, e.g. to match a paper's body text. Math (`$...$`) keeps its TeX font.
136
+ - A `texture` set through style doesn't override `freeze()` / `trainable()` (ice and magma win). A texture set on the block itself does.
137
+ - `configure` also takes `theme` `flow` `gap` `materials` `legend`.
138
+
139
+ ## Export
140
+
141
+ ```ts
142
+ await fig.download('figure.png') // the format follows the extension: .svg .png .jpg/.jpeg .webp
143
+ await fig.download('figure.png', { scale: 3 }) // raster resolution
144
+ const blob = await fig.toBlob({ format: 'webp', quality: 0.9, background: '#fff', theme: 'dark' })
145
+ fig.svg() // string (works in Node too)
146
+ ```
147
+
148
+ - Raster formats (png / jpeg / webp) need a browser (canvas). External image URLs inside are inlined as data URLs before export. JPEG gets a white background by default.
149
+ - PDF isn't supported. For papers, an SVG export (vector) can go into LaTeX via Inkscape or `svg2pdf`.
150
+
151
+ From the command line (e.g. in a Makefile), `npx forwardfig model.ts -o fig.svg` writes the module's default export (a `Figure`, or a function returning one). The format follows the extension: `.svg`, or `.png` / `.jpg` / `.jpeg` / `.webp`, which need the optional peer dependency `sharp` (`npm i -D sharp`) and take `--scale` (default 2) and `--background`. Without a default export, each exported `Figure` becomes `<name>.svg` in the `-o` directory. `--theme dark` picks the theme. `.ts` files need Node ≥ 22.6 (`NODE_OPTIONS=--experimental-strip-types` before 23.6) or Bun.
152
+
153
+ ## Drawing core
154
+
155
+ The layout and rendering engine is independent of the API above. `fig.diagram()` returns plain `Diagram` data (nodes, edges, groups, ranks), which `draw(diagram, { theme })` turns into SVG. Anything that can generate a Diagram, from another language or as JSON, can reuse the same engine.
156
+
157
+ - Layout: a grid of ranks (longest path) and lanes (a block inherits its first input's lane), so chains are always straight. Edges against the flow (e.g. a column that goes back up, aligned with `fig.rank`) are drawn straight, as an L or as a Z when the path is clear. A block whose only inputs come against the flow inherits the lane of the block it feeds. Edges are orthogonal: straight, one Z bend, or a detour. When several arrows enter one block, the one that goes dead straight never moves, and on a small ⊕ the others join from the side.
158
+ - Materials: 🧊 / 🔥 run the WebGL shaders from [portfolio](https://github.com/frinfo702/portfolio) once per block size and embed the result as an image. Without WebGL it falls back to a vector look, and `new Figure({ materials: false })` forces that fallback.
159
+ - Text is measured with canvas `measureText` (cached). Without a DOM, a width-table approximation is used.
160
+
161
+ To develop locally: run `bun install && bun test && bun run build`, then `python3 -m http.server` and open `demo/`. `bun run docs:dev` builds the docs in `doc/` and serves them at http://localhost:4321. Pushing to `main` deploys the demo to GitHub Pages.
162
+
163
+ Known limits: crossings are reduced by reordering lanes, not minimized (a greedy pass that stops at a local minimum, and never moves a `lane` you set). TeX is a subset.
package/dist/api.d.ts ADDED
@@ -0,0 +1,410 @@
1
+ import { type TexEngine } from './tex';
2
+ import type { Diagram, Dir, Group } from './types';
3
+ type V2 = [number, number];
4
+ export type Ref = Tensor | Layer<any>;
5
+ /**
6
+ * Surface of a block. ice / magma are what freeze() / trainable() give; the rest are
7
+ * for whatever a figure wants to call out:
8
+ * glass (soft prompts, adapters), metal (fixed ops, hardware), noise (noise
9
+ * injection, diffusion), quantized (low-bit weights), sparse (pruned), hatch (masked).
10
+ */
11
+ export type Texture = 'ice' | 'magma' | 'glass' | 'metal' | 'noise' | 'quantized' | 'sparse' | 'hatch';
12
+ export interface NodeOptions {
13
+ id?: string;
14
+ /** palette name (pink orange yellow green blue purple teal red gray white) or #hex */
15
+ color?: string;
16
+ /** 🧊 frozen weights (ice) */
17
+ frozen?: boolean;
18
+ /** 🔥 trainable weights (magma) */
19
+ trainable?: boolean;
20
+ /** surface texture; wins over frozen / trainable */
21
+ texture?: Texture;
22
+ dashed?: boolean;
23
+ bold?: boolean;
24
+ /** a ✕ over the block: removed, ablated, dropped */
25
+ crossed?: boolean;
26
+ w?: number;
27
+ h?: number;
28
+ /** stacked copies behind the block, e.g. heads of multi-head attention */
29
+ ghost?: number;
30
+ /** small text under the block */
31
+ caption?: string;
32
+ /** cross-axis column; fractions sit between columns */
33
+ lane?: number;
34
+ /** same column as another block */
35
+ align?: Ref;
36
+ leftOf?: Ref;
37
+ rightOf?: Ref;
38
+ above?: Ref;
39
+ below?: Ref;
40
+ /** gap for leftOf/rightOf/above/below (px) */
41
+ dist?: number;
42
+ /** absolute position (px) */
43
+ at?: V2;
44
+ /** shift after layout (px) */
45
+ nudge?: V2;
46
+ }
47
+ export interface TensorOptions extends NodeOptions {
48
+ size?: [w: number, h: number, depth: number];
49
+ }
50
+ export interface StackOptions extends NodeOptions {
51
+ n?: number;
52
+ size?: V2;
53
+ offset?: number;
54
+ }
55
+ export interface NeuronOptions extends NodeOptions {
56
+ n?: number;
57
+ }
58
+ export interface ImageOptions extends NodeOptions {
59
+ size?: V2;
60
+ fit?: 'cover' | 'contain';
61
+ /** overlay a g×g patch grid (ViT) */
62
+ grid?: number;
63
+ /** draw a batch of n frames */
64
+ n?: number;
65
+ /** degrees: turn the image around its vertical axis, drawn obliquely; positive sends the right edge back (up), negative the left */
66
+ tilt?: number;
67
+ /** degrees: rotate in-plane */
68
+ rotate?: number;
69
+ }
70
+ export interface MagnifyOptions extends NodeOptions {
71
+ /** region of the input image to enlarge: [x, y, w, h] as fractions of its frame */
72
+ region?: [number, number, number, number];
73
+ /** or one cell of the input image's patch grid: [column, row] */
74
+ patch?: [number, number];
75
+ /** 'circle' (default): a magnifying glass; 'square': an inset */
76
+ lens?: 'circle' | 'square';
77
+ /** what the lens shows; default: the region cropped from the input image */
78
+ src?: ImageSource;
79
+ fit?: 'cover' | 'contain';
80
+ size?: V2;
81
+ }
82
+ /**
83
+ * One cell of a matrix / voxels block: a number in [0, 1] is a shade of the block's
84
+ * color, a string is any color, an object can also set texture, label and crossed.
85
+ */
86
+ export type Cell = number | string | {
87
+ value?: number;
88
+ color?: string;
89
+ texture?: Texture;
90
+ label?: string;
91
+ crossed?: boolean;
92
+ };
93
+ export interface MatrixOptions extends NodeOptions {
94
+ rows?: number;
95
+ cols?: number;
96
+ /** per cell, as rows of cells or (row, col) => cell; default: varied shades */
97
+ cells?: Cell[][] | ((r: number, c: number) => Cell);
98
+ /** cell size (px, default 16) */
99
+ cellSize?: number;
100
+ /** names for the extent along each axis, e.g. { rows: '$T$', cols: '$d$' } */
101
+ axes?: {
102
+ rows?: string;
103
+ cols?: string;
104
+ };
105
+ }
106
+ export interface VoxelOptions extends NodeOptions {
107
+ rows?: number;
108
+ cols?: number;
109
+ depth?: number;
110
+ /** per cell, (row, col, depth) => cell with depth 0 in front, or [row][col][depth]; default: varied shades */
111
+ cells?: Cell[][][] | ((r: number, c: number, d: number) => Cell);
112
+ /** cell size (px, default 16) */
113
+ cellSize?: number;
114
+ /** names for the extent along each axis, e.g. { rows: 'H', cols: 'W', depth: 'C' } */
115
+ axes?: {
116
+ rows?: string;
117
+ cols?: string;
118
+ depth?: string;
119
+ };
120
+ }
121
+ export interface GaussianOptions extends NodeOptions {
122
+ /** [x, y, amplitude?, sigma?] in [-1, 1]²; default one centered bump */
123
+ peaks?: [number, number, number?, number?][];
124
+ size?: V2;
125
+ mesh?: number;
126
+ }
127
+ export interface SpaceOptions extends NodeOptions {
128
+ /** [x, y, z, class?] in [-1, 1]³ */
129
+ points?: number[][];
130
+ /** axis names (default x, y, z) */
131
+ axes?: {
132
+ x?: string;
133
+ y?: string;
134
+ z?: string;
135
+ };
136
+ size?: V2;
137
+ }
138
+ export interface PointCloudOptions extends NodeOptions {
139
+ /** [x, y, z, class?] in [-1, 1]³; default: sampled from `shape` */
140
+ points?: number[][];
141
+ shape?: 'torus' | 'sphere' | 'cube';
142
+ /** points sampled when `points` is omitted (default 240) */
143
+ n?: number;
144
+ size?: V2;
145
+ }
146
+ export interface WaveformOptions extends NodeOptions {
147
+ /** raw audio in [-1, 1]; reduced to ≤256 min/max columns. Default: a short synthetic utterance */
148
+ samples?: ArrayLike<number>;
149
+ size?: V2;
150
+ }
151
+ export interface SphereOptions extends NodeOptions {
152
+ /** directions [x, y, z, class?]; normalized onto the sphere */
153
+ points?: number[][];
154
+ size?: V2;
155
+ }
156
+ export interface EdgeOptions {
157
+ label?: string;
158
+ dashed?: boolean;
159
+ arrow?: 'end' | 'both' | 'none';
160
+ thick?: boolean;
161
+ /** all-to-all lines between two neurons() layers */
162
+ dense?: boolean;
163
+ /** force the detour around blocks (residuals usually get it automatically) */
164
+ skip?: boolean;
165
+ curve?: boolean;
166
+ /** side a detour runs on: left / right in TB / BT, top / bottom in LR / RL */
167
+ side?: 'left' | 'right' | 'top' | 'bottom';
168
+ color?: string;
169
+ /** a ✕ on the edge: a cut connection */
170
+ crossed?: boolean;
171
+ /** 'side': come down the source's line and turn into the target's side (an L), e.g. conditioning taps */
172
+ join?: 'side';
173
+ }
174
+ type Rec = {
175
+ id: string;
176
+ mods: Module<any>[];
177
+ groups: string[];
178
+ };
179
+ /** @internal the trace a Figure records into */
180
+ export declare class Graph {
181
+ nodes: Map<Layer<any>, Rec>;
182
+ edges: {
183
+ from: string;
184
+ to: Ref;
185
+ style: EdgeOptions;
186
+ }[];
187
+ groups: Map<Module<any>, Group>;
188
+ scope: Module<any>[];
189
+ ranks: Ref[][];
190
+ ids: Set<string>;
191
+ /** a user id must be unique; an auto id (prefix + counter) skips taken ones */
192
+ claim(id: string | undefined, prefix: string, i: number): string;
193
+ node(L: Layer<any>): string;
194
+ apply(L: Layer<any>, xs: Tensor[]): Tensor;
195
+ enter(m: Module<any>): void;
196
+ id(r: Ref): string;
197
+ }
198
+ export declare class Tensor {
199
+ readonly graph: Graph;
200
+ readonly node: string;
201
+ readonly style: EdgeOptions;
202
+ /** @internal */
203
+ constructor(graph: Graph, node: string, style?: EdgeOptions);
204
+ /** Style the edge(s) this tensor flows into next: x.edge('$r$'), x.edge({ dashed: true }) */
205
+ edge(style: EdgeOptions | string): Tensor;
206
+ }
207
+ export type Shape = 'block' | 'thin' | 'text' | 'circle' | 'op' | 'enc' | 'dec' | 'tensor' | 'stack' | 'neurons' | 'vector' | 'matrix' | 'voxels' | 'image' | 'magnify' | 'gaussian' | 'space' | 'sphere' | 'pointcloud' | 'waveform';
208
+ export interface Layer<O extends NodeOptions = NodeOptions> {
209
+ (...inputs: Tensor[]): Tensor;
210
+ readonly shape: Shape;
211
+ /** preset name, used to look up `kinds` in a style config (e.g. 'mha', 'norm', 'block') */
212
+ readonly kind: string;
213
+ label: string;
214
+ /** what the caller passed; always wins */
215
+ options: O;
216
+ /** what the preset supplies; loses to any style config */
217
+ readonly defaults: NodeOptions;
218
+ /** mark as frozen (🧊) */
219
+ freeze(): this;
220
+ /** mark as trainable (🔥) */
221
+ trainable(): this;
222
+ }
223
+ export declare function layer<O extends NodeOptions>(shape: Shape, label: string, options: O, kind?: string, defaults?: NodeOptions): Layer<O>;
224
+ export type Factory<O extends NodeOptions = NodeOptions> = {
225
+ (label?: string, options?: O): Layer<O>;
226
+ (options?: O): Layer<O>;
227
+ };
228
+ /**
229
+ * Define a block type, the way the built-in presets are defined:
230
+ * const rmsnorm = preset('thin', 'RMSNorm', 'yellow')
231
+ * const moe = preset('block', 'MoE', { color: 'orange', ghost: 3, kind: 'moe' })
232
+ * The third argument is a color or default options. `kind` (default: the shape)
233
+ * is the key a style config's `kinds` uses.
234
+ */
235
+ export declare function preset<O extends NodeOptions = NodeOptions>(shape: Shape, label?: string, defaults?: string | (NodeOptions & {
236
+ kind?: string;
237
+ })): Factory<O>;
238
+ export declare const block: Factory<NodeOptions>;
239
+ export declare const thin: Factory<NodeOptions>;
240
+ export declare const text: Factory<NodeOptions>;
241
+ export declare const circle: Factory<NodeOptions>;
242
+ export declare const op: Factory<NodeOptions>;
243
+ export declare const enc: Factory<NodeOptions>;
244
+ export declare const dec: Factory<NodeOptions>;
245
+ export declare const tensor: Factory<TensorOptions>;
246
+ export declare const stack: Factory<StackOptions>;
247
+ export declare const neurons: Factory<NeuronOptions>;
248
+ export declare function vector(values: string[], a?: string | NodeOptions, b?: NodeOptions): Layer<NodeOptions>;
249
+ export declare const mha: Factory<NodeOptions>;
250
+ export declare const attention: Factory<NodeOptions>;
251
+ export declare const ffn: Factory<NodeOptions>;
252
+ export declare const mlp: Factory<NodeOptions>;
253
+ export declare const norm: Factory<NodeOptions>;
254
+ export declare const embed: Factory<NodeOptions>;
255
+ export declare const linear: Factory<NodeOptions>;
256
+ export declare const softmax: Factory<NodeOptions>;
257
+ export declare const matmul: Factory<NodeOptions>;
258
+ export declare const concat: Factory<NodeOptions>;
259
+ export declare const conv: Factory<NodeOptions>;
260
+ export declare const pool: Factory<NodeOptions>;
261
+ export declare const act: Factory<NodeOptions>;
262
+ export declare const dropout: Factory<NodeOptions>;
263
+ export declare const loss: Factory<NodeOptions>;
264
+ export declare const gaussian: Factory<GaussianOptions>;
265
+ export declare const space: Factory<SpaceOptions>;
266
+ export declare const sphere: Factory<SphereOptions>;
267
+ export declare const pointcloud: Factory<PointCloudOptions>;
268
+ /** Audio as a waveform. Long signals are reduced to min/max columns, so the SVG stays small. */
269
+ export declare function waveform(a?: string | WaveformOptions, b?: WaveformOptions): Layer<WaveformOptions>;
270
+ export type ImageSource = string | {
271
+ currentSrc?: string;
272
+ src?: string;
273
+ } | {
274
+ toDataURL(type?: string): string;
275
+ };
276
+ /**
277
+ * A real image: URL, data URL, <img> or <canvas>. An array draws a stack of
278
+ * frames, the first one in front: image([t0, t1, t2], { tilt: 35 }).
279
+ */
280
+ export declare function image(src: ImageSource | ImageSource[], a?: string | ImageOptions, b?: ImageOptions): Layer<ImageOptions>;
281
+ /**
282
+ * Enlarge part of the image it's called on, like a magnifying glass: magnify({ patch: [1, 2] })(img).
283
+ * `src` puts your own picture in the lens (e.g. a high-resolution crop) instead of the cropped region.
284
+ */
285
+ export declare function magnify(a?: string | MagnifyOptions, b?: MagnifyOptions): Layer<MagnifyOptions>;
286
+ /** A grid of cells, each with its own shade, color, texture or label. */
287
+ export declare function matrix(a?: string | MatrixOptions, b?: MatrixOptions): Layer<MatrixOptions>;
288
+ /** A block of voxels seen from the front, top and right, each cell with its own look. */
289
+ export declare function voxels(a?: string | VoxelOptions, b?: VoxelOptions): Layer<VoxelOptions>;
290
+ export declare const add: (...xs: Tensor[]) => Tensor;
291
+ export declare const mul: (...xs: Tensor[]) => Tensor;
292
+ /** What a style config can set on blocks. */
293
+ export interface Style {
294
+ color?: string;
295
+ texture?: Texture;
296
+ /** label size (px); at figure / configure level it also sizes edge, group and caption text */
297
+ fontSize?: number;
298
+ /** CSS font family; at figure / configure level it sets the whole figure's font */
299
+ fontFamily?: string;
300
+ dashed?: boolean;
301
+ bold?: boolean;
302
+ }
303
+ /** `style` applies to every block in scope, `kinds` to one preset (wins over `style` at the same level). */
304
+ export interface StyleConfig {
305
+ style?: Style;
306
+ kinds?: Record<string, Style>;
307
+ }
308
+ export interface ModuleOptions extends StyleConfig {
309
+ id?: string;
310
+ /** group title */
311
+ label?: string;
312
+ /** side note such as "N×" */
313
+ repeat?: string;
314
+ /** where `repeat` goes; 'auto' (default) takes the first side that collides with nothing */
315
+ repeatSide?: 'auto' | 'top' | 'bottom' | 'left' | 'right';
316
+ /** draw a group box (default: when label or repeat is given) */
317
+ box?: boolean;
318
+ color?: string;
319
+ dashed?: boolean;
320
+ /** texture of the group box */
321
+ texture?: Texture;
322
+ /** 🧊 every block inside, like freeze() */
323
+ frozen?: boolean;
324
+ /** 🔥 every block inside, like trainable() */
325
+ trainable?: boolean;
326
+ }
327
+ export interface Module<O = Tensor> {
328
+ (...inputs: Tensor[]): O;
329
+ }
330
+ /**
331
+ * Subclass and implement forward(), like nn.Module. Instances are callable:
332
+ * `this.attn(x)` inside forward, `model(x)` in a figure.
333
+ */
334
+ export declare abstract class Module<O = Tensor> {
335
+ options: ModuleOptions;
336
+ constructor(options?: ModuleOptions);
337
+ abstract forward(...inputs: Tensor[]): O;
338
+ /** freeze every block created inside (🧊), like requires_grad_(False) */
339
+ freeze(): this;
340
+ /** mark every block created inside as trainable (🔥) */
341
+ trainable(): this;
342
+ }
343
+ export interface FigureOptions extends StyleConfig {
344
+ flow?: Dir;
345
+ theme?: 'light' | 'dark';
346
+ /** [along the flow, across it] in px */
347
+ gap?: V2;
348
+ /** bake WebGL ice / magma when available (default true) */
349
+ materials?: boolean;
350
+ /** a key to the looks used: ❄ frozen, 🔥 trainable, each texture, ✕ crossed */
351
+ legend?: boolean | LegendOptions;
352
+ }
353
+ export interface LegendOptions {
354
+ /** default 'bottom' */
355
+ position?: 'bottom' | 'right';
356
+ /** names to show instead of the defaults, e.g. { trainable: 'Trained', quantized: '4-bit' } */
357
+ labels?: Partial<Record<'frozen' | 'trainable' | Exclude<Texture, 'ice' | 'magma'> | 'crossed', string>>;
358
+ }
359
+ export type { TexEngine };
360
+ /** configure() also takes the TeX engine: MathJax is used automatically when the page has it */
361
+ export interface Config extends FigureOptions {
362
+ /** tex → <svg viewBox> (MathJax tex2svg shape); null = built-in fallback only */
363
+ tex?: TexEngine | null;
364
+ }
365
+ /**
366
+ * Library-wide defaults, like matplotlib's rcParams. Figures, modules and blocks
367
+ * override them; `configure(null)` resets. Returns the current config.
368
+ * configure({ style: { fontSize: 14, color: 'gray' }, kinds: { mha: { color: 'purple' } } })
369
+ */
370
+ export declare function configure(config: Config | null): FigureOptions;
371
+ export declare class Figure {
372
+ readonly options: FigureOptions;
373
+ private g;
374
+ constructor(options?: FigureOptions);
375
+ /** A starting point: a text label, or any layer used as a source (options are added to it). */
376
+ input(what?: string | Layer<any>, options?: NodeOptions): Tensor;
377
+ /** An extra edge that isn't a forward call, e.g. a backward process. */
378
+ edge(from: Tensor, to: Ref, style?: EdgeOptions | string): this;
379
+ /** Put blocks on the same rank (row in TB/BT, column in LR/RL). */
380
+ rank(...refs: Ref[]): this;
381
+ /** Compile to the drawing core's plain data. */
382
+ diagram(): Diagram;
383
+ /** Render to an SVG string (works without a DOM). */
384
+ svg(options?: {
385
+ theme?: 'light' | 'dark';
386
+ }): string;
387
+ /** Export as svg / png / jpeg / webp. Raster formats need a browser. */
388
+ toBlob(options?: ExportOptions): Promise<Blob>;
389
+ /** Save to a file; the format follows the extension: fig.download('figure.png') */
390
+ download(filename?: string, options?: ExportOptions): Promise<void>;
391
+ /** Render into an element (or a CSS selector). */
392
+ mount(target: string | Element, options?: {
393
+ theme?: 'light' | 'dark';
394
+ }): this;
395
+ }
396
+ export type ExportFormat = 'svg' | 'png' | 'jpeg' | 'webp';
397
+ export interface ExportOptions {
398
+ /** default: from the file name in download(), else 'svg' */
399
+ format?: ExportFormat;
400
+ theme?: 'light' | 'dark';
401
+ /** raster resolution multiplier (default 2) */
402
+ scale?: number;
403
+ /** raster background; default transparent (white for jpeg, or the theme's background) */
404
+ background?: string;
405
+ /** jpeg / webp quality 0–1 (default 0.92) */
406
+ quality?: number;
407
+ }
408
+ /** Build a figure in one block: figure(fig => { ... }) or figure({ flow: 'BT' }, fig => { ... }) */
409
+ export declare function figure(build: (fig: Figure) => unknown): Figure;
410
+ export declare function figure(options: FigureOptions, build: (fig: Figure) => unknown): Figure;
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import{writeFileSync as d}from"node:fs";import{join as S,resolve as E}from"node:path";import{pathToFileURL as R}from"node:url";import{parseArgs as L}from"node:util";var l=(t)=>{let e={},r=t.split(" ");for(let i=0;i<r.length;i+=2)e[r[i]]=r[i+1];return e},N=l("alpha α beta β gamma γ delta δ epsilon ϵ varepsilon ε zeta ζ eta η theta θ vartheta ϑ iota ι kappa κ lambda λ mu μ nu ν xi ξ pi π rho ρ sigma σ tau τ upsilon υ phi ϕ varphi φ chi χ psi ψ omega ω Gamma Γ Delta Δ Theta Θ Lambda Λ Xi Ξ Pi Π Sigma Σ Phi Φ Psi Ψ Omega Ω"),C=l("cdot · times × oplus ⊕ otimes ⊗ odot ⊙ to → rightarrow → leftarrow ← gets ← Rightarrow ⇒ leftrightarrow ↔ mapsto ↦ sum ∑ prod ∏ int ∫ infty ∞ partial ∂ nabla ∇ in ∈ notin ∉ sim ∼ approx ≈ propto ∝ leq ≤ le ≤ geq ≥ ge ≥ neq ≠ ne ≠ pm ± mp ∓ ldots … dots … cdots ⋯ vdots ⋮ mid ∣ | ‖ ast ∗ star ⋆ circ ∘ bullet • forall ∀ exists ∃ emptyset ∅ cup ∪ cap ∩ subset ⊂ subseteq ⊆ top ⊤ perp ⊥ langle ⟨ rangle ⟩ ell ℓ hbar ℏ dagger † prime ′ { { } } % % _ _ # # & & $ $"),f={};for(let t of"+−×·±∓⊕⊗⊙∘∗⋆∪∩")f[t]="bin";for(let t of"=<>≤≥≠≈∼∝∈∉⊂⊆→←⇒↔↦∣")f[t]="rel";for(let t of",;")f[t]="punct";f["|"]="mid";for(let t of"([⟨")f[t]="open";for(let t of")]⟩")f[t]="close";var P=new Set("log exp ln sin cos tanh max min arg argmax argmin softmax lim det tr sup inf Pr KL diag sign".split(" ")),I=l("hat ̂ widehat ̂ tilde ̃ widetilde ̃ bar ̄ overline ̅ dot ̇ ddot ̈ vec ⃗"),j=l(",   :   ;   quad   qquad    ! ​"),T=l("B ℬ E ℰ F ℱ H ℋ I ℐ L ℒ M ℳ R ℛ"),B=l("C ℂ H ℍ N ℕ P ℙ Q ℚ R ℝ Z ℤ");var F=new Set("left right big Big bigl bigr Bigl Bigr displaystyle".split(" "));var u={light:{none:["#e2e5e9","#7f8794"],bg:"none",text:"#1d2430",sub:"#5b6474",edge:"#39414f",halo:"#fff",group:["#f5f6f9","#d5dae2"],shadow:"rgba(30,40,60,.10)",dark:!1,ice:["#f7fdff","#d2effb","#aedcf2","#6fb9de"],fire:["#fff6e8","#ffcf9f","#ee7d36"],c:{white:["#ffffff","#8b95a5"],gray:["#eceff3","#9aa3b2"],pink:["#fde1e4","#e58c98"],orange:["#fde4c8","#e69a50"],yellow:["#f4f5cc","#c0c064"],green:["#d8f1de","#68b880"],blue:["#d6eafc","#5f9fd8"],purple:["#e4defa","#9584db"],red:["#ffd7d2","#dd6559"],teal:["#d1f0ec","#48a99f"]}},dark:{none:["#2a2f37","#8a93a2"],bg:"#0e1117",text:"#e8edf5",sub:"#9aa4b5",edge:"#c3cad6",halo:"#0e1117",group:["#161a22","#2b323f"],shadow:"rgba(0,0,0,.45)",dark:!0,ice:["#29506a","#163a50","#0f2c3e","#7fd4ff"],fire:["#4a2a12","#33180a","#ff9447"],c:{white:["#1b2029","#7d8799"],gray:["#1f242d","#8792a4"],pink:["#3a1e29","#ff8ea2"],orange:["#3a2916","#ffae5c"],yellow:["#2f3018","#e3da66"],green:["#142e22","#5ad08a"],blue:["#13273c","#5ab1ff"],purple:["#241e3f","#a892ff"],red:["#3c1b19","#ff786a"],teal:["#11312f","#4cd3c3"]}}};var v=-0.65,y=0.45,et=Math.cos(v),ot=Math.sin(v),rt=Math.cos(y),st=Math.sin(y);var m=`Usage: forwardfig <file> [-o out.svg|.png|.jpg|.webp] [--theme light|dark]
3
+
4
+ Renders the module's default export (a Figure, or a function returning one) to the
5
+ -o file, in the format of its extension, or as SVG to stdout. Without a default export,
6
+ every exported Figure is written as <name>.svg into the -o directory (default: the
7
+ current directory).
8
+
9
+ Options:
10
+ -o, --out <path> output file (default export) or directory (named exports)
11
+ --theme <name> ${Object.keys(u).join(" | ")} (default: the figure's own)
12
+ --scale <n> raster resolution multiplier (default 2)
13
+ --background <css> raster background (default transparent; white for JPEG)
14
+ -h, --help show this help
15
+
16
+ .ts files need Node >= 22.6 (with --experimental-strip-types before 23.6) or Bun.
17
+ PNG / JPEG / WebP need sharp: npm i -D sharp`,g=(t)=>typeof t?.svg==="function",b={png:"png",jpg:"jpeg",jpeg:"jpeg",webp:"webp"};function n(t){process.stderr.write(`forwardfig: ${t}
18
+ `),process.exit(1)}async function A(){let t;try{t=L({allowPositionals:!0,options:{out:{type:"string",short:"o"},theme:{type:"string"},scale:{type:"string"},background:{type:"string"},help:{type:"boolean",short:"h"}}})}catch(o){n(`${o.message}
19
+
20
+ ${m}`)}let{values:e,positionals:r}=t;if(e.help)return void process.stdout.write(m+`
21
+ `);if(r.length!==1)n(`expected one input file
22
+
23
+ ${m}`);if(e.theme&&!(e.theme in u))n(`unknown theme "${e.theme}" (${Object.keys(u).join(", ")})`);let i=Number(e.scale??2);if(!(i>0))n(`--scale must be a positive number, got "${e.scale}"`);let s=e.out&&/\.(\w+)$/.exec(e.out)?.[1].toLowerCase();if(s&&s!=="svg"&&!(s in b))n(`unknown output format ".${s}" (svg, png, jpg, jpeg, webp)`);let a;try{a=await import(R(E(r[0])).href)}catch(o){n(`cannot import ${r[0]}: ${o.message}`)}let c=(o)=>o.svg({theme:e.theme});if(a.default!==void 0){let o=typeof a.default==="function"&&!g(a.default)?await a.default():a.default;if(!g(o))n(`the default export of ${r[0]} is not a Figure`);if(!e.out)process.stdout.write(c(o));else if(s&&s in b)d(e.out,await z(c(o),b[s],i,e.background));else d(e.out,c(o));return}let h=Object.entries(a).filter(([,o])=>g(o));if(!h.length)n(`${r[0]} exports no Figure (export default a Figure, or export named Figures)`);for(let[o,p]of h){let x=S(e.out??".",`${o}.svg`);d(x,c(p)),process.stderr.write(`wrote ${x}
24
+ `)}}async function z(t,e,r,i){let s;try{s=(await import("sharp")).default}catch{n(`${e.toUpperCase()} output needs sharp: npm i -D sharp`)}for(let h of new Set([...t.matchAll(/<image href="(https?:[^"]+)"/g)].map((o)=>o[1])))try{let o=await fetch(h.replace(/&amp;/g,"&")),p=`data:${o.headers.get("content-type")??""};base64,${Buffer.from(await o.arrayBuffer()).toString("base64")}`;t=t.split(`href="${h}"`).join(`href="${p}"`)}catch{}let a=i??(e==="jpeg"?"#ffffff":void 0),c=s(Buffer.from(t),{density:72*r});return(a?c.flatten({background:a}):c).toFormat(e,{quality:92}).toBuffer()}A().catch((t)=>n(t?.stack??String(t)));