Skip to content

Project the softmax parametrization exactly onto the capped simplex each step - #1096

Open
MaxGhenis wants to merge 12 commits into
mainfrom
softmax-exact-cap-projection
Open

MaxGhenis wants to merge 12 commits into
mainfrom
softmax-exact-cap-projection

Conversation

@MaxGhenis

Copy link
Copy Markdown
Contributor

Stacked on #1078 (l2-design-basis, which adds mass_parametrization="softmax"). Until #1078 merges, this PR's diff includes it; review b7b4c2935..HEAD. Once #1078 merges, main will be merged in here and the pins re-checked.

What changes

Under mass_parametrization="softmax", every Adam step ended on a loop: renormalize so exp(log_w) sums to the total, clamp to the ratio cap, repeat, for at most 32 rounds. This PR replaces that loop with the exact projection onto {w : sum(w) = total, w <= cap * d} in log space (_project_softmax_log_weights_ in solve.py). It is the Kullback-Leibler projection, log_w <- min(log_w + s, log cap), with one common shift s:

  • How s is found. Active-set rounds, each one float64 pass over the records. With C the records already at their caps, the next shift solves sum(cap[C]) + exp(s) * sum(w[not C]) = total. That shift is a lower bound on the answer for every C, so C only grows, and a round that adds no record is exact.
  • Worst case. An adversarial input can need one round per record. After 8 rounds, a sort over the remaining records finishes it, so the worst case is O(n log n).
  • Receipt. options["iterate_selection_receipt"]["softmax_in_loop_max_cap_ratio"] records the largest ratio of a realized in-loop weight to its float64 cap, over every forward pass and the closing iterate, before the closing projection. It replaces softmax_cap_rounds_exhausted_epochs, which is always zero now. The ACS local-area tool carries the new key in its calibration summary and build manifest (last epoch batch, as before).
  • Unchanged. The "projection" parametrization, the closing float64 projection and every default path. test_defaults_reproduce_the_pre_change_optimizer_bytes still passes against optimizer_bda72cb02.py, and so do the best-iterate oracle pins in test_solve.py.

Invariants (all property-tested)

test_softmax_cap_projection.py checks the projection for every input Hypothesis draws, 300 examples per property. The inputs include zero design weights (zero caps), a cap of exactly 1 (the caps sum to the total), and totals from 5% to 100% of the caps' sum.

  1. Conservation and caps: sum(exp(P(x))) == total (rel 1e-12), and P(x) <= log cap exactly. A record with zero weight or a zero cap stays at zero.
  2. Idempotence and shift invariance: P(P(x)) == P(x) and P(x + c) == P(x).
  3. Feasible inputs: an input already within its caps after the shift moves by the shift alone.
  4. KKT of the KL projection: every uncapped record moved by one common shift, and every capped record would pass its cap under it.
  5. Differential against an independent reference. The result equals a float64 water-fill written from the definition. The active-set and sorted paths agree for 0, 1, 2 and 8 rounds. A constructed chain that needs 12 rounds exercises the sort fallback and matches the reference.
  6. Float32, as the solver runs it: the clamp is exact in log space, and the realized total * softmax(log_w) sits within its float64 caps to 1e-5.

In test_l2_basis.py:

  • Receipt bound: the receipt is <= 1 + 1e-5 for every softmax solve the property draws. That includes caps of 1, 1.001 and 1.05, where the cap binds on nearly every record; at a cap of 1 the solve returns the design weights.
  • No new receipt under projection: projection solves record no softmax receipt.
  • CLARABEL path tests unchanged: they pass with the new step.

Weights are compared in weight units (atol 1e-12 × total), not log units. When the caps nearly meet the total, the uncapped records hold only a small remainder, and their float64 rounding is a few ulps of the total. That error is large in log space and small in weight space.

Full-scale measurement (Modal, off the shared Mac)

experiments/us-acs-local-l2-basis-20260928/exact_cap_modal.py reran the ACS local release surface on Modal: 1,588,854 households, 4,459 targets, a 5x cap and 800 epochs. It used two configurations whose earlier runs ran out of rounds. Each ran under the new step and under #1078's loop (copied into the script), with the largest in-loop ratio recorded at every forward pass (results/exact_cap.md):

