Skip to content

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

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

MaxGhenis wants to merge 30 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 30 commits September 28, 2026 16:27
…m calibration

l2_basis selects the L2 concentration penalty's form: the historical
record-weighted mean(r**2) (default, bit-identical) or GREG's
anchor-weighted chi-square distance sum(d*(r-1)**2)/sum(d), whose
target-free optimum is the anchor itself. mass_parametrization selects
how mass='conserve' holds the total: the historical per-step uniform
log shift (default, bit-identical) or w = total*softmax(log_w), which
hands Adam the constraint-reduced gradient. Both are recorded in the
result options; chi_square_distance reports the realized distance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hypothesis properties for the penalty algebra (zero at the anchor,
nonnegative, float32 torch form equal to the float64 reference, the
weighting-effect identity, each basis's mass-constrained stationary
point), target-free convergence to the design weights, softmax mass
and cap invariants, the regularization-path monotonicity with a slack
derived from the measured optimizer error, the projection stall as a
pinned intended violation, option provenance and validation, and a
byte-for-byte pin of the defaults against the verbatim bda72cb
optimizer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both default to the historical solve and take the kernel's names. The
calibration summary and build manifest record them with the realized
chi-square distance; a resumed run refuses weights solved under other
penalty settings (a legacy checkpoint resumes only under the defaults it
was solved with); and the concentration limitation no longer claims
l2_lambda=0 for a penalized run.

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

The harness recalibrates the 2026-09-23 ACS local release's 4,459-target
surface from a sparse copy of its checkpoint, verified row-for-row against
3,000 sampled dense rows, with the release's settings plus the run's L2 and
mass options. optimizer_reference.py compares the kernel under each mass
setting with CLARABEL's exact optimum. The frontier sections of the doc
follow the sweep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s and the Modal grid

seeding_options.py measures the starting weights' Kish ESS (rows and distinct
source households) for ACS shares 0.5/0.7/0.9/proportional and 1/4/16 donor
location clones. The harness takes an acs_share prior, which becomes the
frame weights, the chi-square anchor and the cap base. modal_sweep.py runs
the grid, gated on reproducing the published release.

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

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

