Klaus commited on
Commit
ca824b6
·
verified ·
1 Parent(s): 2c1954e

SPRIG v0.1 — research preview release

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ samples.jpg filter=lfs diff=lfs merge=lfs -text
37
+ samples_prompt_bank.jpg filter=lfs diff=lfs merge=lfs -text
38
+ texel_atlas.png filter=lfs diff=lfs merge=lfs -text
DESIGN.md ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPRIG v0.1 — Design & Contracts (single source of truth)
2
+
3
+ Reference docs: full architecture spec `~/workspace/SPRIG-C-architecture.md`; project plan `~/.claude/plans/ok-now-i-ltierally-calm-sifakis.md` (data/eval/infra details).
4
+ v0.1 = finite-lattice Stage A at 64x64. No continuous cuts, no morphogen, no occlusion masks, no DINO channel.
5
+
6
+ ## 1. Repo layout & file ownership
7
+
8
+ ```
9
+ sprig/data/procgen/{vocab,sampler,captions,render,writer}.py # owner: PROCGEN agent
10
+ sprig/data/{dataset,embed_t5}.py, sprig/data/clevr/prep.py # owner: DATA agent
11
+ sprig/dp/{lattice,inside}.py # owner: DP agent
12
+ sprig/model/{gmt,atlas,dl,sprig}.py # owner: MODEL agent
13
+ sprig/eval/{baseline_pixmix,tree_metrics,color_checks,probe,prompts,report,monitors}.py # owner: EVAL agent
14
+ train.py, configs/*.yaml, infra/*.sh # owner: HARNESS agent
15
+ tests/test_<module>_*.py # each agent writes tests for its own modules
16
+ ```
17
+ Python 3.12, torch. No deps beyond: torch, numpy, pillow, transformers, sentencepiece, protobuf, tensorboard, matplotlib, pyyaml, tqdm, einops, pytest. Everything must run CPU-only for tests (no `.cuda()` hardcoded; device from config).
18
+
19
+ ## 2. Global config (configs/main64.yaml values)
20
+
21
+ - Canvas 64x64 RGB. Grid stride g=8 px → 8x8 cells.
22
+ - S=1024 nonterminal symbols, R=64 rule components, T_v=256 texels, d=384 model width.
23
+ - Caption: precomputed T5-base token embeddings [L,768], L≤64.
24
+ - Leaf-eligible regions: both sides ≤ 16 px. Regions with any side > 16 px MUST expand. 8x8 regions MUST terminate.
25
+ - Batch 256. AdamW: lr 3e-4 for embedding tables (E_N, E_T, V, W, P_T, cut-type tables), 1e-4 for GMT + atlas renderer + Φ; betas (0.9, 0.95); cosine decay to 10% over 250k steps, 2k warmup; grad clip 1.0; EMA 0.9999 (eval only).
26
+ - Rule-logit temperature τ_ann: 2.0 → 1.0 linearly over first 50k steps (divide rule/termination logits by τ_ann).
27
+ - 10% of steps: caption replaced by null embedding (dataloader-level).
28
+ - bf16 autocast for matmuls; ALL logsumexp/log-domain accumulation in fp32. Emission DL log-scales clamped [-7, 2].
29
+
30
+ ## 3. Lattice (sprig/dp/lattice.py) — DP agent
31
+
32
+ Region = axis-aligned rect with corners on the 8-px grid, i.e. cell-interval pair. All (C(9,2))²=1296 rects are regions. Precompute once (pure function of canvas/grid config, cache to `.pt`):
33
+ - `regions: int32 [N_reg, 4]` (x0,y0,x1,y1 in px), `region_id` lookup dict.
34
+ - Cuts: for each region, every interior grid line on each axis is a valid cut → children are regions. Global **cut-type vocabulary** for parameter tying: (axis ∈ {H,V}) × (relative offset bucket ∈ 7 buckets, nearest of {1/8..7/8}) = 14 cut types. Per region: `cut_list` of (cut_type_id, child_lo_id, child_hi_id). child_lo = lesser-coordinate child (left for V, top for H) — this ordering is what lets the grammar express left/right, above/below.
35
+ - Level order: by cell area ascending (levels 1..64). Per level: flattened index tensors `parent_ids [M]`, `cut_type [M]`, `child_lo [M]`, `child_hi [M]` for vectorized DP.
36
+ - `leaf_mask [N_reg]` (both sides ≤16), `must_terminate [N_reg]` (8x8), `must_expand [N_reg]` (any side >16), `phi_geom [N_reg, 64]` Fourier features of (log-area, log-aspect, center-x, center-y).
37
+ - Leaf shape groups: leaf regions grouped by (h,w) ∈ {8,16}² for batched emission scoring.
38
+
39
+ ## 4. Model (sprig/model/) — MODEL agent
40
+
41
+ **gmt.py — GrammarModulationTransformer.** Symbol embeddings E_N [S,d] (queries; no symbol-symbol self-attention). 4 blocks: {cross-attn to projected caption tokens (768→d, key_padding_mask from emb_len), FFN d→4d→d, pre-LN}. Output H [B,S,d]. Heads:
42
+ - `U = H @ W_u` → p(k|A,c) logits [B,S,R].
43
+ - Termination: MLP([H_A ; phi_geom(r)]) → logit per (B, region, S) — computed lazily as MLP over H then dot with a geometry projection: implement as `term_logit[b,r,A] = MLP_h(H)[b,A,:] · MLP_g(phi_geom)[r,:] + bias_A` (factorized, cheap).
44
+ - Cut-type distribution p(s|k,c): component embeddings e_k [R,d] cross-attend once to caption → logits [B,R,14]; per region, mask to valid cut types and renormalize (log-softmax over the masked set). NOTE: multiple concrete cuts in a region can share a cut type — split p(s|k,c) mass uniformly across same-type concrete cuts (add -log(count) correction, precomputed per region).
45
+ - Terminal texel prior: p(T|A,c) = Σ_k p(k|A,c) softmax(P_T[k]) with static P_T [R,T_v].
46
+ - Children: static V, W [R,S]: p(B|k)=softmax(V[k]), p(C|k)=softmax(W[k]). (v0.1: no q(B) coupling.)
47
+ - Illumination field Φ(c): mean-pooled caption → MLP → deconv to [B,8,16,16], bilinear-resampled at leaf positions, FiLM (scale,shift per DL-mean channel group) on emission means.
48
+
49
+ **atlas.py — TexelAtlas.** E_T [T_v,d]. Renderer: per texel, cross-attn(E_T row → caption, 2 blocks width 256) → seed [256,4,4] → 2× (conv + 2x nearest-upsample) → atlas [B,T_v,40,16,16]. 40 ch = 4 DL components × (1 weight + 3 means + 3 log-scales + 3 channel-coupling coeffs). Per-texel additive **bias grid** [T_v,40,16,16] (trainable table, no renderer) — the resurrection-writable parameterization (F3.3/M1.2).
50
+ - Emission scoring: for each leaf shape group (h,w): resample atlas 16x16 → (h,w) via adaptive avg pool; FiLM by Φ at leaf position; score pixels under 4-comp discretized logistic with RGB coupling (means: μ_R; μ_G+α·R; μ_B+β·R+γ·G), quantized to 256 bins → `ell [B, n_leaf_in_group, T_v]` summed over pixels. fp32 result.
51
+
52
+ **dl.py** — discretized logistic mixture log-prob (PixelCNN++ style, vectorized, fp32-safe).
53
+
54
+ **sprig.py — SPRIGModel** (implements contracts):
55
+ - `log_marginal(image_u8, emb, emb_len) -> logZ [B]` — full inside DP (tempered during training via `self.eta`; an `eta=0` flag for reported numbers).
56
+ - `loss(batch) -> (loss, metrics_dict)` — see §6.
57
+ - `map_parse(image, emb, emb_len) -> list[ParseNode]` — Viterbi (max-semiring) + backtrace. `ParseNode{rect, axis, cut_px, symbol, texel, children}` (dataclass in sprig/model/sprig.py; texel/leaf fields None for internal nodes).
58
+ - `posterior_usage(image, emb, emb_len) -> dict(symbol_usage [S], texel_usage [T_v], node_entropy, emit_mag, rule_mag, mean_depth, mean_leaves)` — expected counts via the autograd identity: make per-(region,symbol) termination potentials and per-(region,cut) potentials require grad, grad of logZ w.r.t. them = posterior marginals; node entropy = occupancy-weighted entropy of conditional split posteriors (good enough for the PI controller).
59
+ - `sample(emb, emb_len, seed_struct, seed_material, n) -> (images_u8 [n,64,64,3], trees)` — ancestral, breadth-parallel over the frontier; two `torch.Generator`s: structural draws (term/k/s/B/C) from seed_struct, material draws (texel choice + pixel sampling; pixels = DL means for v0.1 crispness, texel choice sampled) from seed_material. Best-of-K: `sample_bestof(emb, K)` reranks K derivations by joint log p(τ, x̂|c).
60
+
61
+ ## 5. Inside DP (sprig/dp/inside.py) — DP agent
62
+
63
+ `inside(ell_leaf, term_logits, cut_logits, U_logmix, logV, logW, lattice, temper_kappa) -> beta [B,N_reg,S] fp32, logZ [B]`
64
+ - β(r,A) for leaf-eligible r: logaddexp( log p_term + logsumexp_T(log p(T|A,c) + ell(r,T)/κ(r)), log(1−p_term) + expand_term(r,A) ) with must_terminate/must_expand masks (∓inf).
65
+ - expand_term via level-synchronous sweep, area ascending. Per level, with index tensors:
66
+ `Bhat[m,k] = logbmm(beta[:, child_lo[m], :], logV.T)`; same for Chat with logW; `comb[m,k] = logsumexp over cuts grouped by parent of (log p(s=cut_type|k,c) − log count_correction + Bhat + Chat)`; `expand[parent,A] = logbmm(comb, U_log.T)` where U_log = log-softmax(U/τ_ann).
67
+ Group-by-parent logsumexp via `torch.segment_reduce` or index_put with scatter-logsumexp helper (write one: max-shift per segment, scatter_add of exp, log). All fp32.
68
+ - `logbmm(x_log [.., K], w_log [K, M])`: max-shifted: `x_log.max(-1)` shift, exp, matmul (bf16 ok), log, add shifts. Provide exact fp32 fallback for tests.
69
+ - Tempering: κ(r) = max(1, area_px(r)^η), η a module buffer. PI controller (in train.py, every 2k steps): target node entropy band [0.5, 3.0] nats; if H < 0.5: η += 0.05 + 0.1·(0.5−H); if H > 3.0: η −= 0.05; clamp [0, 1.5]. Anneal η→0 linearly over final 20k steps; all REPORTED bpd at η=0.
70
+ - Viterbi: same sweep, max instead of logsumexp, argmax backtrace tables per level.
71
+ - CORRECTNESS TEST (blocking): tiny lattice (16x16 canvas, 8px grid → 9 regions), tiny config (S=3, R=2, T_v=2, random weights), brute-force enumerate ALL derivation trees (recursive over cuts/symbols/texels), sum exact joint probs, compare to inside logZ within 1e-4. Also: Viterbi tree's joint prob ≤ logZ; posterior marginals from autograd sum to expected node counts.
72
+
73
+ ## 6. Loss & regularizers
74
+
75
+ ```
76
+ L = mean_b( −logZ_tempered / (3·64·64) ) # nats/subpixel
77
+ + 1.0 · Σ_T max(0, 1/(4·T_v) − texel_usage_T) # texel under-use hinge (F3.2)
78
+ + 0.5 · Σ_A max(0, 1/(4·S) − symbol_usage_A) # symbol under-use hinge (M1-style)
79
+ ```
80
+ usage vectors = batch-mean posterior expected counts, normalized to sum 1 (from `posterior_usage` computed on the training batch — reuse the same graph's grads: compute via one extra backward-free trick or just take grads of logZ before the optimizer backward; simplest correct: compute usage with `torch.autograd.grad(logZ.sum(), potentials, retain_graph=True)`).
81
+ InfoNCE caption-contrast: DEFERRED; add only if caption-swap margin is flat by 50k steps.
82
+ Dead-texel resurrection (train.py, every 2k steps): texels with usage < 0.1/T_v → overwrite bias grid with (a random training-image 16x16 crop converted to DL-mean params, small noise on other channels), perturb E_T row ±ε. Log resurrection count.
83
+
84
+ ## 7. Training curriculum (procedural main run)
85
+
86
+ - Steps 0–200k on proc2d with tier reweighting: 0–20k tiers (0:.55,1:.35,2:.10,3:0); 20k–60k (.25,.35,.30,.10); >60k (.10,.30,.40,.20) [target mix].
87
+ - η per PI controller; τ_ann 2→1 over 50k; report-eta-0 bpd from 180k; final 20k η→0 anneal.
88
+ - CLEVR fine-tune (separate config): init from main ckpt, lr×0.3, 30–50k steps, 20% proc2d replay.
89
+ - Smoke config (configs/smoke.yaml): S=128, R=16, T_v=32, d=128, batch 32, 200 steps, val_fast 64 — must run CPU-only on Mac AND on klaus-1 GPU.
90
+
91
+ ## 8. Contracts (C1–C4) — binding for all agents
92
+
93
+ **C1 batch** (from `sprig/data/dataset.py` collate): `{image: u8 [B,64,64,3], emb: f16 [B,L,768], emb_len: i32 [B], tier: i8 [B], idx: i64 [B]}`. Null-caption substitution (10%, train only) in the dataset/loader using `~/data/sprig/t5/null.f16`.
94
+ **C2** `model.log_marginal(image, emb, emb_len) -> logZ [B]` (η=0 honored when `model.eval()` + `report_mode=True`).
95
+ **C3** `model.map_parse(...)`, `model.posterior_usage(...)` as §4.
96
+ **C4** `model.sample(emb, emb_len, seed_struct, seed_material, n)`, `model.sample_bestof(emb, emb_len, K, seed)`.
97
+ Data formats, eval metrics, prompt bank, run infra: per the plan file §Part 1/3/4 (memmaps, `meta.jsonl` GT-tree schema, 32-prompt bank, `scalars.jsonl`, atomic checkpoints, run_forever.sh) — copy schemas exactly from there.
98
+
99
+ ## 9. Milestone gates (in order, each blocking)
100
+
101
+ - **G0**: full pytest suite green on Mac CPU (incl. DP brute-force test, holdout scan test, determinism hashes, tree-metric self-test).
102
+ - **G1**: overfit 1 image (script `scripts/overfit1.py`): 2k steps tiny config → bpd < 1.0, MAP parse stable across steps.
103
+ - **G2**: overfit 100 images with captions: Δ_c > 0 (caption info gain), texels alive > 50%.
104
+ - **G3**: klaus-1 smoke (30 min real config): >2 steps/s at batch 256 (else profile before main run), dataloader ≥95% synthetic throughput, ckpt kill/resume works, eval callback produces grids/overlays.
105
+ - **G4**: main run launch. Twice-daily monitor checks per plan alarms.
README.md ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ tags:
4
+ - text-to-image
5
+ - scene-grammar
6
+ - probabilistic-grammar
7
+ - research-preview
8
+ - novel-architecture
9
+ library_name: sprig
10
+ pipeline_tag: text-to-image
11
+ ---
12
+
13
+ # SPRIG v0.1 — a text-to-image model where images are *derived*, not denoised
14
+
15
+ **Research preview.** SPRIG (Stochastic Production-Rule Image Grammar) is a
16
+ from-scratch generative architecture that is **not** a diffusion model, not
17
+ autoregressive, not a GAN, not a VAE. A caption modulates the production
18
+ probabilities of a learned **probabilistic scene grammar**; an image is produced
19
+ by a single top-down **derivation** that recursively splits the canvas into
20
+ typed regions, each painted by a learned "texel" material. Training is **exact
21
+ maximum likelihood** — the marginal over *all* derivation trees, computed by an
22
+ inside dynamic program (log-semiring DP). No noise process, no adversary, no
23
+ ELBO, no token ordering.
24
+
25
+ This is **v0.1 at 64×64**: a proof-of-concept for the mechanism. It is honest
26
+ about what works and what does not (see the scorecard). ~16M trainable
27
+ parameters on top of a frozen T5-base caption encoder.
28
+
29
+ <p align="center"><img src="samples.jpg" width="360" alt="SPRIG v0.1 samples"></p>
30
+
31
+ ## What SPRIG does differently
32
+
33
+ | | Diffusion / Flow | Autoregressive | **SPRIG** |
34
+ |---|---|---|---|
35
+ | Generative act | denoise a fixed grid over many steps | predict tokens in an order | **derive a tree**: recursively split the canvas, commit each node once |
36
+ | Latent | noisy image | token prefix | an *unobserved random tree* summed out |
37
+ | Training | denoising / score matching | next-token likelihood | **exact marginal likelihood** via inside DP |
38
+ | Free bonus | — | — | a real **likelihood** + an interpretable **parse** of any image |
39
+
40
+ Because analysis and synthesis are the *same* grammar run in two directions, the
41
+ model can also **parse** a real image (infer its most likely derivation) — see
42
+ `parses.png`. This is the strongest, most novel capability and it works well.
43
+
44
+ ## Honest scorecard (v0.1, 50k steps, held-out procedural scenes)
45
+
46
+ Success criteria were fixed in advance. **Passes 1 of 5** — but the failures are
47
+ localized and understood, not diffuse.
48
+
49
+ | Gate | Target | Result | |
50
+ |---|---|---|---|
51
+ | Likelihood vs. no-grammar baseline | beat by ≥0.15 bpd | **2.66 vs 6.28 bpd** | ✅ crushes it |
52
+ | Caption information gain Δc | ≥ 0.05 | **0.248** | ✅ 5× |
53
+ | Visible-cut parse F1 | ≥ 0.6 | **0.765** | ✅ parsing works |
54
+ | Object-cell parse recall (tier1/2) | ≥ 0.70 / 0.50 | 0.20 / 0.22 | ❌ scenes too busy |
55
+ | Prompt-swap attribute control | ≥ 0.80 | 0.37 | ❌ partial |
56
+ | — size attribute specifically | — | **1.00** | ✅ size binds perfectly |
57
+ | Spatial-relation accuracy | ≥ 0.70 | 0.00 | ❌ |
58
+ | Compositional holdout (unseen combos) | ≥ 0.60 | 0.01 | ❌ |
59
+ | Grammar health (S_eff / alive texels) | ≥256 / ≥50% | 968 / 43% | ⚠️ texels over-pruned |
60
+
61
+ **What this means.** The architecture's structural claims are proven: it models
62
+ data far better than a no-grammar baseline, routes caption information, recovers
63
+ scene structure by parsing, and (after a targeted fix) paints real objects. The
64
+ open problem is **caption→object binding**: the model can draw objects and binds
65
+ *size* perfectly, but does not yet reliably paint the *specific* object a prompt
66
+ asks for, and places too many per scene. That is a conditioning/architecture
67
+ issue targeted by v0.2 — not a matter of more training. The full story
68
+ (including a failure diagnosis that traced an earlier version's blank object
69
+ vocabulary to an emission-weighting/tempering interaction) is in the code repo.
70
+
71
+ ## Usage
72
+
73
+ ```bash
74
+ pip install torch safetensors transformers pillow
75
+ # get the `sprig` package + this file from the code repo, then:
76
+ python inference.py --prompt "a red circle on a white background" --out out.png
77
+ ```
78
+
79
+ ```python
80
+ from inference import load_sprig, sample
81
+ model = load_sprig("sprig-v0.1.safetensors", "config.json") # ~16M params, CPU-friendly
82
+ img = sample(model, "a green triangle", seed=0) # PIL.Image, 64x64
83
+ ```
84
+
85
+ The model outputs native **64×64** images (upscale with nearest-neighbor to
86
+ view). It also returns the derivation tree, so you can inspect *why* each region
87
+ was drawn.
88
+
89
+ ## Files
90
+
91
+ - `sprig-v0.1.safetensors` — EMA-merged inference weights (60.8 MB, fp32, 15.9M params)
92
+ - `config.json` — architecture config + release metadata
93
+ - `inference.py` — minimal load + sample + T5 caption encoding
94
+ - `metrics.json` — full evaluation numbers
95
+ - `samples.jpg`, `texel_atlas.png`, `parses.png` — qualitative outputs
96
+ - `DESIGN.md` — the concrete v0.1 architecture specification
97
+
98
+ ## Training
99
+
100
+ 64×64, 2M procedural compositional scenes (colored shapes with attributes and
101
+ spatial relations, templated dense captions, held-out attribute combinations),
102
+ frozen T5-base captions precomputed. Exact-likelihood objective + closed-form
103
+ grammar-health regularizers. One RTX PRO 6000 Blackwell GPU, ~50k steps.
104
+ Generator: see the companion dataset repo (seeded, deterministic).
105
+
106
+ ## Limitations & intended use
107
+
108
+ Research artifact for studying grammar-based generation and exact-likelihood
109
+ text-to-image. **Not** a production image generator: 64×64, synthetic domain,
110
+ object binding incomplete. Samples are blocky by construction (axis-aligned
111
+ region splits). MIT licensed — build on it.
112
+
113
+ ## Citation
114
+
115
+ ```bibtex
116
+ @software{sprig_v0_1_2026,
117
+ title = {SPRIG: Text-to-Image by a Stochastic Production-Rule Image Grammar (v0.1)},
118
+ year = {2026},
119
+ note = {Research preview. Images are derived by a probabilistic scene grammar
120
+ trained by exact marginal likelihood, not denoised.}
121
+ }
122
+ ```
config.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architecture": "SPRIG",
3
+ "version": "0.1",
4
+ "weights_file": "sprig-v0.1.safetensors",
5
+ "weights_merged": "ema",
6
+ "trained_steps": 50000,
7
+ "canvas_px": 64,
8
+ "grid_px": 8,
9
+ "model": {
10
+ "S": 1024,
11
+ "R": 64,
12
+ "T_v": 256,
13
+ "d": 384,
14
+ "canvas": 64,
15
+ "grid": 8,
16
+ "emb_dim": 768,
17
+ "L_max": 64,
18
+ "leaf_chunk": 8,
19
+ "texel_hinge_weight": 0.5,
20
+ "emission_obj_weight": 12.0
21
+ },
22
+ "note": "Inference-only export: EMA-merged weights, buffers eta/tau are re-initialised at load (tau=1.0, eta=0.0)."
23
+ }
inference.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal inference for SPRIG v0.1 — load the released safetensors and sample.
2
+
3
+ Requires the `sprig` package (https://github.com/ -- or the code repo bundled
4
+ with this release) plus torch, safetensors, transformers (for the T5 caption
5
+ encoder). The model itself is ~16M params and runs on CPU.
6
+
7
+ python inference.py --weights sprig-v0.1.safetensors --config config.json \
8
+ --prompt "a red circle on a white background" --out out.png
9
+
10
+ Programmatic:
11
+
12
+ from inference import load_sprig, sample
13
+ model = load_sprig("sprig-v0.1.safetensors", "config.json")
14
+ img = sample(model, "a green triangle", seed=0) # PIL.Image, 64x64
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ from pathlib import Path
21
+
22
+ import torch
23
+ from PIL import Image
24
+ from safetensors.torch import load_file
25
+
26
+ _T5 = None
27
+
28
+
29
+ def _t5_embed(prompt: str, device: str = "cpu"):
30
+ """Encode a caption with frozen T5-base -> (emb [1,L,768] f16, len [1] i32)."""
31
+ global _T5
32
+ if _T5 is None:
33
+ from transformers import T5EncoderModel, T5TokenizerFast
34
+ tok = T5TokenizerFast.from_pretrained("google-t5/t5-base")
35
+ enc = T5EncoderModel.from_pretrained("google-t5/t5-base").eval().to(device)
36
+ _T5 = (tok, enc)
37
+ tok, enc = _T5
38
+ ids = tok(prompt, return_tensors="pt", truncation=True, max_length=64).to(device)
39
+ with torch.no_grad():
40
+ h = enc(**ids).last_hidden_state # [1, L, 768]
41
+ n = int(ids["attention_mask"].sum())
42
+ return h[:, :n].to(torch.float16), torch.tensor([n], dtype=torch.int32, device=device)
43
+
44
+
45
+ def load_sprig(weights: str, config: str, device: str = "cpu"):
46
+ from sprig.model.sprig import SPRIGModel, SPRIGConfig
47
+ meta = json.loads(Path(config).read_text())
48
+ fields = set(SPRIGConfig.__dataclass_fields__)
49
+ cfg = SPRIGConfig(**{k: v for k, v in meta.get("model", {}).items() if k in fields})
50
+ model = SPRIGModel(cfg)
51
+ model.load_state_dict(load_file(weights), strict=False)
52
+ model.tau.fill_(1.0) # deployment temperature
53
+ model.eta.fill_(0.0) # untempered (exact) emissions
54
+ return model.eval().to(device)
55
+
56
+
57
+ def sample(model, prompt: str, seed: int = 0, device: str = "cpu") -> Image.Image:
58
+ emb, ln = _t5_embed(prompt, device)
59
+ with torch.no_grad():
60
+ imgs, _trees = model.sample(emb, ln, seed_struct=seed, seed_material=seed, n=1)
61
+ return Image.fromarray(imgs[0].cpu().numpy().astype("uint8"))
62
+
63
+
64
+ def main() -> None:
65
+ ap = argparse.ArgumentParser()
66
+ ap.add_argument("--weights", default="sprig-v0.1.safetensors")
67
+ ap.add_argument("--config", default="config.json")
68
+ ap.add_argument("--prompt", required=True)
69
+ ap.add_argument("--seed", type=int, default=0)
70
+ ap.add_argument("--out", default="out.png")
71
+ ap.add_argument("--device", default="cpu")
72
+ ap.add_argument("--upscale", type=int, default=6)
73
+ args = ap.parse_args()
74
+ model = load_sprig(args.weights, args.config, args.device)
75
+ img = sample(model, args.prompt, args.seed, args.device)
76
+ if args.upscale > 1:
77
+ img = img.resize((64 * args.upscale, 64 * args.upscale), Image.NEAREST)
78
+ img.save(args.out)
79
+ print("saved", args.out)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
metrics.json ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "metrics": {
3
+ "bpd_val": 2.6107561223037,
4
+ "bpd_per_tier": {
5
+ "0": 2.2038232899707055,
6
+ "1": 2.3735981695328294,
7
+ "2": 2.6973096096747566,
8
+ "3": 3.006890315533517
9
+ },
10
+ "bpd_tier_ge1": 2.656977891677827,
11
+ "n": 2000,
12
+ "delta_c": 0.24781480995112007,
13
+ "caption_swap_win_frac": 0.25,
14
+ "b0_bpd": 6.28435796590519,
15
+ "tree": {
16
+ "visible_cut_f1": 0.7648812746568768,
17
+ "leaf_ari": 0.5688396790527814,
18
+ "recall_per_tier": {
19
+ "0": 0.19008264462809918,
20
+ "3": 0.4357142857142857,
21
+ "1": 0.20238095238095238,
22
+ "2": 0.22413333333333335
23
+ },
24
+ "recall_tier1": 0.20238095238095238,
25
+ "recall_tier2": 0.22413333333333335
26
+ },
27
+ "prompt_swap": {
28
+ "per_pair": {
29
+ "a red circle -> a blue circle": 0.0625,
30
+ "a green square -> a purple square": 0.015625,
31
+ "a yellow diamond -> a cyan diamond": 0.921875,
32
+ "an orange ring -> a magenta ring": 0.078125,
33
+ "a red circle to the left of a blue square -> a red circle to the right of a blue square": 0.0,
34
+ "a green circle above a yellow square -> a green circle below a yellow square": 0.0,
35
+ "a small purple square -> a large purple square": 1.0,
36
+ "a red circle -> a red square": 0.15625
37
+ },
38
+ "attribute_move": 0.3723958333333333,
39
+ "relation_accuracy": 0.0
40
+ },
41
+ "holdout_probe_acc": 0.01171875,
42
+ "heldout_per_combo": {
43
+ "a blue triangle": 0.03125,
44
+ "a red ring": 0.015625,
45
+ "a green star": 0.0,
46
+ "a yellow cross": 0.0
47
+ },
48
+ "health": {
49
+ "S": 1024,
50
+ "T_v": 256,
51
+ "s_eff": 968.4937738012005,
52
+ "alive_texel_frac": 0.4296875,
53
+ "node_entropy": 0.13297630846500397
54
+ }
55
+ },
56
+ "gates": {
57
+ "1_likelihood": {
58
+ "status": "PASS",
59
+ "detail": "bpd(tier>=1)=2.657 vs B0=6.284 (need margin >= 0.15); delta_c=0.248 (need >= 0.05)"
60
+ },
61
+ "2_parses": {
62
+ "status": "FAIL",
63
+ "detail": "recall t1=0.20 (>= 0.7), t2=0.22 (>= 0.5), cut F1=0.76 (>= 0.6)"
64
+ },
65
+ "3_prompt_control": {
66
+ "status": "FAIL",
67
+ "detail": "attribute-move=0.37 (>= 0.8), relation=0.00 (>= 0.7)"
68
+ },
69
+ "4_compositional": {
70
+ "status": "FAIL",
71
+ "detail": "held-out combo probe acc=0.01 (>= 0.6)"
72
+ },
73
+ "5_health": {
74
+ "status": "FAIL",
75
+ "detail": "S_eff=968 (>= 256), alive texels=43% (>= 50%)"
76
+ }
77
+ }
78
+ }
parses.png ADDED
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ torch
2
+ safetensors
3
+ transformers
4
+ pillow
5
+ numpy
6
+ einops
samples.jpg ADDED

Git LFS Details

  • SHA256: 5d29137e9ff57654bb41398ce7207bb4cb54b8e0fca4497a835afc930158992e
  • Pointer size: 131 Bytes
  • Size of remote file: 116 kB
samples_prompt_bank.jpg ADDED

Git LFS Details

  • SHA256: 6cb5cb9636c126cec6fbcc8d5485c94e867839f573b96778a8088f5ebbda5bfb
  • Pointer size: 131 Bytes
  • Size of remote file: 474 kB