Configuration Cap step Largest in-loop weight / cap Steps out of rounds Cap step Final loss National ESS
share 0.5, chi-square λ 0.03 exact projection 1 + 9.0e-6 0 of 800 12.7 ms 0.018991 21,834
share 0.5, chi-square λ 0.03 32-round loop 1 + 1.2e-5 713 of 800 53.8 ms 0.019169 21,835
share 0.9, no penalty exact projection 1 + 9.0e-6 0 of 800 22.5 ms 0.031293 93,609
share 0.9, no penalty 32-round loop 1 + 8.7e-6 705 of 800 48.7 ms 0.031365 93,598

What this shows:

  • The documented limitation was smaller than feared. Chi-square design-weight penalty and softmax mass parametrization for calibration; ACS local ESS frontier #1078's docs could not say how far past the cap the loop left the weights. The answer is a float32-sized amount, about the same as the exact step's.
  • Both residuals come from the solve's float32 softmax. Its normalizer missed the total by up to 8.2e-6. With a float64 normalizer, the projected vectors sit within 1 + 4.9e-7 of their caps (exact_cap_float32.py, results/exact_cap_float32.json).
  • The loop ran out because of float32 too. Its shift was log(total) in float64 minus a float32 logsumexp. That bottoms out at log(total)'s float32 rounding residual (7.8e-8, measured exactly). Each round, that shift lifts one to three capped records whose log caps are below 2 in magnitude one float32 step over their caps. On vectors the projection had already made exact, the loop still ran out in 10 of 12 trials.
  • What the change buys:
    • the step is exact and never runs out (one to three rounds on every step at full scale);
    • it is 2.2-4.2x cheaper;
    • the in-loop overshoot is now recorded rather than unknown.
  • Final metrics. ESS agreed within 0.1%. At λ = 0.03, the loop run reproduced the grid's run exactly (0.019169), and the earlier rerun gave 0.019104. The projection's 0.018991 is 0.6-0.9% below those three, from one run, so not established.

docs/calibration-l2-basis.md replaces the "known limitation" section with this account. The experiment README adds it. Projection remains the recommended parametrization at that scale: softmax's training loss at λ = 0.03 is still 5% above projection's.

