Run B is decided against: the power analysis, the 8-epoch cosine rung, and the gate applied (#135) - #138
Conversation
…does not help (#135) Answers the prerequisite #135 put before the 1,675-3,350 GPU-hour spend, from committed data alone: no cluster time, no .model_cache, no GPU, no network. Two results decide the question. Pairing is worth 2-3x and it is free. Both checkpoints are scored on the same 1,000 panoramas against the same ground truth, so the pano-to-pano difficulty that dominates the unpaired noise cancels. Measured on manual_gold, the s.e. of a difference drops 1.8-3.6x, and the MDE on max-F1 goes from 0.0117 unpaired to a bracket of 0.0043-0.0135 paired. The quantity that governs it is the discordance between the two detectors, not n*p*(1-p), which is why the 0.01 tie bar -- an unpaired bar -- is the wrong instrument for an epoch-vs-epoch comparison. Pooling is worth 7%. Ten splits hold 6,560 instances to manual_gold's 3,919 and the MDE moves 0.0117 -> 0.0109; the nine city splits alone are worse than manual_gold alone (0.0219). Not a lever, and it would mix an independently-labeled GT with nine RampNet-anchored ones. Two things fall out along the way. Run A's plateau, re-read paired, is real on capability and not on calibration. Epochs 2 vs 6 are identical on max-F1 to four decimals and 3 vs 6 is unreadable even at the favourable end of the bracket -- so the #84 finding survives a sharper instrument. But the recall gaps at the fixed 0.30 point need 20-34% discordance to be unreadable against 2.1-9.1% observed, so the operating-point drift #84 described is measured, not hedged: the epoch-3 preference under the recall-first stance is a result rather than a judgment call. The 0.008 the tie bar was derived from is sqrt(0.25/3919), the binomial worst case at p=0.5. At RampNet's actual recall it is 0.0048, and the measured clustered value is 0.0055 -- so the bar was conservative by ~45%, and lands near the right unpaired answer for the wrong reason. Panorama clustering is real: design effect 1.23-1.88, which is why the bootstrap resamples panos and not instances. Instrument check, committed rather than noted: the RampNet-vs-RampNet pair straddles records.jsonl and op_cache, and #132's exclude_border artifact puts 30% of its disagreements at the seam against a 2.1% baseline. The two pairs drawn from one source read the baseline exactly, which is the control saying the diagnostic does not fire spuriously. The limit that actually binds is not the benchmark: this is panorama sampling variance only, and Run B is n=1. Nothing here or anywhere in the repo measures training-seed variance, so a gain below ~0.01 max-F1 would be measurable but not attributable to the anneal. Recorded next to the number, with the recommendation. Verified: metrics() under all-ones weights reproduces detection_eval.aggregate to 1e-12 on four (split, model) combinations spanning both GT schemes and both detection sources. Suite 1234 passed, 1 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#135) The #135 write-up bracketed the paired MDE at [0.0043, 0.0135] because the epoch-to-epoch discordance of two RampNet checkpoints has never been measured -- only bounded -- and said closing it would cost about a GPU-hour. It costs none. The 2026-08-17 scoring cache survived on makelab2 intact: 13 GB at run_a_84/evaluate_cache/heatmaps/, all EIGHT epochs single-pass, 1,000 panos each. Peak extraction from a cached heatmap is CPU-only numpy, so the measurement needs no model, no panorama images (they are not on that host anyway), no GPU and no network -- and it covers the whole curve rather than the two checkpoints the doc proposed. A separate script rather than a flag on stage_two/evaluate.py, deliberately: that evaluator produced every committed Stage 2 number and its heatmap cache key is <fingerprint>_<dataset>_<tta> and nothing else, so the cheapest way to guarantee this analysis cannot perturb either is to not touch it. What it does share is the part that must not diverge -- extract_peaks_from_heatmap, PEAK_MIN_DISTANCE and MODEL_HEATMAP_SIZE are imported from it, not copied. --threshold truncates the tail on purpose. Run A scored at --threshold 0.0, which keeps every local maximum: ~511,000 predictions over 1,000 panos, nearly all noise floor. The 0.05 default matches op_cache's floor, keeps files ~200 KB instead of ~40 MB, and sits far below everything this is used for (protocol point 0.30; Run A max-F1 lands at 0.268-0.582). The cost is that AP is not recoverable below it, which is stated in the file and beside the output. --verify re-scores each dump against manual_labels and checks it reproduces docs/data/run_a_84_manual_gold/summary.csv. That is the load-bearing part: it is what proves the dump is the same instrument that produced the committed curve rather than a second, similar one. Output lands in the published-detections shape, so benchmark_power_135.py and every other reader of benchmark/model_detections/ takes it with no special case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…135) The write-up could only BRACKET the paired standard error for two Stage 2 checkpoints at [0.0043, 0.0135], because Run A's committed artifacts are aggregate PR curves and carry nothing per-panorama. That bracket is now a measurement, and it cost no GPU: the 2026-08-17 scoring cache survived on makelab2 with all eight epochs at 1,000 panos each, so dump_peaks_from_cache.py recovered the detections in 4 minutes with no model, no images and no network. MEASURED across all 28 pairs of Run A checkpoints on manual_gold: se(d max-F1) 0.0016-0.0029, median 0.0021; discordance 2.5-6.6%. For pairs >= 3 epochs apart -- the closest analogue to Run B vs Run A -- median se 0.0022, i.e. MDE 0.0063 at 80% power against the unpaired bar's 0.0117. The old bracket contained it and the working assumption (4-6% discordance => 0.006-0.009) was nearly exact. s.e. grows with epoch separation, 0.0016 at gap 1 to 0.0029 at gap 7, so a Run B checkpoint -- further from any Run A checkpoint than any pair here -- should be planned at se ~0.003, MDE ~0.008. That is an extrapolation of a measured trend and is flagged as one. AND IT CORRECTS #84's CURVE SHAPE. Each pair now has its own measured s.e. instead of one global bracket, and epochs 7 and 8 are measurably BELOW the plateau: 3 vs 7 at z=3.9, 3 vs 8 at 3.0, 6 vs 7 at 2.8. The unpaired 0.01 bar could not see it. So the curve is not "steps up once from 1 to 2 then flat" -- it is epoch 1 low, epochs 2-6 a genuine plateau (2 vs 6 identical to 4 dp, 3 vs 6 at z=1.4), then a measurable decline. A shallow inverted U. The headline is unchanged -- still no resolvable peak, 2-6 remain mutually indistinguishable -- but at constant LR the model does begin to LOSE capability after ~epoch 6. That sharpens what Run B is testing: not "does a longer budget add to a flat curve" but "does cosine decay arrest a decline that is now measured". It cuts both ways -- that decline is the classic signature of an LR left too high, which is what an anneal fixes; but a 30-epoch run whose decay does not bite could land BELOW epoch 3. It also makes the #84 amendment's 8-epoch cosine rung (~450 GPU-h, budget-matched) more attractive than it was, because B-A does not attribute on its own. Verification is the load-bearing part: re-scoring each dump against manual_labels reproduces the committed summary.csv max-F1 to 5e-9..4e-7 across all eight epochs. max-F1 is a property of the whole PR curve, so agreement at 1e-7 says the peak extraction is the same operation, not a similar one. The uniform 1.2-1.4e-4 offset on F1@0.30 is the committed table's own 0.005-grid downsampling, which its provenance note already documents. The dumps land in docs/data/run_a_84_detections/, NOT benchmark/model_detections/: roster.py asserts every file there belongs to a registered challenger leg (#122), and Run A's epochs are internal checkpoints, not entries in the RampNet-vs-VLM comparison. The suite caught the first attempt to put them there -- the registry working as intended. They are committed because the 13 GB cache they came from cannot be, which is stated beside them as the one input a clean clone cannot get. Suite 1236 passed, 1 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deep reviewRe-ran everything from the branch head (
So everything below is a labeling/interpretation problem sitting on top of an artifact that is exactly what the script produces. One of them makes a headline number wrong by ~2×. 1. High — the nine city bundles are hard-truncated at conf 0.55, so "the #54 operating point of 0.30" is not what was measured on 9 of 10 splits
Four consequences:
The repo already documents the truncation at Measured impact, re-running with
"Pooling is worth 7%" is understated by ~2× — it is ~14%. The conclusion holds (14% is still not a lever; POOLED cities alone stays far worse than manual_gold alone), but the number in the summary table and the PR body is wrong. Caveat on that check: The headline paired numbers are all 2. Medium — the "LOWER BOUND" section cannot bound what it claims
That matters because the module docstring assigns this section exactly that job ("…so they give the lower bound"). For max-F1 — the metric the headline bracket and the whole recommendation are stated in — it cannot. No published number is wrong (the real 0.0015 comes from 3. Medium — the doc's own reproducibility claim fails on its headline numberLine 281: "Every derived number in this document is in It errs safe (interpolating the three committed points to 4–6% gives about 0.0056–0.0068), but per the repo's replication rule it should be computed by the script, and that sentence should not stand as written. Related: the stated mechanism "the standard error tracks the discordance" does not hold on its own three points — 8.1% gives 0.0029 but 9.1% gives 0.0048, a 12% rise in discordance against a 66% rise in s.e. For max-F1 there is no McNemar form, as the doc itself says. 4. Medium-low — the summary table mixes F1 and max-F1 standard errors under a max-F1 headerThe headline table (and the PR body verbatim) is headed "s.e. on Numerically immaterial, but it is the one table people will quote, and "0.01 is very nearly the correct unpaired MDE of 0.0117" is stated about the wrong column. (The separate "1.8×–3.6× gain" claim is like-for-like — both sides F1.) 5. Medium-low — the #84 correction is not discoverable from #84's own docThe PR's best secondary result is that the epoch-3 preference is now a measured result rather than a judgment call. 6. One statistical cautionThe load-bearing assumption is hedged in one place and dropped in another. The max-F1 bracket is carefully "bracketed rather than point-estimated." But the recall table prints a flat "resolvable" for gaps needing 20–34% discordance, justified by "real pairs on this benchmark run 2.1–9.1%" — where those pairs are two cross-architecture YOLO comparisons and one seam-contaminated self-pair, none of them epoch-vs-epoch. Run A's own data suggests epoch-to-epoch calibration is unstable ( 7. Low
What holds upStating this because a defect list on its own misrepresents the PR:
The irony worth naming: finding 1 is exactly what "verify the instrument before judging" exists to catch, and the evidence was sitting in this PR's own committed JSON as 18 rows of Fixes for 1–7 to follow on this branch. 🤖 Generated with Claude Code (claude-opus-5[1m]) |
…safe schedule (#135) Run A measured that constant LR does not merely flatten -- epochs 7-8 are measurably BELOW the plateau (3v7 z=3.9, 3v8 z=3.0, 6v7 z=2.8). Drifting back up after settling is the signature of a learning rate left too high to settle with, which is what a decay fixes. So there is now something for an 8-epoch anneal to act on; before that measurement the curve looked flat and this rung would have had nothing to demonstrate against. It is also the only comparison that ATTRIBUTES. Run B is 30-60 epochs AND cosine, so B - A confounds length with schedule -- the #84 amendment says so explicitly. This rung holds the budget at Run A's 8 epochs and changes only the schedule, at ~480 GPU-h and ~34 h calendar against Run B's ~1,800 and ~5-7 days. It is not a substitute: an 8-epoch cosine decays fast and cannot show a benefit that needs length. It answers the narrower question for a quarter of the cost. THE SCHEDULE IS STATELESS AND THAT IS THE DESIGN. train.py's new lr_at_step computes the rate from the absolute step index, not from a scheduler object. Stage 2 runs on preemptible ckpt-all and resumes from latest_checkpoint.pth (Run A was requeued twice); a stateful scheduler restarts its decay from the peak on every requeue unless its state is ALSO serialized, turning a cosine into a sawtooth. That failure is silent -- the job completes and the loss curve looks plausible. Reading global_step, which is already checkpointed and already broadcast to every rank, makes it impossible by construction instead of by remembering one more field. tests/test_train_lr_schedule.py pins it by simulating a run chopped into segments by requeues and asserting the LR sequence matches an uninterrupted one. train.py now logs per-step LR to tensorboard, because a sawtooth is invisible in the loss curve and obvious there. Read that scalar before trusting the run. Default stays --lr-schedule constant, so a re-run of Run A or the paper recipe is unaffected; a test asserts that default rather than trusting it. No warmup: it would be a second change, and Run A was stable from step 0. The decision rule is pre-registered BEFORE launch, in docs/stage2_cosine_rung_135.md -- primary test (cosine ep8 vs Run A ep8, paired, max-F1, each pair against its own bootstrap s.e.), secondary mechanism test (is Run A's own ep3->ep8 decline arrested?), and the gate on whether Run B follows. A tie on both is explicitly NOT an automatic cancellation; it is a judgment call, recorded as one rather than dressed as a rule. What it cannot settle is stated in the same file: seed variance is unmeasured and this is n=1, so a difference below ~0.01 max-F1 is measured but not attributable. Suite 1243 passed, 1 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…not assumed (#135) klone's train.py was byte-identical to main before the upload (ccce228b), so what runs is main plus the scheduler and nothing else -- its post-upload hash 051e3256 matches this branch exactly. The launcher landed as ASCII, not CRLF, which would have died with a bad-interpreter error. Dataset counted rather than stat'ed (300,126 entries under train/ = 150,063 pairs = Run A's 9,378 steps/epoch at world size 16), since the directory predates scrubbed's purge window. 270 GB free on /gscratch/makelab for a ~10 GB run. Env reused from Run A's durable copy, so the environment is identical to the arm being compared against. Fresh start confirmed at submit: no latest_checkpoint.pth in the new run dir, which is deliberately NOT Run A's -- pointing it there would have resumed Run A's weights and overwritten its artefacts. Recorded the first thing to check once it starts: the per-step LR scalar must fall smoothly from 1e-5. A sawtooth means a requeue reset the schedule, and that is invisible in the loss curve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cosine rung's whole premise is that `lr_at_step` is stateless, so a preemption cannot restart the decay from the peak. That was argued in a docstring and covered by a unit test of the *formula*; nothing checked what the running job actually applied after a resume. The failure mode is silent -- a sawtooth job completes and its loss curve looks fine -- so "it should be correct by construction" is exactly the claim that needs an instrument pointed at it. `scripts/analysis/check_lr_schedule_135.py` reads the per-step LR scalar train.py logs and asks four things a mis-resume would fail: is the merged series monotone, do overlapping incarnations agree on the same step index, what is the rate at each resume as a ratio of the peak (1.000000 is the sawtooth signature), and does every value match `lr_at_step` at that step. It reuses `stage2_train_cost.read_scalars` via `stage2_epoch_curve.read_scalars_by_file` -- stdlib only, no tensorboard install, and the per-file split is what makes incarnations visible at all. Two things worth naming: - **The off-by-one is real.** train.py computes `lr_at_step(n)` then logs it under step `n + 1`. Reading it without that shift makes a correct run look wrong by one step's worth of decay, so the script names a whole-series shift as a convention change rather than reporting it as a broken schedule. - **The lift out of train.py now has one home.** `tests/test_train_lr_schedule.py` had its own AST copy; it imports the script's `load_schedule` instead. That test covers the formula, this script covers where the formula was applied. The tests are mostly negative, on purpose: a checker that passed vacuously would be worse than none, because a PASS would be read as evidence. `test_a_sawtooth_resume_ fails` builds the exact defect and asserts it is caught -- note the teeth are only 0.7% deep 4,000 steps into a 75,024-step cosine, which is why this is a ratio and a monotonicity test rather than an eyeball. Validated against job 38640313's first incarnation (4,003 steps): 0 non-decreasing violations, max deviation 4.547e-13, reproducing the ad-hoc 49-step check that preceded it at three orders of magnitude more data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tions (#135) `sacct` collapses a requeued job to its LAST incarnation, so the count in these three docstrings was read off a view that cannot show the others. `sacct -D` on Run A's two job ids returns six incarnations, i.e. five requeues: 38541865 1s ran 8:12:19 REQUEUED 38541865 2m04s ran 0:59:35 PREEMPTED 38541865 2m26s ran 8:09:12 REQUEUED 38541865 2m01s ran 2:47:04 FAILED (its own error, not the scheduler) 38566413 16m02s ran 8:06:45 REQUEUED 38566413 2m03s ran 4:47:18 COMPLETED This strengthens rather than weakens the argument the docstrings are making: the stateless schedule had to survive five resume boundaries on the run that motivated it, not two. Comment-only. The copy of train.py under test on klone is untouched and still hashes to c359a45, matching the PROVENANCE.txt pin for job 38640313, so the run in flight is unaffected and its provenance still resolves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng everything (#135) The cosine rung spent 8h54m on 2026-08-19/20 advancing its durable checkpoint by zero steps, across nine allocations, four of which never logged a single training step. Two independent causes, both fixed here. **1. Resuming decoded every batch it meant to skip.** The fast-forward was for i, (images, target_heatmaps) in enumerate(train_loader): if epoch == start_epoch and i < batch_idx_in_epoch: continue which pulls each skipped batch all the way through the DataLoader -- reading and decoding a 2048x4096 panorama per skipped step -- and throws it away. Measured from event wall-times against sacct -D start times: fresh start (step 0) 4.6 min to first logged step resume from step 4,000 23.2, 23.4 min resume from step 9,000 29.6 min `ResumeSkipSampler` drops the *indices* instead, so the workers never fetch them. The batches that remain and their order are identical -- DistributedSampler's permutation is a pure function of (seed, epoch), so an islice off the front leaves the rest untouched. This is a speed fix, not a change to what the model sees, and `test_skipping_matches_the_old_discard_loop` is what holds that claim up: if it ever breaks, a resumed run trains on a different sample of the epoch than an uninterrupted one and nothing downstream would reveal it. **2. Checkpoints every 1,000 steps are 22 min apart.** Now `--checkpoint-interval-steps`, still defaulting to 1000 so every committed Stage 2 number is untouched; the rung's launcher passes 200 (~4.5 min). A test pins the default and asserts the launcher opts down explicitly rather than inheriting it. Together: a slice had to last ~45-52 min to bank anything, against observed slices of 36 s to 6 min. That is a livelock, not slow progress. Two traps worth naming, both silent if got wrong: - **The LR horizon must not move on resume.** `total_train_steps` now reads `train_sampler.epoch_length` (full, unskipped) rather than `len(train_loader)`, which shrinks on the epoch a resume lands in. Had it moved, a resumed run would decay over a shorter horizon than the one it is continuing and the schedule under test would quietly not be the pre-registered one. - **`batch_idx_in_epoch` is now `resume_offset + i + 1`.** With the skip moved into the sampler, `i` restarts at 0, so checkpointing bare `i + 1` would rewind further on every successive preemption. This is a latent defect in every preemptible Stage 2 run, not just this rung -- Run B would have hit it harder, being longer. It does not invalidate anything already measured: the LR verification across four resume boundaries stands on its own. Suite 1361 passed, 1 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The red CI was a real matcher divergence, and the seam is worth 0.000264 to this analysisProvenance note first: this work landed inside commit fa02b37, whose message describes What was failingFailing since 2026-08-18, three runs, both Python versions. Not a flake. This branch predates
What it moved: one numberRe-scoring all eight committed epoch dumps with the wrapping matcher:
One prediction on one panorama now claims a ground-truth ramp across the seam instead of The conclusions hold, and sharpen slightly0.000264 is 4% of the 0.0063 paired MDE the recommendation rests on, and it moves epoch 7
So "epoch 1 low → 2–6 a genuine plateau → 7–8 measurably declining" stands, with 3 vs 7 now One thing this exposed that was not the seamThe max-F1 table was mixing matchers: point estimates read from Both matchers are now pinned. The historical curve is checked against the historical matcher at Spun outRe-scoring the whole roster to check the blast radius turned up three committed YOLO 🤖 Generated with Claude Code (claude-opus-5[1m]) |
…it (#135) Job 38640313 finished 2026-08-21T19:07:16 after 21 restarts. It was never cancelled and never resubmitted -- the requeue livelock diagnosed on 08-20 broke on its own when ckpt-all capacity freed up and incarnations went from minutes to eight-hour slices. Recorded as a caveat rather than a success: fa02b37 was NEVER applied to this job. The train.py that ran carries no ResumeSkipSampler and still has checkpoint_interval_steps hardcoded to 1000. The rung finished on capacity weather, which says the livelock is a property of the partition regime -- it will recur -- and that a run whose completion depends on luck is not one to schedule deliberately. **The check the pre-registration named as load-bearing now covers the whole run.** Only five resume boundaries had ever been checked against 21 restarts. Re-run against the complete event set, with the AS-RUN train.py as the lift source rather than this branch's copy: 75,024 of 75,024 unique steps (100.00%), zero non-decreasing violations, max |logged - lr_at_step(step-1)| = 4.547e-13 against a 1e-11 tolerance. Every boundary lands at the rate its step index predicts and never at 1.000000, which is the sawtooth signature. The schedule under test is the schedule that was applied. The auto-label half of the curve, budget-matched against Run A (one difference: the schedule decays): - **Annealing does not move the optimum.** Both arms bottom at epoch 5. - **Annealing damps the post-minimum decline.** Run A rises +4.6% from its own minimum to epoch 8; the rung rises +1.4%. About 70% of the late-epoch degradation is schedule, not overfitting. The reason to believe the second is the shape, not the size: the arms are indistinguishable through epochs 1-3, where cosine is still between 1.00x and 0.69x of peak, and separate monotonically from epoch 4 as the anneal bites. With n=1 per arm, seed variance remains the unmeasured term #138 identified as binding. manual_gold is NOT scored yet and this settles nothing about #135's actual question. That sweep is running on makelab2 with the protocol copied verbatim from run_a_84/run_evals.sh and the eval host's repo pinned at dc7450e -- the commit Run A was scored under -- so the comparison isolates the schedule rather than confounding it with the #140 matcher change (#148). A prediction is recorded in the doc before the numbers arrive, so it can be wrong. Committed so this re-derives from a clean clone with no cluster access, per Run A's precedent: - stage_two/cosine_rung_135_events/ -- all 18 event files, 7.4 MB, with SHA256SUMS. Verified byte-identical to the klone originals after transfer, and the LR check reproduces the same PASS from these committed copies on a different OS. - docs/data/stage2_cosine_rung_135.csv -- the curve, script-derived. stage2_epoch_curve.py grows --curve-label / --reference-label. Its CSV column names were hardcoded to run_a_val_loss / paper_val_loss, so writing a second run through it would have produced an artifact headed with the name of a run it does not contain -- worse than no artifact, because nothing downstream could tell. Defaults are unchanged and Run A's committed CSV regenerates byte-identically; the full suite passes (1361 passed, 1 skipped). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pre-registered question is answered, and the prediction recorded in this file before the numbers existed was right: annealing changes nothing measurable on the benchmark. Largest |delta| on either pre-registered metric is 0.0042, against #138's measured paired MDE of 0.0063. No epoch separates the arms. The F1@0.30 peaks are 0.9161 (Run A, epoch 6) and 0.9163 (cosine, epoch 4) -- the same number at a different epoch -- and both arms replicate #84's finding of no resolvable human peak. **The finding is a negative one about the selection signal, not about the schedule.** Annealing measurably improves auto-label validation loss: up to 3.980% at epoch 8, monotone in the anneal, and mechanistically consistent, with the arms indistinguishable while the cosine is still near peak and separating as it bites. That is not noise. None of it reaches human-labelled F1. The ~1% auto-val gain at the shared optimum buys nothing on 3,919 human-placed instances, consistent with #84's exchange rate (13.5% auto-val bought ~0.009 F1), which is what the prediction was scaled from. Auto-label val loss is a real optimisation signal only loosely coupled to the thing we care about. **AP disagrees with F1, and that is recorded rather than quietly dropped.** At epoch 7 the AP gap is +0.0092 while max-F1 is +0.0042 -- more than double. AP integrates the whole PR curve, F1 is read at a point, so the natural reading is that annealing improves the low-confidence tail more than the operating region. Quoting the AP delta as the headline would overstate the result 2x. Nothing above rests on AP; it is not a pre-registered metric here. One directional hint, flagged as a hint: from each arm's own F1@0.30 peak to epoch 8, Run A declines -0.0073 and the cosine arm -0.0039, so the late-epoch damping visible in auto-val survives into F1 at about half size. Both straddle the tie bar, so this is a thing to test at length, not to claim at n=1. For the Run B gate: this settles that at 8 epochs, budget- and seed-matched, the schedule does not change manual_gold. The mechanism-based argument for the annealed arm now has no benchmark support at this length. It does NOT settle whether a 30-epoch annealed run helps -- that changes length and schedule together, the confound the #84 amendment flagged, and this rung was built not to answer it. Provenance: scored on makelab2 with the repo pinned at dc7450e, the commit Run A was scored under, so the comparison isolates the schedule rather than confounding it with the #140 matcher change (#148). The summary script is byte-identical (c42c23dc10f3) to the one that produced Run A's committed table. All 8 checkpoints were sha256-verified after transfer and evaluate.py stamps each fingerprint into its metrics file; the eight fingerprints in summary.csv match the eight source hashes, so every row ties to specific weights rather than a directory name. Downsampled PR-vs-confidence curves committed at docs/data/cosine_rung_135_manual_gold/ (a few KB each; full curves are ~4 MB x 8 and the checkpoints 8.6 GB, neither committable). They re-derive F1 to three decimals, well inside the tie bar, so the table is checkable without cluster access. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deep review, second pass: the ten commits since a6fb658Re-ran everything from the branch head (
The training-side work is sound and verified over the whole run. The problem is on the reading side: the rung's pre-registered test was not run, and the 2026-08-18 review's findings were mostly not fixed — the first review's "Fixes for 1–7 to follow on this branch" is still outstanding for five of the seven. Status of the seven prior findings (review of 2026-08-18)
New findings on the unreviewed delta1. High — the rung's pre-registered test was not run; "tied at every epoch" is read against a different barThe pre-registration ( Those are different bars. The MDE is 2.80 × s.e. at 80% power; the pre-registered significance bar is 1.96 × s.e., 1.43× lower. An effect below the MDE is not thereby non-significant. Using the only paired s.e. this branch has measured — Run A epoch pairs, 0.0016–0.0029 — epoch 7's Δmax-F1 of +0.0042 gives z between 1.4 and 2.6, straddling 1.96; epoch 8's +0.0030 gives 1.0 to 1.9. The pre-registered verdict at epoch 7 is undetermined, not "tie". Nothing on the branch can run the test as written. The comparison needs per-panorama detections for the cosine checkpoints, and none are committed: Two more pre-registered items are missing for the same reason:
Also promised "alongside" and absent: the full 8-epoch paired matrix against Run A (line 113–116). Fix: if the makelab2 eval cache for the rung survives ( 2. Medium —
|
| doc | says | JSON |
|---|---|---|
| line 127, y11x − y26 ΔF1 | +0.1119 | 0.111666 → +0.1117 |
| line 127, ΔmaxF1 | +0.1492 | 0.148884 → +0.1489 |
| line 127, c | 123 | 124 |
| line 177, seam share | 9 of 356 | 10 of 357 |
| line 143, adjacent-epoch discordance | 2.5–4.2% | 2.50–4.26% (7v8) |
| line 152, widest pairs | 6.5–6.6% | 6.48% (1v8), 6.66% (1v7) |
| line 145, all 28 | 2.5–6.6% | 6.66% |
| lines 25, 145, MDE upper | 0.0081 | 2.8016 × 0.002926 = 0.0082 |
The first four are the pre-#140 values of the one proxy pair the seam wrap moved (the same mechanism as the #148 spin-out); the JSON was regenerated after the merge and the doc was not. In the rung doc, line 313 says 0.961% and line 314 says 3.979% where the committed CSV and the table beside them say 0.962% and 3.980% — the trap lines 286–288 of the same file warn about.
Fix: regenerate the affected rows from the JSON/CSV, and have test_committed_json_matches_the_doc pin the headline values (prior 7e) so the next regeneration cannot leave the prose behind.
4. Medium — the recall table shows six of nine pairs, and the missing three include the only "not resolvable"
The script computes nine recall pairs; the doc's recall table (lines 241–248) prints six. Omitted: 1v8 (+0.0043, required discordance 1.7%, not resolvable), 3v8 (−0.0153, resolvable) and 6v7 (−0.0199, resolvable). The max-F1 table directly below prints all nine. Whatever the reason, a table that drops the one verdict that cuts against the paragraph under it reads as selection. Print all nine.
5. Medium — the rung doc says nothing above Results was edited, and it was
Line 3: "Everything above Results is as written on 2026-08-18 and has not been edited." git diff 2cdb01c HEAD -- docs/stage2_cosine_rung_135.md shows two edits above that line: d9c4fcd (2026-08-20) rewrote lines 56–59 ("requeued twice" → "five times across its two job ids, measured with sacct -D"), and cb2fb59 filled the launch record. Both are factual and neither touches the decision rule, but a pre-registration's value is exactly that its no-edit claim is true. Fix: say what was edited above the line, and that the decision rule, comparison and outcome table were not.
6. Low — provenance and cost bookkeeping around the rung
- GPU-hours (560.9) and money ($0,
ckpt-all) are recorded, as the repo rule requires. They are in no committed ledger: the compute ledger is on Record time as well as money for every non-free run (#143) #147's branch and not merged. Say "pending Record time as well as money for every non-free run (#143) #147'scompute_log.jsonl" next to the number so the gap is visible. - "21 restarts", "35.06 h" and "Run A was requeued five times" come from
sacct -Dwith no dump committed here (Record time as well as money for every non-free run (#143) #147'ssacct_klone_2026-08-19.txtends two days before the rung finished). From the committed events a clean clone can count 18 incarnation files (11 with steps), not 21. State the source. - The LR-verification block (lines 244–250) has no reproduce command. It is one line:
python scripts/analysis/check_lr_schedule_135.py --events-dir stage_two/cosine_rung_135_events. I ran it; the output is identical. - The one input finding 1 needs — the makelab2 evaluation heatmap cache for the rung — is not mentioned anywhere. The doc records where the checkpoints are (klone and makelab2, 8.6 GB) and that publishing them is an HF-storage decision; it should say the same for the cache.
7. Low — the epoch boundary is the one window the checkpoint-interval fix does not cover
latest_checkpoint.pth is refreshed every --checkpoint-interval-steps mid-epoch and, at epoch end, only after the validation pass, the 1 GB epoch checkpoint and the peek image (train.py lines 619–627). The committed events show what that costs: incarnations 5 and 6 both logged through step 9378 (end of epoch 1) and the next incarnation resumed at 9001 both times — the whole validation pass plus 378 steps lost, twice. fa02b37's 200-step interval does not shorten that window; one latest_checkpoint.pth write before validation would. Not a correctness defect; noting it because the commit message claims the livelock is fixed and the events show a second mechanism.
8. Low — the PR body's counts are stale
Suite 1236 → 1361 passed; "closes nothing on its own" and the 30-epoch recommendation predate the rung result. See decisions.
Decisions for Jon, not fixes
- The PR body and title describe the first commit. The rung — a ~560 GPU-hour experiment with a pre-registered gate — is now the largest thing on the branch and the body does not mention it. The body should lead with the rung outcome and, per the pre-registration's own words, record the Run B gate decision as a judgment call: primary tie, secondary not met as written, so "proceed / do not proceed" is yours to state. The old recommendation ("run the 30-epoch arm, not 60") should be re-stated or withdrawn in light of it.
- Whether to spend makelab2 time on finding 1. Dumping eight cached epochs is CPU-only and took 4 minutes for Run A; the question is whether the cache still exists. If it does, the pre-registered test costs nothing. If not, the deviation has to be written down.
- Whether to publish the rung checkpoints (8.6 GB) alongside Run A's, which the doc already flags as the same open decision.
What holds up
- The stateless schedule is correct and was applied.
step_index = global_step + i; the mid-epoch checkpoint storesstep_index + 1andresume_offset + i + 1; the epoch end addslen(train_loader) = epoch_length − skip, soglobal_steplands on(e + 1) × 9378regardless of where the resume fell.test_resumed_epoch_lands_on_the_same_global_steppins that arithmetic. The applied rate reproduces from the committed events over 100% of the run to 4.5e-13, across ten resume boundaries at 0.993, 0.965, 0.694, 0.655, 0.166 and 0.00046 × peak — none at 1.000. - The paper recipe is untouched.
--lr-scheduledefaults toconstant, theparam_group['lr']write is guarded by!= 'constant', and both are tested. ResumeSkipSampleris the right fix and the right test.epoch_lengthis frozen so the cosine horizon cannot shrink on resume; the equivalence test against the old discard loop is the property that matters;batch_idx_in_epochis broadcast so every rank skips the same count.- The rung's numbers re-derive. Every cell of the
manual_goldtable and the AP table fromsummary.csv; the auto-val CSV byte-identical from the committed events; both runs' event sets SHA-pinned; the eight rung checkpoint fingerprints tie rows to weights. - The seam re-scoring is pinned properly. Both matchers checked, the single 0.000264 move asserted rather than tolerated, and the per-pair s.e. now come from the same matcher as the point estimates.
- The launcher changes one thing against Run A, guards world size, and takes the checkpoint interval explicitly with a test that the default did not drift.
Fix list
- [F1] High — Run the pre-registered rung test (new 1): dump the eight cosine epochs from the makelab2
cosine_rung_135/evaluate_cachewith the corrected dump script, commit todocs/data/cosine_rung_135_detections/, extendbenchmark_power_135.pywith a cross-arm block (same-epoch pairs, cosine 3 vs 8, full 8×8 matrix), report primary/secondary/gate indocs/stage2_cosine_rung_135.mdper lines 84–111. If the cache is gone, write the deviation next to the table and reword "tied" as "below the MDE; per-pair test not run because …". — files: scripts/analysis/dump_peaks_from_cache.py, scripts/analysis/benchmark_power_135.py, docs/stage2_cosine_rung_135.md, docs/data/cosine_rung_135_detections/, tests/test_benchmark_power_135.py - [F2] High (prior 1) — Unpaired rows measure RampNet at an effective 0.55 on nine splits: switch the reference to an untruncated arm or state the truncation beside every affected number (unpaired table, both POOLED rows, the
self_pairandrampnet_1pass:rampnetcity rows), recompute the pooling gain, and replace "7%" in the doc and PR body. — files: scripts/analysis/benchmark_power_135.py, docs/stage2_run_b_power_135.md, docs/data/benchmark_power_135.json - [F3] Medium (prior 4) — Headline table rows 1 and 4: use
max_f1.se(0.0041 → MDE 0.0114; 0.0039 → 0.0108) or relabel the header. — files: docs/stage2_run_b_power_135.md - [F4] Medium (prior 2) —
self_pairmax-F1se: 0.0is a non-function of the shift, not zero uncertainty: drop max-F1 from that block or make it threshold-aware, and say which in the JSON. — files: scripts/analysis/benchmark_power_135.py, docs/data/benchmark_power_135.json - [F5] Medium (prior 5) — Add a status pointer in
docs/stage2_epoch_curve_84.mdto Run B (#84): the gate passed — decide whether to spend 1,675-3,350 GPU-hours on the annealed arm #135's paired re-read (plateau 2–6, decline at 7–8). — files: docs/stage2_epoch_curve_84.md - [F6] Medium (prior 6) — Condition the recall-table verdicts on the discordance assumption the same way the max-F1 bracket is. — files: docs/stage2_run_b_power_135.md
- [F7] Medium (new 2) —
dump_peaks_from_cache.py: label prefix from an argument or the summary, loud failure on--verifywhen a fingerprint is absent, docstring--out-dircorrected,exclude_borderread from the extractor. — files: scripts/analysis/dump_peaks_from_cache.py - [F8] Medium (new 3) — Regenerate the stale numbers from the committed JSON/CSV (proxy row 3, seam row 3, discordance ranges, MDE upper bound, 0.961/3.979) and pin the headline values in
test_committed_json_matches_the_doc(prior 7e). — files: docs/stage2_run_b_power_135.md, docs/stage2_cosine_rung_135.md, tests/test_benchmark_power_135.py - [F9] Medium (new 4) — Print all nine recall pairs, including
1v8not resolvable. — files: docs/stage2_run_b_power_135.md - [F10] Medium (new 5) — Replace the "has not been edited" sentence with what was edited above Results and what was not. — files: docs/stage2_cosine_rung_135.md
- [F11] Low (new 6) — Add the
check_lr_schedule_135.pyreproduce command under the verification block; "pending Record time as well as money for every non-free run (#143) #147" beside the 560.9 GPU-h; state that restarts/wall-clock come fromsacct -Dand are not re-derivable from the repo; state whether the rung's eval cache survives. — files: docs/stage2_cosine_rung_135.md - [F12] Low (prior 7b–7g) —
--splitsper-group rng seeding; drop theres_a/res_bfull bootstraps; remove deadgt_pano;if c is Noneinstead ofor -1e9; assert pano order inobserved_and_se(paired=…). — files: scripts/analysis/benchmark_power_135.py, tests/test_benchmark_power_135.py - [F13] Low (new 7) — Optional: write
latest_checkpoint.pthonce before validation at epoch end; if not done, note the window in theResumeSkipSamplerdocstring. — files: stage_two/train.py - [F14] Low (new 8) — Refresh the suite count and the stale claims in the PR body when Jon rewrites it (decision 1). — files: PR body
🤖 Generated with Claude Code (claude-fable-5-1)
… B is decided against (#135) #135 exists to decide whether to spend 1,675-3,350 GPU-hours on the annealed arm. Every input to that decision has been in place since 2026-08-29 -- the rung completed, the LR schedule verified over 100% of the run, manual_gold scored and tied at every epoch -- but the gate written into the pre-registration was never actually applied to the numbers, so the issue stayed open with the decision unmade. scripts/analysis/run_b_gate_135.py applies it from the committed summaries rather than restating it in prose, and writes docs/data/run_b_gate_135.json: PRIMARY max-F1(cosine ep8) - max-F1(Run A ep8) = +0.002994 |z| = 1.02 to 1.86 -> not significant SECONDARY Run A ep3->ep8 -0.006624 ; cosine -0.005158, arrested? NO difference of declines +0.001466, |z| = 0.50 to 0.91 -> not significant VERDICT JUDGMENT CALL The primary misses significance AT THE FAVOURABLE END of the measured s.e. bracket, so the reading does not depend on which value inside it is chosen. That robustness is the reason the bracket is honest enough to decide on: a true paired bootstrap between the two arms needs both arms' per-pano detections and the cosine arm's are not committed, so this uses the s.e. #138 measured across 28 Run A epoch pairs on the same panos and GT. Stated in the script's own docstring, not buried. The pre-registration says a tie on both is explicitly NOT an automatic cancellation, so the decision is recorded as the judgment it is. Not running Run B, because: - The mechanism that justified it is the one that failed. Run B's case was #51's annealed tail; the rung tested exactly that at matched budget, seed and data order and moved manual_gold by nothing measurable. A 30-epoch arm changes length AND schedule, so it could not attribute a difference even if it found one. - The gain is real and in the wrong place: up to 3.98% better auto-label val loss, none of it reaching human F1. That is #84's exchange rate replicating. - THE ARGUMENT THAT WAS NOT AVAILABLE WHEN RUN B WAS SPECIFIED: Run B is n=1, and this issue itself established seed variance as the binding limit. The plausible effect (~0.003 of late-epoch damping) sits below the ~0.01 that is "measured but not attributable" without a seed control, so a single 30-epoch run is uninterpretable at any length. A readable Run B is 3 seeds = 5,025-10,050 GPU-h, ~$4,500-9,000. - Opportunity cost, measured: #151 just produced a +0.115 F1 rig effect on the same model. What is NOT claimed is written down too: not that annealing does nothing (the ~0.003 damping is unresolved, not refuted), not that a 30-epoch run would fail (it was never run), and not epoch 7's +0.0042 -- the largest gap anywhere, which WOULD clear 1.96 at the favourable end of the bracket but is not the pre-registered comparison. The artifact flags it so nobody quotes it as the result. Reopening condition is concrete and already in flight: the seed campaign (PR #155, klone 39515025/26/27) prices it directly. Seed SD <= ~0.002 max-F1 and a 0.003-0.008 effect becomes readable at n=1. Two amendments so the repo does not carry contradictory advice: - stage2_run_b_power_135.md's "run the 30-epoch arm" recommendation predates the rung's results; marked SUPERSEDED in place rather than rewritten. - stage2_epoch_curve_84.md's "the gate does not cancel Run B" section gets the later outcome inline, since that doc is where a reader looks for Run A/Run B. Also states a deliberate omission: #135 asked for the benchmark splits as well as manual_gold, and only manual_gold was scored -- because #138 measured the nine city splits pooled at an unpaired MDE of 0.0219 against manual_gold's 0.0117, so they cannot resolve what manual_gold cannot. tests/test_run_b_gate_135.py (10) covers the branches that did NOT fire, since an implementation that only ever emits the observed verdict is untestable by its own output. Full suite: 1,371 passed, 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third pass: the second review's findings were not addressed, and the decision's own artifact contradicts the doc's headlineRe-ran everything from the branch head ( What reproduced. Full suite 1371 passed, 1 skipped. What did not. I checked each prior finding against the code rather than taking it on trust. None of the second review's findings was fabricated — every one reproduced, including the four-decimal drifts. Status of the prior findings, at
|
| id | finding | status | evidence |
|---|---|---|---|
| F1 | rung's pre-registered test not run; "tied" read against the wrong bar | partly fixed | 4171a6e applies the gate, states the s.e.-bracket deviation in the script docstring, and flags epoch 7 in the artifact and in "What is not being claimed". The prescribed reword did not happen: the status line, the Results status and the manual_gold headline all still say "tied at every epoch" against the 0.0063 MDE. See new finding 1. |
| F2 (prior 1) | nine bundles truncated at 0.55; "pooling is worth 7%" | not fixed | measured on the branch: all nine cities hold 0.0% of detections below 0.55 (minima 0.5501–0.5607); manual_gold reaches 0.0501 with 28.8% below. max_f1 == f1 exactly on 8 of 9 cities and on POOLED cities. Doc lines 34/103/109/336 still say 7% under a "0.30" header. |
| F3 (prior 4) | headline table mixes F1 and max-F1 s.e. | not fixed | rows 1 and 4 are unpaired.manual_gold.f1.se = 0.004190 and POOLED all.f1.se = 0.003897; the max-F1 values are 0.004060 and 0.003872 (MDE 0.0114, 0.0108). |
| F4 (prior 2) | self_pair max-F1 se: 0.0 by construction |
not fixed | metrics() still returns max_f1(s, weights, n_gt), no threshold; 24 of 24 rows carry {"observed": 0.0, "se": 0.0}. |
| F5 (prior 5) | stage2_epoch_curve_84.md has no pointer to #135 |
partly fixed | 4171a6e adds one line, at the Run B gate section. The status block at the top still states the superseded curve shape ("steps up once and is then flat") with no pointer, and that is the sentence anyone reads first. |
| F6 (prior 6) | recall-table verdicts stated flat | not fixed | unchanged. |
| F7 (new 2) | dump_peaks_from_cache.py cannot produce the rung's dumps |
not fixed | fingerprint_labels hardcodes run_a_epoch_{N}; --verify skips silently on fingerprint in committed; the docstring still recommends --out-dir benchmark/model_detections, which the same file's --out-dir help forbids; exclude_border stamped as a literal. |
| F8 (new 3) | doc numbers that are not the artifact's | not fixed | verified all: +0.1119 / +0.1492 / c = 123 vs the JSON's 0.111666 / 0.148884 / 124; "9 of 356" vs 10 of 357; discordance 2.5–4.2 / 2.5–6.6 / 6.5–6.6 vs 2.50–4.26 / 2.50–6.66 / 6.48–6.66; MDE upper 0.0081 vs 0.0082; rung doc 0.961/3.979 vs 0.962/3.980. |
| F9 (new 4) | recall table prints 6 of 9 pairs | not fixed | the JSON holds all nine; 1v8 (not resolvable), 3v8, 6v7 omitted. |
| F10 (new 5) | "has not been edited" is untrue | not fixed | cb2fb59 and d9c4fcd both edit above the Results line; confirmed with git diff. |
| F11 (new 6) | provenance/cost bookkeeping | not fixed | rung doc has zero occurrences of check_lr_schedule, #147, ledger, compute_log, or the rung's eval cache. |
| F12 (prior 7b–7g) | rng, res_a/res_b, dead gt_pano, or -1e9, pano-order assert |
not fixed | all five confirmed in place. |
| F13 (new 7) | epoch-boundary checkpoint window | not fixed | train.py still writes latest_checkpoint.pth after validation + the 1 GB epoch checkpoint + the peek image. |
| F14 (new 8) | PR body stale | not fixed | see new finding 2 — it is worse than stale. |
New findings
High
1. docs/stage2_cosine_rung_135.md:3, :211, :342 — the document's headline claim is contradicted by the artifact committed alongside it, because it is read against a bar the pre-registration did not name.
The pre-registration (line 98) is |Δ| / s.e. ≥ 1.96. The Results section instead compares the largest |Δ| to #138's MDE of 0.0063, which is 2.80 × s.e. at 80% power — 1.43× looser. Using the s.e. bracket this branch measured (0.0016–0.0029), epoch 7's +0.0042 gives |z| = 1.45 to 2.64, and docs/data/run_b_gate_135.json records exactly that: "would_reach_significance_at_favourable_se": true. The primary (epoch 8, +0.0030, |z| = 1.02–1.86) is genuinely a tie at both ends, which is the reading the decision rests on and which is robust. Epoch 7 is not a tie; it is undetermined.
Failure scenario: the paper cites "the cosine rung found manual_gold tied at every epoch" as the evidence that annealing does nothing at 8 epochs. A reviewer opens the JSON in the same directory, finds a true flag on epoch 7, and the strongest claim in the document is the one that does not survive contact with its own data. The decision itself is unaffected — reason 3 (n=1 against unmeasured seed variance) is independent of the tie and would still bind if epoch 7 were significant — which is exactly why overstating the tie was unnecessary.
2. PR body — still recommends running Run B.
The body's ## Recommendation section reads "Run the 30-epoch arm; not 60 on spec." and closes with "Input to the #135 decision; closes nothing on its own." The header note added on top says the opposite of both. docs/stage2_run_b_power_135.md got a SUPERSEDED marker in 4171a6e; the body did not. It also still quotes the pre-#140 z = 3.9 / 3.0 / 2.8, the uncorrected 7% pooling figure, "discordance 2.5–6.6%", and "Suite 1236 passed" (measured: 1371).
Failure scenario: the PR body is what shows in the merge commit and in every GitHub notification. Someone budgeting cluster time reads a 1,675–3,350 GPU-hour recommendation from the very PR whose purpose was to decide against it. Left for you — I do not rewrite your PR bodies — but this is the highest-visibility surface the decision has.
Medium
3. docs/stage2_cosine_rung_135.md:378 and scripts/analysis/run_b_gate_135.py:70 — the Run A epoch-7 max-F1 used here is the value this same PR pins as superseded.
The rung table and the gate both read Run A's max-F1 from summary.csv, where epoch 7 is 0.911009. tests/test_benchmark_power_135.py:198 pins the post-#140 value as SEAM_FIXED_MAX_F1 = {7: 0.910745}, and docs/stage2_run_b_power_135.md prints 0.9107. Two committed documents in one PR give different values for the same checkpoint's same metric with nothing reconciling them. It is defensible — both arms were scored at dc7450e, so the comparison is internally consistent — but that reasoning is nowhere in the file, and the s.e. bracket standing beside those point estimates is post-#140, which is the mixed provenance the 2026-08-20 comment said had been eliminated.
Failure scenario: someone quotes Run A epoch 7 from whichever doc they opened, and two published numbers for one checkpoint differ by 0.000264 with no explanation — which is 4% of the MDE the whole analysis is stated in.
4. docs/stage2_cosine_rung_135.md:487 — the reopening condition points at a file that does not exist.
docs/seed_variance_51_135.md is not on this branch (ls docs/ | grep seed → nothing); the seed campaign lives on PR #155. The reopening condition is the load-bearing half of "not being run" rather than "will never be run", so a dead link there is worse than elsewhere.
5. docs/stage2_cosine_rung_135.md:461 — the decision's decisive argument turns on an unmeasured number, and the derivation is not given where it is used.
Reason 3 is the argument the commit message itself calls "THE ARGUMENT THAT WAS NOT AVAILABLE WHEN RUN B WAS SPECIFIED", and it works by putting the plausible ~0.003 effect below "~0.01 max-F1, measured but not attributable". That ~0.01 is not measured anywhere: stage2_run_b_power_135.md:352 sets it by analogy to the #51-scale ~0.02 effect, over a seed variance the same section says nothing in the repo measures. The doc is honest that seed variance is unmeasured, but the threshold itself reads as a quantity when it is an assumption, and it is the one the decision hangs on.
6. docs/stage2_cosine_rung_135.md:468 — "+0.115 F1, measured" is not re-derivable from this branch.
benchmark/ here holds ten bundles and no laurens_*; #151's arms are on another branch. Quoting it as measured opportunity cost, in a document whose job is to justify not spending money, without saying where it can be checked, is the gap the replication rule exists to close.
Low
7. docs/stage2_cosine_rung_135.md:25 — the motivation quotes pre-#140 z values (3.9 / 3.0 / 2.8) that docs/stage2_run_b_power_135.md now reports as 4.0 / 3.0 / 3.0. Keeping the pre-registration unedited is the right instinct; leaving two committed documents disagreeing in the third digit with no footnote is not.
8. docs/stage2_run_b_power_135.md:412 — "Every derived number in this document is in docs/data/benchmark_power_135.json" is false, still. Prior finding 3 flagged the sentence; the number it was flagged for is gone but the sentence stands, and there are now four classes of exception (the seam re-scoring table, the untruncated-pooling comparison, the 1.96-bar arithmetic, and the retained historical interpolation).
What holds up
Stating this because a defect list on its own misrepresents the work.
- The decision is right, and it is right for a reason that survives every finding above. Run B at n=1 cannot be told from a seed draw at any length; that argument does not depend on the tie, on the pooling figure, or on which end of the s.e. bracket you read. The three supporting reasons are independent of each other, and the reopening condition is concrete and already in flight.
run_b_gate_135.pyis the right shape for a decision artifact. It evaluates the pre-registered rule from the committed summaries instead of restating it in prose, it reports the branches that did not fire, it flags the post-hoc trap in its own output, andtests/test_run_b_gate_135.pyexercises the three verdicts that did not occur — which is the only way an implementation that always emits one verdict can be tested at all.- The deviation from the pre-registration is stated, not hidden. The script's docstring says plainly that a true cross-arm bootstrap is impossible without the cosine arm's per-panorama detections and that the Run A bracket is standing in. That is the honest form.
- The training-side work is verified over the whole run. The stateless schedule reproduces from the committed events to 4.5e-13 across ten resume boundaries, none at 1.000× peak; the event set is SHA-pinned;
--lr-scheduledefaults toconstantand the paper recipe is untouched, with tests for both. - The gate correctly refuses to make the call. "A tie on both is not an automatic cancellation" was written before the numbers and is honoured after them; the judgment is recorded as a judgment.
Fixes for everything above except new finding 2 follow in the next commit.
🤖 Generated with Claude Code (claude-opus-5)
…e bundle truncation travels with its numbers (#135) Two review passes on this PR (2026-08-18 and 2026-09-03) left thirteen findings open, and the gate commit 4171a6e added an artifact that contradicts the document it sits beside. This is the fixes pass those reviews said would follow. THE HEADLINE CLAIM WAS READ AGAINST THE WRONG BAR The pre-registration says |delta| / s.e. >= 1.96 per pair. The Results section instead compared the largest delta to #138's MDE of 0.0063, which is 2.80 x s.e. at 80% power -- 1.43x looser -- and concluded "tied at every epoch". Those are different bars: an effect below the MDE is not thereby non-significant. run_b_gate_135.json, added in 4171a6e, already records the consequence: epoch 7's +0.0042 gives |z| = 1.45 to 2.64 over the measured s.e. bracket, and the artifact flags it as clearing 1.96 at the favourable end. The accurate statement is narrower and is now what the document says. The pre-registered primary (epoch 8, +0.0030, |z| = 1.02 to 1.86) is a tie at both ends of the bracket -- which is the reading the decision rests on and which does not depend on which s.e. inside it is chosen. Six of the other seven epochs are ties at both ends. Epoch 7 is undetermined, not a tie, and resolving it needs the cosine arm's per-panorama detections, which are not committed. The deviation from the pre-registration is stated where the numbers are, along with the fact that the pre-registration's own "Exact commands" could not have run the test as written. THE DECISION IS UNCHANGED, AND WAS NEVER LOAD-BEARING ON THE TIE Run B at n=1 cannot be told apart from a seed draw at any length. That argument holds whether or not epoch 7 separates, which is why overstating the tie bought nothing. THE NINE CITY BUNDLES ARE CUT AT 0.55, AND EVERY UNPAIRED ROW INHERITS IT Measured: all nine hold 0.0% of their detections below 0.55 (minima 0.5501-0.5607) while manual_gold reaches 0.0501 with 28.8% below. So "the #54 operating point of 0.30" is a no-op on nine of ten splits, max-F1 there peaks on an already-truncated curve (max_f1 == f1 exactly on eight of nine cities and on POOLED cities), and the pooling gain is understated. Measured against the one untruncated arm in committed data (--reference rampnet_1pass, all ten splits to 0.05): POOLED-all MDE 0.0105 against manual_gold's 0.0121, a 14% gain rather than 7%. That arm is single-pass and missing seam detections, so 14% bounds the correction rather than being it; there is no clean uniform-0.30 RampNet arm in the repo, which is now stated beside the number. The conclusion is unchanged on either reading: pooling is not a lever. The artifact now records reference_min_confidence and protocol_threshold_binds per split and truncated_members per pooled row, so this is visible rather than inferable -- it sat undetected through two reviews precisely because the only evidence was indirect. benchmark_power_135.json regenerates with ZERO changed values: the additions are new keys plus self_pair's max-F1, which is now null. Every number the documents quote still stands. REST OF THE FIX LIST - self_pair max-F1 was identically zero by construction (max-F1 re-picks its own threshold, so shifting the read-out point cannot move it). It read as a measured zero with zero uncertainty in all 24 rows; it is now null with a note. That block bounds F1. - The headline table's rows 1 and 4 were F1 standard errors under a max-F1 header (0.0042/0.0117 and 0.0039/0.0109). Now max-F1: 0.0041/0.0114 and 0.0039/0.0108. - Four-decimal drift against the artifact, all corrected: +0.1119 -> +0.1117, +0.1492 -> +0.1489, c = 123 -> 124, "9 of 356" -> "10 of 357", discordance ranges to 2.50-4.26 / 2.50-6.66 / 6.48-6.66, MDE upper 0.0081 -> 0.0082, and the rung doc's 0.961/3.979 -> 0.962/3.980 (the trap that file's own provenance note warns about). - The recall table printed six of the nine pairs the script computes, and the three it dropped included the only "not resolvable" verdict. All nine now print, and the table's verdicts carry the conditional the max-F1 table already had: they rest on a discordance range taken from three cross-detector pairs, none of them epoch-vs-epoch. - "Every derived number in this document is in benchmark_power_135.json" was false. Four classes of exception are now named, and test_committed_json_matches_the_doc_headline_ numbers pins every headline value so the prose cannot drift again. - dump_peaks_from_cache.py hardcoded a run_a_epoch_N label whatever --summary-csv it was given, so a second arm's dumps were either invisible to the reader or overwrote Run A's committed ones. Adds --label-prefix; --verify now fails loudly on a fingerprint the summary does not contain instead of silently checking nothing; exclude_border is read from the extractor rather than restated; the docstring no longer recommends the one output directory the same file's help text forbids. - stage2_epoch_curve_84.md's status block still stated the superseded curve shape. It now carries the #135 amendment: the plateau is 2-6, not 2-8. - The rung's pre-registration claimed nothing above Results had been edited; two commits had. Says what was edited and what was not. - Provenance gaps stated rather than implicit: the 21 restarts and 35.06 h come from sacct -D with no dump committed (a clean clone can count 18 event files, 11 with steps); the 560.9 GPU-hours are pending #147's compute_log.jsonl; the LR verification block gets its one-line reproduce command; whether the rung's eval cache survived is unknown and is now said so. - The reopening condition pointed at docs/seed_variance_51_135.md, which is not on this branch; it points at PR #155. #151's +0.115 F1 is marked as not re-derivable here. The "~0.01 measured but not attributable" threshold the decision's third reason turns on is marked as a working assumption rather than a measurement. - Run A's epoch-7 max-F1 differs between two documents in this PR (0.9110 from summary.csv, 0.9107 post-#140). Both arms were scored under the pre-#140 matcher so the comparison is internally consistent; that is now said, in both places. - Dead Scored.gt_pano removed. observed_and_se(paired=...) asserts shared panorama order, the way mcnemar already asserts its own. The test's (prediction_confidence(p) or -1e9) mapped a legitimate 0.0 confidence to -1e9; it checks for None. - Deliberately NOT changed, with the reason recorded in the code: the single Generator threaded through every group, and the redundant res_a/res_b bootstraps. Either change shifts the draw stream and moves every standard error these documents quote, for no gain in correctness. The --splits caveat is documented instead. - train.py's epoch-boundary checkpoint window is described in the ResumeSkipSampler docstring rather than fixed: latest_checkpoint.pth is written after validation, so a preemption there loses the whole pass (the committed events show it happening twice). Suite: 1,373 passed, 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review fixes
The two that matteredThe headline claim, corrected against the bar the pre-registration named. The decision is unchanged, and the correction shows why it was robust. Reason 3 — Run B at n=1 cannot be told from a seed draw — holds whether or not epoch 7 separates. Overstating the tie bought nothing. The bundle truncation now travels with every number it qualifies. Measured and stated: all nine city bundles hold 0.0% of their detections below 0.55 (minima 0.5501–0.5607) against
The artifact now records
The rest of the list
Deliberately not fixed
One thing this pass could not doThe pre-registered per-pair test still has not been run, and cannot be from the repo. It needs the cosine arm's per-panorama detections, which need the rung's makelab2 evaluation heatmap cache — whose survival nobody has checked. If it is intact the test is CPU-only and free once 🤖 Generated with Claude Code (claude-opus-5) |
… about the wrapper #138 and #154 merged while this branch was open, and #138 rewrapped the train sampler as ResumeSkipSampler(DistributedSampler(...)). Three conflicts in stage_two/train.py, all additive on both sides except one that is genuinely semantic: train_sampler = ResumeSkipSampler( DistributedSampler(..., shuffle=True, drop_last=True, seed=sampler_seed_for(args.seed))) The seed has to sit on the INNER DistributedSampler. On the wrapper it would be accepted and inert -- the sweep would run with one data order across all three replicates and nothing in any log would say so, which is the exact failure this branch exists to prevent. The other two conflicts are the argparse block and the startup log line; both sides added different things and both are kept. Two tests then failed, and both were right to: - tests/test_seeding.py asserted the assignment was a DistributedSampler call. It is now a wrapper. Rather than loosen the assertion to a substring -- which is what made this test vacuous in the first place -- it walks into the wrapper via _unwrap_to() and fails loudly if the target is ambiguous or absent. - tests/test_resume_skip_sampler.py executes parse_args in a restricted namespace, which now needs HISTORICAL_SEED injected. Re-verified by mutation, both caught: deleting seed= from the inner sampler, and moving it to the wrapper where it would be inert. The second is a NEW failure mode that only exists because of this merge. Full suite: 1,481 passed, 1 skipped. bash -n clean on the launcher. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Answers the prerequisite #135 set before the 1,675–3,350 GPU-hour spend: can
manual_goldresolve the effect Run B might produce, and does pooling the ten splits raise it?No GPU time was spent. Run A was not re-run.
The answer
manual_goldmax-F1Pairing is worth ~2× and it is free. Both checkpoints are scored on the same 1,000 panoramas against the same GT, so pano difficulty cancels. What governs a paired comparison is the discordance between the two detectors — measured at 2.5–6.6% across Run A's epochs — not
n·p·(1−p).Pooling is worth 7% (0.0117 → 0.0109). The nine city splits alone are worse than
manual_goldalone (0.0219) despite more panoramas. #135's per-split instruction is right.The benchmark is not a reason to cancel Run B. At MDE 0.0063 measured — ~0.008 extrapolated to Run B's larger epoch separation — it resolves an effect the size of #51's annealed tail (+0.024 here, 8σ) with room to spare.
The bracket was closed for no GPU at all
An earlier revision could only bracket the paired s.e. at [0.0043, 0.0135] using stand-in pairs, because Run A's committed artifacts are aggregate PR curves. Then the 2026-08-17 scoring cache turned out to have survived on makelab2 — 13 GB, all eight epochs, 1,000 panos each — and peak extraction from a cached heatmap is CPU-only numpy. So the measurement needed no model, no panorama images (they are not on that host), no GPU and no network, took 4 minutes, and covered all 28 epoch pairs instead of the two originally proposed.
The old bracket contained the answer, and the working assumption ("4–6% discordance ⇒ 0.006–0.009") was nearly exact.
Verification is the load-bearing part. Re-scoring each dump against
manual_labels/reproduces the committedsummary.csv:max-F1 is a property of the whole PR curve, so agreement at 10⁻⁷ says the peak extraction is the same operation, not a similar one. The uniform 10⁻⁴ offset on F1@0.30 is the committed table's own 0.005-grid downsampling, which its provenance note already documents.
It corrects #84's curve shape
With each pair read against its own measured s.e. rather than a global bracket, epochs 7 and 8 are measurably below the plateau — 3 vs 7 at z = 4.0, 3 vs 8 at 3.0, 6 vs 7 at 3.0. The unpaired 0.01 bar could not see it.
So the curve is not "steps up once from epoch 1 to 2 and is then flat":
The headline is unchanged — still no resolvable peak; 2, 3, 4, 5, 6 remain mutually indistinguishable (2 vs 6 identical to 4dp, 3 vs 6 at z = 1.4). What changes is that at constant LR the model begins to lose capability after ~epoch 6.
That sharpens what Run B tests: not "does a longer budget add to a flat curve" but "does cosine decay arrest a decline that is now measured." It cuts both ways — that decline is the classic signature of an LR left too high, which is what an anneal fixes; but a 30-epoch run whose decay does not bite could land below epoch 3.
Recommendation
SUPERSEDED 2026-09-03 — Run B is not being run at all. The paragraph below is the recommendation as written on 2026-08-18, kept verbatim because the reasoning that led to it is still the reasoning; what changed is that the pre-registered gate was then actually applied. Applying it returns JUDGMENT CALL, and the judgment is no: Run B is n = 1 and seed variance is the binding limit, so the arm is uninterpretable at any length. A readable Run B is three seeds, roughly $4,500-9,000. See
docs/stage2_run_b_power_135.md.Run the 30-epoch arm; not 60 on spec.The plateau ends at 6, so the extra 30 epochs buy schedule shape rather than useful steps, and a 60-epoch cosine spends proportionally longer at the high LRs the epoch 7–8 decline is evidence against.Pre-register three free amendments: read Run B against Run A paired on max-F1 with the tie bar replaced by the measured MDE (0.008 at Run B's separation); per-split, not pooled; attribute cautiously below ~0.01.
Also flagged, not relitigated: the #84 amendment's 8-epoch cosine rung (~450 GPU-h, budget-matched to Run A) is more attractive than it was, because constant-LR is now known to peak at ~3 and decline by 7, so that rung isolates schedule from length — the confound the amendment already noted B − A carries.
The limit that binds is not the benchmark
Panorama sampling variance only, and Run B is n = 1. Nothing measures training-seed variance, so a gain below ~0.01 max-F1 is measurable but not attributable to the anneal without a seed control that doubles the bill.
Notes
docs/data/run_a_84_detections/, notbenchmark/model_detections/—rampnet/roster.pyasserts every file there belongs to a registered challenger leg (#122), and these are internal checkpoints, not comparison entries. The suite caught the first attempt to put them there.docs/data/run_a_84_manual_gold/.exclude_borderartifact puts 30% of the RampNet-vs-RampNet pair's disagreements at the seam against a 2.1% baseline; the pairs drawn from one source read the baseline exactly.metrics()under all-ones weights reproducesrampnet.detection_eval.aggregateto 1e-12 on four (split, model) combinations. Suite 1236 passed, 1 skipped.Files
docs/stage2_run_b_power_135.md— the write-up, caveats beside the numbersscripts/analysis/benchmark_power_135.py— the analysis, seeded and deterministicscripts/analysis/dump_peaks_from_cache.py— cache → per-pano detections,--verifydocs/data/benchmark_power_135.json,docs/data/run_a_84_detections/— LF-pinnedtests/test_benchmark_power_135.py— 10 testsCloses #135. This PR is the decision: Run B is decided against, and the cosine rung that tested its premise is scored and committed.
🤖 Generated with Claude Code (claude-opus-5[1m])