sprig-v0.1.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b7ec1c3e062a9769be6dc0d28a2e078b2cf30bc2a4f351bc350a0d191a30a5f0
3
+ size 63701808
sprig/__init__.py ADDED
File without changes
sprig/data/__init__.py ADDED
File without changes
sprig/data/clevr/__init__.py ADDED
File without changes
sprig/data/clevr/prep.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLEVR v1.0 -> SPRIG memmap preprocessing.
2
+
3
+ CLI:
4
+
5
+ python -m sprig.data.clevr.prep --clevr-root ~/data/CLEVR_v1.0 \
6
+ --split train --out ~/data/sprig/clevr/train [--limit N] [--seed 0]
7
+
8
+ Reads `<clevr-root>/scenes/CLEVR_<split>_scenes.json` and
9
+ `<clevr-root>/images/<split>/<image_filename>` (480x320 renders). Each image
10
+ is center-cropped to x in [80, 400) (full height) -> 320x320, LANCZOS-resized
11
+ to 64x64, and written to the standard SPRIG memmap layout (see
12
+ `sprig/data/dataset.py`). Objects whose `pixel_coords` x-center falls outside
13
+ the crop are dropped from the caption pool; the dropped fraction is logged.
14
+
15
+ Three caption variants are synthesized per image from the scene graph
16
+ (deterministic per (seed, idx)):
17
+
18
+ 0. pairwise relation read off pixel_coords of two sampled visible objects
19
+ ("a large red rubber cube to the left of a small blue metal sphere";
20
+ left/right from pixel x, in front of/behind from pixel y when the
21
+ x-separation is small);
22
+ 1. partial enumeration capped at 4 objects, order shuffled
23
+ ("a scene with a ..., a ..., a ... and a ...");
24
+ 2. count + existence ("a scene with five objects, including a gray cube").
25
+
26
+ Output files: images.u8 [N,64,64,3], meta.jsonl (records with
27
+ "captions": [c0, c1, c2], visible objects, drop counts), meta_offsets.i64
28
+ [N+1] byte offsets, tier_idx/tier0.i64 (CLEVR has no tiers; everything is
29
+ tier 0). Embeddings are computed afterwards by
30
+ `python -m sprig.data.embed_t5 --data-dir <out>`, which detects the
31
+ multi-caption meta and writes emb0/1/2.f16 with their own offsets; the
32
+ dataset then picks one variant per visit (uniform in training).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import os
40
+ from typing import Dict, List, Optional, Tuple
41
+
42
+ import numpy as np
43
+ from PIL import Image
44
+
45
+ CROP_X0 = 80
46
+ CROP_X1 = 400
47
+ IMG_SIZE = 64
48
+ N_CAPTIONS = 3
49
+ # Min horizontal pixel separation (in original 480-px coords) for a
50
+ # left/right relation; below this we use depth-axis (pixel y) instead.
51
+ X_REL_THRESH = 24.0
52
+
53
+ NUM_WORDS = [
54
+ "zero", "one", "two", "three", "four", "five",
55
+ "six", "seven", "eight", "nine", "ten",
56
+ ]
57
+
58
+
59
+ def num_word(n: int) -> str:
60
+ return NUM_WORDS[n] if 0 <= n < len(NUM_WORDS) else str(n)
61
+
62
+
63
+ def obj_phrase(o: Dict) -> str:
64
+ return "a %s %s %s %s" % (o["size"], o["color"], o["material"], o["shape"])
65
+
66
+
67
+ def crop_resize(img: Image.Image) -> np.ndarray:
68
+ """480x320 CLEVR render -> center crop x in [80,400) -> 64x64 u8 RGB."""
69
+ img = img.convert("RGB").crop((CROP_X0, 0, CROP_X1, img.height))
70
+ img = img.resize((IMG_SIZE, IMG_SIZE), Image.LANCZOS)
71
+ return np.asarray(img, dtype=np.uint8)
72
+
73
+
74
+ def visible_objects(scene: Dict) -> Tuple[List[Dict], int]:
75
+ """Objects whose pixel-x center lies inside the crop; also #dropped."""
76
+ kept: List[Dict] = []
77
+ dropped = 0
78
+ for o in scene["objects"]:
79
+ x = float(o["pixel_coords"][0])
80
+ if CROP_X0 <= x < CROP_X1:
81
+ kept.append(o)
82
+ else:
83
+ dropped += 1
84
+ return kept, dropped
85
+
86
+
87
+ def _relation_caption(objects: List[Dict], rng: np.random.Generator) -> str:
88
+ if not objects:
89
+ return "an empty scene"
90
+ if len(objects) == 1:
91
+ return obj_phrase(objects[0])
92
+ i, j = rng.choice(len(objects), size=2, replace=False)
93
+ a, b = objects[int(i)], objects[int(j)]
94
+ ax, ay = float(a["pixel_coords"][0]), float(a["pixel_coords"][1])
95
+ bx, by = float(b["pixel_coords"][0]), float(b["pixel_coords"][1])
96
+ if abs(ax - bx) >= X_REL_THRESH:
97
+ rel = "to the left of" if ax < bx else "to the right of"
98
+ else:
99
+ # Larger pixel y = lower in frame = closer to the camera.
100
+ rel = "in front of" if ay > by else "behind"
101
+ return "%s %s %s" % (obj_phrase(a), rel, obj_phrase(b))
102
+
103
+
104
+ def _enumeration_caption(objects: List[Dict], rng: np.random.Generator) -> str:
105
+ if not objects:
106
+ return "an empty scene"
107
+ order = rng.permutation(len(objects))[:4]
108
+ phrases = [obj_phrase(objects[int(k)]) for k in order]
109
+ if len(phrases) == 1:
110
+ listed = phrases[0]
111
+ else:
112
+ listed = ", ".join(phrases[:-1]) + " and " + phrases[-1]
113
+ return "a scene with " + listed
114
+
115
+
116
+ def _count_caption(objects: List[Dict], rng: np.random.Generator) -> str:
117
+ n = len(objects)
118
+ if n == 0:
119
+ return "an empty scene"
120
+ o = objects[int(rng.integers(n))]
121
+ plural = "object" if n == 1 else "objects"
122
+ return "a scene with %s %s, including %s" % (num_word(n), plural, obj_phrase(o))
123
+
124
+
125
+ def synth_captions(objects: List[Dict], rng: np.random.Generator) -> List[str]:
126
+ """The three caption variants (relation, enumeration, count/existence)."""
127
+ return [
128
+ _relation_caption(objects, rng),
129
+ _enumeration_caption(objects, rng),
130
+ _count_caption(objects, rng),
131
+ ]
132
+
133
+
134
+ def prep_split(
135
+ clevr_root: str,
136
+ split: str,
137
+ out_dir: str,
138
+ limit: Optional[int] = None,
139
+ seed: int = 0,
140
+ ) -> Dict[str, float]:
141
+ scenes_path = os.path.join(clevr_root, "scenes", "CLEVR_%s_scenes.json" % split)
142
+ with open(scenes_path, "r") as f:
143
+ scenes = json.load(f)["scenes"]
144
+ if limit is not None:
145
+ scenes = scenes[: int(limit)]
146
+ n = len(scenes)
147
+ if n == 0:
148
+ raise ValueError("no scenes found in %s" % scenes_path)
149
+
150
+ os.makedirs(out_dir, exist_ok=True)
151
+ os.makedirs(os.path.join(out_dir, "tier_idx"), exist_ok=True)
152
+
153
+ images = np.memmap(
154
+ os.path.join(out_dir, "images.u8"),
155
+ dtype=np.uint8,
156
+ mode="w+",
157
+ shape=(n, IMG_SIZE, IMG_SIZE, 3),
158
+ )
159
+ meta_offsets = np.zeros(n + 1, dtype=np.int64)
160
+ total_objects = 0
161
+ total_dropped = 0
162
+
163
+ with open(os.path.join(out_dir, "meta.jsonl"), "wb") as meta_f:
164
+ for idx, scene in enumerate(scenes):
165
+ img_path = os.path.join(clevr_root, "images", split, scene["image_filename"])
166
+ with Image.open(img_path) as img:
167
+ images[idx] = crop_resize(img)
168
+
169
+ kept, dropped = visible_objects(scene)
170
+ total_objects += len(scene["objects"])
171
+ total_dropped += dropped
172
+ rng = np.random.Generator(np.random.PCG64(np.random.SeedSequence([seed, idx])))
173
+ captions = synth_captions(kept, rng)
174
+
175
+ record = {
176
+ "idx": idx,
177
+ "image_filename": scene["image_filename"],
178
+ "tier": 0,
179
+ "captions": captions,
180
+ "n_objects": len(kept),
181
+ "n_dropped": dropped,
182
+ "objects": [
183
+ {
184
+ "shape": o["shape"],
185
+ "color": o["color"],
186
+ "size": o["size"],
187
+ "material": o["material"],
188
+ "pixel_coords": o["pixel_coords"],
189
+ }
190
+ for o in kept
191
+ ],
192
+ }
193
+ line = (json.dumps(record) + "\n").encode("utf-8")
194
+ meta_f.write(line)
195
+ meta_offsets[idx + 1] = meta_offsets[idx] + len(line)
196
+
197
+ images.flush()
198
+ del images
199
+ meta_offsets.tofile(os.path.join(out_dir, "meta_offsets.i64"))
200
+ np.arange(n, dtype=np.int64).tofile(os.path.join(out_dir, "tier_idx", "tier0.i64"))
201
+
202
+ drop_frac = total_dropped / max(1, total_objects)
203
+ stats = {
204
+ "n_images": float(n),
205
+ "total_objects": float(total_objects),
206
+ "total_dropped": float(total_dropped),
207
+ "drop_frac": drop_frac,
208
+ }
209
+ print(
210
+ "prep %s: %d images; dropped %d/%d objects outside crop (%.2f%%)"
211
+ % (split, n, total_dropped, total_objects, 100.0 * drop_frac),
212
+ flush=True,
213
+ )
214
+ return stats
215
+
216
+
217
+ def main(argv: Optional[List[str]] = None) -> None:
218
+ ap = argparse.ArgumentParser(description=__doc__)
219
+ ap.add_argument("--clevr-root", required=True, help="CLEVR_v1.0 directory")
220
+ ap.add_argument("--split", choices=["train", "val"], default="train")
221
+ ap.add_argument("--out", required=True, help="output dataset directory")
222
+ ap.add_argument("--limit", type=int, default=None)
223
+ ap.add_argument("--seed", type=int, default=0)
224
+ args = ap.parse_args(argv)
225
+ prep_split(args.clevr_root, args.split, args.out, args.limit, args.seed)
226
+
227
+
228
+ if __name__ == "__main__":
229
+ main()
sprig/data/dataset.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SPRIG dataset over precomputed memmap directories (contract C1).
2
+
3
+ On-disk layout of a dataset directory (one directory per split), written by
4
+ the procgen writer (`sprig/data/procgen/writer.py`) or by CLEVR prep
5
+ (`sprig/data/clevr/prep.py`) + `sprig/data/embed_t5.py`:
6
+
7
+ images.u8 uint8 raw memmap, [N, 64, 64, 3]
8
+ emb.f16 float16 packed-ragged token embeddings, [total_tokens, 768]
9
+ emb_offsets.i64 int64 [N+1]; caption i occupies rows offsets[i]:offsets[i+1]
10
+ meta.jsonl one JSON object per sample (caption(s), tier, objects, GT tree, ...)
11
+ meta_offsets.i64 int64 [N+1] byte offsets of line starts into meta.jsonl
12
+ (a trailing entry equal to the file size; an [N]-shaped
13
+ file of line starts is also accepted)
14
+ tier_idx/tier{t}.i64 int64 sample indices belonging to tier t (t = 0..3)
15
+
16
+ Multi-caption variant (CLEVR: 3 synthesized captions per image): instead of a
17
+ single `emb.f16`/`emb_offsets.i64` pair, the directory holds one ragged pair
18
+ per caption variant:
19
+
20
+ emb0.f16 / emb0_offsets.i64
21
+ emb1.f16 / emb1_offsets.i64
22
+ emb2.f16 / emb2_offsets.i64
23
+
24
+ and each meta line stores `"captions": [c0, c1, c2]`. The dataset picks a
25
+ variant uniformly at random per visit when `train=True`, and deterministically
26
+ as `(idx + epoch) % n_variants` when `train=False` (use `set_epoch` to rotate).
27
+
28
+ Batch contract C1 (produced by `collate`):
29
+ {image: u8 [B,64,64,3], emb: f16 [B,Lmax,768] zero-padded,
30
+ emb_len: i32 [B], tier: i8 [B], idx: i64 [B]}
31
+
32
+ Null-caption substitution: with probability `p_null` (train only) the caption
33
+ embedding is replaced by the precomputed empty-string embedding loaded from
34
+ `null_emb_path` (packed f16, shape [L0, 768], written by
35
+ `embed_t5.py --null-out`).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import math
42
+ import os
43
+ from typing import Dict, Iterator, List, Optional, Sequence, Tuple
44
+
45
+ import numpy as np
46
+ import torch
47
+ from torch.utils.data import Dataset, Sampler
48
+
49
+ EMB_DIM = 768
50
+ IMG_SIZE = 64
51
+ MAX_TIERS = 4
52
+
53
+
54
+ def load_null_emb(path: str) -> np.ndarray:
55
+ """Load the packed-f16 null (empty caption) embedding -> [L0, 768]."""
56
+ arr = np.fromfile(path, dtype=np.float16)
57
+ if arr.size == 0 or arr.size % EMB_DIM != 0:
58
+ raise ValueError("null embedding file %r has invalid size %d" % (path, arr.size))
59
+ return arr.reshape(-1, EMB_DIM)
60
+
61
+
62
+ def load_tier_indices(root: str, n: Optional[int] = None) -> List[np.ndarray]:
63
+ """Load per-tier index arrays from root/tier_idx/tier{t}.i64.
64
+
65
+ If the directory is absent, returns a single tier containing all indices
66
+ (requires `n`).
67
+ """
68
+ tier_dir = os.path.join(root, "tier_idx")
69
+ if not os.path.isdir(tier_dir):
70
+ if n is None:
71
+ raise FileNotFoundError("no tier_idx/ in %r and n not given" % root)
72
+ return [np.arange(n, dtype=np.int64)]
73
+ tiers: List[np.ndarray] = []
74
+ for t in range(MAX_TIERS):
75
+ p = os.path.join(tier_dir, "tier%d.i64" % t)
76
+ if os.path.exists(p):
77
+ tiers.append(np.fromfile(p, dtype=np.int64))
78
+ elif t == 0:
79
+ tiers.append(np.zeros(0, dtype=np.int64))
80
+ else:
81
+ break
82
+ return tiers
83
+
84
+
85
+ def read_meta(root: str, idx: int) -> Dict:
86
+ """Random-access read of one meta.jsonl record using meta_offsets.i64."""
87
+ offsets = np.fromfile(os.path.join(root, "meta_offsets.i64"), dtype=np.int64)
88
+ with open(os.path.join(root, "meta.jsonl"), "rb") as f:
89
+ f.seek(int(offsets[idx]))
90
+ line = f.readline()
91
+ return json.loads(line)
92
+
93
+
94
+ class SprigDataset(Dataset):
95
+ """Dataset over a memmap directory; see module docstring for the layout."""
96
+
97
+ def __init__(
98
+ self,
99
+ root: str,
100
+ p_null: float = 0.0,
101
+ null_emb_path: Optional[str] = None,
102
+ train: bool = True,
103
+ seed: int = 0,
104
+ emit_obj_mask: bool = False,
105
+ ) -> None:
106
+ self.root = root
107
+ self.train = train
108
+ self.p_null = float(p_null)
109
+ self.seed = seed
110
+ # Object-pixel masks from GT bboxes in meta.jsonl (for the weighted
111
+ # emission loss / object-crop resurrection). Offsets loaded eagerly;
112
+ # the meta file handle is opened lazily per worker process.
113
+ self.emit_obj_mask = bool(emit_obj_mask)
114
+ self._meta_offsets: Optional[np.ndarray] = None
115
+ self._meta_file = None
116
+ if self.emit_obj_mask:
117
+ self._meta_offsets = np.fromfile(
118
+ os.path.join(root, "meta_offsets.i64"), dtype=np.int64)
119
+
120
+ flat = np.memmap(os.path.join(root, "images.u8"), dtype=np.uint8, mode="r")
121
+ if flat.size % (IMG_SIZE * IMG_SIZE * 3) != 0:
122
+ raise ValueError("images.u8 in %r has size not divisible by 64*64*3" % root)
123
+ self.images = flat.reshape(-1, IMG_SIZE, IMG_SIZE, 3)
124
+ self.n = self.images.shape[0]
125
+
126
+ # Embedding variants: single (emb.f16) or multi (emb0.f16, emb1.f16, ...).
127
+ pairs: List[Tuple[str, str]] = []
128
+ if os.path.exists(os.path.join(root, "emb.f16")):
129
+ pairs.append(("emb.f16", "emb_offsets.i64"))
130
+ else:
131
+ v = 0
132
+ while os.path.exists(os.path.join(root, "emb%d.f16" % v)):
133
+ pairs.append(("emb%d.f16" % v, "emb%d_offsets.i64" % v))
134
+ v += 1
135
+ if not pairs:
136
+ raise FileNotFoundError("no emb.f16 or emb0.f16 found in %r" % root)
137
+ self.emb: List[np.ndarray] = []
138
+ self.emb_offsets: List[np.ndarray] = []
139
+ for emb_name, off_name in pairs:
140
+ off = np.fromfile(os.path.join(root, off_name), dtype=np.int64)
141
+ if off.shape[0] != self.n + 1:
142
+ raise ValueError(
143
+ "%s has %d entries, expected N+1=%d" % (off_name, off.shape[0], self.n + 1)
144
+ )
145
+ emb = np.memmap(os.path.join(root, emb_name), dtype=np.float16, mode="r")
146
+ emb = emb.reshape(-1, EMB_DIM)
147
+ if emb.shape[0] != int(off[-1]):
148
+ raise ValueError("%s row count %d != offsets[-1]=%d" % (emb_name, emb.shape[0], int(off[-1])))
149
+ self.emb.append(emb)
150
+ self.emb_offsets.append(off)
151
+ self.n_variants = len(self.emb)
152
+
153
+ # Per-sample tier from the tier index arrays (default tier 0).
154
+ self.tier = np.zeros(self.n, dtype=np.int8)
155
+ tier_dir = os.path.join(root, "tier_idx")
156
+ if os.path.isdir(tier_dir):
157
+ for t in range(MAX_TIERS):
158
+ p = os.path.join(tier_dir, "tier%d.i64" % t)
159
+ if os.path.exists(p):
160
+ idxs = np.fromfile(p, dtype=np.int64)
161
+ self.tier[idxs] = t
162
+
163
+ self.null_emb: Optional[np.ndarray] = None
164
+ if null_emb_path is not None:
165
+ self.null_emb = load_null_emb(null_emb_path)
166
+ if self.p_null > 0.0 and self.null_emb is None:
167
+ raise ValueError("p_null > 0 requires null_emb_path")
168
+
169
+ self._epoch = 0
170
+ self._rng: Optional[np.random.Generator] = None
171
+
172
+ def set_epoch(self, epoch: int) -> None:
173
+ """Rotate the deterministic caption-variant choice (eval mode)."""
174
+ self._epoch = int(epoch)
175
+
176
+ def _get_rng(self) -> np.random.Generator:
177
+ # Created lazily per worker process so forked workers do not share a
178
+ # RNG state. Null substitution / variant choice are i.i.d. per visit
179
+ # and are not part of the checkpointable state (the sampler is).
180
+ if self._rng is None:
181
+ info = torch.utils.data.get_worker_info()
182
+ wid = 0 if info is None else info.id
183
+ ss = np.random.SeedSequence([self.seed, wid, os.getpid()])
184
+ self._rng = np.random.Generator(np.random.PCG64(ss))
185
+ return self._rng
186
+
187
+ def __len__(self) -> int:
188
+ return self.n
189
+
190
+ def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
191
+ idx = int(idx)
192
+ image = torch.from_numpy(np.array(self.images[idx])) # copy off the memmap
193
+
194
+ use_null = False
195
+ if self.train and self.p_null > 0.0:
196
+ use_null = bool(self._get_rng().random() < self.p_null)
197
+
198
+ if use_null:
199
+ emb_np = self.null_emb
200
+ else:
201
+ if self.n_variants == 1:
202
+ v = 0
203
+ elif self.train:
204
+ v = int(self._get_rng().integers(self.n_variants))
205
+ else:
206
+ v = (idx + self._epoch) % self.n_variants
207
+ off = self.emb_offsets[v]
208
+ lo, hi = int(off[idx]), int(off[idx + 1])
209
+ emb_np = self.emb[v][lo:hi]
210
+ emb = torch.from_numpy(np.array(emb_np, dtype=np.float16)) # copy off the memmap
211
+
212
+ out = {
213
+ "image": image,
214
+ "emb": emb,
215
+ "emb_len": torch.tensor(emb.shape[0], dtype=torch.int32),
216
+ "tier": torch.tensor(int(self.tier[idx]), dtype=torch.int8),
217
+ "idx": torch.tensor(idx, dtype=torch.int64),
218
+ }
219
+ if self.emit_obj_mask:
220
+ out["objmask"] = self._obj_mask(idx)
221
+ return out
222
+
223
+ def _obj_mask(self, idx: int) -> torch.Tensor:
224
+ """u8 [IMG_SIZE, IMG_SIZE]: 1 inside any GT object bbox, else 0."""
225
+ if self._meta_file is None:
226
+ self._meta_file = open(os.path.join(self.root, "meta.jsonl"), "rb")
227
+ self._meta_file.seek(int(self._meta_offsets[idx]))
228
+ m = json.loads(self._meta_file.readline())
229
+ mask = torch.zeros(IMG_SIZE, IMG_SIZE, dtype=torch.uint8)
230
+ for o in m.get("objects") or []:
231
+ bb = o.get("bbox") or o.get("cell")
232
+ if not bb or len(bb) != 4:
233
+ continue
234
+ x0 = max(0, int(math.floor(float(bb[0]))))
235
+ y0 = max(0, int(math.floor(float(bb[1]))))
236
+ x1 = min(IMG_SIZE, int(math.ceil(float(bb[2]))))
237
+ y1 = min(IMG_SIZE, int(math.ceil(float(bb[3]))))
238
+ if x1 > x0 and y1 > y0:
239
+ mask[y0:y1, x0:x1] = 1
240
+ return mask
241
+
242
+
243
+ def collate(items: Sequence[Dict[str, torch.Tensor]]) -> Dict[str, torch.Tensor]:
244
+ """Collate to contract C1; ragged embeddings zero-padded to the batch max."""
245
+ b = len(items)
246
+ lens = [int(it["emb_len"]) for it in items]
247
+ lmax = max(1, max(lens))
248
+ emb = torch.zeros(b, lmax, EMB_DIM, dtype=torch.float16)
249
+ for i, it in enumerate(items):
250
+ li = lens[i]
251
+ if li > 0:
252
+ emb[i, :li] = it["emb"]
253
+ out = {
254
+ "image": torch.stack([it["image"] for it in items]),
255
+ "emb": emb,
256
+ "emb_len": torch.tensor(lens, dtype=torch.int32),
257
+ "tier": torch.stack([it["tier"] for it in items]),
258
+ "idx": torch.stack([it["idx"] for it in items]),
259
+ }
260
+ if "objmask" in items[0]:
261
+ out["objmask"] = torch.stack([it["objmask"] for it in items])
262
+ return out
263
+
264
+
265
+ class TierCurriculumSampler(Sampler):
266
+ """Infinite sampler drawing tiers according to a stepwise weight schedule.
267
+
268
+ schedule: list of (step_start, [w0..w_{T-1}]) pairs; the entry with the
269
+ largest step_start <= current training step is active. `batch_size`
270
+ converts sample draws into training steps (step = draws // batch_size), so
271
+ schedules can be written in optimizer steps as in DESIGN.md section 7.
272
+
273
+ Checkpointable via state_dict/load_state_dict (RNG state + draw counter).
274
+ Iterate it from the main process (the default for torch DataLoader
275
+ samplers) so the state advances where checkpoints are taken; with
276
+ num_workers > 0 the prefetch queue makes resumes approximate by up to
277
+ (num_workers * prefetch_factor) batches, which is acceptable.
278
+ """
279
+
280
+ def __init__(
281
+ self,
282
+ tier_indices: Sequence[np.ndarray],
283
+ schedule: Sequence[Tuple[int, Sequence[float]]],
284
+ batch_size: int = 1,
285
+ seed: int = 0,
286
+ ) -> None:
287
+ self.tier_indices = [np.asarray(a, dtype=np.int64) for a in tier_indices]
288
+ if not self.tier_indices:
289
+ raise ValueError("tier_indices is empty")
290
+ sched = sorted(((int(s), list(map(float, w))) for s, w in schedule), key=lambda x: x[0])
291
+ if not sched or sched[0][0] != 0:
292
+ raise ValueError("schedule must start at step 0")
293
+ for _, w in sched:
294
+ if len(w) != len(self.tier_indices):
295
+ raise ValueError("schedule weight vectors must have one entry per tier")
296
+ self.schedule = sched
297
+ self.batch_size = int(batch_size)
298
+ self.seed = seed
299
+ self._draws = 0
300
+ self._rng = np.random.Generator(np.random.PCG64(seed))
301
+
302
+ def _weights_at(self, step: int) -> np.ndarray:
303
+ w = self.schedule[0][1]
304
+ for s0, wi in self.schedule:
305
+ if s0 <= step:
306
+ w = wi
307
+ else:
308
+ break
309
+ w = np.asarray(w, dtype=np.float64)
310
+ # Zero out empty tiers so we never draw from them.
311
+ for t, arr in enumerate(self.tier_indices):
312
+ if arr.size == 0:
313
+ w[t] = 0.0
314
+ total = w.sum()
315
+ if total <= 0.0:
316
+ raise ValueError("all active tiers at step %d are empty or zero-weight" % step)
317
+ return w / total
318
+
319
+ def __iter__(self) -> Iterator[int]:
320
+ n_tiers = len(self.tier_indices)
321
+ while True:
322
+ step = self._draws // self.batch_size
323
+ w = self._weights_at(step)
324
+ t = int(self._rng.choice(n_tiers, p=w))
325
+ arr = self.tier_indices[t]
326
+ i = int(arr[int(self._rng.integers(arr.size))])
327
+ self._draws += 1
328
+ yield i
329
+
330
+ def state_dict(self) -> Dict:
331
+ return {"draws": self._draws, "rng_state": self._rng.bit_generator.state}
332
+
333
+ def load_state_dict(self, state: Dict) -> None:
334
+ self._draws = int(state["draws"])
335
+ self._rng = np.random.Generator(np.random.PCG64(self.seed))
336
+ self._rng.bit_generator.state = state["rng_state"]
sprig/data/embed_t5.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Precompute frozen T5-base caption embeddings for SPRIG datasets.
2
+
3
+ CLI (run once per dataset directory; the encoder never runs during training):
4
+
5
+ python -m sprig.data.embed_t5 --data-dir ~/data/sprig/proc2d/train
6
+ python -m sprig.data.embed_t5 --null-out ~/data/sprig/t5/null.f16
7
+ python -m sprig.data.embed_t5 --prompts-out ~/data/sprig/t5/promptbank.npz \
8
+ [--prompts-file prompts.json]
9
+
10
+ --data-dir reads captions from <dir>/meta.jsonl in order and writes
11
+ packed-ragged fp16 token embeddings (valid tokens only, per the tokenizer
12
+ attention mask) plus int64 offsets [N+1]:
13
+
14
+ * single-caption meta ({"caption": ...}) -> emb.f16 / emb_offsets.i64
15
+ * multi-caption meta ({"captions": [c0..ck]}) -> emb{v}.f16 / emb{v}_offsets.i64
16
+ for every variant v (or just one with --variant V).
17
+
18
+ Captions are tokenized with truncation to --max-len (64) tokens,
19
+ length-bucketed within --chunk-size (100k) caption chunks, encoded in batches
20
+ of --batch-size (512), and written back in the original caption order.
21
+
22
+ --null-out embeds the empty string (a single </s> token embedding) to a
23
+ packed f16 file — the null-caption substitute used by the dataloader (C1).
24
+
25
+ --prompts-out embeds a JSON list of prompts (from --prompts-file, else from
26
+ `sprig.eval.prompts.PROMPTS`, imported lazily) into an npz with keys
27
+ {emb: f16 [P, Lmax, 768] zero-padded, len: i32 [P], prompts: str array}.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import os
35
+ from typing import Callable, Iterator, List, Optional, Sequence, Tuple
36
+
37
+ import numpy as np
38
+
39
+ EMB_DIM = 768
40
+ DEFAULT_MODEL = "google-t5/t5-base"
41
+
42
+
43
+ def read_meta_captions(meta_path: str) -> Tuple[List[str], int]:
44
+ """Read captions from meta.jsonl.
45
+
46
+ Returns (flat_captions, n_variants): for single-caption meta the flat list
47
+ is the N captions and n_variants == 1; for multi-caption meta the list is
48
+ variant-major, i.e. [all c0, then all c1, ...], length N * n_variants.
49
+ """
50
+ singles: List[str] = []
51
+ multis: List[List[str]] = []
52
+ with open(meta_path, "r") as f:
53
+ for line in f:
54
+ line = line.strip()
55
+ if not line:
56
+ continue
57
+ d = json.loads(line)
58
+ if "captions" in d:
59
+ multis.append(list(d["captions"]))
60
+ else:
61
+ singles.append(d["caption"])
62
+ if singles and multis:
63
+ raise ValueError("meta.jsonl mixes 'caption' and 'captions' records")
64
+ if singles:
65
+ return singles, 1
66
+ if not multis:
67
+ return [], 1
68
+ n_var = len(multis[0])
69
+ for caps in multis:
70
+ if len(caps) != n_var:
71
+ raise ValueError("inconsistent number of caption variants in meta.jsonl")
72
+ flat: List[str] = []
73
+ for v in range(n_var):
74
+ flat.extend(caps[v] for caps in multis)
75
+ return flat, n_var
76
+
77
+
78
+ def embed_corpus(
79
+ captions: Sequence[str],
80
+ encode_batch: Callable[[List[str]], List[np.ndarray]],
81
+ token_len: Callable[[str], int],
82
+ batch_size: int = 512,
83
+ chunk_size: int = 100000,
84
+ ) -> Iterator[np.ndarray]:
85
+ """Yield one [L_i, 768] f16 array per caption, in the original order.
86
+
87
+ Within each chunk of `chunk_size` captions, indices are sorted by token
88
+ length so batches are near-uniform in length (minimal padding waste), then
89
+ the results are restored to the original order before yielding.
90
+ """
91
+ n = len(captions)
92
+ for c0 in range(0, n, chunk_size):
93
+ chunk = list(captions[c0 : c0 + chunk_size])
94
+ lens = np.asarray([token_len(c) for c in chunk], dtype=np.int64)
95
+ order = np.argsort(lens, kind="stable")
96
+ results: List[Optional[np.ndarray]] = [None] * len(chunk)
97
+ for b0 in range(0, len(order), batch_size):
98
+ batch_idx = order[b0 : b0 + batch_size]
99
+ embs = encode_batch([chunk[int(j)] for j in batch_idx])
100
+ if len(embs) != len(batch_idx):
101
+ raise RuntimeError("encode_batch returned wrong number of embeddings")
102
+ for j, e in zip(batch_idx, embs):
103
+ results[int(j)] = np.asarray(e, dtype=np.float16)
104
+ for r in results:
105
+ assert r is not None
106
+ if r.ndim != 2 or r.shape[1] != EMB_DIM:
107
+ raise RuntimeError("embedding has shape %r, expected [L, %d]" % (r.shape, EMB_DIM))
108
+ yield r
109
+
110
+
111
+ def write_packed(
112
+ arrays: Iterator[np.ndarray], n: int, emb_path: str, offsets_path: str
113
+ ) -> np.ndarray:
114
+ """Stream n ragged [L,768] f16 arrays to a packed file + [N+1] offsets."""
115
+ offsets = np.zeros(n + 1, dtype=np.int64)
116
+ tmp = emb_path + ".tmp"
117
+ count = 0
118
+ with open(tmp, "wb") as f:
119
+ for i, a in enumerate(arrays):
120
+ if i >= n:
121
+ raise RuntimeError("more arrays than expected (n=%d)" % n)
122
+ a.astype(np.float16).tofile(f)
123
+ offsets[i + 1] = offsets[i] + a.shape[0]
124
+ count += 1
125
+ if count != n:
126
+ os.remove(tmp)
127
+ raise RuntimeError("expected %d arrays, got %d" % (n, count))
128
+ os.replace(tmp, emb_path)
129
+ offsets.tofile(offsets_path)
130
+ return offsets
131
+
132
+
133
+ def _load_t5_encoder_nommap(model_name: str, dtype):
134
+ """Load T5EncoderModel with weights read via plain file reads (no mmap).
135
+
136
+ transformers/safetensors normally mmap the checkpoint and materialize
137
+ weight pages lazily at forward time; on memory-constrained hosts (e.g. an
138
+ 8 GB Mac under pressure) those page-ins can SIGBUS the process. Reading
139
+ the bytes up front and deserializing in memory avoids the mmap entirely.
140
+ Only handles single-file safetensors checkpoints; callers fall back to
141
+ from_pretrained otherwise.
142
+ """
143
+ import torch
144
+ from huggingface_hub import hf_hub_download
145
+ from safetensors.torch import load as st_load
146
+ from transformers import AutoConfig, T5EncoderModel
147
+
148
+ cfg = AutoConfig.from_pretrained(model_name)
149
+ path = hf_hub_download(model_name, "model.safetensors")
150
+ with open(path, "rb") as f:
151
+ data = f.read()
152
+ sd = st_load(data)
153
+ del data
154
+ model = T5EncoderModel(cfg)
155
+ # The checkpoint carries the full T5; keep encoder weights only
156
+ # (embed_tokens is tied to `shared` at construction).
157
+ model.load_state_dict(sd, strict=False)
158
+ model.tie_weights()
159
+ return model.to(dtype)
160
+
161
+
162
+ def make_t5_encoder(
163
+ model_name: str = DEFAULT_MODEL, device: str = "cpu", max_len: int = 64
164
+ ) -> Tuple[Callable[[str], int], Callable[[List[str]], List[np.ndarray]]]:
165
+ """Build (token_len, encode_batch) over a frozen T5 encoder.
166
+
167
+ bf16 on cuda, fp32 on cpu; outputs are cast to fp16 numpy arrays holding
168
+ valid (attention-masked) token embeddings only.
169
+ """
170
+ import torch
171
+ from transformers import AutoTokenizer, T5EncoderModel
172
+
173
+ tok = AutoTokenizer.from_pretrained(model_name)
174
+ dtype = torch.bfloat16 if device.startswith("cuda") else torch.float32
175
+ try:
176
+ model = _load_t5_encoder_nommap(model_name, dtype)
177
+ except Exception:
178
+ model = T5EncoderModel.from_pretrained(model_name, dtype=dtype)
179
+ model = model.to(device).eval()
180
+
181
+ def token_len(text: str) -> int:
182
+ return len(tok(text, truncation=True, max_length=max_len).input_ids)
183
+
184
+ def encode_batch(texts: List[str]) -> List[np.ndarray]:
185
+ enc = tok(
186
+ texts,
187
+ truncation=True,
188
+ max_length=max_len,
189
+ padding=True,
190
+ return_tensors="pt",
191
+ ).to(device)
192
+ with torch.no_grad():
193
+ out = model(**enc).last_hidden_state # [B, L, 768]
194
+ mask = enc.attention_mask.bool()
195
+ return [
196
+ out[i, mask[i]].to(torch.float16).cpu().numpy() for i in range(len(texts))
197
+ ]
198
+
199
+ return token_len, encode_batch
200
+
201
+
202
+ def _load_prompts(prompts_file: Optional[str]) -> List[str]:
203
+ if prompts_file is not None:
204
+ with open(prompts_file, "r") as f:
205
+ prompts = json.load(f)
206
+ if not isinstance(prompts, list):
207
+ raise ValueError("--prompts-file must contain a JSON list of strings")
208
+ return [str(p) for p in prompts]
209
+ from sprig.eval.prompts import PROMPTS # lazy: eval module may not exist yet
210
+
211
+ return list(PROMPTS)
212
+
213
+
214
+ def _embed_data_dir(
215
+ data_dir: str,
216
+ variant: Optional[int],
217
+ token_len: Callable[[str], int],
218
+ encode_batch: Callable[[List[str]], List[np.ndarray]],
219
+ batch_size: int,
220
+ chunk_size: int,
221
+ ) -> None:
222
+ captions, n_var = read_meta_captions(os.path.join(data_dir, "meta.jsonl"))
223
+ n = len(captions) // n_var
224
+ variants = range(n_var) if variant is None else [variant]
225
+ for v in variants:
226
+ if not (0 <= v < n_var):
227
+ raise ValueError("--variant %d out of range (meta has %d variants)" % (v, n_var))
228
+ caps_v = captions[v * n : (v + 1) * n]
229
+ if n_var == 1:
230
+ emb_path = os.path.join(data_dir, "emb.f16")
231
+ off_path = os.path.join(data_dir, "emb_offsets.i64")
232
+ else:
233
+ emb_path = os.path.join(data_dir, "emb%d.f16" % v)
234
+ off_path = os.path.join(data_dir, "emb%d_offsets.i64" % v)
235
+ arrays = embed_corpus(caps_v, encode_batch, token_len, batch_size, chunk_size)
236
+ offsets = write_packed(arrays, n, emb_path, off_path)
237
+ print(
238
+ "wrote %s: %d captions, %d tokens" % (emb_path, n, int(offsets[-1])),
239
+ flush=True,
240
+ )
241
+
242
+
243
+ def main(argv: Optional[List[str]] = None) -> None:
244
+ ap = argparse.ArgumentParser(description=__doc__)
245
+ ap.add_argument("--data-dir", default=None, help="dataset dir with meta.jsonl")
246
+ ap.add_argument(
247
+ "--variant",
248
+ type=int,
249
+ default=None,
250
+ help="embed only this caption variant (default: all variants in meta)",
251
+ )
252
+ ap.add_argument("--model", default=DEFAULT_MODEL)
253
+ ap.add_argument("--device", default=None, help="cpu/cuda (default: auto)")
254
+ ap.add_argument("--batch-size", type=int, default=512)
255
+ ap.add_argument("--max-len", type=int, default=64)
256
+ ap.add_argument("--chunk-size", type=int, default=100000)
257
+ ap.add_argument("--null-out", default=None, help="write empty-caption embedding here")
258
+ ap.add_argument("--prompts-out", default=None, help="write promptbank.npz here")
259
+ ap.add_argument("--prompts-file", default=None, help="JSON list of prompts")
260
+ args = ap.parse_args(argv)
261
+
262
+ if args.data_dir is None and args.null_out is None and args.prompts_out is None:
263
+ ap.error("nothing to do: give --data-dir, --null-out, and/or --prompts-out")
264
+
265
+ device = args.device
266
+ if device is None:
267
+ import torch
268
+
269
+ device = "cuda" if torch.cuda.is_available() else "cpu"
270
+
271
+ token_len, encode_batch = make_t5_encoder(args.model, device, args.max_len)
272
+
273
+ if args.data_dir is not None:
274
+ _embed_data_dir(
275
+ args.data_dir,
276
+ args.variant,
277
+ token_len,
278
+ encode_batch,
279
+ args.batch_size,
280
+ args.chunk_size,
281
+ )
282
+
283
+ if args.null_out is not None:
284
+ null = encode_batch([""])[0]
285
+ os.makedirs(os.path.dirname(os.path.abspath(args.null_out)), exist_ok=True)
286
+ null.astype(np.float16).tofile(args.null_out)
287
+ print("wrote %s: [%d, %d]" % (args.null_out, null.shape[0], null.shape[1]))
288
+
289
+ if args.prompts_out is not None:
290
+ prompts = _load_prompts(args.prompts_file)
291
+ embs = encode_batch(prompts) if prompts else []
292
+ lens = np.asarray([e.shape[0] for e in embs], dtype=np.int32)
293
+ lmax = int(lens.max()) if len(embs) else 1
294
+ emb = np.zeros((len(prompts), lmax, EMB_DIM), dtype=np.float16)
295
+ for i, e in enumerate(embs):
296
+ emb[i, : e.shape[0]] = e
297
+ os.makedirs(os.path.dirname(os.path.abspath(args.prompts_out)), exist_ok=True)
298
+ np.savez(args.prompts_out, emb=emb, len=lens, prompts=np.array(prompts))
299
+ print("wrote %s: %d prompts, Lmax=%d" % (args.prompts_out, len(prompts), lmax))
300
+
301
+
302
+ if __name__ == "__main__":
303
+ main()
sprig/data/procgen/__init__.py ADDED
File without changes
sprig/data/procgen/captions.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Templated dense captions for procedural scenes.
2
+
3
+ 10 training templates (T1..T10) + 2 held-out eval templates (E1, E2 — never
4
+ sampled in training mode). 15% of training captions are partial (attribute or
5
+ object dropping) and flagged `partial=True` to teach marginalization.
6
+
7
+ Template inventory (tiers they apply to):
8
+ T1 attribute "a small striped red circle" (0)
9
+ T2 relation, subject-first "a X to the left of a Y" (1)
10
+ T3 attribute + background "a X on a gray background" (0)
11
+ T4 enumerative "a scene with a X, a Y, and a Z" (1,2)
12
+ T5 relation, INVERTED mention order "a Y to the right of a X" (1)
13
+ T6 containment "a X inside a blue frame" (3)
14
+ T7 count + background "a scene with three shapes on ..." (1,2)
15
+ T8 background-first relation "on a gray background, a X above a Y" (1)
16
+ T9 count + enumeration "three shapes: a X, a Y, and a Z" (2)
17
+ T10 containment, frame-first "a blue frame containing a X" (3)
18
+ E1 eval rephrase "the image shows ..." (all)
19
+ E2 eval rephrase "there is ... in the picture" (all)
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
25
+
26
+ import numpy as np
27
+
28
+ from .sampler import Scene
29
+ from .vocab import TEXTURE_ADJ
30
+
31
+ TRAIN_TEMPLATE_IDS: Tuple[str, ...] = (
32
+ "T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "T9", "T10",
33
+ )
34
+ EVAL_TEMPLATE_IDS: Tuple[str, ...] = ("E1", "E2")
35
+
36
+ _TIER_TEMPLATES: Dict[int, Tuple[str, ...]] = {
37
+ 0: ("T1", "T3"),
38
+ 1: ("T2", "T5", "T4", "T7", "T8"),
39
+ 2: ("T4", "T7", "T9"),
40
+ 3: ("T6", "T10"),
41
+ }
42
+
43
+ _NUM_WORDS = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five"}
44
+ # relation type -> (forward phrase for A rel B, inverted phrase for B rel A)
45
+ _REL = {"left": ("to the left of", "to the right of"), "above": ("above", "below")}
46
+
47
+ PARTIAL_RATE = 0.15
48
+
49
+
50
+ @dataclass
51
+ class Caption:
52
+ text: str
53
+ template_id: str
54
+ partial: bool
55
+
56
+
57
+ def _article(phrase: str) -> str:
58
+ return "an" if phrase and phrase[0] in "aeiou" else "a"
59
+
60
+
61
+ def _obj_phrase(obj: Dict[str, Any], drops: Sequence[str] = ()) -> str:
62
+ words: List[str] = []
63
+ if "size" not in drops:
64
+ words.append(obj["size"])
65
+ adj = TEXTURE_ADJ[obj["texture"]]
66
+ if adj and "texture" not in drops:
67
+ words.append(adj)
68
+ if "color" not in drops:
69
+ words.append(obj["color"])
70
+ words.append(obj["shape"])
71
+ core = " ".join(words)
72
+ return "{} {}".format(_article(core), core)
73
+
74
+
75
+ def _list_phrase(phrases: Sequence[str]) -> str:
76
+ if len(phrases) == 1:
77
+ return phrases[0]
78
+ if len(phrases) == 2:
79
+ return "{} and {}".format(phrases[0], phrases[1])
80
+ return "{}, and {}".format(", ".join(phrases[:-1]), phrases[-1])
81
+
82
+
83
+ def _bg_phrase(scene: Scene) -> str:
84
+ names = scene.background.split("|")
85
+ if len(names) == 2:
86
+ return "a {} and {} background".format(names[0], names[1])
87
+ return "a {} background".format(names[0])
88
+
89
+
90
+ def _relation_parts(
91
+ scene: Scene, drops: Sequence[str]
92
+ ) -> Tuple[str, str, str, str]:
93
+ rel = scene.relation
94
+ assert rel is not None, "relation template on a scene without a relation"
95
+ fwd, inv = _REL[rel["type"]]
96
+ pa = _obj_phrase(scene.objects[rel["a"]], drops)
97
+ pb = _obj_phrase(scene.objects[rel["b"]], drops)
98
+ return pa, pb, fwd, inv
99
+
100
+
101
+ def render_template(
102
+ scene: Scene,
103
+ template_id: str,
104
+ drops: Sequence[str] = (),
105
+ obj_order: Optional[Sequence[int]] = None,
106
+ ) -> str:
107
+ """Realize `template_id` for `scene`.
108
+
109
+ `drops`: subset of {"size","texture","color","bg"} removed from the text
110
+ (partial captions). `obj_order`: object index order/subset for the
111
+ enumerative templates T4/T9/E1/E2.
112
+ """
113
+ objs = scene.objects
114
+ order = list(obj_order) if obj_order is not None else list(range(len(objs)))
115
+ phrases = [_obj_phrase(objs[i], drops) for i in order]
116
+
117
+ if template_id == "T1":
118
+ return phrases[0]
119
+ if template_id == "T2":
120
+ pa, pb, fwd, _ = _relation_parts(scene, drops)
121
+ return "{} {} {}".format(pa, fwd, pb)
122
+ if template_id == "T3":
123
+ if "bg" in drops:
124
+ return phrases[0]
125
+ return "{} on {}".format(phrases[0], _bg_phrase(scene))
126
+ if template_id == "T4":
127
+ return "a scene with {}".format(_list_phrase(phrases))
128
+ if template_id == "T5":
129
+ pa, pb, _, inv = _relation_parts(scene, drops)
130
+ return "{} {} {}".format(pb, inv, pa)
131
+ if template_id == "T6":
132
+ assert scene.frame is not None
133
+ fc = scene.frame["color"]
134
+ return "{} inside {} {} frame".format(phrases[0], _article(fc), fc)
135
+ if template_id == "T7":
136
+ n = _NUM_WORDS[len(objs)]
137
+ noun = "shape" if len(objs) == 1 else "shapes"
138
+ if "bg" in drops:
139
+ return "a scene with {} {}".format(n, noun)
140
+ return "a scene with {} {} on {}".format(n, noun, _bg_phrase(scene))
141
+ if template_id == "T8":
142
+ pa, pb, fwd, _ = _relation_parts(scene, drops)
143
+ if "bg" in drops:
144
+ return "{} {} {}".format(pa, fwd, pb)
145
+ return "on {}, {} {} {}".format(_bg_phrase(scene), pa, fwd, pb)
146
+ if template_id == "T9":
147
+ n = _NUM_WORDS[len(order)]
148
+ noun = "shape" if len(order) == 1 else "shapes"
149
+ return "{} {}: {}".format(n, noun, _list_phrase(phrases))
150
+ if template_id == "T10":
151
+ assert scene.frame is not None
152
+ fc = scene.frame["color"]
153
+ return "{} {} frame containing {}".format(_article(fc), fc, phrases[0])
154
+ if template_id in ("E1", "E2"):
155
+ content = _eval_content(scene, phrases)
156
+ if template_id == "E1":
157
+ return "the image shows {}".format(content)
158
+ verb = "are" if len(objs) > 1 else "is"
159
+ return "there {} {} in the picture".format(verb, content)
160
+ raise ValueError("unknown template id: {}".format(template_id))
161
+
162
+
163
+ def _eval_content(scene: Scene, phrases: Sequence[str]) -> str:
164
+ if scene.tier == 1 and scene.relation is not None:
165
+ pa, pb, fwd, _ = _relation_parts(scene, ())
166
+ return "{} {} {}".format(pa, fwd, pb)
167
+ if scene.tier == 3 and scene.frame is not None:
168
+ fc = scene.frame["color"]
169
+ return "{} inside {} {} frame".format(phrases[0], _article(fc), fc)
170
+ return _list_phrase(phrases)
171
+
172
+
173
+ def _drop_candidates(scene: Scene, template_id: str) -> List[str]:
174
+ cands: List[str] = []
175
+ if template_id != "T7": # T7 mentions no object attributes
176
+ cands.extend(["size", "color"])
177
+ if any(TEXTURE_ADJ[o["texture"]] for o in scene.objects):
178
+ cands.append("texture")
179
+ if template_id in ("T3", "T7", "T8"):
180
+ cands.append("bg")
181
+ return cands
182
+
183
+
184
+ def sample_caption(
185
+ scene: Scene, rng: np.random.Generator, mode: str = "train"
186
+ ) -> Caption:
187
+ """Sample a caption for `scene`.
188
+
189
+ mode="train": tier-appropriate training templates, 15% partial captions.
190
+ mode="eval": held-out E1/E2 templates only, never partial.
191
+ """
192
+ if mode == "eval":
193
+ tid = EVAL_TEMPLATE_IDS[int(rng.integers(len(EVAL_TEMPLATE_IDS)))]
194
+ return Caption(render_template(scene, tid), tid, False)
195
+ assert mode == "train", "mode must be 'train' or 'eval'"
196
+
197
+ pool = _TIER_TEMPLATES[scene.tier]
198
+ tid = pool[int(rng.integers(len(pool)))]
199
+
200
+ obj_order: Optional[List[int]] = None
201
+ if tid in ("T4", "T9"):
202
+ obj_order = [int(i) for i in rng.permutation(len(scene.objects))]
203
+
204
+ partial = bool(rng.random() < PARTIAL_RATE)
205
+ drops: Tuple[str, ...] = ()
206
+ if partial:
207
+ if tid == "T4" and len(scene.objects) >= 2 and rng.random() < 0.5:
208
+ # object dropping: keep a strict nonempty subset
209
+ keep = 1 + int(rng.integers(len(scene.objects) - 1))
210
+ assert obj_order is not None
211
+ obj_order = obj_order[:keep]
212
+ else:
213
+ cands = _drop_candidates(scene, tid)
214
+ k = 1 if (len(cands) == 1 or rng.random() < 0.5) else 2
215
+ picked = rng.permutation(len(cands))[:k]
216
+ drops = tuple(cands[int(i)] for i in picked)
217
+
218
+ text = render_template(scene, tid, drops=drops, obj_order=obj_order)
219
+ return Caption(text, tid, partial)
sprig/data/procgen/render.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic rasterizer for procedural scenes.
2
+
3
+ Draws at 256x256 (4x supersample) with numpy + PIL, then BOX-downsamples to
4
+ 64x64. `render_scene` is a pure function of the Scene, and the Scene is a pure
5
+ function of (global_seed, idx) via SeedSequence([global_seed, idx]) — so the
6
+ same idx always yields bit-identical pixels (regression-tested by hash).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
12
+
13
+ import numpy as np
14
+ from PIL import Image, ImageDraw
15
+
16
+ from .sampler import DEFAULT_TIER_MIX, Scene, iter_leaves, sample_scene
17
+ from .vocab import CANVAS, SUPERSAMPLE
18
+
19
+ _S = SUPERSAMPLE
20
+ _HI = CANVAS * _S
21
+ _BOX = getattr(getattr(Image, "Resampling", Image), "BOX")
22
+
23
+ _texture_cache: Dict[str, np.ndarray] = {}
24
+
25
+
26
+ def _texture_mask(name: str) -> np.ndarray:
27
+ """Boolean [256,256] pattern mask in canvas-aligned hi-res coordinates."""
28
+ if name in _texture_cache:
29
+ return _texture_cache[name]
30
+ yy, xx = np.mgrid[0:_HI, 0:_HI]
31
+ if name == "solid":
32
+ m = np.ones((_HI, _HI), dtype=bool)
33
+ elif name == "striped": # diagonal stripes, 2 canvas px on / 2 off
34
+ m = ((xx + yy) // (2 * _S)) % 2 == 0
35
+ elif name == "checker": # 2x2 canvas px checkerboard
36
+ m = ((xx // (2 * _S)) + (yy // (2 * _S))) % 2 == 0
37
+ elif name == "dotted": # dot grid, period 4 canvas px, dot radius ~1.3 px
38
+ p = 4 * _S
39
+ dx = (xx % p) - p / 2 + 0.5
40
+ dy = (yy % p) - p / 2 + 0.5
41
+ m = dx * dx + dy * dy <= (1.3 * _S) ** 2
42
+ else:
43
+ raise ValueError("unknown texture: {}".format(name))
44
+ _texture_cache[name] = m
45
+ return m
46
+
47
+
48
+ def _shape_mask(shape: str, bbox: Sequence[float]) -> np.ndarray:
49
+ """Boolean [256,256] mask of `shape` drawn inside hi-res bbox."""
50
+ x0, y0, x1, y1 = (v * _S for v in bbox)
51
+ w, h = x1 - x0, y1 - y0
52
+ cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
53
+ im = Image.new("L", (_HI, _HI), 0)
54
+ d = ImageDraw.Draw(im)
55
+ if shape == "circle":
56
+ d.ellipse([x0, y0, x1, y1], fill=255)
57
+ elif shape in ("square", "rectangle"):
58
+ d.rectangle([x0, y0, x1, y1], fill=255)
59
+ elif shape == "triangle":
60
+ d.polygon([(cx, y0), (x1, y1), (x0, y1)], fill=255)
61
+ elif shape == "diamond":
62
+ d.polygon([(cx, y0), (x1, cy), (cx, y1), (x0, cy)], fill=255)
63
+ elif shape == "star":
64
+ pts: List[Tuple[float, float]] = []
65
+ for k in range(10):
66
+ ang = -math.pi / 2 + k * math.pi / 5
67
+ r = 1.0 if k % 2 == 0 else 0.42
68
+ pts.append((cx + math.cos(ang) * r * w / 2, cy + math.sin(ang) * r * h / 2))
69
+ d.polygon(pts, fill=255)
70
+ elif shape == "cross":
71
+ tx, ty = w * 0.34, h * 0.34
72
+ d.rectangle([cx - tx / 2, y0, cx + tx / 2, y1], fill=255)
73
+ d.rectangle([x0, cy - ty / 2, x1, cy + ty / 2], fill=255)
74
+ elif shape == "ring":
75
+ d.ellipse([x0, y0, x1, y1], fill=255)
76
+ inset_x, inset_y = w * 0.275, h * 0.275
77
+ d.ellipse([x0 + inset_x, y0 + inset_y, x1 - inset_x, y1 - inset_y], fill=0)
78
+ else:
79
+ raise ValueError("unknown shape: {}".format(shape))
80
+ return np.asarray(im) > 0
81
+
82
+
83
+ def render_scene(scene: Scene) -> np.ndarray:
84
+ """Render a Scene to a [64,64,3] uint8 array."""
85
+ hi = np.empty((_HI, _HI, 3), dtype=np.uint8)
86
+ for leaf in iter_leaves(scene.tree):
87
+ x0, y0, x1, y1 = leaf["rect"]
88
+ hi[y0 * _S : y1 * _S, x0 * _S : x1 * _S] = np.asarray(
89
+ leaf["fill"], dtype=np.uint8
90
+ )
91
+ for obj in scene.objects:
92
+ mask = _shape_mask(obj["shape"], obj["bbox"])
93
+ tex = _texture_mask(obj["texture"])
94
+ rgb = np.asarray(obj["rgb"], dtype=np.float64)
95
+ main = rgb.astype(np.uint8)
96
+ dark = (rgb * 0.45).astype(np.uint8) # texture gaps: darker shade
97
+ hi[mask & tex] = main
98
+ hi[mask & ~tex] = dark
99
+ lo = Image.fromarray(hi).resize((CANVAS, CANVAS), resample=_BOX)
100
+ return np.asarray(lo, dtype=np.uint8)
101
+
102
+
103
+ def generate(
104
+ global_seed: int,
105
+ idx: int,
106
+ tier: Optional[int] = None,
107
+ tier_mix: Sequence[float] = DEFAULT_TIER_MIX,
108
+ ) -> Tuple[Scene, np.ndarray]:
109
+ """Convenience: sample scene `idx` and render it. Deterministic per idx."""
110
+ scene = sample_scene(global_seed, idx, tier=tier, tier_mix=tier_mix)
111
+ return scene, render_scene(scene)
sprig/data/procgen/sampler.py ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ground-truth BSP scene sampler.
2
+
3
+ Scenes are generated BY sampling a BSP region tree first (the tree is layout
4
+ sampler, renderer input, and parse-diagnostic label at once), then decorating
5
+ leaves with backgrounds / objects. The tree is constrained to the model
6
+ support of DESIGN.md §3:
7
+
8
+ - every cut lands on the 8-px grid with relative offset in [0.3, 0.7];
9
+ - leaf regions have both sides in {8, 16} px (any side > 16 must expand,
10
+ 8x8 must terminate);
11
+ - object cells are 16x16 leaves holding exactly one shape, fully inside the
12
+ cell with >= 2 px margin — no overlap, no occlusion.
13
+
14
+ Tree JSON schema (serialized verbatim into meta.jsonl):
15
+ internal node: {"rect": [x0,y0,x1,y1], "axis": "V"|"H", "cut": px,
16
+ "children": [lo, hi]} # lo = left/top child
17
+ leaf node: {"rect": [x0,y0,x1,y1], "leaf": true, "obj": int|null,
18
+ "fill": [r,g,b]} # optional "frame": true
19
+ Tier-3 frame region nodes additionally carry {"frame_color": name}.
20
+
21
+ Object schema: {"shape","color","size","texture": str, "rgb": [r,g,b],
22
+ "cell": [x0,y0,x1,y1], "bbox": [x0,y0,x1,y1]}.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple
28
+
29
+ import numpy as np
30
+
31
+ from .vocab import (
32
+ BACKGROUNDS,
33
+ BACKGROUND_NAMES,
34
+ BG_JITTER,
35
+ CANVAS,
36
+ COLOR_JITTER,
37
+ COLOR_NAMES,
38
+ COLORS,
39
+ GRID,
40
+ HOLDOUT_COMBOS,
41
+ MAX_LEAF,
42
+ MIN_MARGIN,
43
+ OFFSET_HI,
44
+ OFFSET_LO,
45
+ SHAPES,
46
+ SIZE_NAMES,
47
+ SIZES,
48
+ )
49
+
50
+ DEFAULT_TIER_MIX: Tuple[float, float, float, float] = (0.10, 0.30, 0.40, 0.20)
51
+
52
+ # termination probability for leaf-eligible regions in the generic grower
53
+ _TERM_P: Dict[Tuple[int, int], float] = {(16, 16): 0.75, (8, 16): 0.7, (16, 8): 0.7}
54
+ _MAX_TRIES = 200
55
+ _EPS = 1e-9
56
+
57
+
58
+ @dataclass
59
+ class Scene:
60
+ """One procedural scene. All fields are JSON-able plain python."""
61
+
62
+ idx: int
63
+ tier: int
64
+ tree: Dict[str, Any]
65
+ objects: List[Dict[str, Any]]
66
+ background: str # background name; "sky|sand" for tier-2 sky/ground scenes
67
+ relation: Optional[Dict[str, Any]] = None # tier 1: {"type","a","b"}
68
+ frame: Optional[Dict[str, Any]] = None # tier 3: {"color","rgb","rect","inner"}
69
+
70
+
71
+ # --------------------------------------------------------------------------
72
+ # rng helpers
73
+ # --------------------------------------------------------------------------
74
+
75
+ def scene_rng(global_seed: int, idx: int) -> np.random.Generator:
76
+ """The canonical per-sample rng stream: SeedSequence([global_seed, idx])."""
77
+ return np.random.default_rng(np.random.SeedSequence([int(global_seed), int(idx)]))
78
+
79
+
80
+ def caption_rng(global_seed: int, idx: int) -> np.random.Generator:
81
+ """Independent per-sample stream for caption template draws."""
82
+ return np.random.default_rng(
83
+ np.random.SeedSequence([int(global_seed), int(idx), 1])
84
+ )
85
+
86
+
87
+ # --------------------------------------------------------------------------
88
+ # tree construction
89
+ # --------------------------------------------------------------------------
90
+
91
+ def _mk_leaf(rect: Sequence[int]) -> Dict[str, Any]:
92
+ return {"rect": [int(v) for v in rect], "leaf": True, "obj": None, "fill": None}
93
+
94
+
95
+ def _mk_node(
96
+ rect: Sequence[int], axis: str, cut: int, lo: Dict[str, Any], hi: Dict[str, Any]
97
+ ) -> Dict[str, Any]:
98
+ return {
99
+ "rect": [int(v) for v in rect],
100
+ "axis": axis,
101
+ "cut": int(cut),
102
+ "children": [lo, hi],
103
+ }
104
+
105
+
106
+ def _split_rect(
107
+ rect: Sequence[int], axis: str, cut: int
108
+ ) -> Tuple[Tuple[int, int, int, int], Tuple[int, int, int, int]]:
109
+ x0, y0, x1, y1 = rect
110
+ if axis == "V":
111
+ return (x0, y0, cut, y1), (cut, y0, x1, y1)
112
+ return (x0, y0, x1, cut), (x0, cut, x1, y1)
113
+
114
+
115
+ def _valid_cuts(lo: int, hi: int) -> List[int]:
116
+ """Grid cuts of interval [lo,hi) with relative offset in [0.3, 0.7]."""
117
+ length = hi - lo
118
+ out = []
119
+ for j in range(1, length // GRID):
120
+ t = j * GRID / length
121
+ if OFFSET_LO - _EPS <= t <= OFFSET_HI + _EPS:
122
+ out.append(lo + j * GRID)
123
+ return out
124
+
125
+
126
+ def _grow(
127
+ rng: np.random.Generator,
128
+ rect: Tuple[int, int, int, int],
129
+ force_axis: Optional[str] = None,
130
+ ) -> Dict[str, Any]:
131
+ """Sample a random in-support BSP subtree over `rect`.
132
+
133
+ `force_axis` forces the root of this subtree to split on that axis
134
+ (used for tier-2 sky/ground scenes).
135
+ """
136
+ x0, y0, x1, y1 = rect
137
+ w, h = x1 - x0, y1 - y0
138
+ leaf_ok = w <= MAX_LEAF and h <= MAX_LEAF
139
+ if leaf_ok and force_axis is None:
140
+ if (w == GRID and h == GRID) or rng.random() < _TERM_P[(w, h)]:
141
+ return _mk_leaf(rect)
142
+ axes = []
143
+ if _valid_cuts(x0, x1):
144
+ axes.append("V")
145
+ if _valid_cuts(y0, y1):
146
+ axes.append("H")
147
+ if force_axis is not None:
148
+ assert force_axis in axes, "forced axis has no valid cut"
149
+ axes = [force_axis]
150
+ axis = axes[int(rng.integers(len(axes)))]
151
+ cuts = _valid_cuts(x0, x1) if axis == "V" else _valid_cuts(y0, y1)
152
+ cut = cuts[int(rng.integers(len(cuts)))]
153
+ rlo, rhi = _split_rect(rect, axis, cut)
154
+ return _mk_node(rect, axis, cut, _grow(rng, rlo), _grow(rng, rhi))
155
+
156
+
157
+ def iter_leaves(node: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
158
+ """Yield leaf node dicts (mutable references) in depth-first lo-hi order."""
159
+ if node.get("leaf"):
160
+ yield node
161
+ else:
162
+ for child in node["children"]:
163
+ for leaf in iter_leaves(child):
164
+ yield leaf
165
+
166
+
167
+ def _object_cells(node: Dict[str, Any]) -> List[Dict[str, Any]]:
168
+ """Leaves big enough to hold an object (16x16, not frame cells)."""
169
+ out = []
170
+ for leaf in iter_leaves(node):
171
+ x0, y0, x1, y1 = leaf["rect"]
172
+ if x1 - x0 == MAX_LEAF and y1 - y0 == MAX_LEAF and not leaf.get("frame"):
173
+ out.append(leaf)
174
+ return out
175
+
176
+
177
+ # --------------------------------------------------------------------------
178
+ # attribute / placement helpers
179
+ # --------------------------------------------------------------------------
180
+
181
+ def _jitter(rgb: Sequence[int], rng: np.random.Generator, amt: int) -> List[int]:
182
+ out = []
183
+ for v in rgb:
184
+ out.append(int(np.clip(int(v) + int(rng.integers(-amt, amt + 1)), 0, 255)))
185
+ return out
186
+
187
+
188
+ def _sample_attrs(
189
+ rng: np.random.Generator, exclude: Optional[set] = None
190
+ ) -> Dict[str, str]:
191
+ """Rejection-resample attributes so no holdout (color, shape) combo appears."""
192
+ exclude = exclude or set()
193
+ for _ in range(_MAX_TRIES):
194
+ color = COLOR_NAMES[int(rng.integers(len(COLOR_NAMES)))]
195
+ shape = SHAPES[int(rng.integers(len(SHAPES)))]
196
+ if (color, shape) in HOLDOUT_COMBOS or (color, shape) in exclude:
197
+ continue
198
+ size = SIZE_NAMES[int(rng.integers(len(SIZE_NAMES)))]
199
+ texture = "solid" if rng.random() < 0.4 else (
200
+ ("striped", "dotted", "checker")[int(rng.integers(3))]
201
+ )
202
+ return {"shape": shape, "color": color, "size": size, "texture": texture}
203
+ raise RuntimeError("attribute rejection loop exhausted")
204
+
205
+
206
+ def _place_object(
207
+ rng: np.random.Generator,
208
+ cell: Sequence[int],
209
+ attrs: Dict[str, str],
210
+ centered: bool = False,
211
+ ) -> Dict[str, Any]:
212
+ x0, y0, x1, y1 = (float(v) for v in cell)
213
+ s = SIZES[attrs["size"]]
214
+ w = s
215
+ h = round(s * 0.62, 2) if attrs["shape"] == "rectangle" else s
216
+ slack_x = (x1 - x0 - w) / 2.0 - MIN_MARGIN
217
+ slack_y = (y1 - y0 - h) / 2.0 - MIN_MARGIN
218
+ assert slack_x >= -_EPS and slack_y >= -_EPS, "object does not fit cell"
219
+ cx = (x0 + x1) / 2.0
220
+ cy = (y0 + y1) / 2.0
221
+ if not centered:
222
+ cx += float(rng.uniform(-max(slack_x, 0.0), max(slack_x, 0.0)))
223
+ cy += float(rng.uniform(-max(slack_y, 0.0), max(slack_y, 0.0)))
224
+ bbox = [
225
+ round(cx - w / 2.0, 2),
226
+ round(cy - h / 2.0, 2),
227
+ round(cx + w / 2.0, 2),
228
+ round(cy + h / 2.0, 2),
229
+ ]
230
+ return {
231
+ "shape": attrs["shape"],
232
+ "color": attrs["color"],
233
+ "size": attrs["size"],
234
+ "texture": attrs["texture"],
235
+ "rgb": _jitter(COLORS[attrs["color"]], rng, COLOR_JITTER),
236
+ "cell": [int(v) for v in cell],
237
+ "bbox": bbox,
238
+ }
239
+
240
+
241
+ def _apply_bg(tree: Dict[str, Any], bg_rgb: Sequence[int]) -> None:
242
+ for leaf in iter_leaves(tree):
243
+ if leaf["fill"] is None:
244
+ leaf["fill"] = list(bg_rgb)
245
+
246
+
247
+ def _pick_bg(rng: np.random.Generator) -> Tuple[str, List[int]]:
248
+ name = BACKGROUND_NAMES[int(rng.integers(len(BACKGROUND_NAMES)))]
249
+ return name, _jitter(BACKGROUNDS[name], rng, BG_JITTER)
250
+
251
+
252
+ # --------------------------------------------------------------------------
253
+ # tier builders
254
+ # --------------------------------------------------------------------------
255
+
256
+ def _build_tier0(rng: np.random.Generator, idx: int) -> Scene:
257
+ for _ in range(_MAX_TRIES):
258
+ tree = _grow(rng, (0, 0, CANVAS, CANVAS))
259
+ cells = _object_cells(tree)
260
+ if cells:
261
+ break
262
+ else: # pragma: no cover
263
+ raise RuntimeError("tier0: no object cell after retries")
264
+ c = CANVAS / 2.0
265
+ cell = min(
266
+ cells,
267
+ key=lambda l: ((l["rect"][0] + l["rect"][2]) / 2 - c) ** 2
268
+ + ((l["rect"][1] + l["rect"][3]) / 2 - c) ** 2,
269
+ )
270
+ obj = _place_object(rng, cell["rect"], _sample_attrs(rng), centered=True)
271
+ cell["obj"] = 0
272
+ bg_name, bg_rgb = _pick_bg(rng)
273
+ _apply_bg(tree, bg_rgb)
274
+ return Scene(idx=idx, tier=0, tree=tree, objects=[obj], background=bg_name)
275
+
276
+
277
+ def _build_tier1(rng: np.random.Generator, idx: int) -> Scene:
278
+ for _ in range(_MAX_TRIES):
279
+ tree = _grow(rng, (0, 0, CANVAS, CANVAS))
280
+ cells_lo = _object_cells(tree["children"][0])
281
+ cells_hi = _object_cells(tree["children"][1])
282
+ if cells_lo and cells_hi:
283
+ break
284
+ else: # pragma: no cover
285
+ raise RuntimeError("tier1: no cells on both sides of root split")
286
+ cell_a = cells_lo[int(rng.integers(len(cells_lo)))]
287
+ cell_b = cells_hi[int(rng.integers(len(cells_hi)))]
288
+ attrs_a = _sample_attrs(rng)
289
+ attrs_b = _sample_attrs(rng, exclude={(attrs_a["color"], attrs_a["shape"])})
290
+ obj_a = _place_object(rng, cell_a["rect"], attrs_a)
291
+ obj_b = _place_object(rng, cell_b["rect"], attrs_b)
292
+ cell_a["obj"] = 0
293
+ cell_b["obj"] = 1
294
+ relation = {"type": "left" if tree["axis"] == "V" else "above", "a": 0, "b": 1}
295
+ bg_name, bg_rgb = _pick_bg(rng)
296
+ _apply_bg(tree, bg_rgb)
297
+ return Scene(
298
+ idx=idx,
299
+ tier=1,
300
+ tree=tree,
301
+ objects=[obj_a, obj_b],
302
+ background=bg_name,
303
+ relation=relation,
304
+ )
305
+
306
+
307
+ def _build_tier2(rng: np.random.Generator, idx: int) -> Scene:
308
+ n_obj = 3 + int(rng.integers(3)) # 3..5
309
+ skyground = rng.random() < 0.5
310
+ for _ in range(_MAX_TRIES):
311
+ tree = _grow(
312
+ rng, (0, 0, CANVAS, CANVAS), force_axis="H" if skyground else None
313
+ )
314
+ cells = _object_cells(tree)
315
+ if len(cells) >= n_obj:
316
+ break
317
+ else: # pragma: no cover
318
+ raise RuntimeError("tier2: not enough object cells")
319
+ order = rng.permutation(len(cells))[:n_obj]
320
+ objects: List[Dict[str, Any]] = []
321
+ used: set = set()
322
+ for i, ci in enumerate(order):
323
+ attrs = _sample_attrs(rng, exclude=used)
324
+ used.add((attrs["color"], attrs["shape"]))
325
+ cell = cells[int(ci)]
326
+ objects.append(_place_object(rng, cell["rect"], attrs))
327
+ cell["obj"] = i
328
+ if skyground:
329
+ cut = tree["cut"]
330
+ sky_rgb = _jitter(BACKGROUNDS["sky"], rng, BG_JITTER)
331
+ sand_rgb = _jitter(BACKGROUNDS["sand"], rng, BG_JITTER)
332
+ for leaf in iter_leaves(tree):
333
+ leaf["fill"] = list(sky_rgb) if leaf["rect"][3] <= cut else list(sand_rgb)
334
+ bg_name = "sky|sand"
335
+ else:
336
+ bg_name, bg_rgb = _pick_bg(rng)
337
+ _apply_bg(tree, bg_rgb)
338
+ return Scene(idx=idx, tier=2, tree=tree, objects=objects, background=bg_name)
339
+
340
+
341
+ def _frame_region_tree(
342
+ rng: np.random.Generator,
343
+ rect: Tuple[int, int, int, int],
344
+ frame_rgb: List[int],
345
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
346
+ """Decompose a 40x40 region into frame cells around one inner 16x16 cell.
347
+
348
+ Per axis the strips are (16, 16, 8) or (8, 16, 16) so every cut keeps its
349
+ relative offset in [0.3, 0.7]. Returns (subtree, inner_leaf_ref).
350
+ """
351
+ fx, fy, _, _ = rect
352
+
353
+ def frame_leaf(r: Sequence[int]) -> Dict[str, Any]:
354
+ leaf = _mk_leaf(r)
355
+ leaf["frame"] = True
356
+ leaf["fill"] = list(frame_rgb)
357
+ return leaf
358
+
359
+ def frame_subtree(r: Tuple[int, int, int, int]) -> Dict[str, Any]:
360
+ sub = _grow(rng, r)
361
+ for leaf in iter_leaves(sub):
362
+ leaf["frame"] = True
363
+ leaf["fill"] = list(frame_rgb)
364
+ return sub
365
+
366
+ f0x = 16 if rng.random() < 0.5 else 8
367
+ f0y = 16 if rng.random() < 0.5 else 8
368
+ ix0 = fx + f0x # inner cell x range [ix0, ix0+16]
369
+ iy0 = fy + f0y
370
+
371
+ # middle vertical strip (16 wide, 40 tall) split along y into
372
+ # top frame cell / inner object cell / bottom frame cell
373
+ inner = _mk_leaf((ix0, iy0, ix0 + 16, iy0 + 16))
374
+ top = frame_leaf((ix0, fy, ix0 + 16, iy0))
375
+ bot = frame_leaf((ix0, iy0 + 16, ix0 + 16, fy + 40))
376
+ strip_rect = (ix0, fy, ix0 + 16, fy + 40)
377
+ if f0y == 16:
378
+ lower = _mk_node((ix0, iy0, ix0 + 16, fy + 40), "H", iy0 + 16, inner, bot)
379
+ mid = _mk_node(strip_rect, "H", iy0, top, lower)
380
+ else:
381
+ upper = _mk_node((ix0, fy, ix0 + 16, iy0 + 16), "H", iy0, top, inner)
382
+ mid = _mk_node(strip_rect, "H", iy0 + 16, upper, bot)
383
+
384
+ left = frame_subtree((fx, fy, ix0, fy + 40))
385
+ right = frame_subtree((ix0 + 16, fy, fx + 40, fy + 40))
386
+ if f0x == 16:
387
+ rt = _mk_node((ix0, fy, fx + 40, fy + 40), "V", ix0 + 16, mid, right)
388
+ node = _mk_node(rect, "V", ix0, left, rt)
389
+ else:
390
+ lt = _mk_node((fx, fy, ix0 + 16, fy + 40), "V", ix0, left, mid)
391
+ node = _mk_node(rect, "V", ix0 + 16, lt, right)
392
+ return node, inner
393
+
394
+
395
+ def _build_tier3(rng: np.random.Generator, idx: int) -> Scene:
396
+ fx = 0 if rng.random() < 0.5 else 24
397
+ fy = 0 if rng.random() < 0.5 else 24
398
+ frect = (fx, fy, fx + 40, fy + 40)
399
+
400
+ attrs = _sample_attrs(rng)
401
+ frame_color = attrs["color"]
402
+ while frame_color == attrs["color"]:
403
+ frame_color = COLOR_NAMES[int(rng.integers(len(COLOR_NAMES)))]
404
+ frame_rgb = _jitter(COLORS[frame_color], rng, COLOR_JITTER)
405
+
406
+ frame_node, inner = _frame_region_tree(rng, frect, frame_rgb)
407
+ frame_node["frame_color"] = frame_color
408
+ obj = _place_object(rng, inner["rect"], attrs)
409
+ inner["obj"] = 0
410
+
411
+ # isolate the frame region from the canvas root: one V cut, one H cut
412
+ # (in random order); both offsets (24/64, 40/64) are in [0.3, 0.7].
413
+ def isolate(rect: Tuple[int, int, int, int], axis: str, inner_node: Dict) -> Dict:
414
+ x0, y0, x1, y1 = rect
415
+ if axis == "V":
416
+ cut = fx + 40 if fx == 0 else fx
417
+ (rl, rh) = _split_rect(rect, "V", cut)
418
+ keep_lo = fx == 0
419
+ else:
420
+ cut = fy + 40 if fy == 0 else fy
421
+ (rl, rh) = _split_rect(rect, "H", cut)
422
+ keep_lo = fy == 0
423
+ other = _grow(rng, rh if keep_lo else rl)
424
+ lo = inner_node if keep_lo else other
425
+ hi = other if keep_lo else inner_node
426
+ return _mk_node(rect, axis, cut, lo, hi)
427
+
428
+ if rng.random() < 0.5:
429
+ band = isolate((fx, 0, fx + 40, CANVAS), "H", frame_node)
430
+ tree = isolate((0, 0, CANVAS, CANVAS), "V", band)
431
+ else:
432
+ band = isolate((0, fy, CANVAS, fy + 40), "V", frame_node)
433
+ tree = isolate((0, 0, CANVAS, CANVAS), "H", band)
434
+
435
+ bg_name, bg_rgb = _pick_bg(rng)
436
+ _apply_bg(tree, bg_rgb)
437
+ frame_info = {
438
+ "color": frame_color,
439
+ "rgb": list(frame_rgb),
440
+ "rect": list(frect),
441
+ "inner": list(inner["rect"]),
442
+ }
443
+ return Scene(
444
+ idx=idx,
445
+ tier=3,
446
+ tree=tree,
447
+ objects=[obj],
448
+ background=bg_name,
449
+ frame=frame_info,
450
+ )
451
+
452
+
453
+ _BUILDERS = {0: _build_tier0, 1: _build_tier1, 2: _build_tier2, 3: _build_tier3}
454
+
455
+
456
+ # --------------------------------------------------------------------------
457
+ # public API
458
+ # --------------------------------------------------------------------------
459
+
460
+ def sample_scene(
461
+ global_seed: int,
462
+ idx: int,
463
+ tier: Optional[int] = None,
464
+ tier_mix: Sequence[float] = DEFAULT_TIER_MIX,
465
+ ) -> Scene:
466
+ """Deterministically sample scene `idx` under `global_seed`.
467
+
468
+ If `tier` is None it is drawn from `tier_mix` (first rng draw, so the
469
+ scene is a pure function of (global_seed, idx, tier or tier_mix)).
470
+ """
471
+ rng = scene_rng(global_seed, idx)
472
+ if tier is None:
473
+ p = np.asarray(tier_mix, dtype=np.float64)
474
+ p = p / p.sum()
475
+ tier = int(rng.choice(4, p=p))
476
+ return _BUILDERS[int(tier)](rng, int(idx))
sprig/data/procgen/vocab.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vocabulary and geometry constants for the procedural 2D scene generator.
2
+
3
+ Everything here is a plain constant so the sampler / renderer / captioner and
4
+ the eval agents (color anchor classification) share one source of truth.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Dict, Tuple
9
+
10
+ # --- geometry (must match the model lattice in DESIGN.md §2/§3) -------------
11
+ CANVAS: int = 64 # canvas side, px
12
+ GRID: int = 8 # model lattice stride, px — ALL cuts land on this grid
13
+ MAX_LEAF: int = 16 # model support: leaf regions have both sides <= 16 px
14
+ SUPERSAMPLE: int = 4 # render at 256x256, BOX-downsample to 64x64
15
+ OFFSET_LO: float = 0.3 # relative cut offset range (inside model support)
16
+ OFFSET_HI: float = 0.7
17
+ MIN_MARGIN: float = 2.0 # min px margin between a shape bbox and its cell walls
18
+
19
+ # --- object vocabulary -------------------------------------------------------
20
+ # 8 colors: name -> RGB anchor (renderer jitters around these; eval classifies
21
+ # back to nearest anchor in Lab space).
22
+ COLORS: Dict[str, Tuple[int, int, int]] = {
23
+ "red": (210, 45, 45),
24
+ "green": (55, 170, 60),
25
+ "blue": (50, 90, 215),
26
+ "yellow": (235, 215, 55),
27
+ "orange": (240, 145, 40),
28
+ "purple": (140, 60, 185),
29
+ "cyan": (60, 205, 215),
30
+ "magenta": (220, 70, 175),
31
+ }
32
+ COLOR_NAMES: Tuple[str, ...] = tuple(COLORS.keys())
33
+
34
+ SHAPES: Tuple[str, ...] = (
35
+ "circle", "square", "triangle", "rectangle", "diamond", "star", "cross", "ring",
36
+ )
37
+
38
+ # 2 sizes: name -> shape bbox side in px. Objects live in 16x16 leaf cells with
39
+ # >= MIN_MARGIN px of clearance: large 11 -> 2.5 px margin, small 6 -> 5 px.
40
+ SIZES: Dict[str, float] = {"small": 6.0, "large": 11.0}
41
+ SIZE_NAMES: Tuple[str, ...] = tuple(SIZES.keys())
42
+
43
+ TEXTURES: Tuple[str, ...] = ("solid", "striped", "dotted", "checker")
44
+ # caption adjective per texture ("" = no adjective for solid fills)
45
+ TEXTURE_ADJ: Dict[str, str] = {
46
+ "solid": "",
47
+ "striped": "striped",
48
+ "dotted": "dotted",
49
+ "checker": "checkered",
50
+ }
51
+
52
+ # 5 backgrounds: name -> RGB anchor. "sky"/"sand" are also used as the fixed
53
+ # pair for the tier-2 sky/ground root split.
54
+ BACKGROUNDS: Dict[str, Tuple[int, int, int]] = {
55
+ "white": (245, 245, 245),
56
+ "black": (28, 28, 28),
57
+ "gray": (128, 128, 128),
58
+ "sky": (170, 210, 240),
59
+ "sand": (205, 180, 130),
60
+ }
61
+ BACKGROUND_NAMES: Tuple[str, ...] = tuple(BACKGROUNDS.keys())
62
+
63
+ # per-channel uniform jitter amplitude applied to anchors at scene-sample time
64
+ COLOR_JITTER: int = 12
65
+ BG_JITTER: int = 8
66
+
67
+ # --- compositional holdout ---------------------------------------------------
68
+ # These (color, shape) combos NEVER appear in training scenes or captions
69
+ # (enforced by rejection-resampling in the sampler; scanned in tests/CI).
70
+ HOLDOUT_COMBOS: frozenset = frozenset(
71
+ {("blue", "triangle"), ("red", "ring"), ("green", "star"), ("yellow", "cross")}
72
+ )
sprig/data/procgen/writer.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parallel pregeneration of the procedural dataset to raw memmaps.
2
+
3
+ Outputs in --out DIR:
4
+ images.u8 uint8 memmap [N,64,64,3]
5
+ meta.jsonl one JSON object per line:
6
+ {idx, tier, caption, template_id, partial, objects, tree}
7
+ meta_offsets.i64 int64 [N]: byte offset of the START of line i
8
+ tier_idx/tier{0..3}.i64 int64 sorted idx arrays per tier (curriculum)
9
+
10
+ Embeddings are NOT written here — sprig/data/embed_t5.py does that later.
11
+
12
+ CLI:
13
+ python -m sprig.data.procgen.writer --out DIR --n N --seed SEED \
14
+ --tier-mix "0.1,0.3,0.4,0.2" --workers K
15
+
16
+ Workers own disjoint contiguous row ranges; each writes its rows of the image
17
+ memmap plus a meta/tier shard, and the parent concatenates shards in order.
18
+ Every sample is a pure function of (seed, idx), so the output is independent
19
+ of the worker count.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import multiprocessing as mp
26
+ import os
27
+ import sys
28
+ import time
29
+ from typing import List, Optional, Sequence, Tuple
30
+
31
+ import numpy as np
32
+
33
+ from .captions import sample_caption
34
+ from .render import render_scene
35
+ from .sampler import DEFAULT_TIER_MIX, caption_rng, sample_scene
36
+
37
+ IMG_SHAPE = (64, 64, 3)
38
+
39
+
40
+ def _meta_part(out_dir: str, wid: int) -> str:
41
+ return os.path.join(out_dir, "meta.part{:03d}.jsonl".format(wid))
42
+
43
+
44
+ def _tier_part(out_dir: str, wid: int) -> str:
45
+ return os.path.join(out_dir, "tiers.part{:03d}.i64".format(wid))
46
+
47
+
48
+ def _generate_range(
49
+ out_dir: str,
50
+ n: int,
51
+ seed: int,
52
+ tier_mix: Sequence[float],
53
+ lo: int,
54
+ hi: int,
55
+ wid: int,
56
+ seed_start: int = 0,
57
+ ) -> None:
58
+ """Worker: generate rows [lo, hi) into the shared image memmap + shards.
59
+
60
+ Row idx is generated from scene index seed_start + idx, so different
61
+ splits get disjoint scene streams from disjoint seed ranges."""
62
+ images = np.memmap(
63
+ os.path.join(out_dir, "images.u8"), dtype=np.uint8, mode="r+",
64
+ shape=(n,) + IMG_SHAPE,
65
+ )
66
+ tiers = np.empty(hi - lo, dtype=np.int64)
67
+ with open(_meta_part(out_dir, wid), "w", encoding="utf-8") as f:
68
+ for idx in range(lo, hi):
69
+ sidx = seed_start + idx
70
+ scene = sample_scene(seed, sidx, tier_mix=tier_mix)
71
+ images[idx] = render_scene(scene)
72
+ cap = sample_caption(scene, caption_rng(seed, sidx), mode="train")
73
+ rec = {
74
+ "idx": idx,
75
+ "tier": scene.tier,
76
+ "caption": cap.text,
77
+ "template_id": cap.template_id,
78
+ "partial": cap.partial,
79
+ "objects": scene.objects,
80
+ "tree": scene.tree,
81
+ }
82
+ f.write(json.dumps(rec, separators=(",", ":")) + "\n")
83
+ tiers[idx - lo] = scene.tier
84
+ images.flush()
85
+ del images
86
+ tiers.tofile(_tier_part(out_dir, wid))
87
+
88
+
89
+ def write_dataset(
90
+ out_dir: str,
91
+ n: int,
92
+ seed: int = 0,
93
+ tier_mix: Sequence[float] = DEFAULT_TIER_MIX,
94
+ workers: int = 1,
95
+ seed_start: int = 0,
96
+ ) -> None:
97
+ os.makedirs(out_dir, exist_ok=True)
98
+ os.makedirs(os.path.join(out_dir, "tier_idx"), exist_ok=True)
99
+ workers = max(1, min(int(workers), n))
100
+
101
+ # preallocate the image memmap
102
+ images = np.memmap(
103
+ os.path.join(out_dir, "images.u8"), dtype=np.uint8, mode="w+",
104
+ shape=(n,) + IMG_SHAPE,
105
+ )
106
+ images.flush()
107
+ del images
108
+
109
+ # disjoint contiguous row ranges
110
+ bounds = np.linspace(0, n, workers + 1).astype(np.int64)
111
+ ranges: List[Tuple[int, int]] = [
112
+ (int(bounds[w]), int(bounds[w + 1])) for w in range(workers)
113
+ ]
114
+
115
+ t0 = time.time()
116
+ if workers == 1:
117
+ _generate_range(out_dir, n, seed, tier_mix, 0, n, 0, seed_start)
118
+ else:
119
+ ctx = mp.get_context("spawn")
120
+ procs = []
121
+ for wid, (lo, hi) in enumerate(ranges):
122
+ p = ctx.Process(
123
+ target=_generate_range,
124
+ args=(out_dir, n, seed, tuple(tier_mix), lo, hi, wid, seed_start),
125
+ )
126
+ p.start()
127
+ procs.append(p)
128
+ for p in procs:
129
+ p.join()
130
+ failed = [p.exitcode for p in procs if p.exitcode != 0]
131
+ if failed:
132
+ raise RuntimeError("worker(s) failed with exit codes {}".format(failed))
133
+
134
+ # concatenate meta shards in order, recording line-start byte offsets
135
+ offsets = np.empty(n, dtype=np.int64)
136
+ tiers = np.empty(n, dtype=np.int64)
137
+ pos = 0
138
+ row = 0
139
+ with open(os.path.join(out_dir, "meta.jsonl"), "wb") as out:
140
+ for wid in range(workers):
141
+ with open(_meta_part(out_dir, wid), "rb") as part:
142
+ for line in part:
143
+ offsets[row] = pos
144
+ out.write(line)
145
+ pos += len(line)
146
+ row += 1
147
+ tiers_part = np.fromfile(_tier_part(out_dir, wid), dtype=np.int64)
148
+ lo, hi = ranges[wid]
149
+ tiers[lo:hi] = tiers_part
150
+ os.remove(_meta_part(out_dir, wid))
151
+ os.remove(_tier_part(out_dir, wid))
152
+ assert row == n, "meta line count {} != n {}".format(row, n)
153
+ offsets.tofile(os.path.join(out_dir, "meta_offsets.i64"))
154
+ for t in range(4):
155
+ idxs = np.nonzero(tiers == t)[0].astype(np.int64)
156
+ idxs.tofile(os.path.join(out_dir, "tier_idx", "tier{}.i64".format(t)))
157
+
158
+ counts = [int((tiers == t).sum()) for t in range(4)]
159
+ print(
160
+ "wrote {} samples to {} in {:.1f}s (tier counts: {})".format(
161
+ n, out_dir, time.time() - t0, counts
162
+ )
163
+ )
164
+
165
+
166
+ def _parse_tier_mix(s: str) -> Tuple[float, ...]:
167
+ parts = tuple(float(v) for v in s.split(","))
168
+ if len(parts) != 4 or min(parts) < 0 or sum(parts) <= 0:
169
+ raise argparse.ArgumentTypeError(
170
+ "--tier-mix must be 4 nonnegative comma-separated floats"
171
+ )
172
+ return parts
173
+
174
+
175
+ def main(argv: Optional[Sequence[str]] = None) -> None:
176
+ ap = argparse.ArgumentParser(description="Pregenerate the proc2d dataset.")
177
+ ap.add_argument("--out", required=True, help="output directory")
178
+ ap.add_argument("--n", type=int, required=True, help="number of samples")
179
+ ap.add_argument("--seed", type=int, default=0)
180
+ ap.add_argument(
181
+ "--tier-mix", type=_parse_tier_mix,
182
+ default=DEFAULT_TIER_MIX, help='e.g. "0.1,0.3,0.4,0.2"',
183
+ )
184
+ ap.add_argument("--workers", type=int, default=1)
185
+ ap.add_argument("--seed-start", type=int, default=0,
186
+ help="scene-index offset (disjoint ranges per split)")
187
+ args = ap.parse_args(argv)
188
+ write_dataset(args.out, args.n, args.seed, args.tier_mix, args.workers,
189
+ seed_start=args.seed_start)
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main(sys.argv[1:])
sprig/dp/__init__.py ADDED
File without changes
sprig/dp/inside.py ADDED
@@ -0,0 +1,604 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inside DP over the finite region lattice (DESIGN.md section 5).
2
+
3
+ Exposes the three DP entry points SPRIGModel calls through its adapter
4
+ (sprig.model.sprig._call_dp), all keyed by the DESIGN section 5 argument
5
+ names plus ``log_PT`` (the [R, T_v] log texel prior table the leaf T-mix
6
+ needs; p(T|A,c) = logsumexp_k(U_logmix[..,A,k] + log_PT[k,T])):
7
+
8
+ inside(ell_leaf, term_logits, cut_logits, U_logmix, logV, logW,
9
+ lattice, temper_kappa, log_PT) -> (beta [B,N_reg,S] fp32, logZ [B])
10
+ inside_logZ(...) -> alias of ``inside``
11
+ viterbi(...) -> (score [B], trees)
12
+ posterior_marginals(...) -> dict of expected-count tensors
13
+
14
+ Semantics (matching tests/fixtures_dp_stub.py, the brute-force-validated
15
+ reference):
16
+ * beta(r, A) for leaf-eligible r combines
17
+ log p_term(r,A) + logsumexp_T(log p(T|A,c) + ell(r,T)/kappa(r))
18
+ with
19
+ log(1 - p_term(r,A)) + expand(r,A),
20
+ where must_terminate regions force p_term = 1 and must_expand regions
21
+ (any side > leaf_max) expand with probability 1 (no p_term factor).
22
+ * expand(r, A) is computed by a level-synchronous sweep in cell-area
23
+ ascending order using the lattice's flattened per-level cut tables;
24
+ cut-type probability mass is split uniformly across same-type concrete
25
+ cuts (the precomputed -log(count) correction).
26
+ * logZ[b] = beta[b, root, 0] (axiom nonterminal = symbol 0).
27
+
28
+ Numerics: all log-domain accumulation in fp32; every logsumexp/logbmm is
29
+ max-shifted with *detached* shifts. -1e30 sentinels are never fed through
30
+ branches on the differentiable path (they only seed the beta table and the
31
+ scatter-max identity); leaf/expand branches are combined by mask + index so
32
+ the module is safe under double backward (SPRIGModel.loss uses
33
+ create_graph=True through the DP for the under-use hinges).
34
+
35
+ Every function accepts ``exact=True`` to replace the max-shifted
36
+ exp/matmul/log fast path with a direct fp32 logsumexp — the exact fallback
37
+ DESIGN section 5 asks for (used by the parity tests).
38
+ """
39
+ from __future__ import annotations
40
+
41
+ from typing import Dict, List, Optional, Tuple
42
+
43
+ import torch
44
+ import torch.nn.functional as F
45
+
46
+ NEG = -1e30
47
+ # Clamp floor for post-shift exp-sums before the final log. It must be large
48
+ # enough that log's DOUBLE backward (-grad/x^2) stays finite in fp32
49
+ # (x^2 >= 1e-30 > fp32 tiny), because SPRIGModel.loss differentiates the
50
+ # under-use hinges through the DP with create_graph=True. Floor 1e-15 caps a
51
+ # reduction at ~34.5 nats below the factored max-shifts, which only rounds up
52
+ # paths of negligible posterior mass. (The segment-logsumexp sums are >= 1 by
53
+ # construction and never hit the clamp.)
54
+ _TINY = 1e-15
55
+ # Element budget for the [B, chunk, S, R] / [B, chunk, S, T_v] blocks the
56
+ # max-semiring (argmax) passes materialize.
57
+ _MAX_SEMIRING_BUDGET = 1 << 24
58
+
59
+
60
+ def logbmm(x_log: torch.Tensor, w_log: torch.Tensor, exact: bool = False) -> torch.Tensor:
61
+ """out[..., m] = logsumexp_k(x_log[..., k] + w_log[k, m]).
62
+
63
+ x_log [..., K] and w_log [K, M], both finite fp32. Max-shifted (detached
64
+ shifts), exp, matmul, log, shifts added back. ``exact=True`` is the direct
65
+ fp32 logsumexp fallback for tests.
66
+ """
67
+ x_log = x_log.float()
68
+ w_log = w_log.float()
69
+ if exact:
70
+ expand_shape = (1,) * (x_log.dim() - 1) + tuple(w_log.shape)
71
+ return torch.logsumexp(x_log.unsqueeze(-1) + w_log.view(expand_shape), dim=-2)
72
+ sx = x_log.max(dim=-1, keepdim=True).values.detach() # [..., 1]
73
+ sw = w_log.max(dim=0, keepdim=True).values.detach() # [1, M]
74
+ out = torch.matmul(torch.exp(x_log - sx), torch.exp(w_log - sw))
75
+ return torch.log(out.clamp(min=_TINY)) + sx + sw
76
+
77
+
78
+ def logbmm_batched(x_log: torch.Tensor, w_log: torch.Tensor, exact: bool = False) -> torch.Tensor:
79
+ """out[b, p, m] = logsumexp_k(x_log[b, p, k] + w_log[b, k, m]).
80
+
81
+ Batched-weight variant of :func:`logbmm` (x [B, P, K], w [B, K, M])."""
82
+ x_log = x_log.float()
83
+ w_log = w_log.float()
84
+ if exact:
85
+ return torch.logsumexp(x_log.unsqueeze(-1) + w_log.unsqueeze(1), dim=-2)
86
+ sx = x_log.max(dim=-1, keepdim=True).values.detach() # [B, P, 1]
87
+ sw = w_log.max(dim=1, keepdim=True).values.detach() # [B, 1, M]
88
+ out = torch.bmm(torch.exp(x_log - sx), torch.exp(w_log - sw))
89
+ return torch.log(out.clamp(min=_TINY)) + sx + sw
90
+
91
+
92
+ def _segment_logsumexp(
93
+ x: torch.Tensor, seg_index: torch.Tensor, n_seg: int
94
+ ) -> torch.Tensor:
95
+ """Group-by-segment logsumexp along dim 1: x [B, M, R], seg_index [M]
96
+ -> [B, n_seg, R]. Max-shift per segment (detached), scatter-add of exp,
97
+ log."""
98
+ B, M, R = x.shape
99
+ idx = seg_index.view(1, -1, 1).expand(B, M, R)
100
+ mx = torch.full((B, n_seg, R), NEG, device=x.device, dtype=x.dtype).scatter_reduce(
101
+ 1, idx, x.detach(), reduce="amax", include_self=True
102
+ )
103
+ ex = torch.exp(x - mx[:, seg_index, :])
104
+ ssum = torch.zeros(B, n_seg, R, device=x.device, dtype=x.dtype).index_add_(
105
+ 1, seg_index, ex
106
+ )
107
+ return mx + torch.log(ssum.clamp(min=_TINY))
108
+
109
+
110
+ def _row_cut_logp(cut_logits: torch.Tensor, lattice, lv, exact: bool = False) -> torch.Tensor:
111
+ """Per concrete cut row: masked + renormalized cut-type log-prob with the
112
+ uniform same-type mass split -> [B, M, R].
113
+
114
+ cut_logits [B, R, 14]; validity mask per unique parent from
115
+ lattice.type_present; log-softmax over the masked set; -log(count)
116
+ correction per row (lv.log_cnt). (Fallback path — the sweep normally uses
117
+ :func:`_cut_logp_flat` + one gather per level.)"""
118
+ del exact # the masked log-softmax is already exact fp32
119
+ B, R = cut_logits.shape[0], cut_logits.shape[1]
120
+ M = lv.parent_ids.shape[0]
121
+ pres = lattice.type_present[lv.parents] # [P, 14]
122
+ tl = cut_logits.float().unsqueeze(1).masked_fill(
123
+ ~pres.view(1, -1, 1, pres.shape[-1]), float("-inf")
124
+ ) # [B, P, R, 14]
125
+ tls = F.log_softmax(tl, dim=-1)
126
+ sel = tls[:, lv.parent_index, :, :] # [B, M, R, 14]
127
+ idx = lv.cut_type.view(1, -1, 1, 1).expand(B, M, R, 1)
128
+ return sel.gather(-1, idx).squeeze(-1) - lv.log_cnt.view(1, -1, 1)
129
+
130
+
131
+ def _cut_logp_flat(cut_logits: torch.Tensor, lattice) -> Optional[torch.Tensor]:
132
+ """Masked + renormalized cut-type log-probs for every DISTINCT
133
+ type-presence pattern, flattened for per-level gathering:
134
+
135
+ out [B, n_pat * 14, R]; row(pattern p, type t) = out[:, p*14 + t, :].
136
+
137
+ One log-softmax per sweep instead of one per level; identical values (the
138
+ per-parent mask in :func:`_row_cut_logp` only depends on the parent's
139
+ type-presence pattern). Returns None when the lattice does not carry the
140
+ precomputed pattern tables (foreign/stub lattices -> per-level fallback).
141
+ """
142
+ pats = getattr(lattice, "type_patterns", None)
143
+ if pats is None:
144
+ return None
145
+ B, R = cut_logits.shape[0], cut_logits.shape[1]
146
+ n_pat, T = pats.shape
147
+ tl = cut_logits.float().unsqueeze(1).masked_fill(
148
+ ~pats.view(1, n_pat, 1, T), float("-inf")
149
+ ) # [B, n_pat, R, 14]
150
+ tls = F.log_softmax(tl, dim=-1)
151
+ return tls.permute(0, 1, 3, 2).reshape(B, n_pat * T, R)
152
+
153
+
154
+ def _level_cut_logp(
155
+ tls_flat: Optional[torch.Tensor], cut_logits: torch.Tensor, lattice, lv,
156
+ ) -> torch.Tensor:
157
+ """Per-row cut log-prob [B, M, R] for one level (gather from the per-sweep
158
+ flat table when available, else the per-level fallback)."""
159
+ if tls_flat is not None and getattr(lv, "flat_type_idx", None) is not None:
160
+ return tls_flat[:, lv.flat_type_idx, :] - lv.log_cnt.view(1, -1, 1)
161
+ return _row_cut_logp(cut_logits, lattice, lv)
162
+
163
+
164
+ def _mt_meta(lattice) -> Tuple[torch.Tensor, torch.Tensor]:
165
+ """(must-terminate region ids, their leaf slots) — precomputed on the
166
+ lattice when available (no per-call ``nonzero``)."""
167
+ rid_mt = getattr(lattice, "mt_ids", None)
168
+ if rid_mt is None:
169
+ rid_mt = torch.nonzero(lattice.must_terminate, as_tuple=False).reshape(-1)
170
+ slot_mt = getattr(lattice, "mt_slots", None)
171
+ if slot_mt is None:
172
+ slot_mt = lattice.leaf_index_of_region[rid_mt]
173
+ return rid_mt, slot_mt
174
+
175
+
176
+ def _sweep_meta(lattice) -> List[Tuple]:
177
+ """Per-level (has_leaf, has_nonleaf, leaf_pos, nonleaf_pos, leaf_rids,
178
+ nonleaf_rids, leaf_slots). Reads the tensors precomputed at lattice build;
179
+ falls back to computing them (with syncs) for foreign/stub lattices."""
180
+ metas: List[Tuple] = []
181
+ for lv in lattice.levels:
182
+ if getattr(lv, "leaf_pos", None) is not None:
183
+ metas.append((lv.has_leaf, lv.has_nonleaf, lv.leaf_pos,
184
+ lv.nonleaf_pos, lv.leaf_rids, lv.nonleaf_rids,
185
+ lv.leaf_slots))
186
+ else:
187
+ is_leaf = lattice.leaf_mask[lv.parents]
188
+ leaf_pos = torch.nonzero(is_leaf, as_tuple=False).reshape(-1)
189
+ nonleaf_pos = torch.nonzero(~is_leaf, as_tuple=False).reshape(-1)
190
+ leaf_rids = lv.parents[leaf_pos]
191
+ nonleaf_rids = lv.parents[nonleaf_pos]
192
+ leaf_slots = lattice.leaf_index_of_region[leaf_rids]
193
+ metas.append((bool(leaf_pos.numel() > 0), bool(nonleaf_pos.numel() > 0),
194
+ leaf_pos, nonleaf_pos, leaf_rids, nonleaf_rids, leaf_slots))
195
+ return metas
196
+
197
+
198
+ def _leaf_terms(
199
+ ell_leaf: torch.Tensor,
200
+ term_logits: torch.Tensor,
201
+ U_logmix: torch.Tensor,
202
+ log_PT: torch.Tensor,
203
+ lattice,
204
+ temper_kappa: torch.Tensor,
205
+ term_mark: Optional[torch.Tensor] = None,
206
+ semiring: str = "sum",
207
+ exact: bool = False,
208
+ ) -> Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]:
209
+ """Termination scores for every leaf-eligible region.
210
+
211
+ Returns (term_score [B, n_leaf, S], log_cont [B, n_leaf, S], argT).
212
+ term_score = log p_term + mix_T(log p(T|A,c) + ell/kappa) where mix is
213
+ logsumexp (sum semiring) or max (max semiring; argT returned).
214
+ must_terminate: log p_term = 0, log(1 - p_term) = NEG sentinel (never fed
215
+ through logaddexp — the caller indexes around it).
216
+ """
217
+ prior = logbmm(U_logmix.float(), log_PT.float(), exact=exact) # [B, S, T_v]
218
+ ell_t = ell_leaf.float() / temper_kappa.float().view(1, -1, 1) # [B, n_leaf, T_v]
219
+ if semiring == "sum":
220
+ mix = logbmm_batched(ell_t, prior.transpose(1, 2), exact=exact) # [B, n_leaf, S]
221
+ argT = None
222
+ else:
223
+ B, n_leaf, T_v = ell_t.shape
224
+ S = prior.shape[1]
225
+ step = max(1, _MAX_SEMIRING_BUDGET // max(1, B * S * T_v))
226
+ mixes, args = [], []
227
+ for i0 in range(0, n_leaf, step):
228
+ scores = prior.unsqueeze(1) + ell_t[:, i0 : i0 + step].unsqueeze(2)
229
+ m, a = scores.max(dim=-1) # [B, chunk, S]
230
+ mixes.append(m)
231
+ args.append(a)
232
+ mix = torch.cat(mixes, dim=1)
233
+ argT = torch.cat(args, dim=1)
234
+ lt = term_logits.float()[:, lattice.leaf_ids, :]
235
+ log_term = F.logsigmoid(lt)
236
+ log_cont = F.logsigmoid(-lt)
237
+ mt = lattice.must_terminate[lattice.leaf_ids].view(1, -1, 1)
238
+ log_term = torch.where(mt, torch.zeros_like(log_term), log_term)
239
+ log_cont = torch.where(mt, torch.full_like(log_cont, NEG), log_cont)
240
+ term_score = log_term + mix
241
+ if term_mark is not None:
242
+ term_score = term_score + term_mark[:, lattice.leaf_ids, :]
243
+ return term_score, log_cont, argT
244
+
245
+
246
+ def _sweep(
247
+ ell_leaf: torch.Tensor,
248
+ term_logits: torch.Tensor,
249
+ cut_logits: torch.Tensor,
250
+ U_logmix: torch.Tensor,
251
+ logV: torch.Tensor,
252
+ logW: torch.Tensor,
253
+ lattice,
254
+ temper_kappa: torch.Tensor,
255
+ log_PT: torch.Tensor,
256
+ term_mark: Optional[torch.Tensor] = None,
257
+ expand_mark: Optional[torch.Tensor] = None,
258
+ cut_mark: Optional[torch.Tensor] = None,
259
+ exact: bool = False,
260
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
261
+ """Level-synchronous inside sweep -> (beta [B, N_reg, S] fp32, logZ [B]).
262
+
263
+ All shape-dependent decisions (leaf/non-leaf membership per level,
264
+ must-terminate ids, cut-type masks) come from lattice-precomputed index
265
+ tensors and python bools so the loop issues no CPU-GPU syncs (no
266
+ ``bool(tensor)``, no bool-mask advanced indexing, no ``nonzero``).
267
+
268
+ Two equivalent implementations: the fast sweep (contiguous-id lattices,
269
+ i.e. every Lattice built by sprig.dp.lattice) keeps per-level beta blocks
270
+ in a list and maintains the R-compressed child tables
271
+ Blo[b,r,k] = logsumexp_B(beta[b,r,B] + logV[k,B]) (Bhi with logW)
272
+ so the per-level child gathers and writes touch [B,N,R] instead of
273
+ [B,N,S] — identical values (the compression is exactly the logbmm the
274
+ reference applies per cut row, hoisted per region) but ~S/R times less
275
+ index/index_put traffic, which dominates the DP's backward. The reference
276
+ sweep is kept for foreign/stub lattices."""
277
+ B, _N, S = term_logits.shape
278
+ device = ell_leaf.device
279
+ logV = logV.float()
280
+ logW = logW.float()
281
+ U_log = U_logmix.float()
282
+
283
+ term_score, log_cont, _ = _leaf_terms(
284
+ ell_leaf, term_logits, U_log, log_PT, lattice, temper_kappa,
285
+ term_mark=term_mark, semiring="sum", exact=exact,
286
+ )
287
+ tls_flat = _cut_logp_flat(cut_logits, lattice)
288
+ U_log_t = U_log.transpose(1, 2)
289
+ rid_mt, slot_mt = _mt_meta(lattice)
290
+
291
+ if getattr(lattice, "contiguous_ids", False):
292
+ return _sweep_fast(
293
+ term_score, log_cont, cut_logits, U_log_t, logV, logW, lattice,
294
+ tls_flat, slot_mt, expand_mark, cut_mark, exact,
295
+ )
296
+
297
+ beta = torch.full((B, lattice.n_regions, S), NEG, device=device)
298
+ beta[:, rid_mt, :] = term_score[:, slot_mt, :]
299
+
300
+ row_offset = 0
301
+ for lv, meta in zip(lattice.levels, _sweep_meta(lattice)):
302
+ has_leaf, has_nonleaf, leaf_pos, nonleaf_pos, leaf_rids, nonleaf_rids, leaf_slots = meta
303
+ M = lv.parent_ids.shape[0]
304
+ P = lv.parents.shape[0]
305
+ # Bhat[b,m,k] = logsumexp_B(beta[b, child_lo[m], B] + logV[k, B]); Chat same with logW.
306
+ Bhat = logbmm(beta[:, lv.child_lo, :], logV.t(), exact=exact) # [B, M, R]
307
+ Chat = logbmm(beta[:, lv.child_hi, :], logW.t(), exact=exact)
308
+ contrib = _level_cut_logp(tls_flat, cut_logits, lattice, lv) + Bhat + Chat
309
+ if cut_mark is not None:
310
+ contrib = contrib + cut_mark[:, row_offset : row_offset + M].unsqueeze(-1)
311
+ comb = _segment_logsumexp(contrib, lv.parent_index, P) # [B, P, R]
312
+ # expand[b,p,A] = logsumexp_k(comb[b,p,k] + U_log[b,A,k])
313
+ expand = logbmm_batched(comb, U_log_t, exact=exact) # [B, P, S]
314
+ if expand_mark is not None:
315
+ expand = expand + expand_mark[:, lv.parents, :]
316
+
317
+ # Combine the terminate/expand branches. Only finite values may enter
318
+ # the log-add: with the -1e30 sentinel (and equally with legitimate
319
+ # log-domain gaps > ~88 nats once emissions sharpen) torch.logaddexp's
320
+ # first-order backward materializes exp(b - a) = inf nodes whose
321
+ # double-backward (create_graph=True in SPRIGModel.loss) is NaN — so
322
+ # mask/index the branches and use a detached-max shift, which keeps
323
+ # every exp argument <= 0 and the log argument in [1, 2].
324
+ if has_nonleaf:
325
+ beta[:, nonleaf_rids, :] = expand[:, nonleaf_pos, :]
326
+ if has_leaf:
327
+ a = term_score[:, leaf_slots, :]
328
+ b = log_cont[:, leaf_slots, :] + expand[:, leaf_pos, :]
329
+ m = torch.maximum(a, b).detach()
330
+ beta[:, leaf_rids, :] = m + torch.log(torch.exp(a - m) + torch.exp(b - m))
331
+ row_offset += M
332
+
333
+ logZ = beta[:, lattice.root_id, 0]
334
+ return beta, logZ
335
+
336
+
337
+ def _combine_branches(
338
+ expand: torch.Tensor,
339
+ term_score: torch.Tensor,
340
+ log_cont: torch.Tensor,
341
+ lv,
342
+ ) -> torch.Tensor:
343
+ """Terminate/expand combination for one level -> beta block [B, P, S] in
344
+ parent order. Same guarded log-add as the reference sweep (see the long
345
+ comment there); rows are assembled with a precomputed permutation gather
346
+ instead of index_put (cheaper backward, no clone of the block)."""
347
+ if not lv.has_leaf:
348
+ return expand
349
+ a = term_score[:, lv.leaf_slots, :]
350
+ b = log_cont[:, lv.leaf_slots, :] + expand[:, lv.leaf_pos, :]
351
+ m = torch.maximum(a, b).detach()
352
+ leaf_val = m + torch.log(torch.exp(a - m) + torch.exp(b - m))
353
+ if not lv.has_nonleaf:
354
+ return leaf_val
355
+ both = torch.cat([expand[:, lv.nonleaf_pos, :], leaf_val], dim=1)
356
+ return both[:, lv.reorder, :]
357
+
358
+
359
+ def _sweep_fast(
360
+ term_score: torch.Tensor,
361
+ log_cont: torch.Tensor,
362
+ cut_logits: torch.Tensor,
363
+ U_log_t: torch.Tensor,
364
+ logV: torch.Tensor,
365
+ logW: torch.Tensor,
366
+ lattice,
367
+ tls_flat: Optional[torch.Tensor],
368
+ slot_mt: torch.Tensor,
369
+ expand_mark: Optional[torch.Tensor],
370
+ cut_mark: Optional[torch.Tensor],
371
+ exact: bool,
372
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
373
+ """Fast inside sweep over contiguous-id lattices (see _sweep docstring)."""
374
+ B, S = term_score.shape[0], term_score.shape[-1]
375
+ R = logV.shape[0]
376
+ N = lattice.n_regions
377
+ device = term_score.device
378
+
379
+ base = term_score[:, slot_mt, :] # [B, n_mt, S] == ids [0, n_mt)
380
+ parts = [base]
381
+ Blo = torch.full((B, N, R), NEG, device=device)
382
+ Bhi = torch.full((B, N, R), NEG, device=device)
383
+ n_mt = base.shape[1]
384
+ Blo[:, :n_mt, :] = logbmm(base, logV.t(), exact=exact)
385
+ Bhi[:, :n_mt, :] = logbmm(base, logW.t(), exact=exact)
386
+
387
+ row_offset = 0
388
+ for lv in lattice.levels:
389
+ M = lv.parent_ids.shape[0]
390
+ P = lv.parents.shape[0]
391
+ Bhat = Blo[:, lv.child_lo, :] # [B, M, R]
392
+ Chat = Bhi[:, lv.child_hi, :]
393
+ contrib = _level_cut_logp(tls_flat, cut_logits, lattice, lv) + Bhat + Chat
394
+ if cut_mark is not None:
395
+ contrib = contrib + cut_mark[:, row_offset : row_offset + M].unsqueeze(-1)
396
+ comb = _segment_logsumexp(contrib, lv.parent_index, P) # [B, P, R]
397
+ expand = logbmm_batched(comb, U_log_t, exact=exact) # [B, P, S]
398
+ if expand_mark is not None:
399
+ expand = expand + expand_mark[:, lv.parents, :]
400
+ val = _combine_branches(expand, term_score, log_cont, lv) # [B, P, S]
401
+ parts.append(val)
402
+ Blo[:, lv.id_lo : lv.id_hi, :] = logbmm(val, logV.t(), exact=exact)
403
+ Bhi[:, lv.id_lo : lv.id_hi, :] = logbmm(val, logW.t(), exact=exact)
404
+ row_offset += M
405
+
406
+ beta = torch.cat(parts, dim=1) # region-id order
407
+ logZ = beta[:, lattice.root_id, 0]
408
+ return beta, logZ
409
+
410
+
411
+ def inside(
412
+ ell_leaf: torch.Tensor,
413
+ term_logits: torch.Tensor,
414
+ cut_logits: torch.Tensor,
415
+ U_logmix: torch.Tensor,
416
+ logV: torch.Tensor,
417
+ logW: torch.Tensor,
418
+ lattice,
419
+ temper_kappa: torch.Tensor,
420
+ log_PT: torch.Tensor,
421
+ exact: bool = False,
422
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
423
+ """DESIGN section 5 inside DP -> (beta [B, N_reg, S] fp32, logZ [B])."""
424
+ return _sweep(
425
+ ell_leaf, term_logits, cut_logits, U_logmix, logV, logW, lattice,
426
+ temper_kappa, log_PT, exact=exact,
427
+ )
428
+
429
+
430
+ # DESIGN.md section 5 names the function `inside`; SPRIGModel's adapter looks
431
+ # for `inside_logZ` first — keep both bound to the same implementation.
432
+ inside_logZ = inside
433
+
434
+
435
+ def posterior_marginals(
436
+ ell_leaf: torch.Tensor,
437
+ term_logits: torch.Tensor,
438
+ cut_logits: torch.Tensor,
439
+ U_logmix: torch.Tensor,
440
+ logV: torch.Tensor,
441
+ logW: torch.Tensor,
442
+ lattice,
443
+ temper_kappa: torch.Tensor,
444
+ log_PT: torch.Tensor,
445
+ exact: bool = False,
446
+ ) -> Dict[str, torch.Tensor]:
447
+ """Expected-count posterior marginals via the autograd identity.
448
+
449
+ Zero-valued marker potentials are added on the terminate branch, the
450
+ expand branch, and every concrete cut row; grad of logZ w.r.t. a marker
451
+ equals the posterior expected count of that event. Returns detached
452
+ tensors: node/term/expand [B, N_reg, S], cut [B, M_total] (levels
453
+ concatenated in lattice.levels row order), texel [B, n_leaf, T_v]
454
+ (grad w.r.t. ell rescaled by kappa), rule [B, S, R] (grad w.r.t.
455
+ U_logmix), logZ [B].
456
+ """
457
+ B, N, S = term_logits.shape
458
+ device = ell_leaf.device
459
+ M_total = sum(int(lv.parent_ids.shape[0]) for lv in lattice.levels)
460
+ ell_d = ell_leaf.detach().requires_grad_(True)
461
+ U_d = U_logmix.detach().requires_grad_(True)
462
+ tm = torch.zeros(B, N, S, device=device, requires_grad=True)
463
+ em = torch.zeros(B, N, S, device=device, requires_grad=True)
464
+ cm = torch.zeros(B, M_total, device=device, requires_grad=True)
465
+ with torch.enable_grad():
466
+ _, logZ = _sweep(
467
+ ell_d, term_logits.detach(), cut_logits.detach(), U_d,
468
+ logV.detach(), logW.detach(), lattice,
469
+ temper_kappa.detach(), log_PT.detach(),
470
+ term_mark=tm, expand_mark=em, cut_mark=cm, exact=exact,
471
+ )
472
+ g_tm, g_em, g_cm, g_ell, g_u = torch.autograd.grad(
473
+ logZ.sum(), [tm, em, cm, ell_d, U_d]
474
+ )
475
+ texel = g_ell * temper_kappa.detach().float().view(1, -1, 1)
476
+ return {
477
+ "logZ": logZ.detach(),
478
+ "node": (g_tm + g_em).detach(),
479
+ "term": g_tm.detach(),
480
+ "expand": g_em.detach(),
481
+ "cut": g_cm.detach(),
482
+ "texel": texel.detach(),
483
+ "rule": g_u.detach(),
484
+ }
485
+
486
+
487
+ def viterbi(
488
+ ell_leaf: torch.Tensor,
489
+ term_logits: torch.Tensor,
490
+ cut_logits: torch.Tensor,
491
+ U_logmix: torch.Tensor,
492
+ logV: torch.Tensor,
493
+ logW: torch.Tensor,
494
+ lattice,
495
+ temper_kappa: torch.Tensor,
496
+ log_PT: torch.Tensor,
497
+ exact: bool = False,
498
+ ) -> Tuple[torch.Tensor, List[Tuple]]:
499
+ """Max-semiring sweep + argmax backtrace.
500
+
501
+ Returns (score [B], trees): trees[b] is a nested tuple
502
+ (region_id, symbol, texel_or_None, axis_or_None, cut_px_or_None,
503
+ (children...)) rooted at (lattice.root_id, symbol 0). The texel T-mix is
504
+ a max; the texel prior p(T|A,c) itself stays an exact sum over k.
505
+ """
506
+ with torch.no_grad():
507
+ B, N, S = term_logits.shape
508
+ R = U_logmix.shape[-1]
509
+ device = ell_leaf.device
510
+ U_log = U_logmix.float()
511
+ logV_f = logV.float()
512
+ logW_f = logW.float()
513
+ term_score, log_cont, argT = _leaf_terms(
514
+ ell_leaf, term_logits, U_log, log_PT, lattice, temper_kappa,
515
+ semiring="max", exact=exact,
516
+ )
517
+ beta = torch.full((B, N, S), NEG, device=device)
518
+ term_choice = torch.zeros(B, N, S, dtype=torch.bool, device=device)
519
+ leaf_slot = lattice.leaf_index_of_region
520
+ rid_mt, slot_mt = _mt_meta(lattice)
521
+ beta[:, rid_mt, :] = term_score[:, slot_mt, :]
522
+ term_choice[:, rid_mt, :] = True
523
+
524
+ tls_flat = _cut_logp_flat(cut_logits, lattice)
525
+ metas = _sweep_meta(lattice)
526
+ bp: List[Dict[str, torch.Tensor]] = []
527
+ parent_pos: Dict[int, Tuple[int, int]] = {}
528
+ for li, lv in enumerate(lattice.levels):
529
+ for p_i, r in enumerate(lv.parents.tolist()):
530
+ parent_pos[r] = (li, p_i)
531
+ M = lv.parent_ids.shape[0]
532
+ P = lv.parents.shape[0]
533
+
534
+ # Per-row maxima over child symbols and over components, chunked
535
+ # over rows to bound the [B, chunk, R, S] / [B, chunk, S, R]
536
+ # materializations.
537
+ step = max(1, _MAX_SEMIRING_BUDGET // max(1, B * S * R))
538
+ cutlp = _level_cut_logp(tls_flat, cut_logits, lattice, lv) # [B, M, R]
539
+ argB = torch.empty(B, M, R, dtype=torch.int64, device=device)
540
+ argC = torch.empty(B, M, R, dtype=torch.int64, device=device)
541
+ argk = torch.empty(B, M, S, dtype=torch.int64, device=device)
542
+ tmax = torch.empty(B, M, S, device=device)
543
+ for m0 in range(0, M, step):
544
+ m1 = min(M, m0 + step)
545
+ lo_b = beta[:, lv.child_lo[m0:m1], :] # [B, c, S]
546
+ hi_b = beta[:, lv.child_hi[m0:m1], :]
547
+ Bv, aB = (lo_b.unsqueeze(2) + logV_f.view(1, 1, R, S)).max(dim=-1)
548
+ Cv, aC = (hi_b.unsqueeze(2) + logW_f.view(1, 1, R, S)).max(dim=-1)
549
+ argB[:, m0:m1] = aB
550
+ argC[:, m0:m1] = aC
551
+ contrib = cutlp[:, m0:m1] + Bv + Cv # [B, c, R]
552
+ t = contrib.unsqueeze(2) + U_log.unsqueeze(1) # [B, c, S, R]
553
+ tm_c, ak = t.max(dim=-1)
554
+ tmax[:, m0:m1] = tm_c
555
+ argk[:, m0:m1] = ak
556
+
557
+ # Group-max over the rows of each parent; ties -> lowest row index.
558
+ pidx = lv.parent_index.view(1, -1, 1).expand(B, M, S)
559
+ pmax = torch.full((B, P, S), NEG, device=device).scatter_reduce(
560
+ 1, pidx, tmax, reduce="amax", include_self=True
561
+ )
562
+ rowidx = torch.arange(M, device=device).view(1, -1, 1).expand(B, M, S)
563
+ cand = torch.where(
564
+ tmax >= pmax[:, lv.parent_index, :], rowidx,
565
+ torch.full_like(rowidx, M),
566
+ )
567
+ argm = torch.full((B, P, S), M, dtype=torch.int64, device=device).scatter_reduce(
568
+ 1, pidx, cand, reduce="amin", include_self=True
569
+ )
570
+
571
+ rid = lv.parents
572
+ has_leaf, _hn, leaf_pos, _np, _lr, _nr, leaf_slots = metas[li]
573
+ cont = torch.zeros(B, P, S, device=device)
574
+ tb = torch.full((B, P, S), NEG, device=device)
575
+ if has_leaf:
576
+ cont[:, leaf_pos, :] = log_cont[:, leaf_slots, :]
577
+ tb[:, leaf_pos, :] = term_score[:, leaf_slots, :]
578
+ exp_v = cont + pmax
579
+ beta[:, rid, :] = torch.maximum(tb, exp_v)
580
+ term_choice[:, rid, :] = tb >= exp_v
581
+ bp.append({"argB": argB, "argC": argC, "argk": argk, "argm": argm})
582
+
583
+ score = beta[:, lattice.root_id, 0]
584
+
585
+ def extract(b: int, rid: int, sym: int) -> Tuple:
586
+ if bool(term_choice[b, rid, sym]):
587
+ slot = int(leaf_slot[rid])
588
+ tex = int(argT[b, slot, sym])
589
+ return (rid, sym, tex, None, None, ())
590
+ li, p_i = parent_pos[rid]
591
+ lv = lattice.levels[li]
592
+ m = int(bp[li]["argm"][b, p_i, sym])
593
+ k = int(bp[li]["argk"][b, m, sym])
594
+ b_sym = int(bp[li]["argB"][b, m, k])
595
+ c_sym = int(bp[li]["argC"][b, m, k])
596
+ lo, hi = int(lv.child_lo[m]), int(lv.child_hi[m])
597
+ axis, px = int(lv.cut_axis[m]), int(lv.cut_px[m])
598
+ return (
599
+ rid, sym, None, axis, px,
600
+ (extract(b, lo, b_sym), extract(b, hi, c_sym)),
601
+ )
602
+
603
+ trees = [extract(b, lattice.root_id, 0) for b in range(B)]
604
+ return score, trees
sprig/dp/lattice.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Finite region lattice for the SPRIG inside DP.
2
+
3
+ Regions are axis-aligned rectangles whose corners lie on a fixed pixel grid.
4
+ Every cell-interval pair is a region; every interior grid line of a region is
5
+ a valid cut producing two child regions. See DESIGN.md section 3.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from dataclasses import dataclass, field
11
+ from typing import Dict, List, Optional, Tuple
12
+
13
+ import torch
14
+
15
+ N_CUT_TYPES = 14 # 2 axes x 7 relative-offset buckets {1/8 .. 7/8}
16
+ _OFFSET_BUCKETS = [i / 8.0 for i in range(1, 8)]
17
+
18
+
19
+ def _bucket(rel: float) -> int:
20
+ return min(range(7), key=lambda i: abs(_OFFSET_BUCKETS[i] - rel))
21
+
22
+
23
+ def cut_type_id(axis: int, rel: float) -> int:
24
+ """axis: 0 = vertical cut (splits x), 1 = horizontal cut (splits y)."""
25
+ return axis * 7 + _bucket(rel)
26
+
27
+
28
+ @dataclass
29
+ class LatticeLevel:
30
+ """Flattened cut tables for all parent regions of one cell-area level."""
31
+ parent_ids: torch.Tensor # int64 [M]
32
+ cut_type: torch.Tensor # int64 [M] global cut-type id
33
+ log_cnt: torch.Tensor # fp32 [M] log(#same-type cuts in this region)
34
+ child_lo: torch.Tensor # int64 [M] lesser-coordinate child region id
35
+ child_hi: torch.Tensor # int64 [M]
36
+ cut_axis: torch.Tensor # int64 [M] 0=v, 1=h
37
+ cut_px: torch.Tensor # int64 [M] absolute cut coordinate in px
38
+ parents: torch.Tensor # int64 [P] unique parent ids at this level
39
+ parent_index: torch.Tensor # int64 [M] index into `parents` for scatter
40
+ # Precomputed sweep metadata (pure functions of the lattice; filled in by
41
+ # Lattice.__post_init__ so the inside DP never branches on device tensors
42
+ # or bool-mask-indexes inside the per-level loop — those force CPU-GPU
43
+ # syncs). Optional so externally-built levels stay constructible;
44
+ # sprig.dp.inside falls back to computing them on the fly if absent.
45
+ leaf_pos: Optional[torch.Tensor] = None # int64 [Pl] positions in `parents` that are leaf-eligible
46
+ nonleaf_pos: Optional[torch.Tensor] = None # int64 [Pn]
47
+ leaf_rids: Optional[torch.Tensor] = None # int64 [Pl] = parents[leaf_pos]
48
+ nonleaf_rids: Optional[torch.Tensor] = None # int64 [Pn]
49
+ leaf_slots: Optional[torch.Tensor] = None # int64 [Pl] leaf_index_of_region[leaf_rids]
50
+ flat_type_idx: Optional[torch.Tensor] = None # int64 [M] pattern_of_region[parent]*14 + cut_type
51
+ has_leaf: bool = False
52
+ has_nonleaf: bool = False
53
+ reorder: Optional[torch.Tensor] = None # int64 [P]: cat(nonleaf, leaf) -> parent order
54
+ id_lo: int = 0 # parents == arange(id_lo, id_hi) when the
55
+ id_hi: int = 0 # lattice has contiguous_ids (see Lattice)
56
+
57
+
58
+ @dataclass
59
+ class Lattice:
60
+ canvas_px: int
61
+ grid_px: int
62
+ leaf_max_px: int
63
+
64
+ regions: torch.Tensor = field(init=False) # int64 [N,4] x0,y0,x1,y1 (px)
65
+ region_id: Dict[Tuple[int, int, int, int], int] = field(init=False)
66
+ leaf_mask: torch.Tensor = field(init=False) # bool [N]
67
+ must_terminate: torch.Tensor = field(init=False) # bool [N]
68
+ must_expand: torch.Tensor = field(init=False) # bool [N]
69
+ area_px: torch.Tensor = field(init=False) # fp32 [N]
70
+ phi_geom: torch.Tensor = field(init=False) # fp32 [N,64]
71
+ type_present: torch.Tensor = field(init=False) # bool [N,14]
72
+ levels: List[LatticeLevel] = field(init=False) # area-ascending, only levels with cuts
73
+ root_id: int = field(init=False)
74
+ leaf_ids: torch.Tensor = field(init=False) # int64 [NL] leaf-eligible region ids
75
+ leaf_index_of_region: torch.Tensor = field(init=False) # int64 [N] (-1 if not leaf)
76
+ cuts_of_region: Dict[int, List[Tuple[int, int, int, int, int, float]]] = field(init=False)
77
+ # cuts_of_region[r] = list of (axis, px, child_lo, child_hi, type_id, log_cnt)
78
+ # Precomputed sweep/emission metadata (see __post_init__).
79
+ mt_ids: torch.Tensor = field(init=False) # int64 [n_mt] must-terminate region ids
80
+ mt_slots: torch.Tensor = field(init=False) # int64 [n_mt] their leaf slots
81
+ type_patterns: torch.Tensor = field(init=False) # bool [n_pat, 14] distinct type_present rows
82
+ pattern_of_region: torch.Tensor = field(init=False) # int64 [N] (-1 if region has no cuts)
83
+ shape_groups: List[Tuple[int, int, torch.Tensor, torch.Tensor]] = field(init=False)
84
+ contiguous_ids: bool = field(init=False) # id blocks are (mt, level0, level1, ...)
85
+
86
+ def __post_init__(self) -> None:
87
+ g, c = self.grid_px, self.canvas_px
88
+ assert c % g == 0
89
+ n_cells = c // g
90
+ lines = [i * g for i in range(n_cells + 1)]
91
+
92
+ rects: List[Tuple[int, int, int, int]] = []
93
+ for x0 in range(n_cells):
94
+ for x1 in range(x0 + 1, n_cells + 1):
95
+ for y0 in range(n_cells):
96
+ for y1 in range(y0 + 1, n_cells + 1):
97
+ rects.append((lines[x0], lines[y0], lines[x1], lines[y1]))
98
+ rects.sort(key=lambda r: ((r[2] - r[0]) * (r[3] - r[1]), r))
99
+ self.region_id = {r: i for i, r in enumerate(rects)}
100
+ self.regions = torch.tensor(rects, dtype=torch.int64)
101
+ N = len(rects)
102
+
103
+ w = self.regions[:, 2] - self.regions[:, 0]
104
+ h = self.regions[:, 3] - self.regions[:, 1]
105
+ self.leaf_mask = (w <= self.leaf_max_px) & (h <= self.leaf_max_px)
106
+ self.must_terminate = (w == g) & (h == g)
107
+ self.must_expand = (w > self.leaf_max_px) | (h > self.leaf_max_px)
108
+ self.area_px = (w * h).to(torch.float32)
109
+ self.root_id = self.region_id[(0, 0, c, c)]
110
+
111
+ self.leaf_ids = torch.nonzero(self.leaf_mask, as_tuple=False).squeeze(1)
112
+ self.leaf_index_of_region = torch.full((N,), -1, dtype=torch.int64)
113
+ self.leaf_index_of_region[self.leaf_ids] = torch.arange(len(self.leaf_ids))
114
+
115
+ # Fourier geometry features (log-area, log-aspect, center x/y) -> 64 dims.
116
+ la = torch.log(self.area_px / (c * c))
117
+ lasp = torch.log(w.float() / h.float())
118
+ cx = (self.regions[:, 0] + self.regions[:, 2]).float() / (2 * c)
119
+ cy = (self.regions[:, 1] + self.regions[:, 3]).float() / (2 * c)
120
+ base = torch.stack([la / 8.0, lasp / 4.0, cx, cy], dim=1) # [N,4]
121
+ freqs = torch.tensor([1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 3.0, 5.0]) # 8 freqs
122
+ ang = base.unsqueeze(2) * freqs.view(1, 1, 8) * math.pi # [N,4,8]
123
+ self.phi_geom = torch.cat([torch.sin(ang), torch.cos(ang)], dim=2).reshape(N, 64)
124
+
125
+ # Enumerate cuts.
126
+ self.cuts_of_region = {}
127
+ self.type_present = torch.zeros(N, N_CUT_TYPES, dtype=torch.bool)
128
+ per_level: Dict[int, List[Tuple[int, int, int, float, int, int, int]]] = {}
129
+ for rid, (x0, y0, x1, y1) in enumerate(rects):
130
+ cuts: List[Tuple[int, int, int, int, int, float]] = []
131
+ type_counts: Dict[int, int] = {}
132
+ cand: List[Tuple[int, int, int, int, int]] = [] # axis, px, lo, hi, type
133
+ for px in range(x0 + g, x1, g): # vertical cuts
134
+ lo = self.region_id[(x0, y0, px, y1)]
135
+ hi = self.region_id[(px, y0, x1, y1)]
136
+ t = cut_type_id(0, (px - x0) / (x1 - x0))
137
+ cand.append((0, px, lo, hi, t))
138
+ type_counts[t] = type_counts.get(t, 0) + 1
139
+ for py in range(y0 + g, y1, g): # horizontal cuts
140
+ lo = self.region_id[(x0, y0, x1, py)]
141
+ hi = self.region_id[(x0, py, x1, y1)]
142
+ t = cut_type_id(1, (py - y0) / (y1 - y0))
143
+ cand.append((1, py, lo, hi, t))
144
+ type_counts[t] = type_counts.get(t, 0) + 1
145
+ for axis, px, lo, hi, t in cand:
146
+ lc = math.log(type_counts[t])
147
+ cuts.append((axis, px, lo, hi, t, lc))
148
+ self.type_present[rid, t] = True
149
+ self.cuts_of_region[rid] = cuts
150
+ if cuts and not bool(self.must_terminate[rid]):
151
+ area_cells = int(self.area_px[rid].item()) // (g * g)
152
+ per_level.setdefault(area_cells, [])
153
+ for axis, px, lo, hi, t, lc in cuts:
154
+ per_level[area_cells].append((rid, t, lo, hi, lc, axis, px))
155
+
156
+ self.levels = []
157
+ for area_cells in sorted(per_level.keys()):
158
+ rows = per_level[area_cells]
159
+ parent_ids = torch.tensor([r[0] for r in rows], dtype=torch.int64)
160
+ parents, parent_index = torch.unique(parent_ids, return_inverse=True)
161
+ self.levels.append(LatticeLevel(
162
+ parent_ids=parent_ids,
163
+ cut_type=torch.tensor([r[1] for r in rows], dtype=torch.int64),
164
+ log_cnt=torch.tensor([r[4] for r in rows], dtype=torch.float32),
165
+ child_lo=torch.tensor([r[2] for r in rows], dtype=torch.int64),
166
+ child_hi=torch.tensor([r[3] for r in rows], dtype=torch.int64),
167
+ cut_axis=torch.tensor([r[5] for r in rows], dtype=torch.int64),
168
+ cut_px=torch.tensor([r[6] for r in rows], dtype=torch.int64),
169
+ parents=parents,
170
+ parent_index=parent_index,
171
+ ))
172
+
173
+ # ---- precomputed sweep metadata (everything shape-related the DP
174
+ # would otherwise derive from device tensors inside the level loop).
175
+ self.mt_ids = torch.nonzero(self.must_terminate, as_tuple=False).reshape(-1)
176
+ self.mt_slots = self.leaf_index_of_region[self.mt_ids]
177
+
178
+ # Distinct cut-type-presence patterns over regions that actually have
179
+ # cuts (every level parent). Lets the DP do ONE masked log-softmax
180
+ # over [B, n_pat, R, 14] per sweep instead of one per level.
181
+ pat_of: Dict[Tuple[bool, ...], int] = {}
182
+ pats: List[torch.Tensor] = []
183
+ self.pattern_of_region = torch.full((N,), -1, dtype=torch.int64)
184
+ for lv in self.levels:
185
+ for rid in lv.parents.tolist():
186
+ key = tuple(self.type_present[rid].tolist())
187
+ pid = pat_of.setdefault(key, len(pats))
188
+ if pid == len(pats):
189
+ pats.append(self.type_present[rid].clone())
190
+ self.pattern_of_region[rid] = pid
191
+ self.type_patterns = torch.stack(pats, dim=0) # bool [n_pat, 14]
192
+
193
+ for lv in self.levels:
194
+ is_leaf = self.leaf_mask[lv.parents]
195
+ lv.leaf_pos = torch.nonzero(is_leaf, as_tuple=False).reshape(-1)
196
+ lv.nonleaf_pos = torch.nonzero(~is_leaf, as_tuple=False).reshape(-1)
197
+ lv.leaf_rids = lv.parents[lv.leaf_pos]
198
+ lv.nonleaf_rids = lv.parents[lv.nonleaf_pos]
199
+ lv.leaf_slots = self.leaf_index_of_region[lv.leaf_rids]
200
+ lv.flat_type_idx = (
201
+ self.pattern_of_region[lv.parent_ids] * N_CUT_TYPES + lv.cut_type
202
+ )
203
+ lv.has_leaf = bool(lv.leaf_pos.numel() > 0)
204
+ lv.has_nonleaf = bool(lv.nonleaf_pos.numel() > 0)
205
+ lv.reorder = torch.argsort(torch.cat([lv.nonleaf_pos, lv.leaf_pos]))
206
+
207
+ # Region ids are assigned area-ascending (rects sorted by pixel area),
208
+ # so the must-terminate block and each level's parent block are
209
+ # contiguous, consecutive id ranges covering all N regions. The DP's
210
+ # fast sweep relies on this to assemble beta with one cat instead of
211
+ # per-level index_put on the [B, N, S] table. Verified here; any
212
+ # violation falls back to the reference sweep.
213
+ self.contiguous_ids = bool(
214
+ self.mt_ids.equal(torch.arange(self.mt_ids.numel()))
215
+ )
216
+ off = int(self.mt_ids.numel())
217
+ for lv in self.levels:
218
+ P = int(lv.parents.numel())
219
+ if not bool(lv.parents.equal(torch.arange(off, off + P))):
220
+ self.contiguous_ids = False
221
+ break
222
+ lv.id_lo, lv.id_hi = off, off + P
223
+ off += P
224
+ if off != N:
225
+ self.contiguous_ids = False
226
+
227
+ # Leaf shape groups with flattened pixel-gather indices, cached for
228
+ # emission scoring (atlas.score_leaves): (h, w, slots [n_g],
229
+ # pix [n_g*h*w] into a [canvas*canvas]-flattened image).
230
+ self.shape_groups = []
231
+ for (h, w), slots in self.leaf_shape_groups().items():
232
+ rects = self.regions[self.leaf_ids[slots]]
233
+ yy = rects[:, 1].view(-1, 1, 1) + torch.arange(h).view(1, -1, 1)
234
+ xx = rects[:, 0].view(-1, 1, 1) + torch.arange(w).view(1, 1, -1)
235
+ pix = (yy * c + xx).reshape(-1)
236
+ self.shape_groups.append((h, w, slots, pix))
237
+
238
+ @property
239
+ def n_regions(self) -> int:
240
+ return int(self.regions.shape[0])
241
+
242
+ @property
243
+ def n_leaf_regions(self) -> int:
244
+ return int(self.leaf_ids.shape[0])
245
+
246
+ def leaf_shape_groups(self) -> Dict[Tuple[int, int], torch.Tensor]:
247
+ """Map (h, w) -> leaf-slot indices (into the leaf_ids ordering)."""
248
+ groups: Dict[Tuple[int, int], List[int]] = {}
249
+ for slot, rid in enumerate(self.leaf_ids.tolist()):
250
+ x0, y0, x1, y1 = self.regions[rid].tolist()
251
+ groups.setdefault((y1 - y0, x1 - x0), []).append(slot)
252
+ return {k: torch.tensor(v, dtype=torch.int64) for k, v in groups.items()}
253
+
254
+ def to(self, device: torch.device) -> "Lattice":
255
+ device = torch.device(device)
256
+ if getattr(self, "_device", None) == device:
257
+ return self
258
+ for name in ("regions", "leaf_mask", "must_terminate", "must_expand",
259
+ "area_px", "phi_geom", "type_present", "leaf_ids",
260
+ "leaf_index_of_region", "mt_ids", "mt_slots",
261
+ "type_patterns", "pattern_of_region"):
262
+ setattr(self, name, getattr(self, name).to(device))
263
+ for lv in self.levels:
264
+ for name in ("parent_ids", "cut_type", "log_cnt", "child_lo", "child_hi",
265
+ "cut_axis", "cut_px", "parents", "parent_index",
266
+ "leaf_pos", "nonleaf_pos", "leaf_rids", "nonleaf_rids",
267
+ "leaf_slots", "flat_type_idx", "reorder"):
268
+ t = getattr(lv, name)
269
+ if t is not None:
270
+ setattr(lv, name, t.to(device))
271
+ self.shape_groups = [
272
+ (h, w, slots.to(device), pix.to(device))
273
+ for h, w, slots, pix in self.shape_groups
274
+ ]
275
+ self._device = device
276
+ return self
277
+
278
+
279
+ _CACHE: Dict[Tuple[int, int, int], Lattice] = {}
280
+
281
+
282
+ def get_lattice(canvas_px: int, grid_px: int, leaf_max_px: int) -> Lattice:
283
+ key = (canvas_px, grid_px, leaf_max_px)
284
+ if key not in _CACHE:
285
+ _CACHE[key] = Lattice(canvas_px, grid_px, leaf_max_px)
286
+ return _CACHE[key]
sprig/eval/__init__.py ADDED
File without changes
sprig/eval/baseline_pixmix.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """B0 trivial baseline: position-dependent 4-component DL mixture per pixel.
2
+
3
+ One parameter grid [64, 64, 40] (no neural net, no caption): per pixel, the
4
+ same 40-channel discretized-logistic mixture parameterization as the texel
5
+ atlas (sprig/model/dl.py layout: 4 components x contiguous blocks of
6
+ [weight, 3 means, 3 log-scales, 3 coupling coeffs]). B0's bpd measures
7
+ exactly what the grammar buys over a caption-free per-pixel model.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Union
13
+
14
+ import numpy as np
15
+ import torch
16
+ import torch.nn as nn
17
+
18
+ from sprig.model.dl import LOGSCALE_IDX, N_CH, N_COMP, dl_logprob, u8_to_unit
19
+
20
+ LOG2 = math.log(2.0)
21
+ N_PARAMS = N_CH # 40
22
+
23
+
24
+ def _to_u8_tensor(images_u8: Union[np.ndarray, torch.Tensor]) -> torch.Tensor:
25
+ if isinstance(images_u8, np.ndarray):
26
+ images_u8 = torch.from_numpy(np.array(images_u8, dtype=images_u8.dtype))
27
+ return images_u8
28
+
29
+
30
+ class PixMixBaseline(nn.Module):
31
+ """params: [H, W, 40] trainable grid; no other parameters."""
32
+
33
+ def __init__(self, height: int = 64, width: int = 64):
34
+ super().__init__()
35
+ p = torch.zeros(height, width, N_PARAMS)
36
+ # spread component means so the mixture starts multi-modal
37
+ comp_means = torch.linspace(-0.6, 0.6, N_COMP)
38
+ for j in range(N_COMP):
39
+ p[..., 10 * j + 1: 10 * j + 4] = comp_means[j]
40
+ p[..., LOGSCALE_IDX] = -1.0
41
+ self.params = nn.Parameter(p)
42
+
43
+ def logprob_per_image(self, images_u8: Union[np.ndarray, torch.Tensor]) -> torch.Tensor:
44
+ """[B] total log-likelihood (nats) per image."""
45
+ img = _to_u8_tensor(images_u8).to(self.params.device)
46
+ x = u8_to_unit(img).permute(0, 3, 1, 2) # [B,3,H,W]
47
+ # expand (view) params to the batch lead dim: dl_logprob broadcasts
48
+ # x against params but not vice versa
49
+ p = self.params.permute(2, 0, 1).unsqueeze(0).expand(x.shape[0], -1, -1, -1)
50
+ lp = dl_logprob(p, x) # [B,H,W] fp32
51
+ return lp.sum(dim=(1, 2))
52
+
53
+ @torch.no_grad()
54
+ def bpd(self, images_u8: Union[np.ndarray, torch.Tensor], batch_size: int = 256) -> float:
55
+ """Mean bits/dim over images (dims = 3*H*W)."""
56
+ n = int(images_u8.shape[0])
57
+ h, w = self.params.shape[0], self.params.shape[1]
58
+ total = 0.0
59
+ for i in range(0, n, batch_size):
60
+ total += float(self.logprob_per_image(images_u8[i:i + batch_size]).sum())
61
+ return -total / (n * 3.0 * h * w * LOG2)
62
+
63
+
64
+ def fit(
65
+ images_u8: Union[np.ndarray, torch.Tensor],
66
+ steps: int = 2000,
67
+ lr: float = 5e-2,
68
+ batch_size: int = 128,
69
+ device: str = "cpu",
70
+ seed: int = 0,
71
+ ) -> PixMixBaseline:
72
+ """Fit B0 by Adam on random minibatches of training images; returns the model."""
73
+ torch.manual_seed(seed)
74
+ model = PixMixBaseline().to(device)
75
+ opt = torch.optim.Adam(model.parameters(), lr=lr)
76
+ n = images_u8.shape[0]
77
+ gen = np.random.default_rng(seed)
78
+ for _ in range(steps):
79
+ idx = gen.integers(0, n, size=min(batch_size, n))
80
+ if isinstance(images_u8, np.ndarray):
81
+ batch = images_u8[idx]
82
+ else:
83
+ batch = images_u8[torch.from_numpy(idx)]
84
+ nll = -model.logprob_per_image(batch).mean() / (3.0 * 64 * 64)
85
+ opt.zero_grad()
86
+ nll.backward()
87
+ opt.step()
88
+ return model
sprig/eval/color_checks.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GT-free faithfulness checks on generated images.
2
+
3
+ Pipeline (pure numpy, no scipy):
4
+ background = modal border color; foreground = pixels whose max-channel
5
+ deviation from the background exceeds `thresh`; objects = 4-connected
6
+ components of the foreground; object color = median RGB mapped to the
7
+ nearest vocabulary color anchor in CIE Lab; relations compared by centroid.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Dict, List, Optional, Tuple
12
+
13
+ import numpy as np
14
+
15
+ # Vocabulary color anchors (RGB). Single source of truth is
16
+ # sprig/data/procgen/vocab.py (COLORS dict); these literals are the fallback.
17
+ _DEFAULT_ANCHORS: Dict[str, Tuple[int, int, int]] = {
18
+ "red": (210, 45, 45),
19
+ "green": (55, 170, 60),
20
+ "blue": (50, 90, 215),
21
+ "yellow": (235, 215, 55),
22
+ "orange": (240, 145, 40),
23
+ "purple": (140, 60, 185),
24
+ "cyan": (60, 205, 215),
25
+ "magenta": (220, 70, 175),
26
+ }
27
+
28
+
29
+ def _load_anchors() -> Dict[str, Tuple[int, int, int]]:
30
+ try:
31
+ from sprig.data.procgen import vocab as _vocab # type: ignore
32
+
33
+ for name in ("COLOR_ANCHORS", "COLORS", "COLOR_RGB"):
34
+ anchors = getattr(_vocab, name, None)
35
+ if isinstance(anchors, dict) and anchors:
36
+ first = next(iter(anchors.values()))
37
+ if hasattr(first, "__len__") and len(first) == 3:
38
+ return {str(k): tuple(int(v) for v in rgb) for k, rgb in anchors.items()}
39
+ except Exception:
40
+ pass
41
+ return dict(_DEFAULT_ANCHORS)
42
+
43
+
44
+ COLOR_ANCHORS: Dict[str, Tuple[int, int, int]] = _load_anchors()
45
+
46
+
47
+ def rgb_to_lab(rgb: np.ndarray) -> np.ndarray:
48
+ """sRGB (0..255, any leading shape [..,3]) -> CIE Lab (D65), numpy only."""
49
+ c = np.asarray(rgb, dtype=np.float64) / 255.0
50
+ lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
51
+ m = np.array(
52
+ [
53
+ [0.4124564, 0.3575761, 0.1804375],
54
+ [0.2126729, 0.7151522, 0.0721750],
55
+ [0.0193339, 0.1191920, 0.9503041],
56
+ ]
57
+ )
58
+ xyz = lin @ m.T
59
+ xyz = xyz / np.array([0.95047, 1.0, 1.08883])
60
+ d = 6.0 / 29.0
61
+ f = np.where(xyz > d ** 3, np.cbrt(xyz), xyz / (3 * d * d) + 4.0 / 29.0)
62
+ lab = np.empty_like(f)
63
+ lab[..., 0] = 116.0 * f[..., 1] - 16.0
64
+ lab[..., 1] = 500.0 * (f[..., 0] - f[..., 1])
65
+ lab[..., 2] = 200.0 * (f[..., 1] - f[..., 2])
66
+ return lab
67
+
68
+
69
+ _ANCHOR_NAMES = list(COLOR_ANCHORS.keys())
70
+ _ANCHOR_LAB = rgb_to_lab(np.array([COLOR_ANCHORS[n] for n in _ANCHOR_NAMES], dtype=np.float64))
71
+
72
+
73
+ def nearest_color(rgb) -> str:
74
+ """Nearest vocabulary anchor to an RGB triple, in Lab space."""
75
+ lab = rgb_to_lab(np.asarray(rgb, dtype=np.float64))
76
+ d = np.linalg.norm(_ANCHOR_LAB - lab[None, :], axis=1)
77
+ return _ANCHOR_NAMES[int(np.argmin(d))]
78
+
79
+
80
+ def _modal_border_color(img: np.ndarray) -> np.ndarray:
81
+ border = np.concatenate([img[0], img[-1], img[1:-1, 0], img[1:-1, -1]], axis=0)
82
+ codes = (
83
+ border[:, 0].astype(np.int64) * 65536
84
+ + border[:, 1].astype(np.int64) * 256
85
+ + border[:, 2].astype(np.int64)
86
+ )
87
+ vals, counts = np.unique(codes, return_counts=True)
88
+ code = int(vals[np.argmax(counts)])
89
+ return np.array([code // 65536, (code // 256) % 256, code % 256], dtype=np.float64)
90
+
91
+
92
+ def connected_components(mask: np.ndarray) -> Tuple[np.ndarray, int]:
93
+ """4-connectivity components of a boolean mask via label propagation.
94
+
95
+ Returns (labels [H,W] int32 with 0 = background, 1..n components, n).
96
+ """
97
+ h, w = mask.shape
98
+ labels = np.where(mask, np.arange(1, h * w + 1, dtype=np.int64).reshape(h, w), 0)
99
+ while True:
100
+ prev = labels
101
+ up = np.zeros_like(labels)
102
+ up[1:, :] = labels[:-1, :]
103
+ down = np.zeros_like(labels)
104
+ down[:-1, :] = labels[1:, :]
105
+ left = np.zeros_like(labels)
106
+ left[:, 1:] = labels[:, :-1]
107
+ right = np.zeros_like(labels)
108
+ right[:, :-1] = labels[:, 1:]
109
+ stacked = np.stack([labels, up, down, left, right], axis=0)
110
+ stacked = np.where(stacked == 0, np.iinfo(np.int64).max, stacked)
111
+ labels = np.where(mask, stacked.min(axis=0), 0)
112
+ if np.array_equal(labels, prev):
113
+ break
114
+ vals = np.unique(labels)
115
+ vals = vals[vals > 0]
116
+ out = np.zeros_like(labels, dtype=np.int32)
117
+ for i, v in enumerate(vals):
118
+ out[labels == v] = i + 1
119
+ return out, len(vals)
120
+
121
+
122
+ def extract(image: np.ndarray, thresh: int = 40, min_area: int = 12) -> Dict[str, object]:
123
+ """Extract background + object list from a rendered/generated 64x64 image.
124
+
125
+ Returns {"background": color_name, "background_rgb": (r,g,b),
126
+ "objects": [{"color", "rgb", "centroid": (x,y), "area",
127
+ "bbox": (x0,y0,x1,y1)}]} sorted by area descending.
128
+ """
129
+ img = np.asarray(image)
130
+ if img.dtype != np.uint8:
131
+ img = np.clip(img, 0, 255).astype(np.uint8)
132
+ bg = _modal_border_color(img)
133
+ dev = np.abs(img.astype(np.float64) - bg[None, None, :]).max(axis=-1)
134
+ mask = dev > float(thresh)
135
+ labels, n = connected_components(mask)
136
+ objects: List[Dict[str, object]] = []
137
+ for i in range(1, n + 1):
138
+ ys, xs = np.nonzero(labels == i)
139
+ if ys.size < min_area:
140
+ continue
141
+ med = np.median(img[ys, xs].astype(np.float64), axis=0)
142
+ objects.append(
143
+ {
144
+ "color": nearest_color(med),
145
+ "rgb": tuple(float(v) for v in med),
146
+ "centroid": (float(xs.mean()), float(ys.mean())),
147
+ "area": int(ys.size),
148
+ "bbox": (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1),
149
+ }
150
+ )
151
+ objects.sort(key=lambda o: -int(o["area"]))
152
+ return {
153
+ "background": nearest_color(bg),
154
+ "background_rgb": tuple(float(v) for v in bg),
155
+ "objects": objects,
156
+ }
157
+
158
+
159
+ def _find_by_color(objects: List[Dict[str, object]], color: str) -> Optional[Dict[str, object]]:
160
+ for o in objects:
161
+ if o["color"] == color:
162
+ return o
163
+ return None
164
+
165
+
166
+ def relation_holds(
167
+ extraction: Dict[str, object], color_a: str, color_b: str, relation: str
168
+ ) -> Optional[bool]:
169
+ """Does 'the color_a object is <relation> the color_b object' hold by centroids?
170
+
171
+ relation in {"left of", "right of", "above", "below"}.
172
+ Returns None when either object is missing (unscoreable).
173
+ """
174
+ objs = extraction["objects"] # type: ignore[index]
175
+ a = _find_by_color(objs, color_a) # type: ignore[arg-type]
176
+ b = _find_by_color(objs, color_b) # type: ignore[arg-type]
177
+ if a is None or b is None:
178
+ return None
179
+ ax, ay = a["centroid"] # type: ignore[misc]
180
+ bx, by = b["centroid"] # type: ignore[misc]
181
+ if relation == "left of":
182
+ return bool(ax < bx)
183
+ if relation == "right of":
184
+ return bool(ax > bx)
185
+ if relation == "above":
186
+ return bool(ay < by)
187
+ if relation == "below":
188
+ return bool(ay > by)
189
+ raise ValueError("unknown relation: {}".format(relation))
sprig/eval/monitors.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training-health monitors: pure functions over posterior-usage statistics.
2
+
3
+ Inputs come from `SPRIGModel.posterior_usage` (DESIGN §4/C3):
4
+ symbol_usage [S], texel_usage [T_v] (expected-count vectors, any positive
5
+ scale — normalized here), node_entropy (nats/node), emit_mag / rule_mag
6
+ (gradient-magnitude scalars), mean_depth, mean_leaves.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Dict, Tuple, Union
11
+
12
+ import numpy as np
13
+
14
+ ArrayLike = Union[np.ndarray, "torch.Tensor", list] # noqa: F821
15
+
16
+ # Alarm thresholds (plan §Verification).
17
+ S_EFF_COLLAPSE_FRAC = 0.15 # S_eff < 0.15*S -> grammar collapse
18
+ MAG_RATIO_ALARM = 100.0 # emission/rule ratio > 100 ...
19
+ NODE_ENTROPY_FLOOR = 0.1 # ... with node entropy < 0.1 nats
20
+ ALIVE_TEXEL_ALARM_FRAC = 0.25 # alive fraction below this -> texel death
21
+ QUADTREE_DEPTH = 6.0 # uniform 64px -> 8px binary-split depth
22
+ QUADTREE_LEAVES = 64.0 # 8x8 grid of 8px leaves
23
+ ALIVE_USAGE_THRESH_FRAC = 0.1 # texel alive iff usage > 0.1 / T_v
24
+
25
+
26
+ def _to_numpy(x: ArrayLike) -> np.ndarray:
27
+ if hasattr(x, "detach"): # torch tensor without importing torch
28
+ x = x.detach().cpu().numpy()
29
+ return np.asarray(x, dtype=np.float64)
30
+
31
+
32
+ def s_eff(symbol_usage: ArrayLike) -> float:
33
+ """Effective symbol count exp(H(usage)); usage is normalized internally."""
34
+ p = _to_numpy(symbol_usage)
35
+ total = p.sum()
36
+ if total <= 0:
37
+ return 0.0
38
+ p = p / total
39
+ nz = p[p > 0]
40
+ return float(np.exp(-(nz * np.log(nz)).sum()))
41
+
42
+
43
+ def alive_texels(texel_usage: ArrayLike, T_v: int) -> int:
44
+ """Number of texels with normalized usage above 0.1/T_v (resurrection thresh)."""
45
+ p = _to_numpy(texel_usage)
46
+ total = p.sum()
47
+ if total <= 0:
48
+ return 0
49
+ p = p / total
50
+ return int((p > ALIVE_USAGE_THRESH_FRAC / float(T_v)).sum())
51
+
52
+
53
+ def magnitude_ratio(emit_mag: float, rule_mag: float, eps: float = 1e-12) -> float:
54
+ """Emission-vs-rule gradient magnitude ratio (tempered-DP failure detector)."""
55
+ return float(emit_mag) / (float(rule_mag) + eps)
56
+
57
+
58
+ def pi_controller_eta(
59
+ eta: float,
60
+ node_entropy: float,
61
+ band: Tuple[float, float] = (0.5, 3.0),
62
+ eta_range: Tuple[float, float] = (0.0, 1.5),
63
+ ) -> float:
64
+ """One PI-controller update of the tempering exponent eta (DESIGN §5).
65
+
66
+ If posterior node entropy H is below the band, raise eta by
67
+ 0.05 + 0.1*(lo - H); if above the band, lower it by 0.05; clamp to range.
68
+ """
69
+ lo, hi = band
70
+ h = float(node_entropy)
71
+ new_eta = float(eta)
72
+ if h < lo:
73
+ new_eta += 0.05 + 0.1 * (lo - h)
74
+ elif h > hi:
75
+ new_eta -= 0.05
76
+ return float(min(max(new_eta, eta_range[0]), eta_range[1]))
77
+
78
+
79
+ def build_alarms(stats: Dict[str, object], S: int, T_v: int) -> Dict[str, bool]:
80
+ """Alarm dict per the plan's training alarms.
81
+
82
+ `stats` is the posterior_usage dict: keys symbol_usage, texel_usage,
83
+ node_entropy, emit_mag, rule_mag, mean_depth, mean_leaves.
84
+ Returns booleans: grammar_collapse, tempered_dp_failure, texel_death,
85
+ parse_collapse, and `any`.
86
+ """
87
+ seff = s_eff(stats["symbol_usage"])
88
+ ratio = magnitude_ratio(float(stats["emit_mag"]), float(stats["rule_mag"]))
89
+ node_h = float(stats["node_entropy"])
90
+ alive_frac = alive_texels(stats["texel_usage"], T_v) / float(T_v)
91
+ mean_depth = float(stats.get("mean_depth", 0.0))
92
+ mean_leaves = float(stats.get("mean_leaves", 0.0))
93
+
94
+ alarms = {
95
+ "grammar_collapse": seff < S_EFF_COLLAPSE_FRAC * S,
96
+ "tempered_dp_failure": (ratio > MAG_RATIO_ALARM) and (node_h < NODE_ENTROPY_FLOOR),
97
+ "texel_death": alive_frac < ALIVE_TEXEL_ALARM_FRAC,
98
+ "parse_collapse": (mean_leaves >= 0.95 * QUADTREE_LEAVES)
99
+ and (mean_depth >= 0.95 * QUADTREE_DEPTH),
100
+ }
101
+ alarms["any"] = bool(any(alarms.values()))
102
+ return alarms
sprig/eval/probe.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """3-conv-block CNN probe: shape (8-way) + color (8-way) heads.
2
+
3
+ Used for the compositional-holdout score: trained on a probe dataset that
4
+ DOES include the held-out combos (leakage-safe by directory separation),
5
+ then applied to model generations.
6
+
7
+ Labeled image dir formats accepted by `train` (checked in this order):
8
+ 1. memmap pair: `images.u8` [N,64,64,3] + `labels.npy` int64 [N,2]
9
+ (shape_idx, color_idx) in the canonical prompts.SHAPES / prompts.COLORS order;
10
+ 2. flat PNG files named `<shape>_<color>_<anything>.png`.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import glob
15
+ import os
16
+ from typing import List, Optional, Tuple, Union
17
+
18
+ import numpy as np
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch.nn.functional as F
22
+
23
+ from sprig.eval.prompts import COLORS, SHAPES
24
+
25
+
26
+ class ProbeCNN(nn.Module):
27
+ def __init__(self, width: int = 32):
28
+ super().__init__()
29
+ w = width
30
+ self.blocks = nn.Sequential(
31
+ nn.Conv2d(3, w, 3, padding=1), nn.BatchNorm2d(w), nn.ReLU(),
32
+ nn.Conv2d(w, w, 3, padding=1), nn.BatchNorm2d(w), nn.ReLU(),
33
+ nn.MaxPool2d(2), # 32
34
+ nn.Conv2d(w, 2 * w, 3, padding=1), nn.BatchNorm2d(2 * w), nn.ReLU(),
35
+ nn.Conv2d(2 * w, 2 * w, 3, padding=1), nn.BatchNorm2d(2 * w), nn.ReLU(),
36
+ nn.MaxPool2d(2), # 16
37
+ nn.Conv2d(2 * w, 4 * w, 3, padding=1), nn.BatchNorm2d(4 * w), nn.ReLU(),
38
+ nn.Conv2d(4 * w, 4 * w, 3, padding=1), nn.BatchNorm2d(4 * w), nn.ReLU(),
39
+ nn.MaxPool2d(2), # 8
40
+ )
41
+ self.shape_head = nn.Linear(4 * w, len(SHAPES))
42
+ self.color_head = nn.Linear(4 * w, len(COLORS))
43
+
44
+ def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
45
+ h = self.blocks(x)
46
+ h = h.mean(dim=(2, 3))
47
+ return self.shape_head(h), self.color_head(h)
48
+
49
+
50
+ def _images_to_tensor(images: Union[np.ndarray, torch.Tensor]) -> torch.Tensor:
51
+ """u8 [N,64,64,3] (numpy or torch) -> float [N,3,64,64] in [-1,1]."""
52
+ if isinstance(images, np.ndarray):
53
+ images = torch.from_numpy(np.array(images, dtype=images.dtype))
54
+ x = images.to(torch.float32) / 127.5 - 1.0
55
+ return x.permute(0, 3, 1, 2).contiguous()
56
+
57
+
58
+ def _load_probe_dir(data_dir: str) -> Tuple[np.ndarray, np.ndarray]:
59
+ img_path = os.path.join(data_dir, "images.u8")
60
+ lab_path = os.path.join(data_dir, "labels.npy")
61
+ if os.path.exists(img_path) and os.path.exists(lab_path):
62
+ labels = np.load(lab_path).astype(np.int64)
63
+ n = labels.shape[0]
64
+ images = np.memmap(img_path, dtype=np.uint8, mode="r", shape=(n, 64, 64, 3))
65
+ return np.asarray(images), labels
66
+ files = sorted(glob.glob(os.path.join(data_dir, "*.png")))
67
+ if not files:
68
+ raise FileNotFoundError(
69
+ "probe dir {} has neither images.u8+labels.npy nor *.png".format(data_dir)
70
+ )
71
+ from PIL import Image
72
+
73
+ imgs: List[np.ndarray] = []
74
+ labs: List[Tuple[int, int]] = []
75
+ for f in files:
76
+ parts = os.path.basename(f).split("_")
77
+ shape_name, color_name = parts[0], parts[1]
78
+ labs.append((SHAPES.index(shape_name), COLORS.index(color_name)))
79
+ imgs.append(np.asarray(Image.open(f).convert("RGB"), dtype=np.uint8))
80
+ return np.stack(imgs), np.asarray(labs, dtype=np.int64)
81
+
82
+
83
+ def train(
84
+ data_dir: str,
85
+ epochs: int = 10,
86
+ batch_size: int = 64,
87
+ lr: float = 1e-3,
88
+ device: str = "cpu",
89
+ ckpt_path: Optional[str] = None,
90
+ seed: int = 0,
91
+ ) -> str:
92
+ """Train the probe on a labeled image dir; returns the checkpoint path."""
93
+ images, labels = _load_probe_dir(data_dir)
94
+ torch.manual_seed(seed)
95
+ model = ProbeCNN().to(device)
96
+ opt = torch.optim.Adam(model.parameters(), lr=lr)
97
+ n = images.shape[0]
98
+ gen = np.random.default_rng(seed)
99
+ model.train()
100
+ for _ in range(epochs):
101
+ order = gen.permutation(n)
102
+ for i in range(0, n, batch_size):
103
+ idx = order[i:i + batch_size]
104
+ x = _images_to_tensor(images[idx]).to(device)
105
+ ys = torch.from_numpy(labels[idx, 0]).to(device)
106
+ yc = torch.from_numpy(labels[idx, 1]).to(device)
107
+ logit_s, logit_c = model(x)
108
+ loss = F.cross_entropy(logit_s, ys) + F.cross_entropy(logit_c, yc)
109
+ opt.zero_grad()
110
+ loss.backward()
111
+ opt.step()
112
+ if ckpt_path is None:
113
+ ckpt_path = os.path.join(data_dir, "probe.pt")
114
+ torch.save(
115
+ {"state_dict": model.state_dict(), "shapes": SHAPES, "colors": COLORS}, ckpt_path
116
+ )
117
+ return ckpt_path
118
+
119
+
120
+ def load_probe(ckpt_path: str, device: str = "cpu") -> ProbeCNN:
121
+ ckpt = torch.load(ckpt_path, map_location=device)
122
+ model = ProbeCNN().to(device)
123
+ model.load_state_dict(ckpt["state_dict"])
124
+ model.eval()
125
+ return model
126
+
127
+
128
+ @torch.no_grad()
129
+ def score_generations(
130
+ images: Union[np.ndarray, torch.Tensor],
131
+ expected_shape: str,
132
+ expected_color: str,
133
+ ckpt_path: Union[str, ProbeCNN],
134
+ device: str = "cpu",
135
+ batch_size: int = 64,
136
+ ) -> float:
137
+ """Fraction of images classified as (expected_shape AND expected_color)."""
138
+ model = ckpt_path if isinstance(ckpt_path, ProbeCNN) else load_probe(ckpt_path, device)
139
+ model.eval()
140
+ target_s = SHAPES.index(expected_shape)
141
+ target_c = COLORS.index(expected_color)
142
+ x_all = _images_to_tensor(images)
143
+ hits = 0
144
+ for i in range(0, x_all.shape[0], batch_size):
145
+ x = x_all[i:i + batch_size].to(device)
146
+ logit_s, logit_c = model(x)
147
+ pred_s = logit_s.argmax(dim=1)
148
+ pred_c = logit_c.argmax(dim=1)
149
+ hits += int(((pred_s == target_s) & (pred_c == target_c)).sum().item())
150
+ return hits / float(x_all.shape[0])
sprig/eval/prompts.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fixed 32-prompt evaluation bank, minimal pairs, and held-out combos.
2
+
3
+ The bank composition is pinned by the project plan (Part 3):
4
+ 8 seen single-object, 8 relations, 3 counts, 1 containment, 2 sizes,
5
+ 2 backgrounds, 4 held-out combos, 2 partial, 2 CLEVR-style = 32 prompts.
6
+
7
+ Prompt phrasings follow the training caption templates in
8
+ sprig/data/procgen/captions.py (T1/T2/T3/T5/T6/T7/T9 realizations; the
9
+ attribute-dropped forms are valid partial captions), and the CLEVR-style
10
+ prompts follow sprig/data/clevr/prep.py's synthesized caption grammar.
11
+
12
+ The four compositional holdout combos {blue triangle, red ring, green star,
13
+ yellow cross} never appear in training data; they appear here (only) as the
14
+ held-out generalization probes.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import List, Tuple
19
+
20
+ # Canonical attribute vocabularies — single source of truth is
21
+ # sprig/data/procgen/vocab.py; literals kept as fallback for lean imports.
22
+ try:
23
+ from sprig.data.procgen.vocab import COLOR_NAMES as _COLOR_NAMES
24
+ from sprig.data.procgen.vocab import SHAPES as _SHAPES
25
+
26
+ COLORS: List[str] = list(_COLOR_NAMES)
27
+ SHAPES: List[str] = list(_SHAPES)
28
+ except Exception: # pragma: no cover
29
+ COLORS = ["red", "green", "blue", "yellow", "orange", "purple", "cyan", "magenta"]
30
+ SHAPES = ["circle", "square", "triangle", "rectangle", "diamond", "star", "cross", "ring"]
31
+
32
+ SIZES: List[str] = ["small", "large"]
33
+ RELATIONS: List[str] = ["to the left of", "to the right of", "above", "below"]
34
+
35
+ # (color, shape) pairs excluded from all training scenes/captions.
36
+ HELDOUT_COMBOS: List[Tuple[str, str]] = [
37
+ ("blue", "triangle"),
38
+ ("red", "ring"),
39
+ ("green", "star"),
40
+ ("yellow", "cross"),
41
+ ]
42
+
43
+ _SINGLE_OBJECT = [
44
+ "a red circle",
45
+ "a blue square",
46
+ "a green triangle",
47
+ "a yellow star",
48
+ "a purple diamond",
49
+ "an orange cross",
50
+ "a cyan ring",
51
+ "a magenta rectangle",
52
+ ]
53
+
54
+ _RELATIONS = [
55
+ "a red circle to the left of a blue square",
56
+ "a green diamond to the right of a yellow circle",
57
+ "a purple square above an orange circle",
58
+ "a cyan triangle below a magenta square",
59
+ "a yellow rectangle to the left of a purple star",
60
+ "an orange square to the right of a cyan diamond",
61
+ "a magenta circle above a red square",
62
+ "a blue diamond below a green rectangle",
63
+ ]
64
+
65
+ _COUNTS = [
66
+ "a scene with two shapes on a white background",
67
+ "three shapes: a red circle, a blue square, and a green diamond",
68
+ "a scene with four shapes",
69
+ ]
70
+
71
+ _CONTAINMENT = [
72
+ "a yellow circle inside a purple frame",
73
+ ]
74
+
75
+ _SIZES = [
76
+ "a large orange diamond",
77
+ "a small cyan square",
78
+ ]
79
+
80
+ _BACKGROUNDS = [
81
+ "a purple circle on a black background",
82
+ "a red square on a gray background",
83
+ ]
84
+
85
+ _HELDOUT = ["a {} {}".format(c, s) for (c, s) in HELDOUT_COMBOS]
86
+
87
+ _PARTIAL = [
88
+ "a circle",
89
+ "a striped square",
90
+ ]
91
+
92
+ _CLEVR_STYLE = [
93
+ "a large red rubber cube to the left of a small blue metal sphere",
94
+ "a scene with three objects, including a small green metal cylinder",
95
+ ]
96
+
97
+ PROMPTS: List[str] = (
98
+ _SINGLE_OBJECT
99
+ + _RELATIONS
100
+ + _COUNTS
101
+ + _CONTAINMENT
102
+ + _SIZES
103
+ + _BACKGROUNDS
104
+ + _HELDOUT
105
+ + _PARTIAL
106
+ + _CLEVR_STYLE
107
+ )
108
+
109
+ # Named groups for reporting / grid row labels.
110
+ PROMPT_GROUPS = {
111
+ "single_object": _SINGLE_OBJECT,
112
+ "relations": _RELATIONS,
113
+ "counts": _COUNTS,
114
+ "containment": _CONTAINMENT,
115
+ "sizes": _SIZES,
116
+ "backgrounds": _BACKGROUNDS,
117
+ "heldout_combos": _HELDOUT,
118
+ "partial": _PARTIAL,
119
+ "clevr_style": _CLEVR_STYLE,
120
+ }
121
+
122
+ # (prompt_a, prompt_b, changed_attribute); attribute in
123
+ # {"color", "shape", "relation", "size"}.
124
+ MINIMAL_PAIRS: List[Tuple[str, str, str]] = [
125
+ ("a red circle", "a blue circle", "color"),
126
+ ("a green square", "a purple square", "color"),
127
+ ("a yellow diamond", "a cyan diamond", "color"),
128
+ ("an orange ring", "a magenta ring", "color"),
129
+ (
130
+ "a red circle to the left of a blue square",
131
+ "a red circle to the right of a blue square",
132
+ "relation",
133
+ ),
134
+ (
135
+ "a green circle above a yellow square",
136
+ "a green circle below a yellow square",
137
+ "relation",
138
+ ),
139
+ ("a small purple square", "a large purple square", "size"),
140
+ ("a red circle", "a red square", "shape"),
141
+ ]
sprig/eval/report.py ADDED
@@ -0,0 +1,657 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Full evaluation report for a checkpoint: metrics.json + final_report.md.
2
+
3
+ Expected data_dir layout (plan Part 1/3 formats):
4
+ data_dir/val/ images.u8 [N,64,64,3], emb.f16 (packed ragged),
5
+ emb_offsets.i64 [N+1], meta.jsonl
6
+ data_dir/parse_eval/ same files; meta lines carry the GT tree under
7
+ "tree" (or "gt_tree")
8
+ data_dir/train/images.u8 (optional; B0 is fit on val images otherwise)
9
+ data_dir/t5/null.f16 null-caption embedding, packed f16 [L0*768]
10
+ data_dir/t5/promptbank.npz npz {emb: f16 [P,Lmax,768] zero-padded,
11
+ len: i32 [P]} from sprig.data.embed_t5 --prompts-out,
12
+ 32 prompts in sprig.eval.prompts.PROMPTS order
13
+ data_dir/t5/minimal_pairs.npz same npz format, 16 prompts interleaved
14
+ a0,b0,a1,b1,... in MINIMAL_PAIRS order (optional —
15
+ the prompt-control gate is SKIPPED if absent)
16
+
17
+ Stages that need a missing optional input are recorded as None and their
18
+ gate reported SKIPPED, so the report degrades gracefully while any gate that
19
+ can be evaluated is a hard PASS/FAIL.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import math
25
+ import os
26
+ from typing import Dict, List, Optional, Sequence, Tuple
27
+
28
+ import numpy as np
29
+ import torch
30
+ from PIL import Image
31
+
32
+ from sprig.eval import color_checks, monitors, tree_metrics
33
+ from sprig.eval import baseline_pixmix
34
+ from sprig.eval.prompts import COLORS, HELDOUT_COMBOS, MINIMAL_PAIRS, PROMPTS, SHAPES
35
+
36
+ LOG2 = math.log(2.0)
37
+ DIMS = 3.0 * 64 * 64
38
+
39
+ GATE_THRESH = {
40
+ "b0_margin": 0.15,
41
+ "delta_c": 0.05,
42
+ "recall_tier1": 0.70,
43
+ "recall_tier2": 0.50,
44
+ "visible_cut_f1": 0.60,
45
+ "attribute_move": 0.80,
46
+ "relation_accuracy": 0.70,
47
+ "holdout_probe": 0.60,
48
+ "s_eff_frac": 0.25,
49
+ "alive_texel_frac": 0.50,
50
+ }
51
+
52
+
53
+ # --------------------------------------------------------------- data access
54
+
55
+ class MemmapSplit:
56
+ """Reader for one dataset split in the plan's raw-memmap format."""
57
+
58
+ def __init__(self, split_dir: str):
59
+ self.dir = split_dir
60
+ img_path = os.path.join(split_dir, "images.u8")
61
+ n_bytes = os.path.getsize(img_path)
62
+ self.n = n_bytes // (64 * 64 * 3)
63
+ self.images = np.memmap(img_path, dtype=np.uint8, mode="r", shape=(self.n, 64, 64, 3))
64
+ off_path = os.path.join(split_dir, "emb_offsets.i64")
65
+ emb_path = os.path.join(split_dir, "emb.f16")
66
+ self.emb_offsets = np.fromfile(off_path, dtype=np.int64)
67
+ n_rows = int(self.emb_offsets[-1])
68
+ self.emb = np.memmap(emb_path, dtype=np.float16, mode="r", shape=(n_rows, 768))
69
+ self.meta: List[dict] = []
70
+ meta_path = os.path.join(split_dir, "meta.jsonl")
71
+ if os.path.exists(meta_path):
72
+ with open(meta_path) as f:
73
+ self.meta = [json.loads(line) for line in f if line.strip()]
74
+
75
+ def __len__(self) -> int:
76
+ return self.n
77
+
78
+ def image(self, i: int) -> np.ndarray:
79
+ return np.asarray(self.images[i])
80
+
81
+ def emb_i(self, i: int) -> torch.Tensor:
82
+ lo, hi = int(self.emb_offsets[i]), int(self.emb_offsets[i + 1])
83
+ return torch.from_numpy(np.array(self.emb[lo:hi])).to(torch.float16)
84
+
85
+ def tier(self, i: int) -> int:
86
+ return int(self.meta[i].get("tier", 0)) if self.meta else 0
87
+
88
+ def tree(self, i: int) -> Optional[dict]:
89
+ if not self.meta:
90
+ return None
91
+ return self.meta[i].get("tree") or self.meta[i].get("gt_tree")
92
+
93
+
94
+ def load_prompt_npz(path: str) -> List[torch.Tensor]:
95
+ """embed_t5 --prompts-out npz {emb [P,Lmax,768] f16, len [P] i32} ->
96
+ list of unpadded [L_i,768] f16 tensors."""
97
+ z = np.load(path)
98
+ emb, lens = z["emb"], z["len"]
99
+ return [
100
+ torch.from_numpy(np.array(emb[i, : int(lens[i])])).to(torch.float16)
101
+ for i in range(emb.shape[0])
102
+ ]
103
+
104
+
105
+ def load_null_emb(t5_dir: str) -> torch.Tensor:
106
+ flat = np.fromfile(os.path.join(t5_dir, "null.f16"), dtype=np.float16)
107
+ return torch.from_numpy(flat.reshape(-1, 768)).to(torch.float16)
108
+
109
+
110
+ def pad_embs(embs: Sequence[torch.Tensor]) -> Tuple[torch.Tensor, torch.Tensor]:
111
+ """Ragged [L_i,768] f16 list -> (emb [B,Lmax,768] f16, emb_len [B] i32)."""
112
+ lens = torch.tensor([e.shape[0] for e in embs], dtype=torch.int32)
113
+ lmax = int(lens.max())
114
+ out = torch.zeros(len(embs), lmax, 768, dtype=torch.float16)
115
+ for i, e in enumerate(embs):
116
+ out[i, : e.shape[0]] = e
117
+ return out, lens
118
+
119
+
120
+ # -------------------------------------------------------------- model access
121
+
122
+ def _build_model_from_cfg(cfg):
123
+ """Instantiate SPRIGModel from a checkpoint 'config' entry, which may be a
124
+ SPRIGConfig, a full train-yaml dict (model section nested), or a flat dict
125
+ of SPRIGConfig fields."""
126
+ import dataclasses
127
+
128
+ from sprig.model.sprig import SPRIGConfig, SPRIGModel # lazy: heavy import
129
+
130
+ if isinstance(cfg, SPRIGConfig):
131
+ return SPRIGModel(cfg)
132
+ if isinstance(cfg, dict):
133
+ section = cfg.get("model", cfg)
134
+ if isinstance(section, dict):
135
+ fields = {f.name for f in dataclasses.fields(SPRIGConfig)}
136
+ kw = {k: v for k, v in section.items() if k in fields}
137
+ return SPRIGModel(SPRIGConfig(**kw))
138
+ try:
139
+ return SPRIGModel(cfg)
140
+ except TypeError:
141
+ return SPRIGModel(**cfg)
142
+
143
+
144
+ def load_model(ckpt_path: str, device: str = "cpu"):
145
+ """Load a SPRIGModel from a training checkpoint (lazy model import).
146
+
147
+ Prefers EMA weights (DESIGN: EMA is eval-only). train.py checkpoints
148
+ store EMA as {"decay", "shadow": {param_name: tensor}} — the shadow is
149
+ overlaid on the raw 'model' state dict (which also carries the buffers).
150
+ """
151
+ ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
152
+ if isinstance(ckpt, torch.nn.Module):
153
+ model = ckpt
154
+ else:
155
+ cfg = ckpt.get("config") or ckpt.get("cfg") or {}
156
+ model = _build_model_from_cfg(cfg)
157
+ state = None
158
+ for key in ("ema", "ema_state_dict", "model", "model_state_dict", "state_dict"):
159
+ if key in ckpt:
160
+ state = ckpt[key]
161
+ break
162
+ if state is None:
163
+ state = ckpt
164
+ if isinstance(state, dict) and isinstance(state.get("shadow"), dict):
165
+ base = dict(ckpt.get("model") or {})
166
+ base.update(state["shadow"])
167
+ state = base
168
+ model.load_state_dict(state)
169
+ model = model.to(device)
170
+ # Report numbers are untempered by definition: mid-anneal checkpoints carry
171
+ # a nonzero eta buffer which silently deflates bpd/delta_c (eval-audit
172
+ # finding C1 — the 75k ckpt reported bpd 0.53 instead of 3.97).
173
+ eta = getattr(model, "eta", None)
174
+ if isinstance(eta, torch.Tensor):
175
+ eta.data.fill_(0.0)
176
+ model.eval()
177
+ if hasattr(model, "report_mode"):
178
+ model.report_mode = True
179
+ return model
180
+
181
+
182
+ def _as_u8_numpy(images) -> np.ndarray:
183
+ if isinstance(images, torch.Tensor):
184
+ images = images.detach().cpu().numpy()
185
+ return np.asarray(images).astype(np.uint8)
186
+
187
+
188
+ def _log_marginal(model, images: np.ndarray, embs: Sequence[torch.Tensor], device: str) -> np.ndarray:
189
+ emb, emb_len = pad_embs(embs)
190
+ img = torch.from_numpy(np.array(images, dtype=np.uint8)).to(device)
191
+ with torch.no_grad():
192
+ logz = model.log_marginal(img, emb.to(device), emb_len.to(device))
193
+ return logz.detach().cpu().to(torch.float64).numpy()
194
+
195
+
196
+ def bpd_from_logz(logz: np.ndarray) -> np.ndarray:
197
+ return -np.asarray(logz, dtype=np.float64) / (DIMS * LOG2)
198
+
199
+
200
+ def _sample_images(model, emb: torch.Tensor, seed_struct: int, seed_material: int,
201
+ n: int, device: str) -> np.ndarray:
202
+ e, elen = pad_embs([emb])
203
+ with torch.no_grad():
204
+ images, _trees = model.sample(
205
+ e.to(device), elen.to(device), seed_struct, seed_material, n
206
+ )
207
+ return _as_u8_numpy(images)
208
+
209
+
210
+ def _parse_one(model, image: np.ndarray, emb: torch.Tensor, device: str):
211
+ e, elen = pad_embs([emb])
212
+ img = torch.from_numpy(np.array(image[None], dtype=np.uint8)).to(device)
213
+ with torch.no_grad():
214
+ result = model.map_parse(img, e.to(device), elen.to(device))
215
+ if result and isinstance(result[0], (list, tuple)): # batched return
216
+ result = result[0]
217
+ return list(result)
218
+
219
+
220
+ # ------------------------------------------------------------- image grids
221
+
222
+ def image_grid(rows: Sequence[Sequence[np.ndarray]], pad: int = 2) -> Image.Image:
223
+ """[R][C] of u8 [64,64,3] arrays -> one PIL image with white padding."""
224
+ r, c = len(rows), max(len(row) for row in rows)
225
+ h = w = 64
226
+ canvas = np.full((r * (h + pad) + pad, c * (w + pad) + pad, 3), 255, dtype=np.uint8)
227
+ for i, row in enumerate(rows):
228
+ for j, img in enumerate(row):
229
+ y, x = pad + i * (h + pad), pad + j * (w + pad)
230
+ canvas[y:y + h, x:x + w] = np.asarray(img, dtype=np.uint8)
231
+ return Image.fromarray(canvas)
232
+
233
+
234
+ def save_grid_jpeg(rows: Sequence[Sequence[np.ndarray]], path: str) -> None:
235
+ image_grid(rows).save(path, format="JPEG", quality=92)
236
+
237
+
238
+ # ------------------------------------------------------------ eval stages
239
+
240
+ def eval_bpd(model, split: MemmapSplit, device: str, max_images: int = 2000,
241
+ batch_size: int = 32) -> Dict[str, object]:
242
+ n = min(len(split), max_images)
243
+ bpds = np.zeros(n)
244
+ tiers = np.array([split.tier(i) for i in range(n)], dtype=np.int64)
245
+ for i in range(0, n, batch_size):
246
+ j = min(i + batch_size, n)
247
+ embs = [split.emb_i(k) for k in range(i, j)]
248
+ logz = _log_marginal(model, np.asarray(split.images[i:j]), embs, device)
249
+ bpds[i:j] = bpd_from_logz(logz)
250
+ per_tier = {
251
+ int(t): float(bpds[tiers == t].mean()) for t in np.unique(tiers)
252
+ }
253
+ ge1 = bpds[tiers >= 1]
254
+ return {
255
+ "bpd_val": float(bpds.mean()),
256
+ "bpd_per_tier": per_tier,
257
+ "bpd_tier_ge1": float(ge1.mean()) if ge1.size else float(bpds.mean()),
258
+ "tier_ge1_index": np.nonzero(tiers >= 1)[0],
259
+ "n": n,
260
+ }
261
+
262
+
263
+ def eval_delta_c(model, split: MemmapSplit, null_emb: torch.Tensor, device: str,
264
+ max_images: int = 512, batch_size: int = 32) -> float:
265
+ n = min(len(split), max_images)
266
+ total = 0.0
267
+ for i in range(0, n, batch_size):
268
+ j = min(i + batch_size, n)
269
+ imgs = np.asarray(split.images[i:j])
270
+ embs = [split.emb_i(k) for k in range(i, j)]
271
+ logz_c = _log_marginal(model, imgs, embs, device)
272
+ logz_0 = _log_marginal(model, imgs, [null_emb] * (j - i), device)
273
+ total += float((bpd_from_logz(logz_0) - bpd_from_logz(logz_c)).sum())
274
+ return total / n
275
+
276
+
277
+ def eval_caption_swap(model, split: MemmapSplit, device: str, n_groups: int = 4,
278
+ group: int = 8) -> float:
279
+ """8-way in-batch caption swap: fraction of images whose own caption gives
280
+ a higher logZ than all 7 swapped captions."""
281
+ rng = np.random.default_rng(0)
282
+ n = len(split)
283
+ wins = 0
284
+ total = 0
285
+ for _ in range(n_groups):
286
+ idx = rng.choice(n, size=group, replace=False)
287
+ imgs = np.asarray(split.images[np.sort(idx)])
288
+ embs = [split.emb_i(int(i)) for i in np.sort(idx)]
289
+ scores = np.zeros((group, group))
290
+ for j in range(group):
291
+ scores[:, j] = _log_marginal(model, imgs, [embs[j]] * group, device)
292
+ for i in range(group):
293
+ off_diag = np.delete(scores[i], i)
294
+ wins += int(scores[i, i] > off_diag.max())
295
+ total += 1
296
+ return wins / float(total)
297
+
298
+
299
+ def eval_b0(fit_images: np.ndarray, eval_images: np.ndarray, steps: int = 2000,
300
+ device: str = "cpu") -> float:
301
+ b0 = baseline_pixmix.fit(fit_images, steps=steps, device=device)
302
+ return float(b0.bpd(eval_images))
303
+
304
+
305
+ def eval_tree_metrics(model, split: MemmapSplit, device: str,
306
+ max_images: int = 512) -> Dict[str, object]:
307
+ n = min(len(split), max_images)
308
+ recalls: Dict[int, List[float]] = {}
309
+ f1s: List[float] = []
310
+ aris: List[float] = []
311
+ for i in range(n):
312
+ gt = split.tree(i)
313
+ if gt is None:
314
+ continue
315
+ img = split.image(i)
316
+ parse = _parse_one(model, img, split.emb_i(i), device)
317
+ recalls.setdefault(split.tier(i), []).append(
318
+ tree_metrics.object_cell_recall(parse, gt)
319
+ )
320
+ f1s.append(tree_metrics.visible_cut_f1(parse, gt, img))
321
+ aris.append(tree_metrics.leaf_ari(parse, gt))
322
+ out: Dict[str, object] = {
323
+ "visible_cut_f1": float(np.mean(f1s)) if f1s else None,
324
+ "leaf_ari": float(np.mean(aris)) if aris else None,
325
+ "recall_per_tier": {t: float(np.mean(v)) for t, v in recalls.items()},
326
+ }
327
+ out["recall_tier1"] = out["recall_per_tier"].get(1) # type: ignore[union-attr]
328
+ out["recall_tier2"] = out["recall_per_tier"].get(2) # type: ignore[union-attr]
329
+ return out
330
+
331
+
332
+ def sample_prompt_bank_grid(model, prompt_embs: Sequence[torch.Tensor], out_path: str,
333
+ device: str, n_seeds: int = 8, seed0: int = 0) -> None:
334
+ rows = []
335
+ for i, emb in enumerate(prompt_embs):
336
+ row = [
337
+ _sample_images(model, emb, seed0 + 1000 * s + i, seed0 + 5000 + 1000 * s + i,
338
+ 1, device)[0]
339
+ for s in range(n_seeds)
340
+ ]
341
+ rows.append(row)
342
+ save_grid_jpeg(rows, out_path)
343
+
344
+
345
+ def layout_material_grid(model, emb: torch.Tensor, out_path: str, device: str,
346
+ k: int = 4) -> None:
347
+ """k x k grid: rows = frozen structural seed, cols = rerolled material seed."""
348
+ rows = []
349
+ for r in range(k):
350
+ rows.append([
351
+ _sample_images(model, emb, 100 + r, 900 + c, 1, device)[0] for c in range(k)
352
+ ])
353
+ save_grid_jpeg(rows, out_path)
354
+
355
+
356
+ def _tokens(prompt: str) -> List[str]:
357
+ return prompt.replace(",", " ").split()
358
+
359
+ def _color_in(prompt: str) -> List[str]:
360
+ return [t for t in _tokens(prompt) if t in COLORS]
361
+
362
+ def _shape_in(prompt: str) -> List[str]:
363
+ return [t for t in _tokens(prompt) if t in SHAPES]
364
+
365
+ def _relation_in(prompt: str) -> Optional[str]:
366
+ for rel in ("left of", "right of"):
367
+ if rel in prompt:
368
+ return rel
369
+ for rel in ("above", "below"):
370
+ if rel in prompt.split():
371
+ return rel
372
+ return None
373
+
374
+
375
+ def _median_object_area(images: np.ndarray) -> float:
376
+ areas = []
377
+ for img in images:
378
+ objs = color_checks.extract(img)["objects"]
379
+ if objs:
380
+ areas.append(objs[0]["area"])
381
+ return float(np.median(areas)) if areas else 0.0
382
+
383
+
384
+ def eval_minimal_pairs(model, pair_embs: Sequence[torch.Tensor], device: str,
385
+ n_seeds: int = 64, probe_ckpt: Optional[str] = None,
386
+ batch: int = 16) -> Dict[str, object]:
387
+ """pair_embs: 16 embeddings, (a_i, b_i) interleaved, MINIMAL_PAIRS order."""
388
+ per_pair: Dict[str, float] = {}
389
+ move_scores: List[float] = []
390
+ rel_scores: List[float] = []
391
+ for p, (prompt_a, prompt_b, attr) in enumerate(MINIMAL_PAIRS):
392
+ emb_a, emb_b = pair_embs[2 * p], pair_embs[2 * p + 1]
393
+ imgs_b = np.concatenate([
394
+ _sample_images(model, emb_b, 10 * p + s, 77 + 10 * p + s, 1, device)
395
+ for s in range(n_seeds)
396
+ ])
397
+ score: Optional[float] = None
398
+ if attr == "color":
399
+ new_color = [c for c in _color_in(prompt_b) if c not in _color_in(prompt_a)][0]
400
+ hits = [
401
+ any(o["color"] == new_color for o in color_checks.extract(im)["objects"])
402
+ for im in imgs_b
403
+ ]
404
+ score = float(np.mean(hits))
405
+ elif attr == "relation":
406
+ colors = _color_in(prompt_b)
407
+ rel = _relation_in(prompt_b)
408
+ oks = []
409
+ for im in imgs_b:
410
+ r = color_checks.relation_holds(
411
+ color_checks.extract(im), colors[0], colors[1], rel
412
+ )
413
+ oks.append(bool(r) if r is not None else False)
414
+ score = float(np.mean(oks))
415
+ elif attr == "size":
416
+ imgs_a = np.concatenate([
417
+ _sample_images(model, emb_a, 10 * p + s, 77 + 10 * p + s, 1, device)
418
+ for s in range(n_seeds)
419
+ ])
420
+ bigger_in_b = "large" in _tokens(prompt_b)
421
+ area_a, area_b = _median_object_area(imgs_a), _median_object_area(imgs_b)
422
+ score = float(area_b > area_a) if bigger_in_b else float(area_b < area_a)
423
+ elif attr == "shape" and probe_ckpt is not None:
424
+ from sprig.eval import probe
425
+
426
+ score = probe.score_generations(
427
+ imgs_b, _shape_in(prompt_b)[0], _color_in(prompt_b)[0], probe_ckpt, device
428
+ )
429
+ if score is not None:
430
+ per_pair["{} -> {}".format(prompt_a, prompt_b)] = score
431
+ (rel_scores if attr == "relation" else move_scores).append(score)
432
+ return {
433
+ "per_pair": per_pair,
434
+ "attribute_move": float(np.mean(move_scores)) if move_scores else None,
435
+ "relation_accuracy": float(np.mean(rel_scores)) if rel_scores else None,
436
+ }
437
+
438
+
439
+ def eval_heldout_combos(model, prompt_embs: Sequence[torch.Tensor], probe_ckpt: str,
440
+ device: str, n: int = 64) -> Dict[str, object]:
441
+ from sprig.eval import probe
442
+
443
+ model_probe = probe.load_probe(probe_ckpt, device)
444
+ per_combo: Dict[str, float] = {}
445
+ for color, shape in HELDOUT_COMBOS:
446
+ prompt = "a {} {}".format(color, shape)
447
+ emb = prompt_embs[PROMPTS.index(prompt)]
448
+ imgs = np.concatenate([
449
+ _sample_images(model, emb, 313 + s, 707 + s, 1, device) for s in range(n)
450
+ ])
451
+ per_combo[prompt] = probe.score_generations(imgs, shape, color, model_probe, device)
452
+ return {
453
+ "per_combo": per_combo,
454
+ "holdout_probe_acc": float(np.mean(list(per_combo.values()))),
455
+ }
456
+
457
+
458
+ def eval_health(model, split: MemmapSplit, device: str, n_images: int = 64) -> Dict[str, object]:
459
+ n = min(len(split), n_images)
460
+ imgs = torch.from_numpy(np.array(split.images[:n], dtype=np.uint8)).to(device)
461
+ emb, elen = pad_embs([split.emb_i(i) for i in range(n)])
462
+ stats = model.posterior_usage(imgs, emb.to(device), elen.to(device))
463
+ usage = stats["symbol_usage"]
464
+ texels = stats["texel_usage"]
465
+ s = int(usage.shape[-1]) if hasattr(usage, "shape") else len(usage)
466
+ t_v = int(texels.shape[-1]) if hasattr(texels, "shape") else len(texels)
467
+ return {
468
+ "S": s,
469
+ "T_v": t_v,
470
+ "s_eff": monitors.s_eff(usage),
471
+ "alive_texel_frac": monitors.alive_texels(texels, t_v) / float(t_v),
472
+ "node_entropy": float(stats.get("node_entropy", float("nan"))),
473
+ }
474
+
475
+
476
+ # ---------------------------------------------------------------- gates
477
+
478
+ def _gate(ok: Optional[bool], detail: str) -> Dict[str, str]:
479
+ if ok is None:
480
+ return {"status": "SKIPPED", "detail": detail}
481
+ return {"status": "PASS" if ok else "FAIL", "detail": detail}
482
+
483
+
484
+ def evaluate_gates(metrics: Dict[str, object]) -> Dict[str, Dict[str, str]]:
485
+ """The plan's 5 proof-of-concept success criteria as PASS/FAIL/SKIPPED."""
486
+ g = GATE_THRESH
487
+ gates: Dict[str, Dict[str, str]] = {}
488
+
489
+ bpd = metrics.get("bpd_tier_ge1")
490
+ b0 = metrics.get("b0_bpd")
491
+ dc = metrics.get("delta_c")
492
+ if bpd is None or b0 is None or dc is None:
493
+ gates["1_likelihood"] = _gate(None, "missing bpd/b0/delta_c")
494
+ else:
495
+ ok = (b0 - bpd >= g["b0_margin"]) and (dc >= g["delta_c"])
496
+ gates["1_likelihood"] = _gate(ok, "bpd(tier>=1)={:.3f} vs B0={:.3f} (need margin >= {}); delta_c={:.3f} (need >= {})".format(bpd, b0, g["b0_margin"], dc, g["delta_c"]))
497
+
498
+ tm = metrics.get("tree") or {}
499
+ r1, r2, f1 = tm.get("recall_tier1"), tm.get("recall_tier2"), tm.get("visible_cut_f1")
500
+ if r1 is None or r2 is None or f1 is None:
501
+ gates["2_parses"] = _gate(None, "missing tree metrics")
502
+ else:
503
+ ok = (r1 >= g["recall_tier1"]) and (r2 >= g["recall_tier2"]) and (f1 >= g["visible_cut_f1"])
504
+ gates["2_parses"] = _gate(ok, "recall t1={:.2f} (>= {}), t2={:.2f} (>= {}), cut F1={:.2f} (>= {})".format(r1, g["recall_tier1"], r2, g["recall_tier2"], f1, g["visible_cut_f1"]))
505
+
506
+ ps = metrics.get("prompt_swap") or {}
507
+ move, rel = ps.get("attribute_move"), ps.get("relation_accuracy")
508
+ if move is None or rel is None:
509
+ gates["3_prompt_control"] = _gate(None, "missing minimal-pair scores")
510
+ else:
511
+ ok = (move >= g["attribute_move"]) and (rel >= g["relation_accuracy"])
512
+ gates["3_prompt_control"] = _gate(ok, "attribute-move={:.2f} (>= {}), relation={:.2f} (>= {})".format(move, g["attribute_move"], rel, g["relation_accuracy"]))
513
+
514
+ hp = metrics.get("holdout_probe_acc")
515
+ if hp is None:
516
+ gates["4_compositional"] = _gate(None, "no probe checkpoint / heldout scores")
517
+ else:
518
+ gates["4_compositional"] = _gate(hp >= g["holdout_probe"], "held-out combo probe acc={:.2f} (>= {})".format(hp, g["holdout_probe"]))
519
+
520
+ health = metrics.get("health") or {}
521
+ s_eff, s = health.get("s_eff"), health.get("S")
522
+ alive = health.get("alive_texel_frac")
523
+ if s_eff is None or alive is None or s is None:
524
+ gates["5_health"] = _gate(None, "missing health stats")
525
+ else:
526
+ ok = (s_eff >= g["s_eff_frac"] * s) and (alive >= g["alive_texel_frac"])
527
+ gates["5_health"] = _gate(ok, "S_eff={:.0f} (>= {:.0f}), alive texels={:.0%} (>= {:.0%})".format(s_eff, g["s_eff_frac"] * s, alive, g["alive_texel_frac"]))
528
+ return gates
529
+
530
+
531
+ def write_report(metrics: Dict[str, object], gates: Dict[str, Dict[str, str]],
532
+ out_dir: str) -> None:
533
+ os.makedirs(out_dir, exist_ok=True)
534
+
535
+ def _default(o):
536
+ if isinstance(o, (np.integer,)):
537
+ return int(o)
538
+ if isinstance(o, (np.floating,)):
539
+ return float(o)
540
+ if isinstance(o, np.ndarray):
541
+ return o.tolist()
542
+ return str(o)
543
+
544
+ with open(os.path.join(out_dir, "metrics.json"), "w") as f:
545
+ json.dump({"metrics": metrics, "gates": gates}, f, indent=2, default=_default)
546
+ lines = ["# SPRIG v0.1 — Final Report", "", "## Success-criteria gates", ""]
547
+ lines.append("| gate | status | detail |")
548
+ lines.append("|---|---|---|")
549
+ for name, g in gates.items():
550
+ lines.append("| {} | **{}** | {} |".format(name, g["status"], g["detail"]))
551
+ lines += ["", "## Key metrics", "", "```json"]
552
+ lines.append(json.dumps(metrics, indent=2, default=_default))
553
+ lines.append("```")
554
+ with open(os.path.join(out_dir, "final_report.md"), "w") as f:
555
+ f.write("\n".join(lines) + "\n")
556
+
557
+
558
+ # ------------------------------------------------------------- orchestrator
559
+
560
+ def run_report(
561
+ ckpt_path: Optional[str],
562
+ data_dir: str,
563
+ out_dir: str,
564
+ device: str = "cpu",
565
+ model=None,
566
+ probe_ckpt: Optional[str] = None,
567
+ n_bank_seeds: int = 8,
568
+ n_pair_seeds: int = 64,
569
+ max_bpd_images: int = 2000,
570
+ max_parse_images: int = 512,
571
+ b0_steps: int = 2000,
572
+ ) -> Dict[str, object]:
573
+ """Run the full evaluation suite on a checkpoint.
574
+
575
+ Pass `model` directly to skip checkpoint loading (used by tests/harness).
576
+ Returns the metrics dict; writes metrics.json, final_report.md and the
577
+ sample grids into out_dir.
578
+ """
579
+ os.makedirs(out_dir, exist_ok=True)
580
+ if model is None:
581
+ model = load_model(ckpt_path, device)
582
+
583
+ val_dir = os.path.join(data_dir, "val")
584
+ if not os.path.isdir(val_dir):
585
+ val_dir = os.path.join(data_dir, "val_fast")
586
+ val = MemmapSplit(val_dir)
587
+ t5_dir = os.path.join(data_dir, "t5")
588
+
589
+ metrics: Dict[str, object] = {}
590
+
591
+ # --- likelihood
592
+ bpd_stats = eval_bpd(model, val, device, max_images=max_bpd_images)
593
+ tier_ge1_index = bpd_stats.pop("tier_ge1_index")
594
+ metrics.update(bpd_stats)
595
+
596
+ null_path = os.path.join(t5_dir, "null.f16")
597
+ metrics["delta_c"] = (
598
+ eval_delta_c(model, val, load_null_emb(t5_dir), device)
599
+ if os.path.exists(null_path) else None
600
+ )
601
+ metrics["caption_swap_win_frac"] = eval_caption_swap(model, val, device)
602
+
603
+ # --- B0 baseline (fit on train images when available, else val)
604
+ train_imgs_path = os.path.join(data_dir, "train", "images.u8")
605
+ if os.path.exists(train_imgs_path):
606
+ n_fit = os.path.getsize(train_imgs_path) // (64 * 64 * 3)
607
+ fit_imgs = np.memmap(train_imgs_path, dtype=np.uint8, mode="r",
608
+ shape=(n_fit, 64, 64, 3))[: 4096]
609
+ else:
610
+ fit_imgs = np.asarray(val.images[: min(len(val), 4096)])
611
+ eval_idx = tier_ge1_index if len(tier_ge1_index) else np.arange(min(len(val), 512))
612
+ eval_imgs = np.asarray(val.images)[eval_idx[:512]]
613
+ metrics["b0_bpd"] = eval_b0(np.asarray(fit_imgs), eval_imgs, steps=b0_steps, device=device)
614
+
615
+ # --- parse metrics
616
+ parse_dir = os.path.join(data_dir, "parse_eval")
617
+ metrics["tree"] = (
618
+ eval_tree_metrics(model, MemmapSplit(parse_dir), device, max_parse_images)
619
+ if os.path.isdir(parse_dir) else None
620
+ )
621
+
622
+ # --- prompt bank grids
623
+ bank_npz = os.path.join(t5_dir, "promptbank.npz")
624
+ prompt_embs: Optional[List[torch.Tensor]] = None
625
+ if os.path.exists(bank_npz):
626
+ prompt_embs = load_prompt_npz(bank_npz)
627
+ sample_prompt_bank_grid(
628
+ model, prompt_embs, os.path.join(out_dir, "prompt_bank_grid.jpg"),
629
+ device, n_seeds=n_bank_seeds,
630
+ )
631
+ layout_material_grid(
632
+ model, prompt_embs[8], os.path.join(out_dir, "layout_material_grid.jpg"), device
633
+ )
634
+
635
+ # --- minimal pairs
636
+ pairs_npz = os.path.join(t5_dir, "minimal_pairs.npz")
637
+ if os.path.exists(pairs_npz):
638
+ pair_embs = load_prompt_npz(pairs_npz)
639
+ metrics["prompt_swap"] = eval_minimal_pairs(
640
+ model, pair_embs, device, n_seeds=n_pair_seeds, probe_ckpt=probe_ckpt
641
+ )
642
+ else:
643
+ metrics["prompt_swap"] = None
644
+
645
+ # --- held-out combos via probe
646
+ metrics["holdout_probe_acc"] = None
647
+ if probe_ckpt is not None and prompt_embs is not None:
648
+ heldout = eval_heldout_combos(model, prompt_embs, probe_ckpt, device)
649
+ metrics["heldout_per_combo"] = heldout["per_combo"]
650
+ metrics["holdout_probe_acc"] = heldout["holdout_probe_acc"]
651
+
652
+ # --- health
653
+ metrics["health"] = eval_health(model, val, device)
654
+
655
+ gates = evaluate_gates(metrics)
656
+ write_report(metrics, gates, out_dir)
657
+ return metrics
sprig/eval/tree_metrics.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parse metrics vs ground-truth region trees (identifiability-aware).
2
+
3
+ Node schema (duck-typed; both are accepted everywhere a tree is expected):
4
+ - GT JSON node (meta.jsonl "tree", written by sprig/data/procgen/sampler.py):
5
+ internal {"rect": [x0,y0,x1,y1] px (x1/y1 exclusive), "axis": "V"|"H",
6
+ "cut": px, "children": [lo, hi]}; leaf {"rect", "leaf": true,
7
+ "obj": int|null, "fill": ...} — a leaf is an object-role leaf iff "obj"
8
+ is not null (an inline "object" dict or "role" == "object" also works).
9
+ "axis"/"cut" fields are ignored: cut geometry is derived from the children
10
+ rects, which makes the metrics robust to axis naming conventions.
11
+ - Model ParseNode (sprig/model/sprig.py): attributes rect, children (list or
12
+ None), axis, cut_px, symbol, texel.
13
+
14
+ `parse` arguments may be a single root node or the list returned by
15
+ `model.map_parse` (all nodes are collected either way).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import Dict, List, Optional, Sequence, Tuple
20
+
21
+ import numpy as np
22
+
23
+ Rect = Tuple[int, int, int, int]
24
+ CutSeg = Tuple[str, float, float, float] # (axis "V"|"H", position, ext_lo, ext_hi)
25
+
26
+
27
+ # ---------------------------------------------------------------- accessors
28
+
29
+ def _rect(node) -> Rect:
30
+ r = node["rect"] if isinstance(node, dict) else node.rect
31
+ x0, y0, x1, y1 = (int(v) for v in r)
32
+ return (x0, y0, x1, y1)
33
+
34
+
35
+ def _children(node) -> List:
36
+ if isinstance(node, dict):
37
+ ch = node.get("children")
38
+ else:
39
+ ch = getattr(node, "children", None)
40
+ return list(ch) if ch else []
41
+
42
+
43
+ def _object(node) -> Optional[dict]:
44
+ """Object payload of an object-role leaf, else None.
45
+
46
+ Accepts the sampler schema (leaf {"obj": <int index>|null}), an inline
47
+ {"object": {...}} dict, or a "role": "object" marker."""
48
+ if isinstance(node, dict):
49
+ if node.get("object") is not None:
50
+ return node["object"]
51
+ if node.get("obj") is not None:
52
+ return {"index": node["obj"]}
53
+ if node.get("role") == "object":
54
+ return {}
55
+ return None
56
+ return getattr(node, "object", None)
57
+
58
+
59
+ def collect_nodes(tree_or_nodes) -> List:
60
+ """Flatten a root node / list of nodes into a deduped list of all nodes."""
61
+ roots = tree_or_nodes if isinstance(tree_or_nodes, (list, tuple)) else [tree_or_nodes]
62
+ out: List = []
63
+ seen = set()
64
+ stack = list(roots)
65
+ while stack:
66
+ n = stack.pop()
67
+ if id(n) in seen:
68
+ continue
69
+ seen.add(id(n))
70
+ out.append(n)
71
+ stack.extend(_children(n))
72
+ return out
73
+
74
+
75
+ def leaves(tree_or_nodes) -> List:
76
+ return [n for n in collect_nodes(tree_or_nodes) if not _children(n)]
77
+
78
+
79
+ def cut_segments(tree_or_nodes) -> List[CutSeg]:
80
+ """All cut segments, derived from each internal node's children rects."""
81
+ segs: List[CutSeg] = []
82
+ for n in collect_nodes(tree_or_nodes):
83
+ ch = _children(n)
84
+ if len(ch) != 2:
85
+ continue
86
+ (ax0, ay0, ax1, ay1), (bx0, by0, bx1, by1) = _rect(ch[0]), _rect(ch[1])
87
+ if ax1 == bx0 and ay0 == by0 and ay1 == by1: # vertical line at x=ax1
88
+ segs.append(("V", float(ax1), float(ay0), float(ay1)))
89
+ elif bx1 == ax0 and ay0 == by0 and ay1 == by1:
90
+ segs.append(("V", float(bx1), float(ay0), float(ay1)))
91
+ elif ay1 == by0 and ax0 == bx0 and ax1 == bx1: # horizontal line at y=ay1
92
+ segs.append(("H", float(ay1), float(ax0), float(ax1)))
93
+ elif by1 == ay0 and ax0 == bx0 and ax1 == bx1:
94
+ segs.append(("H", float(by1), float(ax0), float(ax1)))
95
+ return segs
96
+
97
+
98
+ # ------------------------------------------------------------------ metrics
99
+
100
+ def _iou(a: Rect, b: Rect) -> float:
101
+ ix = max(0, min(a[2], b[2]) - max(a[0], b[0]))
102
+ iy = max(0, min(a[3], b[3]) - max(a[1], b[1]))
103
+ inter = ix * iy
104
+ if inter == 0:
105
+ return 0.0
106
+ area_a = (a[2] - a[0]) * (a[3] - a[1])
107
+ area_b = (b[2] - b[0]) * (b[3] - b[1])
108
+ return inter / float(area_a + area_b - inter)
109
+
110
+
111
+ def object_cell_recall(parse, gt, iou_thresh: float = 0.8) -> float:
112
+ """Fraction of GT object-role leaves whose rect is matched (IoU >= thresh)
113
+ by ANY node rect of the parse (internal or leaf). Vacuously 1.0 when the
114
+ GT tree has no object leaves."""
115
+ gt_obj_rects = [_rect(n) for n in leaves(gt) if _object(n) is not None]
116
+ if not gt_obj_rects:
117
+ return 1.0
118
+ parse_rects = [_rect(n) for n in collect_nodes(parse)]
119
+ hit = 0
120
+ for gr in gt_obj_rects:
121
+ if any(_iou(gr, pr) >= iou_thresh for pr in parse_rects):
122
+ hit += 1
123
+ return hit / float(len(gt_obj_rects))
124
+
125
+
126
+ def _cut_contrast(image: np.ndarray, seg: CutSeg, band: int = 2) -> float:
127
+ """Max-channel abs difference of mean colors on the two sides of a cut."""
128
+ img = np.asarray(image, dtype=np.float64)
129
+ axis, pos, lo, hi = seg
130
+ p, a, b = int(round(pos)), int(round(lo)), int(round(hi))
131
+ if axis == "V":
132
+ left = img[a:b, max(0, p - band):p, :]
133
+ right = img[a:b, p:min(img.shape[1], p + band), :]
134
+ else:
135
+ left = img[max(0, p - band):p, a:b, :]
136
+ right = img[p:min(img.shape[0], p + band), a:b, :]
137
+ if left.size == 0 or right.size == 0:
138
+ return 0.0
139
+ return float(
140
+ np.abs(left.reshape(-1, 3).mean(axis=0) - right.reshape(-1, 3).mean(axis=0)).max()
141
+ )
142
+
143
+
144
+ def _visible(segs: Sequence[CutSeg], image: np.ndarray, contrast_thresh: float) -> List[CutSeg]:
145
+ return [s for s in segs if _cut_contrast(image, s) > contrast_thresh]
146
+
147
+
148
+ def _seg_match(a: CutSeg, b: CutSeg, tol_px: float) -> bool:
149
+ if a[0] != b[0] or abs(a[1] - b[1]) > tol_px:
150
+ return False
151
+ overlap = min(a[3], b[3]) - max(a[2], b[2])
152
+ shorter = min(a[3] - a[2], b[3] - b[2])
153
+ return shorter > 0 and overlap >= 0.5 * shorter
154
+
155
+
156
+ def visible_cut_f1(
157
+ parse,
158
+ gt,
159
+ image: np.ndarray,
160
+ contrast_thresh: float = 20.0,
161
+ tol_px: float = 1.5,
162
+ ) -> float:
163
+ """Boundary F1 over VISIBLE cut segments.
164
+
165
+ Both GT and parse cut segments are filtered by actual cross-cut mean-color
166
+ contrast in `image` (> contrast_thresh, max over RGB channels); visible
167
+ parse segments are greedily one-to-one matched to visible GT segments
168
+ (same axis, position within tol_px, >=50% extent overlap).
169
+ """
170
+ gt_segs = _visible(cut_segments(gt), image, contrast_thresh)
171
+ pr_segs = _visible(cut_segments(parse), image, contrast_thresh)
172
+ if not gt_segs and not pr_segs:
173
+ return 1.0
174
+ if not gt_segs or not pr_segs:
175
+ return 0.0
176
+ used = [False] * len(gt_segs)
177
+ tp = 0
178
+ for ps in pr_segs:
179
+ cands = [
180
+ (abs(ps[1] - gs[1]), j)
181
+ for j, gs in enumerate(gt_segs)
182
+ if not used[j] and _seg_match(ps, gs, tol_px)
183
+ ]
184
+ if cands:
185
+ used[min(cands)[1]] = True
186
+ tp += 1
187
+ prec = tp / float(len(pr_segs))
188
+ rec = tp / float(len(gt_segs))
189
+ return 0.0 if tp == 0 else 2 * prec * rec / (prec + rec)
190
+
191
+
192
+ def leaf_assignment_map(tree_or_nodes, canvas: int = 64) -> np.ndarray:
193
+ """[canvas,canvas] int32 map: pixel -> index of the leaf covering it."""
194
+ out = np.full((canvas, canvas), -1, dtype=np.int32)
195
+ for i, leaf in enumerate(leaves(tree_or_nodes)):
196
+ x0, y0, x1, y1 = _rect(leaf)
197
+ out[y0:y1, x0:x1] = i
198
+ return out
199
+
200
+
201
+ def adjusted_rand_index(a: np.ndarray, b: np.ndarray) -> float:
202
+ """ARI between two integer label maps of identical shape (numpy only)."""
203
+ a = np.asarray(a).ravel()
204
+ b = np.asarray(b).ravel()
205
+ _, ai = np.unique(a, return_inverse=True)
206
+ _, bi = np.unique(b, return_inverse=True)
207
+ na, nb = ai.max() + 1, bi.max() + 1
208
+ cont = np.bincount(ai * nb + bi, minlength=na * nb).reshape(na, nb).astype(np.float64)
209
+ n = cont.sum()
210
+
211
+ def _comb2(x: np.ndarray) -> np.ndarray:
212
+ return x * (x - 1) / 2.0
213
+
214
+ sum_ij = _comb2(cont).sum()
215
+ sum_a = _comb2(cont.sum(axis=1)).sum()
216
+ sum_b = _comb2(cont.sum(axis=0)).sum()
217
+ total = _comb2(np.array(n))
218
+ expected = sum_a * sum_b / total if total > 0 else 0.0
219
+ max_index = 0.5 * (sum_a + sum_b)
220
+ denom = max_index - expected
221
+ if denom == 0:
222
+ return 1.0 if sum_ij == max_index else 0.0
223
+ return float((sum_ij - expected) / denom)
224
+
225
+
226
+ def leaf_ari(parse, gt, canvas: int = 64) -> float:
227
+ """Adjusted Rand index between parse and GT pixel->leaf assignment maps."""
228
+ return adjusted_rand_index(
229
+ leaf_assignment_map(parse, canvas), leaf_assignment_map(gt, canvas)
230
+ )
sprig/model/__init__.py ADDED
File without changes
sprig/model/atlas.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Texel atlas renderer and leaf emission scoring (DESIGN.md section 4, atlas.py).
2
+
3
+ The renderer amortizes the terminal decoder into a canonical per-texel atlas of
4
+ discretized-logistic parameters: atlas [B, T_v, 40, 16, 16] (channel layout in
5
+ sprig/model/dl.py). A trainable per-texel additive bias grid [T_v, 40, 16, 16]
6
+ is added to the renderer output — the resurrection-writable parameterization
7
+ (F3.3 / M1.2): dead texels are revived by directly overwriting their bias rows.
8
+
9
+ FiLM mapping (illumination field Phi [B, 8, 16, 16] over the canvas), sampled
10
+ bilinearly at each leaf's canvas-center position -> phi [..., 8]:
11
+
12
+ scale_c = 1 + phi[c] for c in {0: R, 1: G, 2: B}
13
+ shift_c = phi[3 + c] for c in {0: R, 1: G, 2: B}
14
+ phi[6:8] reserved (unused in v0.1)
15
+
16
+ Each DL *mean* channel group (R/G/B means, identically across the 4 mixture
17
+ components) is modulated as mean' = mean * scale_c + shift_c. Log-scales,
18
+ weights and coupling coefficients are not modulated.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ import os
24
+ from typing import Optional, Tuple
25
+
26
+ import torch
27
+ import torch.nn as nn
28
+ import torch.nn.functional as F
29
+
30
+ from sprig.model import dl
31
+ from sprig.model.gmt import CrossAttnBlock, caption_padding_mask
32
+
33
+ ATLAS_RES = 16
34
+ ATLAS_WIDTH = 256
35
+
36
+
37
+ _LOG_HALF_RANGE = math.log(127.5)
38
+
39
+
40
+ def _ell_chunk_math(
41
+ pooled_g: torch.Tensor,
42
+ crops_c: torch.Tensor,
43
+ w_c: torch.Tensor,
44
+ sc_c: torch.Tensor,
45
+ sh_c: torch.Tensor,
46
+ ) -> torch.Tensor:
47
+ """One region-chunk of emission scores -> [B, ng, T_v] fp32.
48
+
49
+ Runs under gradient checkpointing: the per-pixel intermediates are
50
+ recomputed in backward instead of stored (storing them across all chunks
51
+ is >90GB at batch 256). Exactly the dl.dl_logprob math with FiLM'd means
52
+ (fp32 throughout), but unrolled over the 4 mixture components x 3 RGB
53
+ channels so every intermediate is a [B,ng,T_v,h,w] slab instead of the
54
+ [B,ng,T_v,4,3,h,w] block — torch.compile then fuses the whole chunk
55
+ without materializing multi-GiB buffers (the tensorized form made
56
+ inductor allocate the full block, ~18 GiB at B=128 leaf_chunk=49).
57
+ """
58
+ # NOTE: a bf16 variant (inputs cast down, fp32 pixel-sum) was tried and
59
+ # REVERTED: eager bf16 shifts ell by tens of nats (bf16 ulp at |ell|~1e3
60
+ # is ~8) and fails the 0.1% loss parity gate by ~40x.
61
+ p = pooled_g.float() # [B,T_v,40,h,w]
62
+ x = crops_c.float() # [B,ng,3,h,w]
63
+ sc = sc_c.float() # [B,ng,3]
64
+ sh = sh_c.float()
65
+ # Mixture-weight log-softmax over the 4 weight channels (pooled-sized).
66
+ wl = [p[:, :, 10 * j] for j in range(dl.N_COMP)] # [B,T_v,h,w] each
67
+ wm = torch.logaddexp(torch.logaddexp(wl[0], wl[1]),
68
+ torch.logaddexp(wl[2], wl[3])) # logsumexp_j
69
+ x_ch = [x[:, :, c].unsqueeze(2) for c in range(3)] # [B,ng,1,h,w]
70
+
71
+ mix: Optional[torch.Tensor] = None
72
+ for j in range(dl.N_COMP):
73
+ acc: Optional[torch.Tensor] = None # sum_c log P_j(c)
74
+ for c in range(3):
75
+ mu = p[:, :, 10 * j + 1 + c].unsqueeze(1) # [B,1,T_v,h,w]
76
+ ls = p[:, :, 10 * j + 4 + c].clamp(
77
+ dl.LOGSCALE_MIN, dl.LOGSCALE_MAX).unsqueeze(1)
78
+ m = mu * sc[:, :, c, None, None, None] + sh[:, :, c, None, None, None]
79
+ if c == 1:
80
+ al = torch.tanh(p[:, :, 10 * j + 7]).unsqueeze(1)
81
+ m = m + al * x_ch[0]
82
+ elif c == 2:
83
+ be = torch.tanh(p[:, :, 10 * j + 8]).unsqueeze(1)
84
+ ga = torch.tanh(p[:, :, 10 * j + 9]).unsqueeze(1)
85
+ m = m + be * x_ch[0] + ga * x_ch[1]
86
+ xc = x_ch[c]
87
+ centered = xc - m # [B,ng,T_v,h,w]
88
+ inv_std = torch.exp(-ls)
89
+ plus_in = inv_std * (centered + dl._HALF_BIN)
90
+ min_in = inv_std * (centered - dl._HALF_BIN)
91
+ log_cdf_plus = plus_in - F.softplus(plus_in)
92
+ log_one_minus_cdf_min = -F.softplus(min_in)
93
+ cdf_delta = torch.sigmoid(plus_in) - torch.sigmoid(min_in)
94
+ mid_in = inv_std * centered
95
+ log_pdf_mid = mid_in - ls - 2.0 * F.softplus(mid_in)
96
+ log_prob_mid = torch.where(
97
+ cdf_delta > 1e-5,
98
+ torch.log(cdf_delta.clamp(min=1e-12)),
99
+ log_pdf_mid - _LOG_HALF_RANGE,
100
+ )
101
+ lp = torch.where(
102
+ xc < -0.999,
103
+ log_cdf_plus,
104
+ torch.where(xc > 0.999, log_one_minus_cdf_min, log_prob_mid),
105
+ )
106
+ acc = lp if acc is None else acc + lp
107
+ pc = acc + (wl[j] - wm).unsqueeze(1) # log w_j + log P_j
108
+ mix = pc if mix is None else torch.logaddexp(mix, pc)
109
+ # Per-pixel importance weights (object-pixel up-weighting; all-ones in the
110
+ # unweighted/eval path — multiplying by exactly 1.0 is bit-identical fp32,
111
+ # so likelihood parity is preserved).
112
+ return (mix * w_c.float().unsqueeze(2)).sum(dim=(-1, -2)).float() # [B,ng,T_v]
113
+
114
+
115
+ _ELL_COMPILED = None # lazily-built torch.compile wrapper of _ell_chunk_math
116
+
117
+
118
+ def _ell_chunk_fn(device: torch.device):
119
+ """Fused (torch.compile) emission-chunk kernel on CUDA; the eager
120
+ reference math elsewhere. The eager path is memory-bound on dozens of
121
+ unfused fp32 elementwise kernel passes per chunk; fusing them is a >3x
122
+ end-to-end step win. Same math, same dtypes — kill switch:
123
+ SPRIG_COMPILE=0."""
124
+ global _ELL_COMPILED
125
+ if device.type != "cuda" or os.environ.get("SPRIG_COMPILE", "1") == "0":
126
+ return _ell_chunk_math
127
+ if _ELL_COMPILED is None:
128
+ try:
129
+ # One graph per (group h/w, chunk rows, batch) shape; a process
130
+ # that probes several batch sizes exceeds dynamo's default limit
131
+ # of 8 and would silently fall back to slow eager for the rest.
132
+ import torch._dynamo.config as _dcfg
133
+ if getattr(_dcfg, "recompile_limit", 0) < 64:
134
+ _dcfg.recompile_limit = 64
135
+ if getattr(_dcfg, "cache_size_limit", 0) < 64:
136
+ _dcfg.cache_size_limit = 64
137
+ _ELL_COMPILED = torch.compile(_ell_chunk_math, dynamic=False)
138
+ except Exception:
139
+ _ELL_COMPILED = _ell_chunk_math
140
+ return _ELL_COMPILED
141
+
142
+
143
+ def film_scale_shift(phi: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
144
+ """phi [..., 8] -> (scale [..., 3], shift [..., 3]) per RGB mean group."""
145
+ return 1.0 + phi[..., 0:3], phi[..., 3:6]
146
+
147
+
148
+ def phi_at_leaf_centers(
149
+ Phi: torch.Tensor, rects: torch.Tensor, canvas_px: int
150
+ ) -> torch.Tensor:
151
+ """Bilinearly sample Phi [B, 8, 16, 16] at leaf-center canvas positions.
152
+
153
+ rects int [n, 4] (x0, y0, x1, y1 in px) -> phi [B, n, 8].
154
+ """
155
+ B = Phi.shape[0]
156
+ cx = (rects[:, 0] + rects[:, 2]).float() / (2.0 * canvas_px) * 2.0 - 1.0
157
+ cy = (rects[:, 1] + rects[:, 3]).float() / (2.0 * canvas_px) * 2.0 - 1.0
158
+ grid = torch.stack([cx, cy], dim=-1).to(Phi.device) # [n, 2] (x, y)
159
+ grid = grid.view(1, -1, 1, 2).expand(B, -1, -1, -1)
160
+ out = F.grid_sample(Phi.float(), grid, mode="bilinear", align_corners=False)
161
+ return out.squeeze(-1).permute(0, 2, 1) # [B, n, 8]
162
+
163
+
164
+ class TexelAtlas(nn.Module):
165
+ """cfg is duck-typed: needs T_v, d, caption_dim (optional: atlas_heads,
166
+ leaf_chunk)."""
167
+
168
+ def __init__(self, cfg) -> None:
169
+ super().__init__()
170
+ self.cfg = cfg
171
+ T_v, d = cfg.T_v, cfg.d
172
+ heads = getattr(cfg, "atlas_heads", 4)
173
+ self.leaf_chunk = int(getattr(cfg, "leaf_chunk", 16))
174
+
175
+ self.E_T = nn.Parameter(torch.randn(T_v, d) * 0.02)
176
+ self.q_proj = nn.Linear(d, ATLAS_WIDTH)
177
+ self.cap_proj = nn.Linear(cfg.caption_dim, ATLAS_WIDTH)
178
+ self.blocks = nn.ModuleList(
179
+ [CrossAttnBlock(ATLAS_WIDTH, heads) for _ in range(2)]
180
+ )
181
+ self.ln_out = nn.LayerNorm(ATLAS_WIDTH)
182
+ self.seed = nn.Linear(ATLAS_WIDTH, ATLAS_WIDTH * 4 * 4)
183
+ self.conv1 = nn.Conv2d(ATLAS_WIDTH, 128, kernel_size=3, padding=1)
184
+ self.conv2 = nn.Conv2d(128, dl.N_CH, kernel_size=3, padding=1)
185
+ # Resurrection-writable per-texel bias grid.
186
+ self.bias_grid = nn.Parameter(torch.zeros(T_v, dl.N_CH, ATLAS_RES, ATLAS_RES))
187
+
188
+ def render(self, emb: torch.Tensor, emb_len: torch.Tensor) -> torch.Tensor:
189
+ """emb [B, L, 768], emb_len [B] -> atlas [B, T_v, 40, 16, 16]."""
190
+ B, L, _ = emb.shape
191
+ T_v = self.E_T.shape[0]
192
+ kv = self.cap_proj(emb.float())
193
+ kpm = caption_padding_mask(emb_len, L, emb.device)
194
+
195
+ h = self.q_proj(self.E_T).unsqueeze(0).expand(B, -1, -1) # [B, T_v, 256]
196
+ for blk in self.blocks:
197
+ h = blk(h, kv, kpm)
198
+ h = self.ln_out(h)
199
+
200
+ z = self.seed(h).reshape(B * T_v, ATLAS_WIDTH, 4, 4)
201
+ z = F.gelu(self.conv1(z))
202
+ z = F.interpolate(z, scale_factor=2, mode="nearest") # [.., 128, 8, 8]
203
+ z = self.conv2(z)
204
+ z = F.interpolate(z, scale_factor=2, mode="nearest") # [.., 40, 16, 16]
205
+ atlas = z.reshape(B, T_v, dl.N_CH, ATLAS_RES, ATLAS_RES)
206
+ return atlas + self.bias_grid.unsqueeze(0)
207
+
208
+ def score_leaves(
209
+ self,
210
+ atlas: torch.Tensor,
211
+ images: torch.Tensor,
212
+ lattice,
213
+ Phi: torch.Tensor,
214
+ chunk: Optional[int] = None,
215
+ pix_weight: Optional[torch.Tensor] = None,
216
+ ) -> torch.Tensor:
217
+ """Emission log-likelihoods for every (leaf region, texel) pair.
218
+
219
+ atlas [B, T_v, 40, 16, 16], images u8 [B, C, C, 3] (C = canvas),
220
+ Phi [B, 8, 16, 16] -> ell fp32 [B, n_leaf_regions, T_v], summed over
221
+ the region's pixels, in lattice.leaf_ids slot order. Vectorized per
222
+ leaf shape group, chunked over regions to bound memory.
223
+
224
+ pix_weight (optional) fp32 [B, C, C]: per-pixel importance weights on
225
+ the log-likelihood (object-pixel up-weighting during training). None
226
+ means all-ones (exact NLL) — used by log_marginal/eval/parsing.
227
+ """
228
+ B, T_v = atlas.shape[0], atlas.shape[1]
229
+ canvas = lattice.canvas_px
230
+ step = int(chunk) if chunk is not None else self.leaf_chunk
231
+
232
+ x = dl.u8_to_unit(images).permute(0, 3, 1, 2).contiguous() # [B,3,C,C]
233
+ x_flat = x.reshape(B, 3, canvas * canvas)
234
+ if pix_weight is None:
235
+ w_flat = torch.ones(B, canvas * canvas, dtype=torch.float32,
236
+ device=atlas.device)
237
+ else:
238
+ w_flat = pix_weight.to(atlas.device, torch.float32).reshape(
239
+ B, canvas * canvas)
240
+
241
+ n_leaf = lattice.n_leaf_regions
242
+ ell = torch.zeros(B, n_leaf, T_v, dtype=torch.float32, device=atlas.device)
243
+
244
+ leaf_rects = lattice.regions[lattice.leaf_ids]
245
+ phi_leaf = phi_at_leaf_centers(Phi, leaf_rects, canvas) # [B,n_leaf,8]
246
+ scale, shift = film_scale_shift(phi_leaf) # [B,n_leaf,3]
247
+
248
+ use_ckpt = torch.is_grad_enabled() and (
249
+ atlas.requires_grad or Phi.requires_grad)
250
+ chunk_fn = _ell_chunk_fn(atlas.device)
251
+
252
+ groups = getattr(lattice, "shape_groups", None)
253
+ if groups is None: # foreign/stub lattice: derive on the fly
254
+ groups = []
255
+ for (h, w), slots in lattice.leaf_shape_groups().items():
256
+ slots = slots.to(atlas.device)
257
+ rects = lattice.regions[lattice.leaf_ids[slots]] # [n_g,4]
258
+ dy = torch.arange(h, device=atlas.device)
259
+ dx = torch.arange(w, device=atlas.device)
260
+ yy = rects[:, 1].view(-1, 1, 1).to(atlas.device) + dy.view(1, -1, 1)
261
+ xx = rects[:, 0].view(-1, 1, 1).to(atlas.device) + dx.view(1, 1, -1)
262
+ groups.append((h, w, slots, (yy * canvas + xx).reshape(-1)))
263
+
264
+ for h, w, slots, pix in groups:
265
+ if (h, w) == (ATLAS_RES, ATLAS_RES):
266
+ pooled = atlas # identity pool
267
+ else:
268
+ pooled = F.adaptive_avg_pool2d(
269
+ atlas.reshape(B * T_v, dl.N_CH, ATLAS_RES, ATLAS_RES), (h, w)
270
+ ).reshape(B, T_v, dl.N_CH, h, w)
271
+
272
+ n_g = slots.shape[0]
273
+ crops = x_flat[:, :, pix].reshape(B, 3, n_g, h, w).permute(0, 2, 1, 3, 4)
274
+ w_crops = w_flat[:, pix].reshape(B, n_g, h, w)
275
+
276
+ sc = scale[:, slots] # [B,n_g,3]
277
+ sh = shift[:, slots]
278
+
279
+ for i0 in range(0, n_g, step):
280
+ i1 = min(n_g, i0 + step)
281
+ args = (pooled, crops[:, i0:i1].contiguous(),
282
+ w_crops[:, i0:i1].contiguous(),
283
+ sc[:, i0:i1].contiguous(), sh[:, i0:i1].contiguous())
284
+ if use_ckpt:
285
+ tot = torch.utils.checkpoint.checkpoint(
286
+ chunk_fn, *args, use_reentrant=False)
287
+ else:
288
+ tot = chunk_fn(*args)
289
+ ell[:, slots[i0:i1], :] = tot
290
+
291
+ return ell
sprig/model/dl.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Discretized-logistic mixture emissions (PixelCNN++ style) with RGB coupling.
2
+
3
+ Parameter layout (the "40 channels", DESIGN.md section 4): 4 mixture components,
4
+ each a contiguous block of 10 channels, so channel index = 10*j + t for
5
+ component j in 0..3 and t in:
6
+
7
+ t = 0 mixture-weight logit
8
+ t = 1..3 raw means for (R, G, B) (image scaled to [-1, 1])
9
+ t = 4..6 log-scales for (R, G, B) (clamped to [-7, 2])
10
+ t = 7..9 channel-coupling coeffs (alpha: G|R, beta: B|R, gamma: B|G),
11
+ squashed by tanh inside this module.
12
+
13
+ Coupled means (per component): m_R = mu_R; m_G = mu_G + alpha * x_R;
14
+ m_B = mu_B + beta * x_R + gamma * x_G, where x_R/x_G are OBSERVED values in
15
+ [-1, 1] (teacher-forced coupling, exactly as in PixelCNN++).
16
+
17
+ All math is done in fp32 regardless of input dtype.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ from typing import Tuple
23
+
24
+ import torch
25
+ import torch.nn.functional as F
26
+
27
+ N_COMP = 4
28
+ CH_PER_COMP = 10
29
+ N_CH = N_COMP * CH_PER_COMP # 40
30
+
31
+ WEIGHT_IDX = [10 * j for j in range(N_COMP)]
32
+ MEAN_IDX = [10 * j + 1 + c for j in range(N_COMP) for c in range(3)]
33
+ LOGSCALE_IDX = [10 * j + 4 + c for j in range(N_COMP) for c in range(3)]
34
+ COEFF_IDX = [10 * j + 7 + c for j in range(N_COMP) for c in range(3)]
35
+
36
+ LOGSCALE_MIN = -7.0
37
+ LOGSCALE_MAX = 2.0
38
+ _HALF_BIN = 1.0 / 255.0 # half bin width in [-1, 1] scale (256 levels)
39
+
40
+
41
+ def unpack_dl_params(
42
+ params: torch.Tensor,
43
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
44
+ """Split raw params [..., 40, H, W] into mixture pieces.
45
+
46
+ Returns (logit_w [..., 4, H, W], means [..., 4, 3, H, W],
47
+ log_scales [..., 4, 3, H, W] clamped, coeffs [..., 4, 3, H, W] tanh-ed).
48
+ """
49
+ if params.shape[-3] != N_CH:
50
+ raise ValueError("expected %d param channels, got %d" % (N_CH, params.shape[-3]))
51
+ p = params.float()
52
+ lead = p.shape[:-3]
53
+ hw = p.shape[-2:]
54
+ p = p.reshape(lead + (N_COMP, CH_PER_COMP) + hw)
55
+ logit_w = p[..., 0, :, :]
56
+ means = p[..., 1:4, :, :]
57
+ log_scales = p[..., 4:7, :, :].clamp(LOGSCALE_MIN, LOGSCALE_MAX)
58
+ coeffs = torch.tanh(p[..., 7:10, :, :])
59
+ return logit_w, means, log_scales, coeffs
60
+
61
+
62
+ def couple_means(
63
+ means: torch.Tensor, coeffs: torch.Tensor, x: torch.Tensor
64
+ ) -> torch.Tensor:
65
+ """Coupled per-component means given observed pixels.
66
+
67
+ means/coeffs: [..., 4, 3, H, W]; x: broadcastable to [..., 3, H, W],
68
+ values in [-1, 1]. Returns coupled means [..., 4, 3, H, W].
69
+ """
70
+ xb = x.float().unsqueeze(-4) # [..., 1, 3, H, W]
71
+ x_r = xb[..., 0, :, :]
72
+ x_g = xb[..., 1, :, :]
73
+ m_r = means[..., 0, :, :]
74
+ m_g = means[..., 1, :, :] + coeffs[..., 0, :, :] * x_r
75
+ m_b = means[..., 2, :, :] + coeffs[..., 1, :, :] * x_r + coeffs[..., 2, :, :] * x_g
76
+ m_r, m_g, m_b = torch.broadcast_tensors(m_r, m_g, m_b)
77
+ return torch.stack([m_r, m_g, m_b], dim=-3)
78
+
79
+
80
+ def dl_channel_logprobs(
81
+ means: torch.Tensor,
82
+ log_scales: torch.Tensor,
83
+ coeffs: torch.Tensor,
84
+ x: torch.Tensor,
85
+ ) -> torch.Tensor:
86
+ """Per-component, per-channel discretized-logistic log P(bin of x).
87
+
88
+ means/log_scales/coeffs: [..., 4, 3, H, W] (log_scales already clamped,
89
+ coeffs already tanh-ed — i.e. outputs of unpack_dl_params). x broadcastable
90
+ to [..., 3, H, W] in [-1, 1] on the 256-level grid. Returns [..., 4, 3, H, W].
91
+ Edge bins (0 and 255) integrate the full tails, so per-channel bin
92
+ probabilities sum to 1.
93
+ """
94
+ xb = x.float().unsqueeze(-4)
95
+ m = couple_means(means, coeffs, x)
96
+ centered = xb - m
97
+ inv_std = torch.exp(-log_scales)
98
+ plus_in = inv_std * (centered + _HALF_BIN)
99
+ min_in = inv_std * (centered - _HALF_BIN)
100
+
101
+ log_cdf_plus = plus_in - F.softplus(plus_in) # log sigmoid(plus_in)
102
+ log_one_minus_cdf_min = -F.softplus(min_in) # log(1 - sigmoid(min_in))
103
+ cdf_delta = torch.sigmoid(plus_in) - torch.sigmoid(min_in)
104
+ mid_in = inv_std * centered
105
+ log_pdf_mid = mid_in - log_scales - 2.0 * F.softplus(mid_in)
106
+
107
+ log_prob_mid = torch.where(
108
+ cdf_delta > 1e-5,
109
+ torch.log(cdf_delta.clamp(min=1e-12)),
110
+ log_pdf_mid - math.log(127.5),
111
+ )
112
+ logp = torch.where(
113
+ xb < -0.999,
114
+ log_cdf_plus,
115
+ torch.where(xb > 0.999, log_one_minus_cdf_min, log_prob_mid),
116
+ )
117
+ return logp
118
+
119
+
120
+ def dl_logprob(params: torch.Tensor, x: torch.Tensor) -> torch.Tensor:
121
+ """Full mixture log-prob per pixel.
122
+
123
+ params [..., 40, H, W] raw; x broadcastable to [..., 3, H, W] in [-1, 1].
124
+ Returns fp32 [..., H, W]: log p(x_pixel) = logsumexp_j (log w_j +
125
+ sum_channels log P_j(channel)).
126
+ """
127
+ logit_w, means, log_scales, coeffs = unpack_dl_params(params)
128
+ ch = dl_channel_logprobs(means, log_scales, coeffs, x) # [..., 4, 3, H, W]
129
+ per_comp = ch.sum(dim=-3) # [..., 4, H, W]
130
+ log_w = F.log_softmax(logit_w, dim=-3)
131
+ return torch.logsumexp(log_w + per_comp, dim=-3)
132
+
133
+
134
+ def dl_mean_pixels(params: torch.Tensor) -> torch.Tensor:
135
+ """Deterministic 'DL-mean' rendering: per pixel pick the argmax-weight
136
+ component, then roll out the coupled means sequentially
137
+ (R = mu_R, G = mu_G + a R, B = mu_B + b R + c G), clamped to [-1, 1].
138
+
139
+ params [..., 40, H, W] -> pixels [..., 3, H, W] fp32 in [-1, 1].
140
+ """
141
+ logit_w, means, log_scales, coeffs = unpack_dl_params(params)
142
+ idx = logit_w.argmax(dim=-3) # [..., H, W]
143
+ gather_idx = idx.unsqueeze(-3).unsqueeze(-4).expand(
144
+ means.shape[:-4] + (1, 3) + means.shape[-2:]
145
+ )
146
+ m = means.gather(-4, gather_idx).squeeze(-4) # [..., 3, H, W]
147
+ cf = coeffs.gather(-4, gather_idx).squeeze(-4)
148
+ r = m[..., 0, :, :].clamp(-1.0, 1.0)
149
+ g = (m[..., 1, :, :] + cf[..., 0, :, :] * r).clamp(-1.0, 1.0)
150
+ b = (m[..., 2, :, :] + cf[..., 1, :, :] * r + cf[..., 2, :, :] * g).clamp(-1.0, 1.0)
151
+ return torch.stack([r, g, b], dim=-3)
152
+
153
+
154
+ def u8_to_unit(x_u8: torch.Tensor) -> torch.Tensor:
155
+ """uint8 [0,255] -> fp32 [-1, 1] on the 256-level grid."""
156
+ return x_u8.float() / 127.5 - 1.0
157
+
158
+
159
+ def unit_to_u8(x: torch.Tensor) -> torch.Tensor:
160
+ """fp32 [-1, 1] -> uint8, rounding to the nearest of the 256 levels."""
161
+ return ((x.clamp(-1.0, 1.0) + 1.0) * 127.5).round().clamp(0, 255).to(torch.uint8)
sprig/model/gmt.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grammar Modulation Transformer (DESIGN.md section 4, gmt.py).
2
+
3
+ Symbol embeddings are queries; caption tokens are keys/values. There is no
4
+ symbol-symbol self-attention. Outputs the caption-conditioned quantities the
5
+ grammar needs: H, U (p(k|A,c) logits), cut-type logits, illumination field Phi,
6
+ plus the static tables V, W, P_T and the factorized termination head.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import NamedTuple
11
+
12
+ import torch
13
+ import torch.nn as nn
14
+ import torch.nn.functional as F
15
+
16
+ N_CUT_TYPES = 14
17
+
18
+
19
+ class CrossAttnBlock(nn.Module):
20
+ """Pre-LN {cross-attention(queries -> memory), FFN width*4} block."""
21
+
22
+ def __init__(self, width: int, n_heads: int) -> None:
23
+ super().__init__()
24
+ self.ln_q = nn.LayerNorm(width)
25
+ self.ln_kv = nn.LayerNorm(width)
26
+ self.attn = nn.MultiheadAttention(width, n_heads, batch_first=True)
27
+ self.ln_f = nn.LayerNorm(width)
28
+ self.ffn = nn.Sequential(
29
+ nn.Linear(width, 4 * width), nn.GELU(), nn.Linear(4 * width, width)
30
+ )
31
+
32
+ def forward(
33
+ self,
34
+ q: torch.Tensor,
35
+ kv: torch.Tensor,
36
+ key_padding_mask: torch.Tensor,
37
+ ) -> torch.Tensor:
38
+ kvn = self.ln_kv(kv)
39
+ a, _ = self.attn(
40
+ self.ln_q(q), kvn, kvn,
41
+ key_padding_mask=key_padding_mask, need_weights=False,
42
+ )
43
+ h = q + a
44
+ return h + self.ffn(self.ln_f(h))
45
+
46
+
47
+ def caption_padding_mask(emb_len: torch.Tensor, L: int, device: torch.device) -> torch.Tensor:
48
+ """bool [B, L], True = padding (ignored by attention). Position 0 is always
49
+ kept valid as a guard against fully-masked rows (NaN attention)."""
50
+ idx = torch.arange(L, device=device)
51
+ mask = idx.unsqueeze(0) >= emb_len.to(device).long().unsqueeze(1)
52
+ mask[:, 0] = False
53
+ return mask
54
+
55
+
56
+ class GMTOut(NamedTuple):
57
+ H: torch.Tensor # [B, S, d]
58
+ U: torch.Tensor # [B, S, R] p(k|A,c) logits
59
+ cut_logits: torch.Tensor # [B, R, 14] cut-type logits per component
60
+ Phi: torch.Tensor # [B, 8, 16, 16] illumination field
61
+
62
+
63
+ class GrammarModulationTransformer(nn.Module):
64
+ """cfg is duck-typed: needs S, R, T_v, d, n_heads, d_t, caption_dim, n_geom."""
65
+
66
+ def __init__(self, cfg) -> None:
67
+ super().__init__()
68
+ self.cfg = cfg
69
+ S, R, T_v, d = cfg.S, cfg.R, cfg.T_v, cfg.d
70
+ if d % cfg.n_heads != 0:
71
+ raise ValueError("d must be divisible by n_heads")
72
+
73
+ self.E_N = nn.Parameter(torch.randn(S, d) * 0.02)
74
+ self.cap_proj = nn.Linear(cfg.caption_dim, d)
75
+ self.blocks = nn.ModuleList(
76
+ [CrossAttnBlock(d, cfg.n_heads) for _ in range(4)]
77
+ )
78
+ self.ln_out = nn.LayerNorm(d)
79
+
80
+ # Heads.
81
+ self.W_u = nn.Linear(d, R)
82
+ self.mlp_h = nn.Sequential(nn.Linear(d, cfg.d_t), nn.GELU(), nn.Linear(cfg.d_t, cfg.d_t))
83
+ self.mlp_g = nn.Sequential(nn.Linear(cfg.n_geom, cfg.d_t), nn.GELU(), nn.Linear(cfg.d_t, cfg.d_t))
84
+ self.term_bias = nn.Parameter(torch.zeros(S))
85
+
86
+ # Cut-type head: component embeddings cross-attend once to the caption.
87
+ self.e_k = nn.Parameter(torch.randn(R, d) * 0.02)
88
+ self.cut_block = CrossAttnBlock(d, cfg.n_heads)
89
+ self.cut_ln = nn.LayerNorm(d)
90
+ self.cut_out = nn.Linear(d, N_CUT_TYPES)
91
+
92
+ # Static grammar tables (caption-independent).
93
+ self.P_T = nn.Parameter(torch.randn(R, T_v) * 0.02)
94
+ self.V = nn.Parameter(torch.randn(R, S) * 0.02)
95
+ self.W = nn.Parameter(torch.randn(R, S) * 0.02)
96
+
97
+ # Illumination field: masked-mean-pooled caption -> MLP -> 2x deconvs.
98
+ self.phi_mlp = nn.Sequential(nn.Linear(d, 128), nn.GELU(), nn.Linear(128, 32 * 4 * 4))
99
+ self.phi_deconv1 = nn.ConvTranspose2d(32, 16, kernel_size=4, stride=2, padding=1)
100
+ self.phi_deconv2 = nn.ConvTranspose2d(16, 8, kernel_size=4, stride=2, padding=1)
101
+ # Zero-init the final deconv so Phi == 0 at init (FiLM starts at identity).
102
+ nn.init.zeros_(self.phi_deconv2.weight)
103
+ nn.init.zeros_(self.phi_deconv2.bias)
104
+
105
+ def forward(self, emb: torch.Tensor, emb_len: torch.Tensor) -> GMTOut:
106
+ """emb [B, L, 768] (any float dtype), emb_len [B] int -> GMTOut."""
107
+ B, L, _ = emb.shape
108
+ kv = self.cap_proj(emb.float()) # [B, L, d]
109
+ kpm = caption_padding_mask(emb_len, L, emb.device)
110
+
111
+ h = self.E_N.unsqueeze(0).expand(B, -1, -1)
112
+ for blk in self.blocks:
113
+ h = blk(h, kv, kpm)
114
+ H = self.ln_out(h) # [B, S, d]
115
+
116
+ U = self.W_u(H) # [B, S, R]
117
+
118
+ q_k = self.e_k.unsqueeze(0).expand(B, -1, -1)
119
+ hk = self.cut_block(q_k, kv, kpm)
120
+ cut_logits = self.cut_out(self.cut_ln(hk)) # [B, R, 14]
121
+
122
+ keep = (~kpm).float().unsqueeze(-1) # [B, L, 1]
123
+ pooled = (kv * keep).sum(1) / keep.sum(1).clamp(min=1.0)
124
+ z = self.phi_mlp(pooled).reshape(B, 32, 4, 4)
125
+ z = F.gelu(self.phi_deconv1(z))
126
+ Phi = self.phi_deconv2(z) # [B, 8, 16, 16]
127
+
128
+ return GMTOut(H=H, U=U, cut_logits=cut_logits, Phi=Phi)
129
+
130
+ def termination_logits(self, H: torch.Tensor, phi_geom: torch.Tensor) -> torch.Tensor:
131
+ """Factorized termination head:
132
+ term_logit[b, r, A] = MLP_h(H)[b, A, :] . MLP_g(phi_geom)[r, :] + bias_A.
133
+
134
+ H [B, S, d], phi_geom [N_reg, n_geom] -> logits [B, N_reg, S] fp32.
135
+ """
136
+ hs = self.mlp_h(H) # [B, S, d_t]
137
+ gs = self.mlp_g(phi_geom.float()) # [N_reg, d_t]
138
+ term = torch.einsum("bsd,nd->bns", hs.float(), gs)
139
+ return term + self.term_bias.view(1, 1, -1)
sprig/model/sprig.py ADDED
@@ -0,0 +1,593 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SPRIGModel — wires GMT + TexelAtlas + inside DP (contracts C2/C3/C4).
2
+
3
+ DP access: all calls to sprig/dp/inside.py go through the small adapters at the
4
+ bottom of this file (`_dp`, `_call_dp`), keyed by the DESIGN.md section 5
5
+ argument names (ell_leaf, term_logits, cut_logits, U_logmix, logV, logW,
6
+ lattice, temper_kappa; plus log_PT for the texel prior). Tests may override the
7
+ DP module via `_DP_MODULE`.
8
+
9
+ Conventions:
10
+ - Axiom (root) nonterminal is symbol 0.
11
+ - Rule-logit temperature `tau` (buffer) divides all rule/termination logits
12
+ (U, cut-type, V, W, P_T, termination) per DESIGN.md section 2.
13
+ - Emission tempering `eta` (buffer): kappa(r) = max(1, area_px(r)^eta);
14
+ `log_marginal(..., report_mode=True)` forces eta = 0 (C2).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import inspect
19
+ import math
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Dict, List, Optional, Tuple
22
+
23
+ import torch
24
+ import torch.nn as nn
25
+ import torch.nn.functional as F
26
+
27
+ from sprig.dp.lattice import Lattice, get_lattice
28
+ from sprig.model import dl
29
+ from sprig.model.atlas import ATLAS_RES, TexelAtlas, film_scale_shift, phi_at_leaf_centers
30
+ from sprig.model.gmt import GrammarModulationTransformer
31
+
32
+ _MATERIAL_SEED_OFFSET = 0x9E3779B9
33
+
34
+
35
+ @dataclass
36
+ class SPRIGConfig:
37
+ S: int = 1024
38
+ R: int = 64
39
+ T_v: int = 256
40
+ d: int = 384
41
+ canvas: int = 64
42
+ grid: int = 8
43
+ leaf_max: int = 16
44
+ n_heads: int = 6
45
+ d_t: int = 64
46
+ caption_dim: int = 768
47
+ n_geom: int = 64
48
+ atlas_heads: int = 4
49
+ leaf_chunk: int = 16
50
+ axiom: int = 0
51
+ texel_hinge_weight: float = 1.0
52
+ symbol_hinge_weight: float = 0.5
53
+ # Object-pixel importance weight on the emission log-likelihood during
54
+ # training (diagnosis fix 1: objects are <10% of pixels, so unweighted NLL
55
+ # lets background fidelity dominate and no object texels ever form).
56
+ # Requires the dataloader to provide batch["objmask"] u8 [B,C,C].
57
+ # 1.0 = exact NLL (eval/log_marginal always use exact NLL regardless).
58
+ emission_obj_weight: float = 1.0
59
+ # Second-order grad through the DP so the under-use hinges actually train
60
+ # (usage comes from autograd.grad of logZ; without create_graph the hinge
61
+ # would be constant w.r.t. parameters).
62
+ hinge_create_graph: bool = True
63
+ resurrect_threshold_frac: float = 0.1 # of uniform usage 1/T_v
64
+
65
+ def __post_init__(self) -> None:
66
+ if self.d % self.n_heads != 0:
67
+ raise ValueError("d must be divisible by n_heads")
68
+
69
+
70
+ @dataclass
71
+ class ParseNode:
72
+ rect: Tuple[int, int, int, int]
73
+ axis: Optional[int] # None for leaves; 0 = vertical cut, 1 = horizontal
74
+ cut_px: Optional[int]
75
+ symbol: int
76
+ texel: Optional[int] # None for internal nodes
77
+ children: List["ParseNode"] = field(default_factory=list)
78
+
79
+
80
+ class SPRIGModel(nn.Module):
81
+ def __init__(self, cfg: SPRIGConfig) -> None:
82
+ super().__init__()
83
+ self.cfg = cfg
84
+ self.gmt = GrammarModulationTransformer(cfg)
85
+ self.atlas = TexelAtlas(cfg)
86
+ self.lattice: Lattice = get_lattice(cfg.canvas, cfg.grid, cfg.leaf_max)
87
+ self.register_buffer("tau", torch.tensor(1.0))
88
+ self.register_buffer("eta", torch.tensor(0.0))
89
+
90
+ # ------------------------------------------------------------------ utils
91
+
92
+ def _lat(self, device: torch.device) -> Lattice:
93
+ self.lattice.to(device)
94
+ return self.lattice
95
+
96
+ def _kappa(self, eta: float, device: torch.device) -> torch.Tensor:
97
+ """kappa(r) = max(1, area_px(r)^eta) over leaf slots -> fp32 [n_leaf]."""
98
+ lat = self._lat(device)
99
+ area = lat.area_px[lat.leaf_ids].float()
100
+ if eta <= 0.0:
101
+ return torch.ones_like(area)
102
+ return torch.clamp(area ** eta, min=1.0)
103
+
104
+ def _conditionals(
105
+ self,
106
+ emb: torch.Tensor,
107
+ emb_len: torch.Tensor,
108
+ images: Optional[torch.Tensor] = None,
109
+ pix_weight: Optional[torch.Tensor] = None,
110
+ ) -> Dict[str, torch.Tensor]:
111
+ """All caption-conditioned quantities, temperature already applied."""
112
+ lat = self._lat(emb.device)
113
+ out = self.gmt(emb, emb_len)
114
+ tau = self.tau.clamp(min=1e-3)
115
+ cond: Dict[str, torch.Tensor] = {}
116
+ cond["H"] = out.H
117
+ cond["Phi"] = out.Phi
118
+ cond["U_logmix"] = F.log_softmax(out.U / tau, dim=-1) # [B,S,R]
119
+ cond["log_PT"] = F.log_softmax(self.gmt.P_T / tau, dim=-1) # [R,T_v]
120
+ cond["logV"] = F.log_softmax(self.gmt.V / tau, dim=-1) # [R,S]
121
+ cond["logW"] = F.log_softmax(self.gmt.W / tau, dim=-1) # [R,S]
122
+ cond["term_logits"] = self.gmt.termination_logits(out.H, lat.phi_geom) / tau
123
+ cond["cut_logits"] = out.cut_logits / tau # [B,R,14]
124
+ cond["atlas"] = self.atlas.render(emb, emb_len)
125
+ if images is not None:
126
+ cond["ell"] = self.atlas.score_leaves(
127
+ cond["atlas"], images, lat, out.Phi, pix_weight=pix_weight)
128
+ return cond
129
+
130
+ def _texel_prior_log(self, cond: Dict[str, torch.Tensor]) -> torch.Tensor:
131
+ """log p(T|A,c) = logsumexp_k(log p(k|A,c) + log p(T|k)) -> [B,S,T_v]."""
132
+ return torch.logsumexp(
133
+ cond["U_logmix"].unsqueeze(-1) + cond["log_PT"].unsqueeze(0).unsqueeze(0),
134
+ dim=2,
135
+ )
136
+
137
+ def _dp_kwargs(
138
+ self, cond: Dict[str, torch.Tensor], kappa: torch.Tensor
139
+ ) -> Dict[str, Any]:
140
+ return dict(
141
+ ell_leaf=cond["ell"],
142
+ term_logits=cond["term_logits"],
143
+ cut_logits=cond["cut_logits"],
144
+ U_logmix=cond["U_logmix"],
145
+ logV=cond["logV"],
146
+ logW=cond["logW"],
147
+ lattice=self.lattice,
148
+ temper_kappa=kappa,
149
+ log_PT=cond["log_PT"],
150
+ )
151
+
152
+ def _inside(self, cond: Dict[str, torch.Tensor], kappa: torch.Tensor) -> torch.Tensor:
153
+ mod = _dp()
154
+ fn = getattr(mod, "inside_logZ", None) or getattr(mod, "inside")
155
+ res = _call_dp(fn, self._dp_kwargs(cond, kappa))
156
+ if isinstance(res, (tuple, list)):
157
+ return res[-1] # (beta, logZ) per DESIGN section 5
158
+ return res
159
+
160
+ # -------------------------------------------------------------- contracts
161
+
162
+ def log_marginal(
163
+ self,
164
+ image: torch.Tensor,
165
+ emb: torch.Tensor,
166
+ emb_len: torch.Tensor,
167
+ report_mode: bool = False,
168
+ ) -> torch.Tensor:
169
+ """C2: exact log p(x|c) [B]; report_mode forces eta = 0."""
170
+ eta = 0.0 if report_mode else float(self.eta)
171
+ cond = self._conditionals(emb, emb_len, images=image)
172
+ kappa = self._kappa(eta, emb.device)
173
+ return self._inside(cond, kappa)
174
+
175
+ def loss(self, batch: Dict[str, torch.Tensor]) -> Tuple[torch.Tensor, Dict[str, float]]:
176
+ """DESIGN section 6: tempered NLL (nats/subpixel) + under-use hinges.
177
+
178
+ Usage vectors are posterior expected counts obtained from
179
+ torch.autograd.grad(logZ.sum(), [ell, U_logmix], retain_graph=True):
180
+ grad wrt ell (x kappa) = expected (leaf, texel) counts; grad wrt
181
+ U_logmix summed over k = expected node counts per symbol (the texel
182
+ prior is mixed from U_logmix, so termination counts are included).
183
+ """
184
+ images = batch["image"]
185
+ emb = batch["emb"]
186
+ emb_len = batch["emb_len"]
187
+ cfg = self.cfg
188
+ pix_weight = None
189
+ if cfg.emission_obj_weight != 1.0 and batch.get("objmask") is not None:
190
+ pix_weight = 1.0 + (cfg.emission_obj_weight - 1.0) * batch[
191
+ "objmask"].to(images.device, torch.float32)
192
+ cond = self._conditionals(emb, emb_len, images=images,
193
+ pix_weight=pix_weight)
194
+ kappa = self._kappa(float(self.eta), emb.device)
195
+ logZ = self._inside(cond, kappa)
196
+
197
+ n_subpix = 3 * cfg.canvas * cfg.canvas
198
+ nll = (-logZ / n_subpix).mean()
199
+
200
+ create = cfg.hinge_create_graph and torch.is_grad_enabled()
201
+ g_ell, g_u = torch.autograd.grad(
202
+ logZ.sum(),
203
+ [cond["ell"], cond["U_logmix"]],
204
+ retain_graph=True,
205
+ create_graph=create,
206
+ )
207
+ texel_counts = (g_ell * kappa.view(1, -1, 1)).sum(dim=(0, 1)) # [T_v]
208
+ symbol_counts = g_u.sum(dim=(0, 2)) # [S]
209
+ texel_usage = texel_counts / texel_counts.sum().clamp(min=1e-12)
210
+ symbol_usage = symbol_counts / symbol_counts.sum().clamp(min=1e-12)
211
+
212
+ texel_hinge = F.relu(1.0 / (4.0 * cfg.T_v) - texel_usage).sum()
213
+ symbol_hinge = F.relu(1.0 / (4.0 * cfg.S) - symbol_usage).sum()
214
+ total = (
215
+ nll
216
+ + cfg.texel_hinge_weight * texel_hinge
217
+ + cfg.symbol_hinge_weight * symbol_hinge
218
+ )
219
+
220
+ with torch.no_grad():
221
+ tu = texel_usage.detach()
222
+ su = symbol_usage.detach()
223
+ metrics = {
224
+ "loss": float(total),
225
+ "nll": float(nll),
226
+ "bpd": float(nll) / math.log(2.0),
227
+ "logZ_mean": float(logZ.mean()),
228
+ "texel_hinge": float(texel_hinge),
229
+ "symbol_hinge": float(symbol_hinge),
230
+ "texel_alive_frac": float((tu >= 1.0 / (4.0 * cfg.T_v)).float().mean()),
231
+ "symbol_eff": float(torch.exp(-(su.clamp(min=1e-12) * su.clamp(min=1e-12).log()).sum())),
232
+ "mean_leaves": float((g_ell.detach() * kappa.view(1, -1, 1)).sum() / images.shape[0]),
233
+ }
234
+ return total, metrics
235
+
236
+ def posterior_usage(
237
+ self, image: torch.Tensor, emb: torch.Tensor, emb_len: torch.Tensor
238
+ ) -> Dict[str, Any]:
239
+ """C3: expected-count diagnostics from posterior marginals.
240
+
241
+ node_entropy = occupancy-weighted entropy of the conditional
242
+ split posterior at each region (choices: terminate + each concrete
243
+ cut). mean_depth is the area-based proxy E[log2(canvas_area /
244
+ leaf_area)] over leaf marginals.
245
+ """
246
+ cfg = self.cfg
247
+ with torch.enable_grad():
248
+ cond = self._conditionals(emb, emb_len, images=image)
249
+ kappa = self._kappa(float(self.eta), emb.device)
250
+ marg = _call_dp(_dp().posterior_marginals, self._dp_kwargs(cond, kappa))
251
+
252
+ lat = self._lat(emb.device)
253
+ node = marg["node"].float() # [B, N_reg, S]
254
+ term = marg["term"].float() # [B, N_reg, S]
255
+ cut = marg["cut"].float() # [B, M_total] concatenated level rows
256
+ texel = marg["texel"].float() # [B, n_leaf, T_v]
257
+ rule = marg["rule"].float() # [B, S, R]
258
+
259
+ symbol_counts = node.sum(dim=(0, 1))
260
+ texel_counts = texel.sum(dim=(0, 1))
261
+ symbol_usage = symbol_counts / symbol_counts.sum().clamp(min=1e-12)
262
+ texel_usage = texel_counts / texel_counts.sum().clamp(min=1e-12)
263
+
264
+ # Occupancy-weighted entropy of conditional split posteriors.
265
+ B = node.shape[0]
266
+ occ = node.sum(dim=-1) # [B, N_reg]
267
+ term_tot = term.sum(dim=-1) # [B, N_reg]
268
+ parent_of_row = torch.cat([lv.parent_ids for lv in lat.levels]).to(emb.device)
269
+ eps = 1e-12
270
+ occ_safe = occ.clamp(min=eps)
271
+ q_term = (term_tot / occ_safe).clamp(min=0.0)
272
+ plogp_term = torch.where(
273
+ q_term > eps, q_term * q_term.clamp(min=eps).log(), torch.zeros_like(q_term)
274
+ )
275
+ q_cut = cut / occ_safe[:, parent_of_row]
276
+ plogp_cut_rows = torch.where(
277
+ q_cut > eps, q_cut * q_cut.clamp(min=eps).log(), torch.zeros_like(q_cut)
278
+ )
279
+ plogp_cut = torch.zeros_like(occ).index_add_(
280
+ 1, parent_of_row, plogp_cut_rows
281
+ )
282
+ ent_r = -(plogp_term + plogp_cut) # [B, N_reg]
283
+ w = occ.clamp(min=0.0)
284
+ node_entropy = float((w * ent_r).sum() / w.sum().clamp(min=eps))
285
+
286
+ # Magnitude monitors.
287
+ ell = cond["ell"].detach().float()
288
+ emit_mag = float(
289
+ ((texel * ell).sum() / texel.sum().clamp(min=eps)).abs()
290
+ )
291
+ rule_mag = float(
292
+ ((rule * cond["U_logmix"].detach()).sum() / rule.sum().clamp(min=eps)).abs()
293
+ )
294
+
295
+ leaf_area = lat.area_px[lat.leaf_ids].float()
296
+ depth_proxy = torch.log2(float(cfg.canvas * cfg.canvas) / leaf_area)
297
+ leaf_marg = texel.sum(dim=-1) # [B, n_leaf]
298
+ mean_depth = float(
299
+ (leaf_marg * depth_proxy.unsqueeze(0)).sum() / leaf_marg.sum().clamp(min=eps)
300
+ )
301
+ mean_leaves = float(leaf_marg.sum() / B)
302
+
303
+ return {
304
+ "symbol_usage": symbol_usage.detach(),
305
+ "texel_usage": texel_usage.detach(),
306
+ "node_entropy": node_entropy,
307
+ "emit_mag": emit_mag,
308
+ "rule_mag": rule_mag,
309
+ "mean_depth": mean_depth,
310
+ "mean_leaves": mean_leaves,
311
+ }
312
+
313
+ def map_parse(
314
+ self, image: torch.Tensor, emb: torch.Tensor, emb_len: torch.Tensor
315
+ ) -> List[ParseNode]:
316
+ """C3: Viterbi (max-semiring) parse per batch element."""
317
+ with torch.no_grad():
318
+ cond = self._conditionals(emb, emb_len, images=image)
319
+ kappa = self._kappa(float(self.eta), emb.device)
320
+ res = _call_dp(_dp().viterbi, self._dp_kwargs(cond, kappa))
321
+ _score, trees = res
322
+ return [self._to_parse_node(t) for t in trees]
323
+
324
+ def _to_parse_node(self, node: Tuple) -> ParseNode:
325
+ """Convert the DP viterbi tree tuple (region_id, symbol, texel, axis,
326
+ cut_px, children) into a ParseNode."""
327
+ rid, sym, texel, axis, cut_px, children = node
328
+ rect = tuple(int(v) for v in self.lattice.regions[rid].tolist())
329
+ return ParseNode(
330
+ rect=rect,
331
+ axis=None if axis is None else int(axis),
332
+ cut_px=None if cut_px is None else int(cut_px),
333
+ symbol=int(sym),
334
+ texel=None if texel is None else int(texel),
335
+ children=[self._to_parse_node(ch) for ch in children],
336
+ )
337
+
338
+ # --------------------------------------------------------------- sampling
339
+
340
+ @torch.no_grad()
341
+ def sample(
342
+ self,
343
+ emb: torch.Tensor,
344
+ emb_len: torch.Tensor,
345
+ seed_struct: int,
346
+ seed_material: int,
347
+ n: int,
348
+ ) -> Tuple[torch.Tensor, List[ParseNode]]:
349
+ """C4: ancestral sampling, breadth-parallel over the frontier.
350
+
351
+ Two RNG streams: structural draws (termination, k, cut, B, C) from
352
+ seed_struct; material draws (texel choice) from seed_material. Pixels
353
+ are rendered as DL means (no pixel noise). Returns
354
+ (images u8 [n, canvas, canvas, 3] on CPU, list of n ParseNode roots).
355
+ """
356
+ images, trees, _scores = self._sample_scored(emb, emb_len, seed_struct, seed_material, n)
357
+ return images, trees
358
+
359
+ @torch.no_grad()
360
+ def sample_bestof(
361
+ self, emb: torch.Tensor, emb_len: torch.Tensor, K: int, seed: int
362
+ ) -> Tuple[torch.Tensor, List[ParseNode]]:
363
+ """C4: sample K derivations, rerank by joint log p(tree, rendered
364
+ image | c), return the best as (images u8 [1, canvas, canvas, 3], [tree])."""
365
+ images, trees, scores = self._sample_scored(
366
+ emb, emb_len, int(seed), int(seed) + _MATERIAL_SEED_OFFSET, K
367
+ )
368
+ best = int(torch.tensor(scores).argmax())
369
+ return images[best : best + 1], [trees[best]]
370
+
371
+ @torch.no_grad()
372
+ def _sample_scored(
373
+ self,
374
+ emb: torch.Tensor,
375
+ emb_len: torch.Tensor,
376
+ seed_struct: int,
377
+ seed_material: int,
378
+ n: int,
379
+ ) -> Tuple[torch.Tensor, List[ParseNode], List[float]]:
380
+ cfg = self.cfg
381
+ device = next(self.parameters()).device
382
+ if emb.dim() == 2:
383
+ emb = emb.unsqueeze(0)
384
+ if not torch.is_tensor(emb_len):
385
+ emb_len = torch.tensor([emb_len])
386
+ emb_len = emb_len.reshape(-1)[:1]
387
+ emb = emb[:1].to(device)
388
+
389
+ cond = self._conditionals(emb, emb_len)
390
+ lat = self._lat(device)
391
+
392
+ # Single-caption conditionals on CPU for generator-driven draws.
393
+ u_logmix = cond["U_logmix"][0].float().cpu() # [S,R]
394
+ term_logits = cond["term_logits"][0].float().cpu() # [N,S]
395
+ cut_logits = cond["cut_logits"][0].float().cpu() # [R,14]
396
+ logv = cond["logV"].float().cpu() # [R,S]
397
+ logw = cond["logW"].float().cpu() # [R,S]
398
+ texel_prior = self._texel_prior_log(cond)[0].float().cpu() # [S,T_v]
399
+ atlas0 = cond["atlas"][0].float().cpu() # [T_v,40,16,16]
400
+ phi0 = cond["Phi"][:1].float().cpu() # [1,8,16,16]
401
+
402
+ pooled_cache: Dict[Tuple[int, int], torch.Tensor] = {}
403
+ for (h, w), _slots in lat.leaf_shape_groups().items():
404
+ pooled_cache[(h, w)] = F.adaptive_avg_pool2d(atlas0, (h, w))
405
+
406
+ gen_s = torch.Generator().manual_seed(int(seed_struct) & 0x7FFFFFFFFFFFFFFF)
407
+ gen_m = torch.Generator().manual_seed(int(seed_material) & 0x7FFFFFFFFFFFFFFF)
408
+
409
+ regions_cpu = lat.regions.cpu()
410
+ leaf_mask = lat.leaf_mask.cpu()
411
+ must_term = lat.must_terminate.cpu()
412
+ type_present = lat.type_present.cpu()
413
+
414
+ images_out: List[torch.Tensor] = []
415
+ trees: List[ParseNode] = []
416
+ scores: List[float] = []
417
+ for _ in range(n):
418
+ root = ParseNode(
419
+ rect=tuple(int(v) for v in regions_cpu[lat.root_id].tolist()),
420
+ axis=None, cut_px=None, symbol=cfg.axiom, texel=None,
421
+ )
422
+ frontier: List[Tuple[int, int, ParseNode]] = [(lat.root_id, cfg.axiom, root)]
423
+ leaves: List[Tuple[Tuple[int, int, int, int], int]] = []
424
+ logp = 0.0
425
+ while frontier:
426
+ nxt: List[Tuple[int, int, ParseNode]] = []
427
+ for rid, sym, pn in frontier:
428
+ can_term = bool(leaf_mask[rid])
429
+ forced_term = bool(must_term[rid])
430
+ if forced_term:
431
+ terminate = True
432
+ elif not can_term:
433
+ terminate = False
434
+ else:
435
+ p_term = torch.sigmoid(term_logits[rid, sym])
436
+ u = torch.rand((), generator=gen_s)
437
+ terminate = bool(u < p_term)
438
+ logp += float(torch.log(p_term if terminate else 1.0 - p_term))
439
+ if terminate:
440
+ probs = torch.exp(texel_prior[sym])
441
+ t = int(torch.multinomial(probs, 1, generator=gen_m))
442
+ logp += float(texel_prior[sym, t])
443
+ pn.texel = t
444
+ leaves.append((pn.rect, t))
445
+ else:
446
+ k = int(torch.multinomial(torch.exp(u_logmix[sym]), 1, generator=gen_s))
447
+ logp += float(u_logmix[sym, k])
448
+ # Concrete cut: masked cut-type softmax, mass split
449
+ # uniformly across same-type concrete cuts.
450
+ cuts = lat.cuts_of_region[rid]
451
+ tl = cut_logits[k].masked_fill(~type_present[rid], float("-inf"))
452
+ tls = F.log_softmax(tl, dim=-1)
453
+ row_logp = torch.tensor(
454
+ [float(tls[c[4]]) - c[5] for c in cuts]
455
+ )
456
+ ci = int(torch.multinomial(torch.exp(row_logp), 1, generator=gen_s))
457
+ logp += float(row_logp[ci])
458
+ axis, px, lo, hi, _t, _lc = cuts[ci]
459
+ b_sym = int(torch.multinomial(torch.exp(logv[k]), 1, generator=gen_s))
460
+ c_sym = int(torch.multinomial(torch.exp(logw[k]), 1, generator=gen_s))
461
+ logp += float(logv[k, b_sym]) + float(logw[k, c_sym])
462
+ pn.axis, pn.cut_px = int(axis), int(px)
463
+ ch_lo = ParseNode(
464
+ rect=tuple(int(v) for v in regions_cpu[lo].tolist()),
465
+ axis=None, cut_px=None, symbol=b_sym, texel=None,
466
+ )
467
+ ch_hi = ParseNode(
468
+ rect=tuple(int(v) for v in regions_cpu[hi].tolist()),
469
+ axis=None, cut_px=None, symbol=c_sym, texel=None,
470
+ )
471
+ pn.children = [ch_lo, ch_hi]
472
+ nxt.append((lo, b_sym, ch_lo))
473
+ nxt.append((hi, c_sym, ch_hi))
474
+ frontier = nxt
475
+
476
+ img, emit_ll = self._render_leaves(leaves, pooled_cache, phi0)
477
+ images_out.append(img)
478
+ trees.append(root)
479
+ scores.append(logp + emit_ll)
480
+
481
+ return torch.stack(images_out, dim=0), trees, scores
482
+
483
+ def _render_leaves(
484
+ self,
485
+ leaves: List[Tuple[Tuple[int, int, int, int], int]],
486
+ pooled_cache: Dict[Tuple[int, int], torch.Tensor],
487
+ phi0: torch.Tensor,
488
+ ) -> Tuple[torch.Tensor, float]:
489
+ """Paint DL-mean pixels for each leaf; also return the total emission
490
+ log-likelihood of the rendered (quantized) pixels — the emission part
491
+ of the joint score used by sample_bestof."""
492
+ cfg = self.cfg
493
+ canvas = torch.zeros(3, cfg.canvas, cfg.canvas)
494
+ emit_ll = 0.0
495
+ for (x0, y0, x1, y1), texel in leaves:
496
+ h, w = y1 - y0, x1 - x0
497
+ p = pooled_cache[(h, w)][texel].clone() # [40,h,w]
498
+ rect_t = torch.tensor([[x0, y0, x1, y1]])
499
+ phi = phi_at_leaf_centers(phi0, rect_t, cfg.canvas)[0, 0] # [8]
500
+ scale, shift = film_scale_shift(phi)
501
+ for c in range(3):
502
+ idx = [10 * j + 1 + c for j in range(dl.N_COMP)]
503
+ p[idx] = p[idx] * scale[c] + shift[c]
504
+ pix = dl.dl_mean_pixels(p.unsqueeze(0))[0] # [3,h,w]
505
+ pix_u8 = dl.unit_to_u8(pix)
506
+ canvas[:, y0:y1, x0:x1] = dl.u8_to_unit(pix_u8)
507
+ emit_ll += float(
508
+ dl.dl_logprob(p.unsqueeze(0), dl.u8_to_unit(pix_u8).unsqueeze(0)).sum()
509
+ )
510
+ img_u8 = dl.unit_to_u8(canvas).permute(1, 2, 0).contiguous() # [C,C,3] u8
511
+ return img_u8, emit_ll
512
+
513
+ # ----------------------------------------------------------- resurrection
514
+
515
+ @torch.no_grad()
516
+ def resurrect_texels(
517
+ self,
518
+ usage: torch.Tensor,
519
+ images: torch.Tensor,
520
+ generator: Optional[torch.Generator] = None,
521
+ threshold: Optional[float] = None,
522
+ noise_std: float = 0.01,
523
+ obj_mask: Optional[torch.Tensor] = None,
524
+ ) -> int:
525
+ """F3.3: overwrite the bias grid of under-used texels with a
526
+ training-image 16x16 crop converted to DL-mean params (small noise on
527
+ the other channels) and perturb the E_T row. Returns #resurrected.
528
+
529
+ usage: [T_v] normalized posterior usage; images: u8 [B, canvas, canvas, 3].
530
+ obj_mask (optional) [B, canvas, canvas] bool/u8: when given, crops are
531
+ centered on object pixels (diagnosis fix 3 — random crops are ~90%
532
+ background, so resurrection used to reseed dead texels with yet more
533
+ background material).
534
+ """
535
+ cfg = self.cfg
536
+ thr = threshold if threshold is not None else cfg.resurrect_threshold_frac / cfg.T_v
537
+ dead = torch.nonzero(usage.cpu() < thr, as_tuple=False).reshape(-1)
538
+ if dead.numel() == 0:
539
+ return 0
540
+ gen = generator
541
+ B = images.shape[0]
542
+ hi_y = cfg.canvas - ATLAS_RES
543
+ dev = self.atlas.bias_grid.device
544
+ obj_pix: List[Tuple[int, torch.Tensor]] = []
545
+ if obj_mask is not None:
546
+ m = obj_mask.detach().cpu()
547
+ for b in range(B):
548
+ nz = torch.nonzero(m[b] > 0, as_tuple=False) # [k, 2] (y, x)
549
+ if nz.numel():
550
+ obj_pix.append((b, nz))
551
+ for t in dead.tolist():
552
+ if obj_pix:
553
+ b, nz = obj_pix[int(torch.randint(0, len(obj_pix), (1,), generator=gen))]
554
+ cy, cx = nz[int(torch.randint(0, nz.shape[0], (1,), generator=gen))].tolist()
555
+ y0 = min(max(cy - ATLAS_RES // 2, 0), hi_y)
556
+ x0 = min(max(cx - ATLAS_RES // 2, 0), hi_y)
557
+ else:
558
+ b = int(torch.randint(0, B, (1,), generator=gen))
559
+ y0 = int(torch.randint(0, hi_y + 1, (1,), generator=gen))
560
+ x0 = int(torch.randint(0, hi_y + 1, (1,), generator=gen))
561
+ crop = images[b, y0 : y0 + ATLAS_RES, x0 : x0 + ATLAS_RES].cpu()
562
+ crop_unit = dl.u8_to_unit(crop).permute(2, 0, 1) # [3,16,16]
563
+ bias = noise_std * torch.randn(dl.N_CH, ATLAS_RES, ATLAS_RES, generator=gen)
564
+ for j in range(dl.N_COMP):
565
+ bias[10 * j + 1 : 10 * j + 4] = crop_unit
566
+ self.atlas.bias_grid.data[t] = bias.to(dev)
567
+ self.atlas.E_T.data[t] += noise_std * torch.randn(
568
+ cfg.d, generator=gen
569
+ ).to(self.atlas.E_T.device)
570
+ return int(dead.numel())
571
+
572
+
573
+ # ------------------------------------------------------------------ DP access
574
+
575
+ _DP_MODULE: Optional[Any] = None # test hook: module-like override
576
+
577
+
578
+ def _dp() -> Any:
579
+ if _DP_MODULE is not None:
580
+ return _DP_MODULE
581
+ from sprig.dp import inside
582
+ return inside
583
+
584
+
585
+ def _call_dp(fn: Any, kwargs: Dict[str, Any]) -> Any:
586
+ """Call a DP function by DESIGN section 5 keyword names, dropping any
587
+ kwargs the target does not accept (e.g. log_PT if the DP mixes the texel
588
+ prior itself)."""
589
+ sig = inspect.signature(fn)
590
+ if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
591
+ return fn(**kwargs)
592
+ accepted = {k: v for k, v in kwargs.items() if k in sig.parameters}
593
+ return fn(**accepted)
texel_atlas.png ADDED

Git LFS Details

  • SHA256: 36d823a6975b5886f2b574b089b9252ced90f80c589b672eac91bfd107d81a52
  • Pointer size: 131 Bytes
  • Size of remote file: 185 kB