Re-pins (solve.py's bytes are attested)

  • EXPECTED_HASHES: seed_protocol be39eb6b -> 5795dc07, seed_map 085d8d39 -> 292ad19c. tools/spec_engine_coverage.py --check reports 41/41.
  • US spec_sha256 e0b757ce -> edcd6475, in us-f0-coverage.json and test_us_multispine_pool_tool.py.
  • Loader golden fee5893a -> 5135dd79.
  • Calibrate parity pins, via tools/graph_parity_repin.py calibrate; the direct-call bytes are unchanged.

Tested locally (targeted, on the shared host)

  • Calibrate tests: test_softmax_cap_projection.py (11 passed) and test_l2_basis.py (all 51 passed, including the CLARABEL path tests and the byte pin).
  • Tool and oracle tests: the ACS tool's penalty and settings tests, and the best-iterate oracle tests in test_solve.py.
  • Pins: the loader golden and the graph parity pins (12 passed).
  • Not run locally: the engine jobs (engine-us), which CI runs.

🤖 Generated with Claude Code

MaxGhenis and others added 12 commits October 2, 2026 08:14
…pped simplex

Each softmax step alternated a softmax-invariant renormalization with a
clamp to the ratio cap, for at most 32 rounds. Replace that loop with the
exact projection onto {w : sum(w) = total, w <= cap * d} in log space: one
common shift, then a clamp, with the shift found by active-set rounds (each
a lower bound that only adds records to the capped set) and finished by a
sort if they run long. The shift is computed in float64.

The selection receipt records softmax_in_loop_max_cap_ratio, the largest
ratio of a realized in-loop weight to its float64 cap before the closing
projection, in place of the exhaustion count. The projection parametrization
is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…scale cap-step launcher

The tool's calibration summary and build manifest carry
softmax_in_loop_max_cap_ratio in place of the exhaustion count. The sweep
harness records each batch's selection receipt. exact_cap_modal.py runs one
softmax configuration under the committed kernel and under microcosm#1078's
32-round loop, with the ratio at every forward pass, the cap step's rounds
and its wall time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… projection

Four Modal runs of the ACS local surface (1,588,854 households, 4,459
targets, a 5x cap, 800 epochs), at two softmax configurations whose earlier
runs ran out of cap rounds, each under the committed projection and under
microcosm#1078's 32-round loop (results/exact_cap.md, results/exact_cap/).
The projection never ran out (one to three active-set rounds per step,
12.7-22.5 ms); the loop ran out on 705-713 of 800 steps (48.7-53.8 ms). The
largest in-loop weight over its cap is 1 + 9.0e-6 under the projection and
1 + 8.7e-6 to 1.2e-5 under the loop.

exact_cap_float32.py measures where that residual comes from and why the loop
ran out; it runs on Modal from the sweep's volume.

The solve.py change is the active-set comment, now citing those runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d loop ran out

On full-scale vectors the exact projection had already put within their
caps, microcosm#1078's loop still ran out of rounds in 10 of 12 trials: its
shift is log(total) in float64 minus a float32 logsumexp, which bottoms out
at log(total)'s float32 rounding residual (7.8e-8), and that lifts the 1-3
capped records with log caps below 2 in magnitude one float32 step over each
round. With a float64 normalizer the projected vectors sit within 1 + 4.9e-7
of their caps; the float32 softmax's own total error (up to 8.2e-6 here) is
the rest of the in-loop residual.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ests solve.py moves

docs/calibration-l2-basis.md replaces the cap-rounds limitation with the
projection, its tested invariants and the full-scale measurement; the
experiment README adds the re-measurement; a changelog fragment records the
change and the #1078 fragment drops its exhaustion-count claims.

solve.py's bytes are attested by the US spec-engine seed protocol and the H1
calibrate parity case. Re-pinned on this tree:
- EXPECTED_HASHES seed_protocol be39eb6b -> 5795dc07, seed_map 085d8d39 -> 292ad19c
  (build_inventory_coverage; tools/spec_engine_coverage.py --check: 41/41)
- US spec_sha256 e0b757ce -> edcd6475 (docs/evidence/spec-engine/us-f0-coverage.json,
  test_us_multispine_pool_tool.py)
- loader golden fee5893a -> 5135dd79 (test_spec_engine_loader.py)
- calibrate parity pins (tools/graph_parity_repin.py calibrate; direct-call
  bytes unchanged)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings main 4a36edc in by merge (base b7b4c29). Both sides moved the
US seed protocol and seed map digests since the base, so the five pin
files conflicted. The merge regenerates them for the merged tree with the
repo's tools instead of taking either side's hash:

- tools/repin_us_spec_engine_contracts.py rewrites EXPECTED_HASHES
  seed_map and seed_protocol, the loader golden and the multispine
  spec_sha256. Its first pass reads the stale pins and counts 40 of 41
  inventory items covered, so a second pass restores 41; --check passes.
- tools/spec_engine_coverage.py regenerates us-f0-coverage.json
  (--check: 42305/42305 fields, 41/41 inventory checks). The multispine
  pin equals the JSON's spec_sha256.
- tools/graph_parity_repin.py calibrate re-pins the calibrate parity
  case; its direct.csv bytes did not move.

same_edit.py 16ff6eb 4a36edc flagged nothing. In the engine-free venv,
the pre-fix projection tests, test_l2_basis.py, the spec-engine inventory
and loader tests, the graph parity pins and the ACS tool's two penalty
tests pass on this tree (99 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w S2)

The softmax cap step's two exits lifted every record that could reach its
cap to that cap. The active-set rounds took that exit whenever rounding
left no room after the capped records' caps, or no uncapped mass; the sort
took it when no record met its test. Either way the total could end up to
the uncapped records' caps over. A finite log-weight that underflows
float64 also counted as no weight.

_capped_log_shift now returns the current shift when the room is not
positive: its lower bound keeps the room at least the uncapped records'
share, so a room at or below zero is float64 rounding, and those records
keep a rounding-sized share. When the uncapped weights sum below float64's
normal range, the rounds hand over to _sorted_capped_log_shift. The sort
sums weights in log space with logcumsumexp and includes every finite
log-weight. When room_k is not positive it caps exactly the first k
records, it clamps the answer to the previous record's cap shift so
rounding cannot uncap that record, and it caps every record that carries
weight only when their caps hold less than the total.
_cap_every_record_shift is gone. The common path's arithmetic is unchanged.

Invariants, each checked over up to 300 Hypothesis draws in
test_softmax_cap_projection.py: the projection conserves the total to
1e-12 and respects every cap; zero weights and zero caps, drawn
independently, stay at zero; it is idempotent and invariant to a constant
added to the log-weights; a feasible input moves by the shift alone; it
meets the KL projection's KKT conditions; it equals a 60-digit decimal
water-fill; the rounds and the sort agree; and in float32 the realized
weights stay within their caps to 1e-5. A new strategy lets the caps of a
held set meet the total, or nearly, while the other records sit 45 to
1,100 nats below their caps. A verbatim, SHA-checked copy of the measured
cap step (4bddde6, tests/fixtures/pre_cap_step_fix/) pins the new step
bit for bit wherever the measured one settles in its rounds (review S1).

The measured step fails 11 of the file's 19 tests; this one passes all 19.
Six mutants of the new code each fail at least one test: the sort treating
a non-positive room as +inf, the old all-capped exit, handing over only at
zero uncapped mass, no previous-cap clamp, dropping underflowed records,
and the no-k branch returning the lower bound.

The repo's tools re-pin what the new solve.py bytes move: EXPECTED_HASHES
seed_map and seed_protocol, us-f0-coverage.json (41/41 inventory checks),
the loader golden, the multispine spec_sha256 (equal to the JSON's) and
the calibrate H1 parity pins (direct.csv unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The experiment scripts' copies of microcosm#1078's cap step cited
b115745. They now cite b7b4c29, the #1078 head this branch started
from. solve.py is byte-identical at the two commits, so the copies match
either one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings main 3a9c808 (#1484) in by merge. Both #1484 and this branch
moved the US spec hash, so us-f0-coverage.json and the multispine
spec_sha256 conflicted. The repo's tools regenerate both for the merged
tree: tools/repin_us_spec_engine_contracts.py rewrites the multispine pin
(--check passes), and tools/spec_engine_coverage.py rewrites the JSON
(--check: 42210/42210 fields, 41/41 inventory checks). The pin equals the
JSON's spec_sha256. This branch's seed protocol and seed map pins hold
unchanged, and neither solve.py nor the calibrate parity pins moved.

same_edit.py 8ae6934 3a9c808 flagged nothing. In the engine-free venv
the projection, L2-basis, spec-engine inventory, loader and field-usage
tests, the graph parity pin and H1 parity tests, and the ACS tool's eight
penalty tests pass on this tree (129 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings main 3293730 (#1455, the emitter collector hostname) in by merge.
It merged cleanly and touches no attested module or pin: the repinner's
--check and tools/spec_engine_coverage.py --check (42210/42210 fields,
41/41 inventory checks) pass on this tree, and same_edit.py f86e9e1
3293730 flagged nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, N5)

The measured-step differential holds only where every nonzero weight sits
in float64's normal range: below it the current step hands its rounds over
to the log-space sort, which the measured step lacked. The test docstrings
and docs/calibration-l2-basis.md now say so, and the test docstring names
the quantity that never fell more than 1% between passes: each forward
pass's largest weight-to-cap ratio.

N2: _IN_LOOP_CAP_TOLERANCE and the ACS tool test's matching bound say they
hold for test sizes only; the release surface reached 1 + 9.0e-6.
N4: the docs and the experiment README credit the float32 softmax only
with the projected vectors' residual, the one exact_cap_float32.py
decomposed.
N5: the README scopes "the softmax results stand" to the two rerun
configurations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant