A running log of design decisions and their rationale. The why matters as much as the what — this file exists so a human or coding agent can reconstruct the reasoning behind the code without spelunking git history.
Reference: Chang et al. (2025), Amortized Probabilistic Conditioning for Optimization,
Simulation and Inference (AISTATS 2025). Paper markdown lives in paper/.
The Gaussian ACEP example's published checkpoint is now a 320k-step run (was 80k).
Same architecture, seed, and example defaults — only --steps (and therefore the cosine
T_max) change, so it is a like-for-like longer run with the decay sized to the full
budget, not a re-tuned model. Trained on a single A100 GPU (~2 h 20 m, seed 0).
- Why — a held-out check, not one fixed case. The fixed diagnostic improved (μ-mean abs-error 0.042 → 0.007, log σ-mean 0.040 → 0.002, predictive-mean 0.068 → 0.006), but that single case is deliberately loose. A paired held-out smoke check (38,400 fresh cases, identical batches through both models, seed disjoint from training) confirmed it: mean NLL improves by −0.025 total / −0.083 latent / −0.006 data nats/token — each ~40 SE, with 320k better on 100% of batches for total and latent. The gain is concentrated in the latent (μ/log σ) posterior: the 80k posterior means were biased low and the longer full-budget cosine run corrects that bias. (This was a throwaway comparison, not a new quality gate — nanoACE keeps no standing quality suite by design; see the Initial-design "Open questions.")
- Provenance. Unlike the original four core checkpoints (
{cfg, seed}only), this retrain carries fullconfig(steps=320000), so its step count is self-documenting. - Promotion = re-export + re-parity together (the stale-blob gotcha): the playground
fp16 blob was re-exported and the
gaussianparity/demo fixtures regenerated from the same checkpoint;npm testgreen (37/37); the other tasks' fixtures regenerated byte-identical (onlygaussian.*.jsonchanged). The README weights table and the retained-state entry below were bumped 80k → 320k. - Publish (deploy). The live demo serves the fp16 blob from
acerbilab/nanoACE-playground-weightsand the source checkpoint lives on HFlacerbi/nanoACE, so the change reaches users only once the new blob is pushed there andgaussian_toy.pt(320k) is re-uploaded to HF (with the model-card step count bumped).
This resolves the "How to distribute optional checkpoints" open question from the
Initial-design entry. The full-precision PyTorch checkpoints (the .pt files from
--save-checkpoint, {cfg, seed, state_dict} + optional config provenance) now live on
the Hugging Face Hub at
lacerbi/nanoACE, a public model repo. Six
canonical files: the four core examples (gaussian_toy.pt 320k, gp1d.pt 200k,
sbi_sir.pt 100k, bo1d.pt 200k) and the two extension fine-tunes
(gp1d_arbuffer.pt, the retained 200k concat-read; gp1d_aline.pt, the served 35k).
- Two kinds of weights, kept distinct. HF holds the source checkpoints —
full-precision, loadable straight back into the example scripts via
load_checkpoint(path, device). The fp16 browser blobs (manifest +weights.bin) stay in the separateacerbilab/nanoACE-playground-weightsrepo, copied in by the Pages workflow; those are derived from the source checkpoints. Don't conflate the two homes. - Why HF, and the constraints it satisfies. The Initial-design "Open questions" already
earmarked HF ("a larger model hosted on HF and lazy-downloaded"). It keeps the three
rules that entry set for any distribution scheme: (1) the core stays torch-only — the
fetch is an optional extra (
huggingface_hub), never required to read/run/train; (2) the checkpoint ships config + seed, so it is regenerable, not a mystery binary; (3) it is an example artifact, not the packaged "runtime product" non-goal. HF's transparent LFS also avoids the playground route's "still a Git LFS pointer" failure mode. Namespacelacerbiis personal-now (can transfer to anacerbilaborg later, losslessly with redirects). - Provenance caveat. The original four core checkpoints carried only
{cfg, seed}(theirconfigprovenance was absent — they predated that save behavior), so their step counts come from this DEVLOG, not the file. (Exception: the 2026-06-17 Gaussian 320k retrain carries fullconfig; see the top entry.) The two extension checkpoints carry fullconfig(so steps, base checkpoint, and run flags are self-documenting). The HF model card records each model's architecture (cfg) and seed, and step counts from here. - Fetch path is a one-liner; no committed helper. Documented in
README.mdand the model card:hf_hub_download("lacerbi/nanoACE", "gp1d.pt")→gp1d.load_checkpoint(...). Deliberately nofetch_checkpoint()wrapper and norequirements-hf.txt— a task→filename lookup and a one-package install are below the bar for committed machinery (the repo avoids experiment-management apparatus).huggingface_hubis a documented optionalpip install, kept out of corerequirements.txt. - Linking. Root
README.mdgains a "Trained weights" subsection with a direct-link table (…/resolve/main/<file>.pt) to all six; each extensionREADME.mdlinks its own checkpoint;playground/README.mdcross-references the source-checkpoint home so the two weight homes are mutually discoverable. - Aline = the deployed 35k (
gp1d_aline.pt), verified SHA256-identical togp1d_aline_35k.pt. Uploaded now for consistency with what the playground serves; newern2aline runs are in progress and will replace it on HF (re-upload) once one is promoted to deployed — same swap discipline as the playground (re-export + re-parity together). Scratch/experimental/superseded checkpoints (the arbufferk128/k32/concat20kvariants, the aline5k/n2/n2_frombase/smokevariants, the_review_pool_*data shards) were deliberately excluded.
- What changed.
requirements.txtis now the platform PyPI torch wheel (torch==2.11.0): CPU on Windows, MPS on macOS, CUDA-bundled on Linux. The maintainer's NVIDIA-on-Windows CUDA 12.8 build (torch==2.11.0+cu128, via the PyTorchcu128--extra-index-url) moved to a separaterequirements-cuda.txt. The README/AGENTS command examples were made cross-platform too — activate the venv, then callpython, instead of a Windows-only.venv\Scripts\python.exe. Commits359bc37,7c06701. - Why. The models are small enough to train and run on CPU, so forcing every clone onto a
Windows-specific CUDA wheel was wrong for a "read and run anywhere" example repo. The
cross-platform default makes a fresh clone work on macOS / Linux / Windows-CPU with no
package-index gymnastics; the CUDA wheel is opt-in for the workstation that needs it. The
pinned versions are unchanged (
2.11.0); only the default-vs-optional-CUDA split is new. - Supersedes the present-tense "the project currently pins
torch==2.11.0+cu128" environment notes in the Gaussian-toy implementation entry and the Initial-design Layout section (both corrected inline to point here).
- The playground gained a sixth tab running
extensions/aline/in-browser: a hidden GP function (an exact float64 TS port of the gp1d DGP, fixture-pinned againstgp1d.draw_gp) answers queries; the user only chooses where to sample, with the learned acquisition policy rendered as advice — π(x | data, goal) along the x-axis next to the classical uncertainty-sampling pick — a live goal selector (click switches, shift-click combines parameter goals; the untrained predictive+parameter mix is not selectable; mid-episode switches), "Follow policy" auto-unrolling, and RMSE / log q(θ_true) tracked against the hidden truth. A secondary "your own data" mode keeps the advice live on free-edited points. The TS port adds a policy decoder on top of the untouched base port (final-state reads from the inherited forward; omission ≡ masking equivalences), parity-pinned per policy block plus a teacher-forced episode chain. Local-only: the deploy workflow is untouched; the 5k validation checkpoint serves the tab until the longer fine-tune lands (then re-export + regenerate fixtures together, push the blob, addgp1d_alineto pages.yml's two lists). Plan + verification: docs/plans/aline/PLAN-aline-playground.md; TS deviations + eyeball notes inextensions/aline/DEVLOG.md.
- Second
extensions/entry:extensions/aline/— ALINE (Huang et al., 2025, NeurIPS), joint amortized Bayesian inference and active data acquisition, on the GP-1D active-learning task, warm-started from the retained GP-1D checkpoint. The ACE-native re-expression is the point: the inference network is the unchanged coreACE(parameter/predictive targets are QUERY tokens; the paper's target specifier ξ collapses into which target tokens are active, and query candidates are data QUERY tokens), so the only new model surface is a small read-only policy decoder trained with REINFORCE on self-estimated information gain, behind a structural φ/ψ gradient firewall and a permanent bitwise parity guard on the inference path. First validation run (5k): the policy learns (beats random on both goal families, calibration kept); the US gap and the targeting contrasts remain open at that budget. Design decisions, paper deviations, and run results inextensions/aline/DEVLOG.md; plan + verification log in docs/plans/aline/PLAN-aline.md.
- Every playground tab gained a "?" explainer. A small inline button at the end of
each tab's hint line opens a modal with three short didactic sections — the task,
what ACE is doing, and how it compares with the classical approach — plus paper
attribution (Chang et al. 2025; Hassan et al. 2026 on the AR tab). Deliberately
matter-of-fact: each modal names the exact reference where one exists (the green
oracle curves) and states the amortization trade-off plainly. Shared chrome in
playground/src/explain.ts(reusing the global ACE-modal styles); content lives in each tab'sdemo.ts; smoke tests assert open/close. - The AR-buffer tab now serves the retained 200k concat-read weights
(
artifacts/gp1d_arbuffer.pt, K=64, joint training, ~97% of the slow-AR joint-density gap — seeextensions/arbuffer/DEVLOG.md):ARBUF_CKPTrepointed, blob + fixtures regenerated together, all tests green. - The tab is deployed (same day, after the repos moved to the
acerbilaborg): thegp1d_arbufferblob was published toacerbilab/nanoACE-playground-weightsandpages.ymlnow copies/validates five models. The tests' self-skip behavior stays (clones without local exports skip the arbuf suites), and in CI the blob is present so they run. - Slow-AR toggle in the AR-buffer tab. A sampler radio reruns the chain via full
context re-encoding (
sample_ar's recipe through the plain forward) with a single draw; the timing line reports cost per draw so the two modes compare directly (~20× slower per draw at 4 context points and K=64, growing with context size and chain length). Deliberately didactic: same model, same factorization, no cache. - The global "What is ACE?" modal now teaches amortization. One-line ACE definition first, then four titled sections (per-tab explainer style); the new "Amortized inference" section defines the term the tab modals use in passing, trade-off included. Attribution moved up under the intro.
- Slim sticky page footer. One muted line (built on nanoACE, ACE project page,
work by the MHI group and collaborators),
position: sticky; bottom: 0so it stays visible; fuller references live in the modals. Hassan et al. (2026) deliberately stays in the AR tab's "?" modal (contextual, not bibliography). Inside the "What is ACE?" modal the attribution line moved from the bottom to right under the intro paragraph so it is visible without scrolling; it stays in the modal too, so the dialog stands alone.
- The playground gained a fifth tab running the
extensions/arbuffer/buffered GP-1D model in-browser: context encoded once and cached, a few coherent joint function draws decoded autoregressively against the cache (animated), always shown next to the diagonal band and independent per-point marginal samples. TS port of the incremental sampler inplayground/src/ace/buffered.ts(base port untouched), parity-guarded by new buffered fixtures (packed per-layer + teacher-forced chain). Same day, the port was switched to the extension's retained concat-read architecture (one softmax over[context, buffer]keys with the learned per-headbuf_biassoft gate; separate-read blobs rejected at load), with fixtures regenerated fromgp1d_arbuffer_concat20k.pt. Local-only: the temporary checkpoint is exported locally, not deployed — tab and tests self-skip when the blob is absent, so the four-model deploy flow is unchanged. Plan + verification: docs/plans/arbuffer/PLAN-arbuffer-playground.md; design notes inextensions/arbuffer/DEVLOG.md.
- New root folder
extensions/for non-core model extensions. Each child is self-contained with its own localREADME.mdandDEVLOG.md; the root docs (this file,README.md,AGENTS.md) carry only short pointers. Extensions are torch-only, change no core file, and import the core from the repo root (the playground'ssys.pathbootstrap pattern).playground/stays where it is — it is a port/demo, not a model extension. - First entry:
extensions/arbuffer/— the causal autoregressive buffer of Hassan et al. (2026, ICLR), attached to the retained GP-1D checkpoint as a warm-started, frozen-base fine-tune: encode the context once, cache it, and draw coherent joint function samples through a causal buffer stream instead ofsample_ar's re-encode-everything loop. Doubles as the extensibility demo: schema, embedder, heads,train.fit, checkpoints, GP physics, and the oracle diagnostics are all reused; only the buffer stream and the cached sampler are new. Design decisions, deviations from the paper (zero-init gated buffer read for bit-exact warm starts, all-buffered curriculum under a frozen base, no positional/role embeddings), and verification live inextensions/arbuffer/DEVLOG.md; the plan + full verification log indocs/plans/arbuffer/PLAN-arbuffer.md.
Full plan + verification log: docs/plans/PLAN-offline-data-and-reseed.md.
Adds data.py as a sharded-pool reader that honors the layout invariants, and makes
training reproducibility uniform. This updates the "Layout" / "Data layer" framing
(the saved-pool path now exists) and the "data RNG is not checkpointed" caveat from the
train-loop extraction entry.
-
Per-step reseed;
(step) -> Batch.fitnow callstorch.manual_seed(mix_seed(seed, step))at the top of every step, so each batch is a pure function of(seed, step): from-scratch runs reproduce, resumed runs replay the exact stream of an uninterrupted run, and the stream no longer depends on how much RNG model construction consumed.mix_seedis a splitmix64 hash (decorrelates consecutive step seeds); onemanual_seedcovers CPU + CUDA, sidestepping the dual-RNG-state fragility that made RNG-state checkpointing unattractive. The sampler thunk changed() -> Batch→(step) -> Batch(online thunks ignorestep; the pool reader uses it).- Reverses the earlier "keep online bit-identical" intent, deliberately. Step-keyed reseeding changes the online stream vs the old continuous draw, so artifacts trained under the old stream are no longer seed-reproducible under the new code — fine (regenerable; the retrain/re-export is deferred). Verified on CPU: two same-seed runs give identical weights (max|dW|=0), and 0→20 vs 0→10-then-resume→20 give identical weights (max|dW|=0).
- Why stateless, not RNG-state checkpointing. Reproducible resume comes from the
stream being a pure function of an index (the modern PRNG-key approach) — robust across
CPU/CUDA and device counts, where snapshotting
get_rng_state()is fragile and only approximate on nondeterministic CUDA kernels anyway.
-
draw/assemblesplit (GP-1D, BO). Each sampler splits intodraw_instances(the expensive CPU-float64 physics — GP Cholesky / Matheron planting — the only part worth caching) andassemble(RNG-free tokenization, shared verbatim by the online and pooled paths). Done in two steps: first preserving RNG draw order so it was bit-identical to the pre-split online stream (proving the refactor introduced no bug; verified max|dW|=0), then the behavioral change below. Gaussian and SIR stay monolithic / online-only — their draws are cheap, so a pool buys nothing and the split would be churn. -
Complement-targets. GP-1D and BO moved from a fixed
data_targetscount to the standard "targets = all non-context points" (n_target = N_TOTAL - n_context,N_TOTAL = 64data points,min_context=1/max_context=20): no drawn point is wasted, and it is the natural pool layout. Tensorized as a width-max_contextcontext block and a width-N_TOTALtarget block (masked>= n_context), so context self-attention stays O(max_context²) and only the target cross-attention grows.--data-targetsis now a no-op for GP/BO (still used by Gaussian/SIR). This is a training-distribution change (a retrain is expected eventually). Verifiedn_target ∈ [44, 63]and ≥1 context always (GP min 1; BO min 3, since its two optimum PRIOR tokens are always in context). -
data.py=write_pool+PoolReader, one provenance check. A pool stores onlydraw_instances's struct-of-arrays (continuous float32; GP's categoricalkernelint64) plus a manifest. The reader returnsBatches through the example's ownassemble, recomputing the split (n_context) and reveal (reveal_mask_from_index) from a statelessmix_int64-keyed hash of the absolute logical positionp = (step-1)*B + j— which is batch-size- and steps-independent (positionpis the same dataset under anyB), so the split stream is reproducible and resume-exact (verified max|dW|=0; B-independence verified forn_context, reveal, and physical row). The "both" shuffle (shard order + within-shard) is keyed on(seed, pass). Build is resumable (per-shardmix_seed, skip valid shards, atomic write, manifest last).- Kept: a single DGP config-hash. The manifest carries the
variables()schema (a hard gate — a wrong schema silently misreads the arrays; not overridable) and asha256of the DGPgen_config(the non-schema constants; refused on mismatch, overridable with--pool-force). Rejected: the multi-axis resume-guard matrix — that is experiment-management machinery for a comparative study, which nanoACE is not. - Frozen-in-pool vs free-at-assemble. Frozen (regenerate to change): the DGP physics +
gen_config. Free (no regenerate): batch size,--steps, and the reveal strategy /latent_context_prob. The CLI DGP-constant flags (--jitter, BO--sigma-obs/--sigma-f-max/--prior-uniform-mix) are wired to the sameGEN_*constantsgen_config()reports, so online and pool share one source of truth. - Read-side memory.
PoolReaderkeeps only the manifest in memory at construction, maps each logical batch to touched(shard, row)pairs, lazy-loads shards through a bounded LRU cache, and uses a one-thread prefetcher for upcoming batch shards. Sharded pools therefore scale by shard size plus the cache and in-flight prefetch windows, not by fullpool_size.
- Kept: a single DGP config-hash. The manifest carries the
- Current retained runs. The public playground weights are exported from retained runs under the shared multi-latent reveal DGP: Gaussian 320k steps, GP-1D 200k, SIR 100k, and BO-1D 200k. The corresponding playground parity/demo fixtures were regenerated together with those exports.
- Repository boundary unchanged.
artifacts/andplayground/public/models/remain gitignored in nanoACE; public deployment weights are versioned in the separatelacerbi/nanoACE-playground-weightsrepository and copied in by the Pages workflow.
- UI polish stayed inside the non-core playground. The header now keeps the title, short description, fullscreen control, and "What is ACE?" modal trigger on one row; example controls expose reset/clear actions consistently, plus a uniform-priors action where runtime priors exist. Out-of-distribution warnings now render inside the plotting area so they do not move the surrounding layout.
- The ACE explainer modal is deliberately generalist. It describes ACE as a transformer probabilistic model in the same broad family as PFNs and TNPs, then emphasizes the token-level extension: conditioning and prediction can apply to data, interpretable latents, and runtime prior information, not only observed input-output pairs.
- Playground weights live in a separate public repo. The fp16 browser blobs
remain gitignored under
playground/public/models/in nanoACE. GitHub Pages now checks outlacerbi/nanoACE-playground-weightsbeside the app, copies only the model directories into that path before building, then fails fast if any expectedmanifest.json/weights.binpair is missing or still a Git LFS pointer. It validates each manifest against its blob size, records the resolved weights commit and artifact hashes in the run summary, runsnpm test, and then builds. The manual workflow accepts aweights_refbranch/tag/SHA, so a deploy can be pinned without changing nanoACE. This keeps ordinary nanoACE clones small while preserving a same-build checkout path for deployment.
Full plan and verification log: docs/plans/PLAN-train-loop-extraction.md.
- Why. The four examples carried byte-identical training loops,
build_model,save_checkpoint/load_checkpoint, and ~21 overlapping CLI args. That boilerplate now lives intrain.py; each example keeps only its task-specific science (variables(), the batch sampler,evaluate(),plot_diagnostic()) and a thinmain(). This also adds two shared training features: cosine LR and simple resume. main()is deliberately NOT centralized. Keeping each example runnable/readable end-to-end is the guardrail (initial design: "no separate generic command-line wrapper").train.pyexposes plain functions —common_parser(),build_model(args, variables, device),TrainConfig,fit(model, sample_batch, cfg, ...), the checkpoint helpers — not aTask/registry/config framework.fittakes a sampler thunk. This entry originally introduced a() -> Batchextraction; the current interface is(step) -> Batchafter the offline-pool work above. The important retained decision is still one training path: examples pass online samplers, anddata.py'sPoolReadersatisfies the same interface.- No generic training prefetcher.
fitreads one batch per step synchronously. The optional offline path owns its shard-level lazy loading and prefetching insidePoolReader, where the deterministic pool schedule is available. - Cosine LR is the new default (
--lr-schedule {cosine,constant},--warmup). This changes future from-scratch retrains vs the retained constant-LR artifacts — fine, artifacts are regenerable, and--lr-schedule constantexactly reproduces the old loop (used to prove the extraction is behaviour-preserving: all four examples reproduce their pre-refactor loss trajectories bit-for-bit underconstant). - Simple resume (
--resume,--ckpt-every). A resumable checkpoint adds{optimizer, scheduler, step};load_train_staterestores them and continues from the saved step. Resume = same total--stepsbudget (cosineT_maxis the run total, so a different--stepswould mis-align the curve —fitwarns on mismatch). The torch RNG state is not checkpointed, so a resumed run's data stream differs from an uninterrupted one — this is the intended "simple resume", not exact-stream replay. - Checkpoint format is backward compatible (additive keys).
save_checkpointalways writes{cfg, seed, state_dict}, addsconfig(resolved-run provenance,vars(args)) on the final save, and{optimizer, scheduler, step}only for resumable checkpoints.load_checkpointreads onlycfg/state_dict; legacy three-key checkpoint files load unchanged, andload_train_statetolerates the resume keys being absent. The final--save-checkpointstays model-only (+config), so retained artifacts and the playground stay lean. - The one playground constraint.
playground/export_weights.pyandplayground/parity.pycall<module>.load_checkpoint(path, device)with two args and use<module>.variables(). So each example keeps a 2-argload_checkpoint(path, device)wrapper forwarding totrain.load_checkpoint(path, device, variables()). No playground file changed;parity.pyregenerated its 8 tracked fixtures byte-identically after the refactor (load+forward path unchanged). - Seed ordering — from-scratch is exactly preserved.
main()seeds once, then build-or-load, thenfit(which draws no RNG before the first batch). The load-then-train path's RNG timing shifts slightly (today'sload_checkpointran before the seed; now it runs after) — immaterial: it was never a reproducibility guarantee and--eval-onlynever trains. - Config: dataclass + light YAML, no framework.
--config run.yamlis layered under explicit CLI flags viaset_defaults(precedence: parent/per-example defaults < YAML < CLI); YAML keys are arg dest names (underscored) and unknown keys are rejected so typos fail loudly.TrainConfig.from_argsfeedsfit. Hydra/omegaconf were considered and declined: Hydra is the "config framework" this repo excludes, adds heavy deps, and its@hydra.mainworking-directory change would relocate the examples' relativeartifacts/outputs. Adds one small dep (PyYAML, pinned inrequirements.txt).
- BO added to the non-core web playground.
playground/now has a fourth tab forbo1d.py: editable black-box observations, finite Beta prior tokens forx_optandy_opt, optional zero-spread fixed values for either optimum latent, and live ACE predictive bands. The optimum-location marginal is overlaid along the plot's x-axis and the optimum-value marginal along the y-axis, so the BO quantities stay in the same visual frame as the regression surface. - No oracle, consistent with
bo1d.py. The tab does not add a browser-side simulator posterior or a BO rollout. It only exposes ACE's amortized predictions and conditioning interface. Verification is parity plus Python-orchestration fixtures, not truth-quality.
All four examples now decide how many latents are revealed as context through one
helper, ace.sample_reveal_mask, under a single mixture. Per task:
- with probability
q, reveal nothing (pure inference / pure-prior — the headline); - otherwise split the revealing mass 50/50 between uniform over subsets (a uniform
random non-empty subset) and uniform over count (count
kuniform in1..L, then a uniform random size-ksubset).
Resulting count distribution (q = 1 - latent_context_prob, default q = 0.5):
L=2 → {0:.50, 1:.29, 2:.21}; L=3 → {0:.50, 1:.19, 2:.19, 3:.12} (verified empirically).
- Why a mixture, not one scheme. Uniform-over-subsets is fair to every specific
pin pattern but starves the extremes (revealing all L latents is
1/(2^L-1)of the reveal mass — only ~0.07 at L=3). Uniform-over-count keeps every count (incl. all-revealed) well represented but over-weights the lone all-revealed subset. The 50/50 blend keeps the per-subset floor while lifting "pin everything" (GP all-3 goes from ~0.07 to ~0.12 of total mass), which matters for the playground's "pin an arbitrary subset and predict" interaction. Theqknob keeps the 0-reveal headline explicit and L-independent. - All four examples unified.
gaussian_toy.pyandgp1d.pyalready used the helper (they inherit the new internals).sbi_sir.pyandbo1d.pywere migrated off their privatexorsingle-reveal logic (which revealed exactly one latent, never both) tosample_reveal_mask(2, …). Two-pin SIR / BO contexts are now in-distribution. The--latent-context-probdefault is standardized to 0.5 across all four (was 0.20 for SIR/bo1d), matching the agreed "½ reveal nothing". - Reveal-all is safe. SIR and bo1d build targets as
[latent, latent, data…]with the data-ycolumns always active, so a both-revealed row still predicts data (the forward/simulate direction) — no empty-target row. Gaussian/GP already allowed reveal-all under the old scheme.
Full design in docs/plans/PLAN-bo1d.md. Status: built and run. The plan
was checked by two reviewers, revised, then implemented and validated (CPU run,
torch 2.12.0). The DGP, training, and three-prior diagnostic work end to end;
--scale-check confirms data token values sit in [-1, 1] (~0.5% tail spill).
The structural checks pass: uniform→correct tightens/shifts p(x_opt | D) toward
truth, and the wrong prior is resisted (posterior stays near the data, not the
wrong prior, thanks to the ε floor). The effect is directionally correct but
modest (the chosen fixed case is deliberately hard -- the true optimum sits
unobserved between context points -- and ε=0.05 caps prior influence); sharpening
it is optional loose tuning, recorded in the plan's Status. This entry reflects
the revised plan (see docs/plans/PLAN-bo1d.md "Review notes").
- Fourth example:
bo1d.py. 1D Bayesian optimization. The two latents are the global optimum's locationx_optand valuey_opt. The headline is that ACE amortizesp(x_opt | D)andp(y_opt | D)directly (normally intractable, the reason BO needs bespoke acquisition machinery) and accepts a runtime Beta prior over the optimum location (the paper's πBO / ACEP-TS). It is a mix: GP function sampling + sampled kernel/hyperparameters fromgp1d.py, ACEP Beta prior tokens + the gaussian/sir reveal mechanism + observation noise, and a new optimum-planting DGP. Both latents are bounded continuous and reuse the existing PRIOR-token path, so there is no newace.pymachinery. - Latents are instance properties, not class properties. Unlike
gp1d.py(kernel/hyperparameters describe the function class),x_opt/y_optdescribe the specific sampled function. This is the paper's BO headline and what makesp(x_opt | D)worth amortizing. The kernel/lengthscale/outputscale are sampled nuisance, not predicted --gp1d.pyalready covers the discrete/kernel path, so a kernel latent here would be redundant. - DGP adapted from Appendix C.3.1 (not faithful -- the fold operand and the
role of the min-value distribution differ). Sample hyperparameters; draw
x_optfrom the (contaminated) prior; draw the natural optimum depthdfrom the min-value distribution (min ofN = ceil(2/ℓ)Gaussian draws, withp=0.1an extraExp(1)"unexpectedly low" kick), then clampd = min(d, 0)and cap|d|; sample a GP draw conditioned ong_c(x_opt)=dvia Matheron's rule (a true posterior sample, not a mean-shift); then fold+envelopef(x) = |g_c(x) − d| + (1/5)(x − x_opt)² + y_opt. Both added terms are≥ 0and vanish together only atx = x_opt, and the envelope is strictly positive offx_opt, sox_optis the exact, unique global minimum with valuey_opt-- level-dre-crossings of the fold are lifted abovey_optby the envelope (they create the kinked Fig.-S12 geometry / multi-basin structure, not spurious minima). The min-value machinery shapes the local geometry/depth (the conditioning bump's depth is set byd, its width by the lengthscale); our prior is the leveling shifty_opt, replacing the paper's finalU[-5, 5]offset. This honors keeping the full min-value/Exp(1)machinery: it shapes the function; the prior only sets the absolute level. - No oracle (deliberate). The
|·|fold destroys Gaussianity, so there is no closed-form posterior, and the other three examples already carry numerical grid oracles -- this one demonstrates the no-oracle case. A Monte-Carlo simulator posterior was considered and declined. The gate is structural + qualitative: short run + a token-scale + contamination-marginal histogram check + a fixed diagnostic plotting the true function, marked true(x_opt, y_opt), and ACE's marginals. The three prior columns give real falsifiability -- uniform→correct must tighten (the model uses priors) and correct→wrong must recover (it does not blindly follow). Recorded here and inAGENTS.mdas a departure from the oracle convention. - ε-contamination ("robust prior"). The effective generative prior is
(1−ε)·Beta + ε·Uniform(default ε=0.05, a classic robust-Bayes / ε-contamination prior), applied to both latents. Why it is not redundant with the existingsample_prior_paramsmixture: that helper always draws truth from the same Beta the token encodes -- the token never lies -- so a model trained on it learns to trust a concentrated token fully and the wrong-prior column would fail. ε-contamination's job is to decouple truth from the token a fraction ε of the time, the one thingsample_prior_paramscannot do. Sobo1dreusessample_prior_paramsfor the token and applies contamination only at the truth-draw (newace_prior_beta.sample_contaminated). It lives entirely in the DGP truth-draw and plot overlays, not in the token or model (a single Beta token cannot represent a mixture, and need not). Corrected framing: the model does not learn "a global discount knob" -- the contaminated prior is the true generative prior, NLL learns the Bayes-optimal posterior under it, and the uniform floor keeps that posterior from ever fully committing to a confident-but-wrong location; the override strength is data-dependent, not constant. ε must be fixed (and not in the token) for this to be robust-Bayes rather than an averaged hyperprior. The plot-onlymixture_logprior_on_gridstays local tobo1d.py. The diagnostic makes this visible with three columns: uniform / correct-informative / wrong-informative. - Scaling is the real work.
y_optand datayare the same physical quantity (function values), so they share one affine, written explicitly asscale_y(y) = encode_value(y_opt_var, y)over a frozen module constantY_RANGE(frozen for checkpoint compatibility; not a CLI arg).Y_RANGEcovers the full native y range and is both they_optbounds and the data-yscaling, so the model sees both on one ruler andy_opt ≤ all yis legible. Corrected budget: away fromx_opt,|g_c − d| ≈ |g| + |d|, so the natural depth|d|inflates the whole function height (not just the dip) -- hence the|d|cap and a tamedσ_f. The scale check passed with the current constants: token values sit near[-1, 1]and the drawn-x_optmarginal matches the contaminated prior. Stochastic tails outside[-1, 1]are accepted (soft convention). - Observation noise + reveal. Data
ycarry small Gaussian noise (--sigma-obs), matching the continuous MDN and BO realism. Reveals use the gaussian/sir pattern (replace a finite-spread PRIOR token with a zero-spread known one, token stays active), required soconditional_log_densityfinds an active PRIOR slot. - No BO loop. No iterative acquisition rollout; the conditional
p(x_opt | y_opt = v, D)is shown (viaconditional_log_density, nosample_ar) to gesture at Thompson sampling, nothing more. - Bigger default network than the other examples. BO is the hardest task
(instance-level latents, multimodal
p(x_opt | D)), and the paper's 1D-BO model is correspondingly larger (d_emb=256, 6 layers, 16 heads,K=20).bo1ddefaults tod_model=192, 6 transformer blocks, 16 heads, mlp 384,K=12(~3.9M params, vs the ~1.2Mgp1d/Gaussian defaults). Six blocks were adopted deliberately (the 4-block default underfit the prior-integration);Kis raised from 8 to 12 for the multimodal location posterior. Small CPU validation runs showed that equal-step comparisons were dominated by training budget rather than capacity; judge retained playground behavior from the later exported run recorded in the current-state entry, not from the wiring-time CPU checks. Defaults kept faithful at 6 blocks andε=0.05. - Scope note. This is example #4; "nano ships exactly two" (initial design) is already stretched by SIR. BO earns its place: it adds instance-level latents, the optimum-posterior headline, and the robust prior-injection mechanism, none of which the other examples cover. Recorded as a deliberate decision.
- SIR added to the non-core web playground.
playground/now has a third tab forsbi_sir.py: editable infected-fraction observations, runtime Beta prior controls forbetaandgamma, ACE predictive curves/marginals, and a live browser-side numerical SIR grid oracle. This stays entirely in the playground toolchain;ace.pyand the Python SIR example are unchanged. - Oracle is practical in-browser. Unlike the GP oracle, SIR only needs deterministic
RK4 trajectories over a
(beta, gamma)grid plus Gaussian likelihood scoring. The TS implementation caches the fine-grid trajectories and reuses them as observations and priors change, so the live oracle is cheap enough for the UI. - Export/parity extended.
playground/export_weights.pyaccepts--task sbi_sir;playground/parity.pynow writes SIR parity and demo fixtures;npm testcovers the SIR forward parity, ACE orchestration, TS oracle, and UI smoke path.
- A non-core interactive demo lives in
playground/. It is an example, not part of nanoACE: the core stays torch-only and legible, whileplayground/carries a Vite + TypeScript toolchain. It reimplementsace.py's forward pass in TS so trained models run fully client-side (GitHub Pages, no server). The playground started with GP-1D (add/drag points, infer the kernel, pin a latent and predict) and Gaussian (Beta-prior sliders + observedy, with the analytic oracle overlaid), and now also includes SIR (see the SIR playground entry above). The headline is that amortized conditioning is instant — a forward pass per interaction — which is exactly what an interactive demo makes visible. - The port is a frozen snapshot kept honest by a parity test.
export_weights.pyderives every constant from a liveACEinstance (no hand re-encoding of the schema) and writes plain float16 arrays + a JSON manifest — readable weights, not a mystery binary, regenerable from a checkpoint. Weights are float16 to halve the blobs (~3.6 MB total); parameters are rounded with.half().float()in both the exporter andparity.py, so the shipped weights and the references reflect identical values (only float32-vs-float64 arithmetic differs).parity.pydumps the real model's embeddings, per-layer states, raw head outputs, and derived quantities on deterministic cases covering every token path;npm testasserts the TS forward reproduces them (and that each demo's orchestration matchesgp1d.py/gaussian_toy.py). PyTorch-float32 vs JS-float64 means the gate is a combined relative+absolute tolerance, not bit-parity. Iface.py's forward changes, the parity test fails loudly and localizes the drift; re-port and regenerate. - Gotcha — fixtures + blob can go collectively stale vs the checkpoint. Both
export_weights.pyandparity.pyderive from the checkpoint loaded at run time, and the parity test compares the TS forward only against those fixtures — not against the current checkpoint. So if the checkpoint is retrained but the blob and fixtures aren't regenerated together, the demo silently loads the old model whilenpm teststays green. (This happened 2026-06-07: a GP retrain at 12:52 — after a 12:49 export — left the demo predicting a flat mean from a stale blob; the fix was to re-run export + parity.) Always re-runexport_weights.pyandparity.pytogether after retraining; a Python sanity assert on the freshly exported model (e.g. it tracks a smooth function) would catch this class of bug. - Weight hosting is now resolved by a separate public repo. The exported fp16
blobs (
playground/public/models/) stay gitignored in nanoACE, and GitHub Pages checks outlacerbi/nanoACE-playground-weightsbeside the app and copies only the model directories into that path before the Vite build. This avoids committing binary churn to nanoACE while keeping the deployment flow same-build and explicit; update blobs and parity fixtures together after retraining.
- Third example:
sbi_sir.py. This is the SBI task from the paper's third application area: infer the contact ratebetaand recovery rategammaof an epidemic from a noisily observed infected fraction over time. It is deliberately a fusion of the two existing examples, not new architecture — runtime Beta prior injection (ACEP) fromgaussian_toy.pyplus online time-series simulation, 1D time-indexed data tokens, and a grid oracle fromgp1d.py. The only genuinely new machinery is a small batched RK4 integrator. - Simulator: deterministic SIR ODE + Gaussian observation noise. Chosen over the
paper's Binomial/Poisson counts because the deterministic-trajectory-given-
(beta, gamma)property makes the marginal likelihood a product of Gaussian observation densities, so the(beta, gamma)grid posterior is tractable — the same recipe asgp_oracle. ACE only ever sees simulator draws, never the likelihood, so it is honestly simulation-based; the grid oracle is the reference. Gaussian observation noise also matches the continuous MDN head exactly (no continuous-vs-discrete predictive caveat). - Continuous-only latents.
beta,gammaare bounded continuous latents with identity transform, so the uniform generative prior is exactlyBeta(1, 1), matchinggaussian_toy.py. No discrete latent —gp1d.pyalready exercises that path, and a discrete latent here would be redundant and complicate the oracle. - Data scaling is explicit. The infected fraction is small (~0–0.4) while the
embedders assume values near
[-1, 1], so the example applies a fixed affinescale_value/unscale_value(andscale_time) at the token boundary. The oracle works entirely in native fraction space; ACE predictive moments are decoded before comparison."y"is a data variable, so the coreencode_value/decode_value(which only touch bounded latents) do not apply — the affine is local to this example. This is the "scaling happens at generation" convention made explicit for a small-valued series. - Diagnostic: uniform-vs-informative prior contrast. The headline SBI feature is
prior conditioning, so the fixed case is evaluated twice on the same observation —
once with
Beta(1, 1)and once with an informative Beta — and both posteriors are plotted. To make the prior matter, the fixed context is sparse rise-phase data: early epidemic observations identify the growth rate but leave a broad, near-degeneratebeta/gammaridge (oracle corr ≈ 0.99), so the informative prior visibly tightens the marginals (β std ≈ 0.080 → 0.050, γ ≈ 0.057 → 0.035) and pulls them toward truth. With many clean observations the likelihood dominates and the contrast vanishes; that is the correct behavior, just not an interesting demo. - Shared prior helpers extracted to
ace_prior_beta.py. The Beta prior-token helpers (sample_prior_params,beta_alpha_beta,prior_features,known_latent_features,draw_from_beta,beta_logprior_on_grid) were a pure move out ofgaussian_toy.py; both prior-conditioning examples now import them. The(mean, spread)prior token is a core ACEP representation, so a shared module is the right home. Audit: the model-side prior code (PRIOR_FEATURES,spread_embed, the_embedpayload) and the generalencode_value/decode_valuecoordinate maps stay inace.py;ace_prior_beta.pyimportsPRIOR_FEATURESfrom the core and asserts the two-feature layout. The helper module is Beta-specific; model-side PRIOR token semantics stay inace.py. - Deferred. No discrete-latent runtime prior token (still deferred from the prior redesign). The SIR AR two-latent joint heatmap is computed-capable but not plotted, to keep the uniform-vs-informative contrast legible; the marginals carry the story.
- Multi-reveal migration (DONE 2026-06-07).
sbi_sir.py(andbo1d.py) were migrated off the single-revealxoronto the sharedsample_reveal_mask, so two-pin contexts are now in-distribution and all four examples share one reveal helper. See the "Single shared multi-latent reveal strategy" entry above.
- Bounded continuous latents are tokenized to
[-1, 1].Variable.boundsis now the hard support in the variable's semantic/transformed coordinate (log_sigmabounds are onlog_sigma, notsigma). Data values remain in task coordinates. The helpersencode_value/decode_valueandencode_token_values/decode_token_valuescentralize the affine map so examples do not carry hand-rolled conversions. - Histogram prior grids are removed.
ACEConfig.prior_bins,Variable.prior_range,Variable.prior_bins, and the histogramprior_embedMLP are gone.Tokens.prioris now a fixed two-feature tensor:(mean_internal, spread_internal)for bounded continuous latent information. - Known continuous latents are zero-spread information tokens. A finite
spread token represents a runtime prior; spread zero represents an exact
known latent. The embedder uses
value_embed(mean_internal) + spread * spread_embed(mean, spread), so the zero-spread payload is exactly the same location payload used for a scalar value before mode/variable embeddings are added. Exact continuous latent observations usePRIORmode, notVALUEmode. - Native-coordinate prediction is explicit. Raw
Predictions.log_prob,.mean,.sample, and.continuous_varremain in token coordinates because training and AR context construction live there.log_prob_native,mean_native,continuous_var_native, andsample_nativedecode bounded continuous latents and apply the affine density Jacobian when needed. - Gaussian is now the ACEP demo.
gaussian_toy.pyalways emits one information token per continuous latent. Training samples runtime Beta priors, draws the true latent from those priors, and compares against a Beta-aware analytic grid posterior. Revealed continuous latents replace the finite-spread prior slot with a zero-spread exact slot. - Gaussian diagnostic priors are visible. The fixed Gaussian diagnostic uses
moderately informative runtime priors
EVAL_MU_PRIOR = (mu_unit=0.70, nu=20.0)andEVAL_LOGSIG_PRIOR = (mu_unit=0.70, nu=8.0). Themuprior is shifted slightly left from the first implementation while keeping a simple concentration; thelog_sigmaprior is deliberately broader. The plot overlays these prior curves on the corresponding posterior marginal panels, so the runtime prior information is visible next to the oracle and ACE posterior curves. - GP-1D stays finite-prior-free.
gp1d.pyuses encoded bounded continuous latent targets and zero-spreadPRIORtokens when lengthscale/outputscale are revealed. The discretekernellatent remains a VALUE-mode class label when revealed, and no categorical prior token is implemented. - Deferred. The MDN still predicts unconstrained Gaussian-mixture mass in token space, so exact bounded output distributions are not implemented. Discrete-latent runtime prior tokens are also deferred. Existing checkpoints from the histogram schema are stale and should be regenerated.
This section records the reasoning that led to the 2026-06-07 implementation.
The shipped code no longer uses the histogram→MLP prior encoder. The old design
spent a 64-dim, discretization-noisy vector to encode what is almost always two
numbers, and it dragged in scale coupling — Variable.prior_range,
per-variable prior_bins, and the global-prior_bins validation in
ACE.__init__ existed only to pin the grid. It is also what made the Gaussian
toy harder, which is why runtime priors were initially removed from that
example.
-
Preferred candidate: a rescaled Beta prior token. Encode a continuous latent's prior as a Beta on the latent's bounded range, user-facing
(mean, SD). This fits the toys specifically because their latents are bounded-uniform by construction (MU_RANGE,LOGSIG_RANGE,LOG_LENGTHSCALE_RANGE,LOG_OUTPUTSCALE_RANGE), so a bounded prior matches the true support and the no-information case is exactlyBeta(1,1) = uniform(today's generative prior). Two numbers buy uniform, Gaussian-like bumps, skew (α≠β), and edge-peaked/U-shaped priors (α,β<1). -
Parameterization: user
(mean, SD), internal(μ, ν), location embedded like a value. User interface(mean, SD)for interpretability; sample/store internally as(μ, ν)with concentrationν = α + β— constraint-free, since everyμ∈(0,1), ν>0is a valid Beta. For the embedding, feed the prior mean directly (rescaled to the latent's range / the[-1,1]convention), ideally through the existingvalue_embedso a prior's location enters exactly like an observed value, and carry the concentration as a separate channel.log νis the natural concentration coordinate (a positive multiplicative scale spanning ≈2 to 1000);logit μis not worth it —μis a bounded location with no positivity or scale argument, andd/dμ logit(μ) = 1/(μ(1−μ))over-resolves the degenerate endpoints while compressing the mid-range where informative priors usually sit. (For the value-limit variant below, carry concentration as theσ-gated term instead of rawlog ν.) Recover shape params when needed: with variancev,ν = μ(1−μ)/v − 1,α = μν,β = (1−μ)ν, valid iffσ² < μ(1−μ)(the Bernoulli-SD cap); clamp/validate the user's(μ, σ)against that bound. -
Coordinate convention (avoid space confusion). Three spaces: the user specifies
(mean, SD)in the latent's original range[a,b]; the Beta math (recoverα,β/ν, check validitySD² < μ(1−μ)) lives in unit[0,1]; the embedding uses the[-1,1]convention. Maps are affine (widthw = b−a): to unit,μ_u = (m−a)/w,SD_u = SD/w; to[-1,1],m_± = 2μ_u − 1,SD_± = 2·SD_u(mean through the affine map, SD times the slope). Cruciallyνis coordinate-free — convertSD ↔ νonce in unit space, then carry(location, ν)and only ever transform the location;νnever changes (equivalentlySD_±² = (1−m_±²)/(ν+1)). The prior mean in[-1,1]lands in the same coordinate as a VALUE token for that latent, which is what makes thevalue_embedreuse coherent. Worked example:[a,b]=[-2,8], usermean=2, SD=2→ unitμ=0.4, SD=0.2(not0.5/0.25) →Beta(2,3),ν=5→[-1,1]location−0.2, SD0.4. -
Known-value as a limit, done honestly. An earlier claim that a prior token "collapses to a VALUE token" as spread→0 is not enforced by the current representation: the spread MLP has no zero limit, and the additive
mode_embed(PRIOR)vsmode_embed(VALUE)is a constant offset that never closes. To make it real you must (a) gate the spread so it vanishes by construction — e.g. payloadvalue_embed(mean) + σ·h(mean, σ), feedingσnotlog σso the input stays bounded — and (b) treat a known latent as the zero-spread /ν→∞prior token rather than a separately-moded VALUE token, so "value" is the boundary of "prior." With Beta this falls out naturally:ν→∞is a spike atμ. Even then this is representation continuity only; the model behaves like exact conditioning in the limit only if training visits small spreads. -
Discrete latents stay separate. Beta covers continuous bounded latents. The discrete
kernellatent's runtime prior is a categorical pmf — a small "K-probabilities" prior token, not a Beta. -
Training implication. Sample a Beta hyperprior
(α, β), draw the true latent from it, build the prior token, NLL as usual. This is much simpler than the paper's MoG/geometric-K/Dirichlet prior-generating machinery (already slated for slimming) and is the actual distribution the model would amortize over. -
Default hyperprior over
(μ, ν). The prior-generating mixture, defined in unit(μ, ν)space so it is range-agnostic and reused across all continuous latents:1/3:μ = 0.5, ν = 2→ exactlyBeta(1,1)(uniform; the uninformative case).1/6:μ ~ U(0,1), ν ~ U(0.1, 2)→ broad / skewed / U-shaped (ν<1).1/2:μ ~ U(0,1), log ν ~ U(log 2, log 1000)→ concentrated, up to near-spike.
Clamp
μto(ε, 1−ε)soα,β > 0. Weights and bounds are tunable. The heavy1/2mass on concentrated priors (withνto 1000) is deliberate — it is where prior conditioning matters most and where theν→∞ ≈ known-valuelimit is learned. The1/6U-shaped band is the most exotic slice and the first dial to turn if it hurts (raiseν_min, e.g. 0.1→0.5, or lower the weight). Notelog ν ~ U(2, 1e3)taken literally overflows (e^1000); the intended range islog ν ~ U(log 2, log 1000). -
ACEP always emits prior tokens; "no prior" = uniform. The architecture does not (and need not) enforce "absent prior token ≡ uniform prior," so do not train over a present/absent mixture. The prior-conditioning model (ACEP) always carries one prior token per continuous latent, using
Beta(1,1)when there is no information. This is benign here because the latents are bounded-uniform by construction, so the uniform token is the honest "no extra info" prior rather than the hard amortization burden that arbitrary histograms were. Prior-free ACE and always-prior ACEP are then simply two variants with fixed token layouts (supersedes the earlier idea of a separateprior_probpresent/absent knob). -
Fallbacks if more expressiveness is wanted. Plain moments
(mean, log_std)if even Beta is more than needed (but it cannot express bounded support or skew, and a Gaussian encoder leaks past the latent range); a small fixed quantile vector (e.g. 5/25/50/75/95th percentiles) for asymmetric unimodal priors; a handful of orthogonal-basis coefficients (Hermite/Legendre) only if arbitrary/multimodal priors become an explicit demo. All three still beat a 64-bin histogram on compactness and trainability. -
What adopting any of these deletes.
Tokens.priorshrinks from[B, T, prior_bins]to a 2–3 vector;Variable.prior_range, per-variableVariable.prior_bins, and theprior_bins != cfg.prior_binscheck all go away (a fixed small vector is never ragged); "no prior" becomes "do not emit a PRIOR token" instead of an encoded uniform histogram.
- Standalone GP file.
gp1d.pyowns the GP regression example end to end: online function sampling, command-line training/evaluation, checkpoint helpers, and a fixed diagnostic plot. It follows the same local-file pattern asgaussian_toy.pyrather than introducing a generic training framework. - Latents. The example uses two continuous latents,
log_lengthscaleandlog_outputscale, plus one discretekernellatent with four classes: RBF, Matern-1/2, Matern-3/2, and periodic. This exercises the shared continuous MDN head, the sharedKmaxcategorical head, and discrete value embeddings when a kernel latent is occasionally revealed as context. - Sampler. GP functions are generated online. Kernel matrices and Cholesky sampling
run on CPU float64, then sampled tensors move to the selected ACE device. There is no
sharded data layer yet; add one only when a real GP training run needs saved pools.
Function values are not clipped: with
log_outputscaleup tolog(1.0), sampledyvalues can naturally leave the loose[-1, 1]convention used by the embedders. Treat that as a calibration caveat, not a bug. The default Cholesky jitter is1e-5; increase it if clustered x-locations ever make Matern-1/2 or periodic kernel matrices unstable. - Diagnostic. GP-1D now computes a numerical grid oracle for the fixed context.
For each kernel and each
log_lengthscale/log_outputscalegrid point, it evaluates the GP marginal likelihood of the observed context, applies uniform-prior quadrature weights, and normalizes over the full kernel-by-hyperparameter grid. The diagnostic reports per-kernel integrated marginal likelihood deltas, the kernel posterior, continuous latent marginals, and the posterior predictive moments formed by mixing conditional GP predictives over the grid. This is not a closed-form analytic posterior; it is an oracle up to grid resolution, the bounded hyperparameter ranges, and the Cholesky jitter used by the sampler. The fixed context locations are irregular and clustered, including nearby pairs/triples, because sparse evenly spaced points do not say much about local roughness and make kernel/lengthscale inference mostly guesswork. - Oracle grid sanity check. The fixed GP diagnostic was rerun with
--oracle-bins 32,64, and96. Kernel posterior probabilities, latent posterior moments, and predictive RMSE were stable to the printed precision, so the default 64-bin oracle is adequate for this diagnostic. The RBF integrated marginal likelihood delta moves by about 0.1 log units across those grids, but RBF has posterior mass near 0.002 in this case, so that movement is not practically relevant. This is a one-case numerical check, not a benchmark harness.
- Core implementation.
ace.pynow contains the current source-of-truth implementation:Variable,Tokens,Batch, separated context/target transformer blocks, shared continuous MDN head, shared maskedKmaxcategorical head, prediction object, target NLL loss, andsample_ar. - Executable Gaussian toy file.
gaussian_toy.pyowns the Gaussian example end to end: online batch generation, command-line training/evaluation, oracle math, checkpoint helpers, and plotting. There is no separate generic command-line wrapper because that wrapper would still depend on the Gaussian file to be understood. Reusable grid query helpers live indiagnostics.py. - Gaussian diagnostic.
gaussian_toy.pytrains the Gaussian toy online under fixed latent priors, compares ACE marginals to an analytic grid posterior, and includes an autoregressive two-latent joint diagnostic. The Gaussian example no longer injects random runtime prior tokens: amortizing over arbitrary prior histograms made the small Gaussian example much harder than the posterior-learning behavior this example is meant to inspect. Runtime prior tokens remain in the core ACE representation for examples that explicitly train prior conditioning. The AR diagnostic averages the two factorizationsp(mu)p(log_sigma|mu)andp(log_sigma)p(mu|log_sigma)in probability space so the displayed joint posterior is not tied to one arbitrary order. The deterministic evaluation batch is defined ingaussian_toy.py: three observedyvalues, plus latent truth for printed diagnostics. Keeping that batch constant makes plots comparable across runs without a separate evaluation-case selector. The plot includes a posterior predictive panel for a newy: the analytic curve is the posterior mixturesum p(mu, log_sigma | D) Normal(y | mu, sigma), not a Gaussian plug-in approximation. Training now mixes in latent-value context cases (--latent-context-prob, default0.25): one latent is sometimes revealed as aVALUEtoken and the other remains a target. Without that, AR conditional queries are syntactically valid but out-of-distribution for the toy model. - Artifacts are optional and regenerable.
gaussian_toy.pycan save a diagnostic plot and a lightweight checkpoint underartifacts/, but these are convenience outputs rather than load-bearing repository assets.--eval-only --load-checkpointverifies a saved checkpoint. - Environment. Initially the project pinned the PyTorch CUDA wheel tested on this
workstation:
torch==2.11.0+cu128, PyTorch CUDA runtime 12.8, and the RTX 4060 Laptop GPU. Superseded 2026-06-14: the default install is now cross-platform (requirements.txt→torch==2.11.0); that cu128 build moved torequirements-cuda.txt. See the dated entry above.
- What it is. A single-purpose, legible implementation of ACE that a coding agent
can read end-to-end and extend. The optimization target is the source — legibility
and extensibility — not a packaged runtime artifact (pretrained weights, a stable
calling contract, an MCP tool). That packaging is outside the current scope, not an
anti-goal: a clean
condition().predict()surface is part of legible source anyway, so doing this well keeps that path open rather than foreclosing it. - What "nano" means. Minimal concepts and maximal locality — not minimal lines, and not a trivial toy. nanoACE should be legible and real (you can train real models with it), exactly as nanoGPT is small/readable yet genuinely trains GPT-2. The bar a feature must clear is conceptual surface, not compute.
- Paper-faithful starting point, not paper-bound math. Start from the paper where it
preserves ACE's core idea, but treat the equations as an existence proof rather than a
constraint. The invariants are the conditioning semantics: variables as tokens, data /
latent / prior information in one context set, target tokens that request predictive
distributions, and a type-agnostic
dist.log_probtraining path. Attention details, output-head parameterization, prior features, latent transforms, and training mixtures can change when a tweak is simpler, more robust, or makes the conditioning interface clearer. - Tiebreaker when simplicity vs. performance conflict. Simplicity is the default. A feature can buy its way in if it improves predictions or real-training throughput — but it pays rent in legibility (inline shape contracts, an obvious why). Large experiment-management complexity (clusters, multi-seed pools) is outside this repository's scale target.
- "naïve" is not a synonym for "legible." Evaluate case by case (see Attention).
- Discrete in the core from day one. A
Variableschema (type, cardinality; ~8 lines) travels with each token. Discrete value embedding uses an embedding matrix (E_val); discrete output uses one shared categorical head withKmax = max(cardinality). At distribution construction, mask logits beyond the target variable's cardinality and read only local labels0..k-1. Rationale: the paper's headline is "unifies discrete + continuous," and this keeps the code path shared rather than introducing per-variable heads. - Heads return distribution objects (
.log_prob/.sample). The training loss is then-dist.log_prob(z)for everything, so the loop is type-agnostic — the GMM vs. categorical distinction never reachestrain.py. - Embedder (paper-shaped default).
- data:
f_x(x) + f_val(y) + e_data - latent:
f_val(θ_l) + e_l - prior-latent:
f_prob(p_l) + e_l - target value branch replaced by the learned unknown embedding
e_?. - discrete
f_val→ embedding matrixE_val.
- data:
- Attention: separated context self-attn + target→context cross-attn. No mask.
This is the paper's choice (§3.2,
O(N²+NM)), and it's both cheaper and more legible than the alternative. The "naïve" single masked stream materializes the full(N+M)²matrix and masks ~half away, and buries the key invariant ("targets read context, never each other") inside a fiddly block mask. The separated version needs no mask — the conditioning structure falls out of which sets you pass in:ctx = ctx + self_attn(q=ctx, kv=ctx) # N×N tgt = tgt + cross_attn(q=tgt, kv=ctx) # M×N - Block: standard pre-LN default. Use the stable transformer form
x = x + attn(LN(x));x = x + mlp(LN(x)). No scalar-channel plumbing. If training stability argues for a different normalization placement, change the block rather than preserving paper/formula fidelity for its own sake. - Single MDN head, allowed to be slightly stronger. Default to a shared
MLP(D, hidden) → Linear(3K)→ (raw weight, mean, log-std), with softmax/identity/softplus. Not K separate per-component MLPs + a global raw bias (that's more apparatus than the idea needs). A bareLinear(D, 3K)is acceptable only if the toy posterior still behaves. - Prior: keep the runtime-conditioning idea, not the exact representation. The
histogram→MLP encoder (
f_prob, N_bins grid) is the first implementation because it is direct and visible. If moments, CDF features, learned basis coefficients, or another compact prior summary is simpler or trains better, use that. The elaborate prior generative process (80% MoG / geometric-K / Dirichlet / three mean-std configs) is slimmed to a simple sampler; it demonstrates injection just as well. - Latent loss weight: a single scalar
latent_weight— not the((n_total − ½(max_ctx+min_ctx))/n_latent)^Tformula +Tgrid search (a benchmark-tuning artifact). - Cheap wins taken by default: log-space latent prediction; multi-task loss balancing.
- Canonical internal object: two token sets. The model receives a small
Batchwithcontext: Tokensandtarget: Tokens. Context tokens are known values or latent priors; target tokens are queries whose true values may be present for loss. Use padding masks for ragged batches, but no directional attention mask: conditioning direction comes fromctx = self_attn(ctx)andtgt = cross_attn(tgt, ctx). - Shape contract. The first code pass should implement this directly:
Target truth sits in
Batch( variables: list[Variable], context: Tokens, target: Tokens, ) Tokens( var_id: LongTensor[B, T], x: FloatTensor[B, T, x_dim], value: FloatTensor[B, T], value_index: LongTensor[B, T], prior: FloatTensor[B, T, n_bins], mode: LongTensor[B, T], # VALUE | PRIOR | QUERY mask: BoolTensor[B, T], )
value/value_indexfor loss, but the embedder ignores it whenmode == QUERY. Tokensis padded, explicit, and boring. Fields:var_id: LongTensor[B, T]indexes theVariableschema.x: FloatTensor[B, T, x_dim]stores data covariates; latent tokens use zeros.value: FloatTensor[B, T]stores transformed continuous values or dummy zeros.value_index: LongTensor[B, T]stores discrete labels or dummy zeros.prior: FloatTensor[B, T, n_bins]stores latent-prior histograms, zeros otherwise.mode: LongTensor[B, T]isVALUE,PRIOR, orQUERY.mask: BoolTensor[B, T]marks active padded tokens. Target truth lives invalue/value_index; the embedder ignores it whenmode == QUERY, while the loss uses it wheremaskis true.
Variableowns semantics. Each variable hasname,kind(data/latent), value type (continuous/discrete), optional discretecardinality, transform (identity,log, maybelogit), and optional prior-grid range/bin count. Prior-grid ownership lives on latent variables, not in a global config.- Heads are hidden behind a prediction object.
ACE.forward(batch)returns an object with.log_prob(batch.target),.sample(batch.target, ...), and.mean(batch.target). Continuous targets use the shared MDN path. Discrete targets use the sharedKmax-logit categorical path, masking invalid logits bycardinality[var_id]. The training loop should not branch on variable type. - Autoregression is a helper, not architecture. Joint samples come from
sample_ar(model, batch, order=None), which repeatedly predicts one target, samples it, appends it to context as aVALUEtoken, and continues. The base model stays a diagonal prediction map. Withorder=None, the helper randomizes target order by default; pass an explicit order for deterministic probes. - Continuous head is shared. Use one shared MDN head for all continuous variables;
var_id/ variable embeddings tell the model which scalar is being predicted. Do not add per-variable continuous heads unless a concrete task shows shared capacity is the bottleneck. - Discrete value embeddings use local labels plus offsets. Each discrete variable's
labels are local integers
0..k-1. For context value embeddings, use one global embedding table with per-variable offsets so labels from different variables do not collide. For target predictions, use the sharedKmaxhead described above. - One
n_binsinitially. Latent variables own their prior ranges, but the first implementation uses a global prior-bin count to avoid ragged prior tensors and nuisance padding. Different bin counts can wait until a real example needs them. - Inference targets may omit truth. It is valid to pass target tokens with dummy
value/value_indexfor prediction-only calls..log_prob(target)is only called when target truth is present; example/train code are responsible for that distinction. - Loss weighting stays minimal. Start with
data_weightandlatent_weightscalars, then average per active target token. No benchmark-derived formulas or per-task grids. - Device movement lives on the data objects. Add
Tokens.to(device)andBatch.to(device)immediately so training scripts do not accumulate tensor plumbing.
- Implement in dependency order. Build
ace.pyand the Gaussian toy path first, prove the interface withgaussian_toy.py, then add GP-1D save/load shards. Do not build a generic sharded data layer before the model and toy diagnostic work. - Keep
Variableboring. The schema should be explicit and local:name,kind(data/latent), value type (continuous/discrete), cardinality for discrete variables, and optional transform metadata. Avoid clever token abstractions until the examples force them. - Restrict priors to latents initially. ACE's useful runtime-prior story is about task-relevant latent variables. Do not generalize prior tokens to arbitrary data values until there is a concrete example that needs it.
- Let the Gaussian toy be the first correctness oracle. It should check that posterior moments and autoregressive two-latent predictions are sane before GP-1D adds Cholesky and dataset plumbing.
- Use archived experiments selectively. If a gitignored
temp/directory is present, treat it as external experiment code. Reuse ideas only when they fit nanoACE directly: rectangular attention, CPU float64 GP sampling, and simple train-loop habits are useful; cache provenance, Slurm, prefetch, reeval schemas, and large diagnostic suites are out of scope. - Short verification runs stay loose. The Gaussian toy oracle should catch broken heads and obviously wrong posterior moments, but it should not become a brittle strict-quality gate around stochastic training.
- Checkpoint artifact (resolved 2026-06-14). Originally deferred until the toy trained
reliably and the blob was genuinely small; the trained checkpoints are now published on
Hugging Face (
lacerbi/nanoACE) as an optional fetch. The principle still holds: retraining is the fallback (each checkpoint ships config + seed), and the repository is not designed around a checkpoint.
- Native path: generate → save → train (the nanoGPT
prepare→trainpattern). Decoupled (data.pywrites,train.pyreads), inspectable (the dataset is an artifact you can open), reproducible (not an RNG side-effect of the loop). - Dataset format: minimal
.ptshards plus a tiny manifest. Shards store tensors in the canonicalBatch/Tokensshape or enough arrays to reconstruct it. The manifest records schema version, variable definitions, normalization/transform metadata, and shard list. Avoid cache fingerprints, optimizer provenance, or resume-compatibility matrices. - Sharded pool on disk so it scales past RAM. This is the regime that actually produced results at scale (a large finite pool, a few passes), not a toy shortcut.
- Deterministic
(seed, pass)reconstruction ofnand the context/target split; independent of batch size. - Shuffle is fixed to "both" (permute shard order + rows within shards), seeded by
(seed, pass). Noshuffle_modeenum — drops the field from any manifest and removes one axis from resume checks. - Online generation is not a mode. Generators (
gaussian_toy,gp1d, ...) are pure functions returning a batch. "Online" means calling the generator inside the training loop; it needs no config flag. The boundary is the generator function itself, sotrain.pydoes not need separate online and cached code paths. - Scaling happens at generation. Examples should keep values roughly around
[-1,1], because the embedders/head bias assume that scale. This is not a hard bound: stochastic observations can have tails outside the range, and calibration issues should be checked before adding clipping or rescaling.
- Keep: checkpoint + simple resume; cosine LR; Adam; grad clip; GPU model with a CPU float64 data/oracle split (Cholesky on the Win/WSL2 GPU path can trip the host watchdog).
- No generic training-loop prefetcher. Generate→save moved the expensive Cholesky
off the training loop, so
train.pystays synchronous. The concretePoolReadermay still lazy-load and prefetch shard files because the deterministic pool schedule tells it exactly which shards upcoming batches will need. - Cut entirely (large experiment management): Slurm/HPC layer; finite-pool caching machinery
beyond the simple sharded save (manifest, DGP-hash fingerprint, shuffle enum);
multi-axis resume-provenance guards; schema-migration
reeval; mechanistic probes / partial-R² / causal interventions (those served a separate research question).
- Example 1: Gaussian (
mu,sigma) toy. Two latents, fixed latent priors, analytic Bayesian posterior on a grid, and a matching posterior predictive diagnostic. Runs online and remains small; longer local runs can save a checkpoint/plot for inspection.- An optional pretrained checkpoint is useful because it would make
gaussian_toy.pyinstant and provide a stable regression anchor. Distribution is resolved (2026-06-14): the trained checkpoints are published on Hugging Face (lacerbi/nanoACE) as an optional fetch — see the dated entry at the top and the resolved Open question below.
- An optional pretrained checkpoint is useful because it would make
- Example 2: GP-1D regression. Lengthscale/outputscale as continuous latents,
plus kernel selection as a discrete latent (this is what exercises the discrete
path so it isn't dead code). Use a deliberately diverse kernel set (RBF, Matern,
periodic, ...) so the sampled functions look genuinely different and classification is
meaningful. The first implementation is online in
gp1d.py; use the generate/save/pool path only if longer training makes online Cholesky sampling the bottleneck. - "Add a third task" is the canonical user/agent extension — nano ships exactly two. Generality lives in the architecture (free: attention over a token stream), not in the example surface.
ace.py— config → embedder → block → forward → heads → loss (THE file)diagnostics.py— reusable grid-query helpers for marginal/AR diagnosticsgaussian_toy.py— executable Gaussian toy: generator, training, oracle, eval, checkpoint, plotgp1d.py— executable GP-1D example: generator, training, eval, checkpoint, plotdata.py— offline sharded data pools (write_pool+PoolReader)train.py— shared training loop, checkpointing, config, and resume- Dependencies: torch only in the core; plotting imports are isolated to
gaussian_toy.py. The default install is cross-platform (requirements.txt→torch==2.11.0); the NVIDIA-on-Windows CUDA build lives inrequirements-cuda.txt(torch==2.11.0+cu128viahttps://download.pytorch.org/whl/cu128). (Originally this said to match the pinned cu128 workstation stack on the RTX 4060 Laptop GPU; superseded 2026-06-14 — see the dated entry.) Single dataclass config, no config framework.
- Correctness oracle as an automated test? The Gaussian toy's analytic grid posterior
is kept as a first-class diagnostic (
gaussian_toy.pycomputes/shows predicted-vs-true moments). Whether it also becomes an automated check is undecided — leaning at most a loose check that catches catastrophic breakage, not a strict R²-threshold quality gate (brittle against stochastic training). - Online gen as a one-flag option — resolved: not a flag; it's the generator-as- function (see Data layer).
- How to distribute optional checkpoints — resolved (2026-06-14): the
full-precision source checkpoints are published on the Hugging Face Hub at
lacerbi/nanoACE(the HF route this bullet anticipated);huggingface_hubis a documented optional fetch and the core stays torch-only. See the dated entry at the top of this file. Original deferral and options weighed, kept for history: decide once there is a real trained artifact. Options on the table: a tiny toy checkpoint committed in-repo (keeps the Gaussian example instant + offline; sub-MB, no Git LFS, plainstate_dict); a larger model hosted on HF and lazy-downloaded (natural home for the multi-MB GP model); or both (two-tier). Principles to preserve for any option: core stays torch-only (any HF fetch is an optional extra, never required to read/run/train); the model ships config + seed so it's regenerable, not a mystery binary; and it's an example artifact, not the packaged "runtime product" non-goal.