- The ACS tool keeps the certified-default limitation only for the historical
  solve (no penalty, projection); an unpenalized softmax run is recorded as
  its own. Legacy summaries read as the historical solve in the manifest.
  CLI choices are spelled in the tool (pinned equal to the kernel's) so
  parsing no longer imports torch. The resume guard ignores the basis when
  neither side has a penalty and says which settings it checks.
- The path tests now check each solve against CLARABEL's exact optimum of
  the same program on their own problems (committed fixture and generator),
  and derive the monotonicity slack from that bound on a lambda grid dense
  where the slack is informative.
- The softmax cap loop's docstrings describe what it does; epochs whose
  rounds run out are counted in the iterate-selection receipt. The
  projection stall claim now states its conditions (gradients of one sign,
  well above Adam's eps). Numerics are unchanged from 35ad665.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e at full size

At the release's scale the median record's log-weight gradient is 1.2e-8 to
3.8e-8 with mixed signs, so the sharp same-sign stall does not occur there;
the doc now says so and leaves the full-scale comparison to the sweep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…options into its solver stamp

l2_basis and mass_parametrization join _solver_settings, so main's resume and
already-complete checks cover them; a stamp written before they existed reads
as the historical solve. calibrate_surface takes both with historical
defaults. My separate resume guard is dropped in favor of the stamp.

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

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

Recalibrations of the published ACS local release on Modal (reproduction
gated): the chi-square penalty at lambda 0.03 lifts national ESS 13.6k to
21.8k and beats the release on two held-out folds; the ACS share of the
mass is the larger lever and costs held-out SOI fit. Receipts are compacted
copies of each run's metrics; the README's key table is written by
analyze.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Primary-QRF worker identity pins uv.lock's sha256. Hypothesis already
reaches every test environment through microcosm-graph's dev group, so the
calibrate dev-group entry was redundant; drop it rather than re-pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…imit not a bound, and the merged resume stamp

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oop limit, lead the gate with the relative floor

- README and doc: projection at lambda 0.03 is the recommendation, with both
  held-out metrics on both folds and the fold selection disclosed; the
  frontier claim is limited to lambda <= 0.03; softmax's cap-loop exhaustion
  at production scale is a stated limitation; the gate leads with "no
  district below 25% of its starting ESS", the state floor of 200 is dropped,
  and the relative gate's behaviour at share 0.9 is shown; the release row is
  labelled as reproduced and reconciled with the published and reported
  Massachusetts figures; kernel heads are listed with the solve.py diff.
- analyze.py: parametrization in every key-table label, a held-out
  within-10% column, kernel_head in frontier.csv, and a byte-level
  head-equivalence check for the dup_* reruns.
- Tool: the limitation text fills the historical basis for a legacy summary;
  the refresh recipe carries the penalty flags when they differ from the
  historical solve.
- seeding_options.py emits the Massachusetts split by spine.
- Rename test_path_reference.py to path_reference.py; drop the fixture's
  unchecked module hash; say eps is empirical; assert the unreachable
  softmax best-iterate branch.

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

engine-us CI failed test_us_coverage_is_exact_complete_and_honest: the US
seed protocol attests the installed source bytes of microcosm.calibrate.solve
(spec_engine/seeds.py), so this PR's solve.py edits move three identities:
- EXPECTED_HASHES["seed_protocol"] 91989ed5... -> 0e7ab5fc...
- EXPECTED_HASHES["seed_map"] 3cf10a52... -> f84f4fab...
- the US spec_sha256 pinned in test_us_multispine_pool_tool.py
  ffbb93ed... -> a7eee025...
The new values come from build_inventory_coverage on this tree, where that
was the only failing item. docs/evidence/spec-engine/us-f0-coverage.json was
regenerated with tools/spec_engine_coverage.py; --check passes with 41/41
inventory checks.

engine-free CI failed the path test's byte digest of the generated problem
for one of twelve cases while the other eleven matched. The fixture now
stores per-problem moments (verified locally against the old digests before
they were dropped), compared with rel 1e-9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The head-equivalence reruns are not byte-identical to their originals: the
trajectories already differ at epoch 0 (softmax, the loss at the starting
weights) or epoch 1 (the release), so the solves are not bit-reproducible
across Modal containers. analyze.py now writes results/rerun_variation.json
(bytes, first divergence, every frontier metric's relative change) instead
of a byte-identity check. The release's final loss moved 2.7% between two
identical runs (0.3% at lambda 0.03); concentration measures moved under 1%.

The held-out margins of projection lambda 0.03 over the release (3.6-3.8% in
capped error, 0.9-1.2 points within 10%) are of that order, so the README and
the doc now say "no worse out of sample" rather than "better", and list one
run per configuration as a limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the published weights, keep claims inside the noise

- release_refresh_recipe always names --l2-lambda, --l2-basis and
  --mass-parametrization, so a later default change cannot move a recorded
  release's recipe; the test parses the historical recipe back.
- The calibration summary and build manifest record
  softmax_cap_rounds_exhausted_epochs; calibrate()'s docstring and the
  --mass-parametrization help say the rounds run out at the ACS release's
  scale.
- published_weights.py writes results/published_weights.json, the receipt for
  the published weights' Massachusetts 446 / 149 and national 13,631.
- README and doc: the held-out gain is "ahead on both measures and folds,
  held-out variation unmeasured, optimistic", not justified by the training
  loss noise; softmax versus projection rests on training fit and the cap
  loop; lambda 0 is no longer "neither dominates" on a 3% loss gap; the
  share >= 0.9 floor ranges cover every solve; the 0.3% loss-gap bound is
  shown to say nothing about the overshoot (projection has the same gap);
  module hashes back the head account; the fingerprint docstring names the
  failing CI run without inventing a cause.
- Re-pin the spec-engine digests the docstring edit moves (seed_protocol
  be39eb6b..., seed_map 085d8d39..., US spec_sha256 e0b757ce...); coverage
  --check passes 41/41.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
engine-free CI failed test_every_pinned_platform_key_is_the_one_that_platform_would_compute[calibrate]:
the calibrate kernel's implementation hash covers solve.py's bytes, so this
PR's docstring, counter and guard edits move every platform's node key while
leaving outputs alone. Re-pinned with `uv run python tools/graph_parity_repin.py
calibrate`, which checks the pinned dependency versions match this machine,
refuses if the local direct call's bytes moved (they did not: the defaults are
byte-identical), and derives the other platforms' keys. The parity pin tests
pass locally (11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The minimal-bundle golden in test_spec_engine_loader.py hashes the
resolved legacy-v1 seed protocol, whose legacy_v1_direct_draws kernel
attests microcosm.calibrate.solve's source bytes. A differential run
that substitutes only the merge base's solve.py bytes reproduces the
old golden (6af478ff...) exactly; on this tree the only envelope fields
that differ are that kernel's source_sha256 and the derived
implementation_sha256 (be39eb6b..., the value inventory_coverage.py
already pins for US).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>

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