SPRIG v0.1 — Design & Contracts (single source of truth)
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).
v0.1 = finite-lattice Stage A at 64x64. No continuous cuts, no morphogen, no occlusion masks, no DINO channel.
1. Repo layout & file ownership
sprig/data/procgen/{vocab,sampler,captions,render,writer}.py # owner: PROCGEN agent
sprig/data/{dataset,embed_t5}.py, sprig/data/clevr/prep.py # owner: DATA agent
sprig/dp/{lattice,inside}.py # owner: DP agent
sprig/model/{gmt,atlas,dl,sprig}.py # owner: MODEL agent
sprig/eval/{baseline_pixmix,tree_metrics,color_checks,probe,prompts,report,monitors}.py # owner: EVAL agent
train.py, configs/*.yaml, infra/*.sh # owner: HARNESS agent
tests/test_<module>_*.py # each agent writes tests for its own modules
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).
2. Global config (configs/main64.yaml values)
- Canvas 64x64 RGB. Grid stride g=8 px → 8x8 cells.
- S=1024 nonterminal symbols, R=64 rule components, T_v=256 texels, d=384 model width.
- Caption: precomputed T5-base token embeddings [L,768], L≤64.
- Leaf-eligible regions: both sides ≤ 16 px. Regions with any side > 16 px MUST expand. 8x8 regions MUST terminate.
- 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).
- Rule-logit temperature τ_ann: 2.0 → 1.0 linearly over first 50k steps (divide rule/termination logits by τ_ann).
- 10% of steps: caption replaced by null embedding (dataloader-level).
- bf16 autocast for matmuls; ALL logsumexp/log-domain accumulation in fp32. Emission DL log-scales clamped [-7, 2].
3. Lattice (sprig/dp/lattice.py) — DP agent
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):
regions: int32 [N_reg, 4](x0,y0,x1,y1 in px),region_idlookup dict.- 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_listof (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. - 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. 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).- Leaf shape groups: leaf regions grouped by (h,w) ∈ {8,16}² for batched emission scoring.
4. Model (sprig/model/) — MODEL agent
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:
U = H @ W_u→ p(k|A,c) logits [B,S,R].- 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). - 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).
- Terminal texel prior: p(T|A,c) = Σ_k p(k|A,c) softmax(P_T[k]) with static P_T [R,T_v].
- Children: static V, W [R,S]: p(B|k)=softmax(V[k]), p(C|k)=softmax(W[k]). (v0.1: no q(B) coupling.)
- 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.
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).
- 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.
dl.py — discretized logistic mixture log-prob (PixelCNN++ style, vectorized, fp32-safe).
sprig.py — SPRIGModel (implements contracts):
log_marginal(image_u8, emb, emb_len) -> logZ [B]— full inside DP (tempered during training viaself.eta; aneta=0flag for reported numbers).loss(batch) -> (loss, metrics_dict)— see §6.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).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).sample(emb, emb_len, seed_struct, seed_material, n) -> (images_u8 [n,64,64,3], trees)— ancestral, breadth-parallel over the frontier; twotorch.Generators: 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).
5. Inside DP (sprig/dp/inside.py) — DP agent
inside(ell_leaf, term_logits, cut_logits, U_logmix, logV, logW, lattice, temper_kappa) -> beta [B,N_reg,S] fp32, logZ [B]
- β(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).
- expand_term via level-synchronous sweep, area ascending. Per level, with index tensors:
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). Group-by-parent logsumexp viatorch.segment_reduceor index_put with scatter-logsumexp helper (write one: max-shift per segment, scatter_add of exp, log). All fp32. 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.- 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.
- Viterbi: same sweep, max instead of logsumexp, argmax backtrace tables per level.
- 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.
6. Loss & regularizers
L = mean_b( −logZ_tempered / (3·64·64) ) # nats/subpixel
+ 1.0 · Σ_T max(0, 1/(4·T_v) − texel_usage_T) # texel under-use hinge (F3.2)
+ 0.5 · Σ_A max(0, 1/(4·S) − symbol_usage_A) # symbol under-use hinge (M1-style)
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)).
InfoNCE caption-contrast: DEFERRED; add only if caption-swap margin is flat by 50k steps.
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.
7. Training curriculum (procedural main run)
- 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].
- η per PI controller; τ_ann 2→1 over 50k; report-eta-0 bpd from 180k; final 20k η→0 anneal.
- CLEVR fine-tune (separate config): init from main ckpt, lr×0.3, 30–50k steps, 20% proc2d replay.
- 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.
8. Contracts (C1–C4) — binding for all agents
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.
C2 model.log_marginal(image, emb, emb_len) -> logZ [B] (η=0 honored when model.eval() + report_mode=True).
C3 model.map_parse(...), model.posterior_usage(...) as §4.
C4 model.sample(emb, emb_len, seed_struct, seed_material, n), model.sample_bestof(emb, emb_len, K, seed).
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.
9. Milestone gates (in order, each blocking)
- G0: full pytest suite green on Mac CPU (incl. DP brute-force test, holdout scan test, determinism hashes, tree-metric self-test).
- G1: overfit 1 image (script
scripts/overfit1.py): 2k steps tiny config → bpd < 1.0, MAP parse stable across steps. - G2: overfit 100 images with captions: Δ_c > 0 (caption info gain), texels alive > 50%.
- 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.
- G4: main run launch. Twice-daily monitor checks per plan alarms.