Linear (Identity) baseline arm for the scv2-spike pipeline (SI Figure S10)
📌 Standalone — descoped from EPIC #290 on 2026-08-17
This work was originally Phase 3 of EPIC #290. It will not be
completed as part of that epic: the epic is closing out its remaining
phases (#296 figure assembly, #297 written handoff) without the linear
arm, and this issue carries the work forward on its own schedule.
Nothing about the spec below was weakened by the descope. The body is
the same fully-specified, skeptic-reviewed spec that was written for the
phase, preserved verbatim apart from the epic-coupling edits listed in §0.
It remains ready to be worked on — it is not a stub.
|
|
| Blocks nothing |
#296 and #297 no longer wait on this issue |
| Depends on |
the (tol, maxiter) pair chosen by #291 and the cached spike fit from #292 — both landed, so this is unblocked today |
| Deliverable |
SI Figure S10 (shrinkage_analysis_linear_models) plus the linear arm's rule, CSVs, tests and docs page |
0. What the descope changed
Only the epic-coupling statements were edited. The scientific content, the
design decisions (§2), the deliverables (§3), the validation gates (§4) and
the open questions (§4c) are untouched.
| Was |
Now |
| "Phase 7 was told to surface [the S10 erratum]" |
§1a's refutation stands on its own; the EPIC's handoff phase (#297) has been told to drop the erratum claim rather than inherit it |
| Figure name kept "so Phase 6's number-diff can match it" |
kept so any later number-diff can match it — #296 now audits the epic's figures without S10 |
| "Figure assembly … is Phase 6 (#296)" |
still out of scope here; #296 ships without S10 and this issue owns S10 end-to-end, including its own number-diff against si.tex |
| Implicit: S10 lands before the manuscript handoff |
It does not. #297's handoff must state that S10 was not regenerated. Landing this issue later means a follow-up note to the manuscript, not a revision of #297 |
⚠️ The one substantive consequence. With S10 descoped, SI Figure S10 is
now a carried-over-unchanged figure in the manuscript rather than a
regenerated one — while every other figure it would be read alongside was
regenerated under the new (tol, maxiter) and the new λ = 8.0e-05. Whoever
picks this up must re-check §1a's refutation against the current fit, not
against the state of the world when the spec was written.
⚠️ READ FIRST — this phase is an experiment, not a reproduction
Figure S10's published caption asserts that "the linear models result in a
higher (worse) loss on the training and validation data." This phase does
not assume that claim and must not be designed to produce it. It fits the
linear arm, measures the losses, and reports what they are — including the
outcome where the caption is not supported.
A skeptic review of an earlier draft of this spec found three independent
mechanisms by which a careless design would have produced "linear is worse"
with no involvement from global epistasis at all. They are not hypothetical;
each is quantified in §D1. They are the reason the α and regularization
settings are treated below as open questions the run answers empirically,
rather than as constants chosen up front:
| # |
Artifact mechanism |
Why it would fake the result |
| A1 |
beta_clip_range binding |
At low α the required |β| exceeds the clip, truncating exactly the large-effect mutations and inflating linear loss |
| A2 |
Effective-L2 mis-scaling |
On the identified scale the penalty is l2reg/α², so α alone changes shrinkage by orders of magnitude |
| A3 |
Noise floor |
The sigmoid's validation minimum is ~0.7% deep while between-replicate spread is comparable — the effect may be unresolvable |
The governing rule for this phase: every one of A1–A3 must be measured and
reported, and panel B must state plainly when the evidence does not support
the caption. A run that cannot separate the arms is a valid result, not a
failure to be tuned away.
1. Corrections to the stub's premises
Two claims in the stub body were checked against source and one is wrong.
Both are recorded here because the epic's handoff phase (#297) was told to
surface the second as an erratum, and it must not — see §0.
1a. The "known defect in the reference implementation" is REFUTED
The stub states: "legacy notebook cell 103 calls mc.get_conditional_loss_df()
where mc is the sigmoid CV collection — linear_mc is built and annotated but
never read. So the published Figure S10's middle panel does not show
linear-model CV loss."
This is not what the notebook does. The legacy notebook is recoverable at
fc89753:notebooks/spike-analysis.ipynb (the stub's 6c98b7b does not resolve
in this repo; it was added by fc89753 "latest before moving spike analysis"
and deleted by 9198572 "formatting"). By 0-based cell index:
| idx |
exec |
source |
| 67 |
37 |
cross_validation_df = mc.get_conditional_loss_df() — sigmoid |
| 103 |
127 |
fit_params_linear["dataset"] = train; ... fit_models(...) — the fit call, not a loss call |
| 104 |
128 |
linear_mc = ModelCollection(linear_models_cv); linear_mc.add_validation_loss(test, overwrite=True) |
| 105 |
132 |
cross_validation_df = linear_mc.get_conditional_loss_df() — linear, and it IS read |
| 106 |
133 |
saveas="shrinkage_analysis_linear_models" — the S10 figure |
The stored execution_counts are monotone across 128 → 132 → 133, so the last
write to cross_validation_df before S10 was rendered was the linear one.
Cells 101/102 (exec 125/126) likewise rebind sparsity_df and corr_df from
the linear collection, so panels A and C are linear too.
Decision: do not file an erratum, and do not carry the bug claim into
the manuscript handoff (#297) as established fact. The execution record is consistent with the
published figure being linear. Note the evidential limit honestly: stored
execution_counts record an execution order, not proof that the saved figure
file was rendered from it. The reproduction in this phase is the decisive
check — if the reproduced panel B disagrees with the published figure, reopen
this premise rather than attributing the discrepancy elsewhere. The stub's separate warning that cells 98–100 rebind
module-level frames is still a real fragility — the two arms share variable
names, so a re-run in a different order would silently mix arms — and this spec
keeps the arms in separate namespaces for exactly that reason. But the
committed execution record shows the bug did not fire, and the published figure
is sound.
1b. The α claim is CONFIRMED, and is the central design constraint
Verified in multidms/jaxmodels.py:
Identity.__call__ (line 209) is a pure pass-through with no parameters.
Model.predict_score (line 307) computes
α * (GE(φ(X)) − GE(φ(x_wt))), and φ(X) = β0 + X @ β (line 189).
Under Identity the GE is the identity, so this collapses exactly:
α · ((β0 + X@β) − (β0 + x_wt@β)) = α · (X − x_wt) @ β
β0 cancels identically from predict_score and only the product α·β is
determined. Under Sigmoid the expit nonlinearity blocks the cancellation, so α
is identified there — and empirically it is: the cached sigmoid fit reports
α ≈ 17.8 (rep_1) to 19.3 (rep_2), far from alpha_init: 6.0
(results-prod-292-spike-fit-tuning/ge_params.csv).
Note β0 is not thereby unconstrained: _beta_ridge_penalty
(jaxmodels.py:532) penalizes (β0_d − β0_ref)² at beta0_ridge: 0.01, so
under Identity β0 receives no gradient from the likelihood but is still pulled
toward a common value by the ridge. Expect β0 in the output to be ridge-
determined rather than arbitrary.
α is not frozen under Identity — filter_spec_calibration sets α=α_true
(line 611–616) regardless of ge_type — so the optimizer simply faces a flat
direction and α drifts little from alpha_init.
The consequence for regularization is the load-bearing part. Both penalties are
in β units, with no α anywhere:
# jaxmodels.py:573-577
fusion_penalty += fusion_weights[d] * jnp.abs(model.φ[d].β - model.φ[ref].β).sum()
l2_penalty += (β**2).sum()
So if the data pin α·β, then raising α shrinks β proportionally, and a fixed
fusionreg bites harder on the smaller β. Worse, the two penalties do not
rescale together: fusion is linear in β (a 6× β change needs a 6× λ change)
while L2 is quadratic (needs 36×). No single scalar rescaling of Phase 2's
ladder can keep both comparable. Phase 2's ladder is therefore not
transferable, and "divide λ by 6" is not a fix.
2. Design decisions
D1. α and the regularizers are OPEN QUESTIONS the run measures
Do not pin alpha_init to a value chosen to make the comparison come out.
An earlier draft of this spec recommended alpha_init: 1.0. That
recommendation was wrong, and the way it was wrong is the central lesson of
this phase, so it is recorded rather than deleted.
Why α is not a free gauge
Under Identity the likelihood depends only on the product α·β (§1b). It is
tempting to conclude α is an arbitrary gauge. That is true of the
likelihood and false of the objective, because the penalties depend on
β alone, with no α (jaxmodels.py:560, 573-577):
objective = loss(α·β) + l2reg·(β²).sum() + fusionreg·|β_d − β_ref|
Reparameterizing by the identified quantity c = α·β, the penalties become
effective l2reg = l2reg / α² effective fusionreg = fusionreg / α
so α sets the effective regularization strength. l2reg is nonzero
(config.yaml:65, 1.0e-6) and is a single inherited scalar that is not
swept, so a fusion-ladder calibration cannot absorb an α-induced change in
it. The objective is strictly decreasing in α, so α is weakly identified
through the penalties, with a gradient pushing it upward.
The three artifact mechanisms, quantified
These are why α cannot simply be picked. Measured from the cached sigmoid fit
(results-prod-292-spike-fit-tuning/ge_params.csv: α ≈ 17.79 rep_1 / 19.27
rep_2; max |β| ≈ 1.967 at lasso_choice), the identified product is
α·β ≈ 34.99:
alpha_init |
required max |β| |
effective l2reg |
beta_clip_range [-10,10] |
| 17.79 (sigmoid's own) |
≈ 1.97 |
3.2e-09 |
inactive |
| 6.0 (fit-tier default) |
≈ 5.83 |
2.8e-08 |
inactive |
| 1.0 (earlier draft) |
≈ 34.99 |
1.0e-06 |
✗ BINDS HARD |
- A1 — clipping.
beta_clip_range is applied in β units
(jaxmodels.py:601-604). At α = 1 the required β far exceeds the bound, so
the clip truncates precisely the large-effect mutations. That inflates the
linear arm's loss and reproduces the caption's claim as a clipping
artifact.
- A2 — effective L2. Between α = 6 and α = 1 the effective L2 changes by
36× and effective fusion by 6×. Since fusion is linear in β and L2 is
quadratic, no single scalar rescaling makes both comparable — which is also
why Phase 2's ladder is not transferable and "divide λ by 6" is not a fix.
- A3 — noise floor. The sigmoid's own validation curve
(cross_validation_loss.csv, replicate-averaged) has a minimum only ≈ 0.7 %
deep across the ladder, while the between-replicate spread at fixed λ is
comparable. An unstable rung can inflate the objective by ~10 % — over an
order of magnitude larger than the effect being measured.
What the phase does instead
The arm is fit at more than one α setting and the outcome is reported as a
sensitivity, not collapsed to a single number. Concretely:
- Primary arm — α at the sigmoid's own scale. Set
alpha_init to the
sigmoid's fitted α so the two arms sit on the same identified scale and the
clip is demonstrably inactive. This is the setting in which a
linear-vs-sigmoid loss comparison is least confounded.
- Sensitivity arm — α at the fit-tier default (6.0). Fit the same ladder
again. If the linear-vs-sigmoid verdict flips between arms 1 and 2, the
verdict is an artifact of the α/regularization choice and must be reported
as such, not resolved by preferring one.
l2reg is reported alongside every result on the identified scale
(l2reg/α²), so a reader can see what shrinkage each arm actually received.
Whether to additionally fit at l2reg: 0.0 — which removes the quadratic term
so the fusion ladder is the only shrinkage and A2 disappears — is left
open for the implementer to decide from arm 1/2 evidence, and the choice
must be recorded in the PR's ## Deviations from spec.
Mandatory instrument checks
These are not optional validations; without them the arm's output is not
interpretable:
- C1 — the clip must be proven inactive. Report
max |β| per fit against
beta_clip_range. If the bound binds anywhere on the ladder, the loss
comparison at that rung is void and must be reported as void, not
plotted. Widening the clip is permitted only as an explicit, recorded
deviation, and requires re-reporting C1.
- C2 — α drift is measured, not asserted. Report fitted α against its init
per fit. Because the objective is not flat in α, a small drift is evidence
about the calibration block's step budget (cal_kwargs: maxiter: 10), not
proof of exact non-identifiability. Do not fail the run on drift; report it.
- C3 — same-scale check. Fit one rung at two
alpha_init values with the
regularizers rescaled onto the identified scale and confirm the predictions
agree. This is the direct test of the degeneracy claim; C2 is not.
The λ ladder (D1a)
The ladder is chosen empirically, per α setting, against a dimensionless
criterion — stop-codon shift sparsity — because sparsity is a fraction of
zeroed shifts and is therefore immune to the β-scale problem that contaminates
every other cross-arm quantity.
- Probe a coarse ladder on one replicate at the inherited
(tol, maxiter).
Anchor it on the α-scaling prediction λ_linear ≈ λ_sigmoid · (α_sig/α_lin)
rather than sweeping blind; for arm 1 this predicts a ladder near Phase 2's.
- Record stop-codon sparsity per rung.
- Choose an 8–10 rung geometric ladder bracketing the region where sparsity
climbs from ≈ 0 to ≈ 1. If the probe span does not bracket that range,
extend it by decades until it does — do not silently accept a truncated
window.
- Write the ladder into the config as a literal list with the probe's sparsity
table quoted in a comment, following the evidence-in-config convention
config_downstream.yaml already uses for lasso_choice.
Record the observed sparsity-vs-λ curve against the α-scaling prediction, so
the ladder rationale is confirmed by the probe rather than assumed from
§1b.
Fairness is necessary but not sufficient. Comparing each arm at its own
minimum-validation rung is required, and still does not make the comparison
safe on its own: per A3 the minima are shallow relative to replicate spread.
Panel B's obligations are specified in §D2.
D2. Reproduce S10 faithfully as a three-panel figure
Panels mirror S9 (shrinkage_analysis_trace_plots_beta), refit with Identity,
all sharing one categorical λ x-axis:
-
A — reproducibility: replicate-vs-replicate R² of shift params
(Delta, BA.2). Producer: the linear arm must export its own
linear_replicate_correlation.csv; the sigmoid panel reads
library_replicate_correlation.csv, an evaluate output computed over the
sigmoid collection, which has no linear equivalent until this arm makes
one. Mirror that file's schema.
-
B — held-out loss: training vs validation loss, selected on
dataset ∈ {training, validation}. Do not filter condition == "total" —
see the schema note in §3.2; the exported loss is already the total and has no
condition column. This is the panel carrying the caption's scientific claim,
and it therefore carries reporting obligations that the other panels do not:
- B1 — show the noise floor. Plot/report per-replicate losses, not the
replicate mean. The between-replicate spread at fixed λ is comparable to the
entire λ effect (A3), and a mean hides exactly that.
- B2 — show how well-resolved each minimum is. Report each arm's loss at
its selected rung and at both neighbouring rungs. A minimum that is not
resolved by the ladder cannot support a comparison.
- B3 — state the verdict honestly, including the null. The notebook must
compute the linear-vs-sigmoid validation gap and compare it against the
within-arm replicate spread. If the gap does not exceed that spread, the
notebook must say the figure does not support the caption. This sentence
is a required output, not a fallback.
- B4 — report the α sensitivity. State the verdict from both α arms
(§D1). If they disagree, the panel reports the disagreement rather than
choosing a winner.
- B5 — report convergence at the compared rungs. Give
n_outer_sweeps,
final_objective_error, and whether maxiter was hit, for both arms. A
loss difference accompanied by a convergence difference is not evidence
about global epistasis; say so where it occurs.
Both arms must be scored on identical variant sets: reuse
cross_validation.ipynb's split logic verbatim — same seed, same
train_frac, same aggregation, same all_muts_known test filter — and assert
equal scored-variant counts per (replicate, condition). A mean-Huber
comparison across different denominators is not a comparison.
Note "total" is a sum over conditions (model.py:616,
result["total"] = sum(result.values())), not a mean. Label the axis
accordingly or divide by len(conditions) — do not inherit S9's "loss per
variant" label, which is wrong by a factor of 3.
-
C — sparsity: percent of shift params equal to zero, split by mutation
class. fit_sparsity.csv's mut_type has exactly two values in this
pipeline — nonsynonymous and stop. There is no in-frame-deletion class:
prepare_data drops every deletion-bearing variant, which is why the legacy
Figure 3 deletion row rendered empty and was removed. Do not plot a third
category.
Figure name: shrinkage_analysis_linear_models — the legacy saveas value
(cell 106), kept so a number-diff can match it against the manuscript. Because
S10 is descoped from EPIC #290's assembly phase (§0), this issue owns that
diff: report the reproduced panel values against the published S10 caption
and si.tex as part of its own deliverable.
D3. Scope boundary: the linear arm needs its own cross-validation
Panel B is a held-out loss trace, so the arm must fit a CV split, not just a
point fit. This is the one place this arm is materially heavier than the
naive baseline arm landed in #294 (whose single-condition fits needed no
ladder and no CV).
Consequence: like manuscript_figures, the S10 figure requires CV. When
SKIP_CV is set the linear arm still fits (it is a fit in its own right) but
S10 is not built — mirroring the existing BUILD_FIGURES = not SKIP_CV
precedent, which drops a figure rather than emitting one with a missing panel.
3. Deliverables
3.1 experiments/scv2-spike/notebooks/_downstream.py
Add, as siblings of fit_naive_arm:
-
fit_linear_arm(func_score_df, conditions, replicates, fit_config, linear_config, cv_split=None, verbose=False)
→ (models, convergence_df, ge_params_df). Multi-condition (unlike
fit_single_condition), one fit per (replicate, fusionreg), calling
multidms.Model(..., ge_type="Identity", ...) directly — bypassing
_common.build_fit_params, exactly as fit_single_condition does.
⚠ The alpha_init override must be plumbed explicitly. fit_config is
bound from spike["fitting"] (the fit-tier block, where alpha_init: 6.0
lives). Simply declaring an alpha_init under spike.linear would be
silently ignored, and the arm would inherit the fit-tier 6.0 — which
would quietly decide Q1 and Q2 by accident rather than by measurement.
fit_linear_arm therefore takes linear_config as its own parameter and
overlays it: effective = {**fit_config, **linear_config}, with
ge_type forced to "Identity". The overlay must be asserted in the
notebook (print the effective alpha_init and ge_type) so the override is
visible in the executed notebook rather than assumed.
-
derive_linear_shifts(models, reference, times_seen_threshold=1) → long-form
mutation frame.
-
report_alpha_drift(ge_params_df, alpha_init) implementing C2 — a
measurement and report, not an assertion (see §D1).
-
assert_clip_inactive(models, beta_clip_range) implementing C1 — reports
max |β| per fit and marks any rung where the bound binds as void.
How the arm reaches ModelCollection without touching _common.py.
Both cross-validation (§D3) and stop-codon sparsity (§D1a) are
ModelCollection capabilities — ModelCollection.shift_sparsity()
(model_collection.py:1326) and the CV loss export — and are unreachable
from a bare dict of models. The route is nonetheless open, because
fit_models(params, ...) (model_collection.py:289) takes a plain dict:
fit_linear_arm builds its own params dict locally, with
"ge_type": ["Identity"] and the chosen alpha_init, and passes it to
fit_models → ModelCollection. The issue's prohibition is on editing
_common.build_fit_params (which is input: on all four fit-tier rules and
would invalidate the cached fit); it is not a prohibition on using
fit_models. Sparsity then comes from shift_sparsity(return_data=True)
exactly as evaluate.ipynb does, and CV from the split logic reused verbatim
from cross_validation.ipynb (§D2 panel B).
_downstream.py is the correct home: its docstring states it is a deliberate
sibling of _common.py precisely so downstream helpers cannot invalidate the
fit. _common.py must not be modified.
3.2 experiments/scv2-spike/notebooks/linear_baseline.ipynb
Papermill notebook mirroring naive_baseline.ipynb's shape. Parameters cell,
verbatim shape:
config_path = "config/config.yaml"
downstream_config_path = "config/config_downstream.yaml"
output_dir = None
Reads training_functional_scores.csv only. It must never load
fit_collection.pkl. Keeps all linear frames in their own names — never
rebinding anything a sigmoid cell owns (the fragility of §1a).
Outputs:
| file |
schema |
linear_muts.csv |
mutation, wts, sites, muts, replicate, fusionreg, condition, beta, shift, times_seen |
linear_convergence.csv |
replicate, fusionreg, converged, n_outer_sweeps, final_objective_error, tol |
linear_ge_params.csv |
replicate, fusionreg, condition, alpha, beta0, n_variants |
linear_cv_loss.csv |
fusionreg, dataset_name, dataset, loss, mean_loss, fusionreg_cat |
linear_sparsity.csv |
dataset_name, fusionreg, mut_type, mut_param, sparsity |
linear_replicate_correlation.csv |
mirrors library_replicate_correlation.csv (panel A source) |
Schemas deliberately match the sigmoid equivalents (fit_sparsity.csv,
cross_validation_loss.csv) column-for-column where the quantity is the same,
so panel code can be shared rather than forked.
⚠ Two schema facts, verified against the landed CSVs — do not "improve" them.
linear_cv_loss.csv has no condition column. The real
cross_validation_loss.csv header is
fusionreg,dataset_name,dataset,loss,mean_loss,fusionreg_cat. It is built
by melting total_loss_{training,validation}, which is already the
total aggregate, so there is no per-condition axis to filter. Panel B
must therefore select on dataset ∈ {training, validation} and must
not filter condition == "total" — that filter is inherited from the
legacy get_conditional_loss_df(), a different producer, and would
raise KeyError here. §D2 is corrected accordingly.
linear_ge_params.csv is hand-assembled, not a dump of
get_ge_params_df(). That method returns
condition, alpha, beta0, bundle_sum, wildtype_latent, n_bundle_mutations
— no n_variants. Phase 4's naive_ge_params.csv gets its shape because
fit_single_condition builds the diagnostics dict itself. fit_linear_arm
does the same, adding replicate, fusionreg, and n_variants. The V1
portability note refers to the method being ge_type-agnostic, not to
the CSV schema.
3.3 experiments/scv2-spike/config/*_downstream.yaml — ALL SIX
Add a spike.linear: block declaring zero new fit-tier keys. It goes in
all six downstream configs, not just the default one — verified: every one
of them already carries the sibling naive: block, and V6 requires a
test-profile run, which reads config_test_downstream.yaml:
config_downstream.yaml config_recompute_false_downstream.yaml
config_test_downstream.yaml config_recompute_false_test_downstream.yaml
config_experimental_downstream.yaml config_recompute_false_maxiter200_downstream.yaml
A missing sibling fails evaluate. The ladder may be shortened in the test and
experimental variants.
linear:
# alpha_init is a REAL MODELING CHOICE here, not cosmetics. Under
# ge_type Identity only the product alpha*beta is identified, so alpha
# sets the effective regularization: l2reg/alpha^2 and fusionreg/alpha on
# the identified scale. It also decides whether beta_clip_range binds --
# at alpha=1 the required |beta| is ~35 against a [-10,10] clip.
# Two arms are fit and compared as a sensitivity; neither is "the" answer.
# See jaxmodels.py:307,601-604 and issue #293 section D1.
alpha_init_primary: null # set to the sigmoid's fitted alpha
alpha_init_sensitivity: 6.0 # the fit-tier default, for the A2 check
times_seen_threshold: 1
conditions: [Delta, Omicron_BA1, Omicron_BA2]
# Chosen by the sparsity-matching calibration of D1a; probe table quoted
# here at implementation time.
fusionreg_values: [...]
config/config.yaml must not change — that is a fit-tier edit and refits
everything.
3.4 experiments/scv2-spike/Snakefile
-
New rule linear_baseline, copying rule naive_baseline's input/output/
params/shell block and its docstring reasoning. Declaring config=CONFIG_PATH
and common=COMMON as input: is permitted and precedented (rule
naive_baseline and rule evaluate both do it) because invalidation flows
into a rule's outputs, and this rule produces no input of any fit-tier rule.
The invariant to preserve: nothing here may become an input of prepare_data,
fit_models, cross_validation, or evaluate, and the contents of
config.yaml / _common.py must not change.
The stub says the new rule must not declare config/common as input:.
That instruction predates Phase 4, which established the opposite precedent
with an explicit correctness argument. This spec follows the landed
precedent; the deviation is recorded here and belongs in the PR's
## Deviations from spec.
-
Register "S10": "shrinkage_analysis_linear_models" in FIGURE_NAMES.
-
Add linear_baseline.ipynb + linear_muts.csv to _all_targets in the
unconditional block (the arm is a fit in its own right), following the
naive precedent's comment.
-
Add the linear CSVs to rule manuscript_figures's input:.
3.5 experiments/scv2-spike/notebooks/manuscript_figures.ipynb
Add a markdown + code cell pair following the Figure 3 template (cells 25/26):
read("linear_*.csv"), pivot to wide (quantity, replicate, condition), take an
explicit shared mutation index against mutations_df with a ValueError guard
when the intersection is < 2, render the three panels, and
savefig(fig, "shrinkage_analysis_linear_models", FIGURES_DIR, FORMATS, DPI).
Reads cache-boundary CSVs only; never fit_collection.pkl.
3.6 tests/test_linear_baseline.py
Phase 4 shipped tests/test_naive_baseline.py (8 tests) and its review still
found a vacuous test — one that passed because the regularizers were never
forwarded at all. Mirror the file and make V3 genuinely falsifiable: a test that
fails if fusionreg/l2reg/ge_type are dropped on the way into
multidms.Model. Add a test that ge_type is "Identity" in the constructed
params dict, since that is the entire point of the arm.
3.7 Docs
docs/spike_linear_baseline.nblink:
{
"path": "../experiments/scv2-spike/results/linear_baseline.ipynb"
}
- Add
spike_linear_baseline to the Spike Analysis toctree in
docs/index.rst, between spike_naive_baseline and
spike_manuscript_figures.
The .nblink resolves through experiments/scv2-spike/results, an untracked
hand-made symlink that currently points at the most recent landed spike
results dir. pixi run docs will fail until this arm's run lands and the
symlink is repointed — expected, and part of V6 rather than a defect.
4. Validation
-
V1 — α drift under Identity is measured and reported (not asserted).
Record every fitted α against the arm's own alpha_init and report the
spread, for both α arms (§D1). Do
not fail the run on drift: per the §D1 correction, the penalties break the
degeneracy and α is expected to move somewhat. What the run must surface is
the magnitude, because a large drift means the reported β scale — and hence
the meaning of the arm's λ ladder — moved with it.
Readback is safe: Model.get_ge_params_df() (model.py:865) derives
alpha, beta0, bundle_sum, wildtype_latent from model structure only
— none of it depends on ge_type, so the method is portable to the
Identity arm unchanged. The CSV schema is not: n_variants is
hand-assembled by the fitter, not returned by the method (see §3.2).
-
V2 — the arm did not disturb the sigmoid cache. After a full run,
md5 of fit_collection.pkl and mutations_df.csv is unchanged, and
snakemake -n reports nothing to be done for the fit-tier rules.
-
V3 — regularizers are genuinely forwarded. A test that would fail if
fusionreg/l2reg were dropped on the way into multidms.Model — the
Phase 4 review found exactly this class of vacuous test, so it is called out.
-
V4 — convergence is recorded, not asserted. Report converged/total per
rung. A rung that fails to converge is reported and excluded from S10 with a
visible marker, following the excluded_fusionreg precedent — never silently
plotted.
-
V5 — shared mutation index. Panels comparing linear against sigmoid use
one explicit intersected index, with the count printed. The Phase 4 review
found panels silently using two different indices; this makes it impossible.
-
V7 — no result is reported from a void rung. Any rung where the β clip
binds (C1) is excluded from every cross-arm comparison and labelled void in
the figure, following the excluded_fusionreg marking precedent. Silent
inclusion is the failure mode this guards.
-
V8 — the null outcome is a first-class result. The notebook must be able
to conclude "the data do not separate the arms" and say so in-figure (B3).
A run producing that conclusion is complete and correct, and must not be
re-tuned to produce a difference.
-
V9 — pre-flight cache guard. Run snakemake -n and confirm no fit-tier
rule is scheduled before any real run. Phase 4 destroyed the 1.76 GB
cache with --forcerun and no dry-run; V2's md5 check is after-the-fact and
would not have prevented it.
-
V6 — test profile runs. The whole rule completes under the test profile
(subsampled), with thin-coverage guards so a correlation over n < 2 cannot
crash the notebook (the Phase 4 run hit exactly this).
4b. Scope: this may want to be two PRs (open)
A fresh completeness review rated this unit oversized for one PR. The work is:
a ladder-calibration probe requiring its own fit; a multi-condition ladder fit
with cross-validation (structurally comparable to cross_validation.ipynb,
the pipeline's second-most-expensive rule); two α arms for the sensitivity;
six CSV exports; a Snakemake rule; a three-panel figure; tests; docs. For
calibration, Phase 4 — explicitly lighter, with no ladder and no CV — still
landed with seven numbered deviations and a spun-out follow-up issue.
Recommended split, left open for the implementer to confirm or reject:
- 3a — the arm.
_downstream.py helpers, the ladder probe, the config
blocks, the Snakemake rule, the CSVs, tests, docs. Deliverable: a committed
ladder with its probe table, plus linear_*.csv on disk and the C1/C2/C3
instrument checks reported.
- 3b — the figure. S10's three panels, the ladder-parameterized axis
helpers, FIGURE_NAMES registration, and panel B's B1–B5 obligations.
The split also de-risks the serial dependency: 3b cannot be written until the
probe has run, so bundling them parks the figure work behind a fit.
Panel code is not shareable as-is. S9 hard-codes LADDER_SORTED from the
fit-tier fusionreg_values, and rung_x()/RUNG_POS are module-level
globals built from it; drop_excluded() is driven by the sigmoid-specific
excluded_fusionreg. The linear arm has a different ladder by design, so
these must be parameterized by ladder before reuse — calling rung_x() on a
linear λ raises KeyError.
4c. Open questions — deliberately unresolved
These are recorded as open because resolving them by fiat is how a baseline arm
turns into a rigged comparison. Each must be answered from the run's own
evidence and the answer recorded in the PR's ## Deviations from spec.
| # |
Question |
How it gets answered |
| Q1 |
What alpha_init does the primary arm use? |
Set to the sigmoid's fitted α so both arms share an identified scale and C1 shows the clip inactive. Confirm from ge_params.csv, don't hard-code from this spec. |
| Q2 |
Does the linear-vs-sigmoid verdict survive the α sensitivity? |
Compare arm 1 vs arm 2 (§D1). Disagreement is itself the reported result. |
| Q3 |
Should the linear arm fit at l2reg: 0.0? |
Decide from arm 1/2 evidence. Zeroing removes A2 entirely but changes the model being compared — a real trade, not a formality. |
| Q4 |
Is beta_clip_range widened for this arm? |
Only if C1 shows binding, only as a recorded deviation, and only with C1 re-reported afterward. |
| Q5 |
Does the caption's claim hold at all? |
Genuinely open. B3 requires the notebook to report "not supported" when the gap is within replicate spread. |
| Q6 |
One PR or two? |
§4b. Implementer's call on evidence from the probe's actual runtime. |
None of Q1–Q6 may be resolved in the direction that makes the published
caption more likely to be confirmed. If a choice is genuinely ambiguous,
report both outcomes.
5. Out of scope
Linear (Identity) baseline arm for the scv2-spike pipeline (SI Figure S10)
0. What the descope changed
Only the epic-coupling statements were edited. The scientific content, the
design decisions (§2), the deliverables (§3), the validation gates (§4) and
the open questions (§4c) are untouched.
si.tex1. Corrections to the stub's premises
Two claims in the stub body were checked against source and one is wrong.
Both are recorded here because the epic's handoff phase (#297) was told to
surface the second as an erratum, and it must not — see §0.
1a. The "known defect in the reference implementation" is REFUTED
The stub states: "legacy notebook cell 103 calls
mc.get_conditional_loss_df()where
mcis the sigmoid CV collection —linear_mcis built and annotated butnever read. So the published Figure S10's middle panel does not show
linear-model CV loss."
This is not what the notebook does. The legacy notebook is recoverable at
fc89753:notebooks/spike-analysis.ipynb(the stub's6c98b7bdoes not resolvein this repo; it was added by
fc89753"latest before moving spike analysis"and deleted by
9198572"formatting"). By 0-based cell index:cross_validation_df = mc.get_conditional_loss_df()— sigmoidfit_params_linear["dataset"] = train; ... fit_models(...)— the fit call, not a loss calllinear_mc = ModelCollection(linear_models_cv); linear_mc.add_validation_loss(test, overwrite=True)cross_validation_df = linear_mc.get_conditional_loss_df()— linear, and it IS readsaveas="shrinkage_analysis_linear_models"— the S10 figureThe stored
execution_counts are monotone across 128 → 132 → 133, so the lastwrite to
cross_validation_dfbefore S10 was rendered was the linear one.Cells 101/102 (exec 125/126) likewise rebind
sparsity_dfandcorr_dffromthe linear collection, so panels A and C are linear too.
Decision: do not file an erratum, and do not carry the bug claim into
the manuscript handoff (#297) as established fact. The execution record is consistent with the
published figure being linear. Note the evidential limit honestly: stored
execution_counts record an execution order, not proof that the saved figurefile was rendered from it. The reproduction in this phase is the decisive
check — if the reproduced panel B disagrees with the published figure, reopen
this premise rather than attributing the discrepancy elsewhere. The stub's separate warning that cells 98–100 rebind
module-level frames is still a real fragility — the two arms share variable
names, so a re-run in a different order would silently mix arms — and this spec
keeps the arms in separate namespaces for exactly that reason. But the
committed execution record shows the bug did not fire, and the published figure
is sound.
1b. The α claim is CONFIRMED, and is the central design constraint
Verified in
multidms/jaxmodels.py:Identity.__call__(line 209) is a pure pass-through with no parameters.Model.predict_score(line 307) computesα * (GE(φ(X)) − GE(φ(x_wt))), andφ(X) = β0 + X @ β(line 189).Under Identity the GE is the identity, so this collapses exactly:
β0 cancels identically from
predict_scoreand only the product α·β isdetermined. Under Sigmoid the
expitnonlinearity blocks the cancellation, so αis identified there — and empirically it is: the cached sigmoid fit reports
α ≈ 17.8 (rep_1) to 19.3 (rep_2), far from
alpha_init: 6.0(
results-prod-292-spike-fit-tuning/ge_params.csv).Note β0 is not thereby unconstrained:
_beta_ridge_penalty(
jaxmodels.py:532) penalizes(β0_d − β0_ref)²atbeta0_ridge: 0.01, sounder Identity β0 receives no gradient from the likelihood but is still pulled
toward a common value by the ridge. Expect β0 in the output to be ridge-
determined rather than arbitrary.
α is not frozen under Identity —
filter_spec_calibrationsetsα=α_true(line 611–616) regardless of
ge_type— so the optimizer simply faces a flatdirection and α drifts little from
alpha_init.The consequence for regularization is the load-bearing part. Both penalties are
in β units, with no α anywhere:
So if the data pin
α·β, then raising α shrinks β proportionally, and a fixedfusionregbites harder on the smaller β. Worse, the two penalties do notrescale together: fusion is linear in β (a 6× β change needs a 6× λ change)
while L2 is quadratic (needs 36×). No single scalar rescaling of Phase 2's
ladder can keep both comparable. Phase 2's ladder is therefore not
transferable, and "divide λ by 6" is not a fix.
2. Design decisions
D1. α and the regularizers are OPEN QUESTIONS the run measures
Do not pin
alpha_initto a value chosen to make the comparison come out.An earlier draft of this spec recommended
alpha_init: 1.0. Thatrecommendation was wrong, and the way it was wrong is the central lesson of
this phase, so it is recorded rather than deleted.
Why α is not a free gauge
Under Identity the likelihood depends only on the product
α·β(§1b). It istempting to conclude α is an arbitrary gauge. That is true of the
likelihood and false of the objective, because the penalties depend on
β alone, with no α (
jaxmodels.py:560, 573-577):Reparameterizing by the identified quantity
c = α·β, the penalties becomeso α sets the effective regularization strength.
l2regis nonzero(
config.yaml:65,1.0e-6) and is a single inherited scalar that is notswept, so a fusion-ladder calibration cannot absorb an α-induced change in
it. The objective is strictly decreasing in α, so α is weakly identified
through the penalties, with a gradient pushing it upward.
The three artifact mechanisms, quantified
These are why α cannot simply be picked. Measured from the cached sigmoid fit
(
results-prod-292-spike-fit-tuning/ge_params.csv: α ≈ 17.79 rep_1 / 19.27rep_2; max |β| ≈ 1.967 at
lasso_choice), the identified product isα·β ≈ 34.99:alpha_initl2regbeta_clip_range [-10,10]beta_clip_rangeis applied in β units(
jaxmodels.py:601-604). At α = 1 the required β far exceeds the bound, sothe clip truncates precisely the large-effect mutations. That inflates the
linear arm's loss and reproduces the caption's claim as a clipping
artifact.
36× and effective fusion by 6×. Since fusion is linear in β and L2 is
quadratic, no single scalar rescaling makes both comparable — which is also
why Phase 2's ladder is not transferable and "divide λ by 6" is not a fix.
(
cross_validation_loss.csv, replicate-averaged) has a minimum only ≈ 0.7 %deep across the ladder, while the between-replicate spread at fixed λ is
comparable. An unstable rung can inflate the objective by ~10 % — over an
order of magnitude larger than the effect being measured.
What the phase does instead
The arm is fit at more than one α setting and the outcome is reported as a
sensitivity, not collapsed to a single number. Concretely:
alpha_initto thesigmoid's fitted α so the two arms sit on the same identified scale and the
clip is demonstrably inactive. This is the setting in which a
linear-vs-sigmoid loss comparison is least confounded.
again. If the linear-vs-sigmoid verdict flips between arms 1 and 2, the
verdict is an artifact of the α/regularization choice and must be reported
as such, not resolved by preferring one.
l2regis reported alongside every result on the identified scale(
l2reg/α²), so a reader can see what shrinkage each arm actually received.Whether to additionally fit at
l2reg: 0.0— which removes the quadratic termso the fusion ladder is the only shrinkage and A2 disappears — is left
open for the implementer to decide from arm 1/2 evidence, and the choice
must be recorded in the PR's
## Deviations from spec.Mandatory instrument checks
These are not optional validations; without them the arm's output is not
interpretable:
max |β|per fit againstbeta_clip_range. If the bound binds anywhere on the ladder, the losscomparison at that rung is void and must be reported as void, not
plotted. Widening the clip is permitted only as an explicit, recorded
deviation, and requires re-reporting C1.
per fit. Because the objective is not flat in α, a small drift is evidence
about the calibration block's step budget (
cal_kwargs: maxiter: 10), notproof of exact non-identifiability. Do not fail the run on drift; report it.
alpha_initvalues with theregularizers rescaled onto the identified scale and confirm the predictions
agree. This is the direct test of the degeneracy claim; C2 is not.
The λ ladder (D1a)
The ladder is chosen empirically, per α setting, against a dimensionless
criterion — stop-codon shift sparsity — because sparsity is a fraction of
zeroed shifts and is therefore immune to the β-scale problem that contaminates
every other cross-arm quantity.
(tol, maxiter).Anchor it on the α-scaling prediction
λ_linear ≈ λ_sigmoid · (α_sig/α_lin)rather than sweeping blind; for arm 1 this predicts a ladder near Phase 2's.
climbs from ≈ 0 to ≈ 1. If the probe span does not bracket that range,
extend it by decades until it does — do not silently accept a truncated
window.
table quoted in a comment, following the evidence-in-config convention
config_downstream.yamlalready uses forlasso_choice.Record the observed sparsity-vs-λ curve against the α-scaling prediction, so
the ladder rationale is confirmed by the probe rather than assumed from
§1b.
D2. Reproduce S10 faithfully as a three-panel figure
Panels mirror S9 (
shrinkage_analysis_trace_plots_beta), refit with Identity,all sharing one categorical λ x-axis:
A — reproducibility: replicate-vs-replicate R² of shift params
(Delta, BA.2). Producer: the linear arm must export its own
linear_replicate_correlation.csv; the sigmoid panel readslibrary_replicate_correlation.csv, anevaluateoutput computed over thesigmoid collection, which has no linear equivalent until this arm makes
one. Mirror that file's schema.
B — held-out loss: training vs validation loss, selected on
dataset ∈ {training, validation}. Do not filtercondition == "total"—see the schema note in §3.2; the exported loss is already the total and has no
conditioncolumn. This is the panel carrying the caption's scientific claim,and it therefore carries reporting obligations that the other panels do not:
replicate mean. The between-replicate spread at fixed λ is comparable to the
entire λ effect (A3), and a mean hides exactly that.
its selected rung and at both neighbouring rungs. A minimum that is not
resolved by the ladder cannot support a comparison.
compute the linear-vs-sigmoid validation gap and compare it against the
within-arm replicate spread. If the gap does not exceed that spread, the
notebook must say the figure does not support the caption. This sentence
is a required output, not a fallback.
(§D1). If they disagree, the panel reports the disagreement rather than
choosing a winner.
n_outer_sweeps,final_objective_error, and whethermaxiterwas hit, for both arms. Aloss difference accompanied by a convergence difference is not evidence
about global epistasis; say so where it occurs.
Both arms must be scored on identical variant sets: reuse
cross_validation.ipynb's split logic verbatim — sameseed, sametrain_frac, same aggregation, sameall_muts_knowntest filter — and assertequal scored-variant counts per
(replicate, condition). A mean-Hubercomparison across different denominators is not a comparison.
Note
"total"is a sum over conditions (model.py:616,result["total"] = sum(result.values())), not a mean. Label the axisaccordingly or divide by
len(conditions)— do not inherit S9's "loss pervariant" label, which is wrong by a factor of 3.
C — sparsity: percent of shift params equal to zero, split by mutation
class.
fit_sparsity.csv'smut_typehas exactly two values in thispipeline —
nonsynonymousandstop. There is no in-frame-deletion class:prepare_datadrops every deletion-bearing variant, which is why the legacyFigure 3 deletion row rendered empty and was removed. Do not plot a third
category.
Figure name:
shrinkage_analysis_linear_models— the legacysaveasvalue(cell 106), kept so a number-diff can match it against the manuscript. Because
S10 is descoped from EPIC #290's assembly phase (§0), this issue owns that
diff: report the reproduced panel values against the published S10 caption
and
si.texas part of its own deliverable.D3. Scope boundary: the linear arm needs its own cross-validation
Panel B is a held-out loss trace, so the arm must fit a CV split, not just a
point fit. This is the one place this arm is materially heavier than the
naive baseline arm landed in #294 (whose single-condition fits needed no
ladder and no CV).
Consequence: like
manuscript_figures, the S10 figure requires CV. WhenSKIP_CVis set the linear arm still fits (it is a fit in its own right) butS10 is not built — mirroring the existing
BUILD_FIGURES = not SKIP_CVprecedent, which drops a figure rather than emitting one with a missing panel.
3. Deliverables
3.1
experiments/scv2-spike/notebooks/_downstream.pyAdd, as siblings of
fit_naive_arm:fit_linear_arm(func_score_df, conditions, replicates, fit_config, linear_config, cv_split=None, verbose=False)→
(models, convergence_df, ge_params_df). Multi-condition (unlikefit_single_condition), one fit per(replicate, fusionreg), callingmultidms.Model(..., ge_type="Identity", ...)directly — bypassing_common.build_fit_params, exactly asfit_single_conditiondoes.derive_linear_shifts(models, reference, times_seen_threshold=1)→ long-formmutation frame.
report_alpha_drift(ge_params_df, alpha_init)implementing C2 — ameasurement and report, not an assertion (see §D1).
assert_clip_inactive(models, beta_clip_range)implementing C1 — reportsmax |β|per fit and marks any rung where the bound binds as void._downstream.pyis the correct home: its docstring states it is a deliberatesibling of
_common.pyprecisely so downstream helpers cannot invalidate thefit.
_common.pymust not be modified.3.2
experiments/scv2-spike/notebooks/linear_baseline.ipynbPapermill notebook mirroring
naive_baseline.ipynb's shape. Parameters cell,verbatim shape:
Reads
training_functional_scores.csvonly. It must never loadfit_collection.pkl. Keeps all linear frames in their own names — neverrebinding anything a sigmoid cell owns (the fragility of §1a).
Outputs:
linear_muts.csvmutation, wts, sites, muts, replicate, fusionreg, condition, beta, shift, times_seenlinear_convergence.csvreplicate, fusionreg, converged, n_outer_sweeps, final_objective_error, tollinear_ge_params.csvreplicate, fusionreg, condition, alpha, beta0, n_variantslinear_cv_loss.csvfusionreg, dataset_name, dataset, loss, mean_loss, fusionreg_catlinear_sparsity.csvdataset_name, fusionreg, mut_type, mut_param, sparsitylinear_replicate_correlation.csvlibrary_replicate_correlation.csv(panel A source)Schemas deliberately match the sigmoid equivalents (
fit_sparsity.csv,cross_validation_loss.csv) column-for-column where the quantity is the same,so panel code can be shared rather than forked.
3.3
experiments/scv2-spike/config/*_downstream.yaml— ALL SIXAdd a
spike.linear:block declaring zero new fit-tier keys. It goes inall six downstream configs, not just the default one — verified: every one
of them already carries the sibling
naive:block, andV6requires atest-profile run, which readsconfig_test_downstream.yaml:A missing sibling fails
evaluate. The ladder may be shortened in the test andexperimental variants.
config/config.yamlmust not change — that is a fit-tier edit and refitseverything.
3.4
experiments/scv2-spike/SnakefileNew
rule linear_baseline, copyingrule naive_baseline's input/output/params/shell block and its docstring reasoning. Declaring
config=CONFIG_PATHand
common=COMMONasinput:is permitted and precedented (rulenaive_baselineand ruleevaluateboth do it) because invalidation flowsinto a rule's outputs, and this rule produces no input of any fit-tier rule.
The invariant to preserve: nothing here may become an input of
prepare_data,fit_models,cross_validation, orevaluate, and the contents ofconfig.yaml/_common.pymust not change.Register
"S10": "shrinkage_analysis_linear_models"inFIGURE_NAMES.Add
linear_baseline.ipynb+linear_muts.csvto_all_targetsin theunconditional block (the arm is a fit in its own right), following the
naive precedent's comment.
Add the linear CSVs to
rule manuscript_figures'sinput:.3.5
experiments/scv2-spike/notebooks/manuscript_figures.ipynbAdd a markdown + code cell pair following the Figure 3 template (cells 25/26):
read("linear_*.csv"), pivot to wide(quantity, replicate, condition), take anexplicit shared mutation index against
mutations_dfwith aValueErrorguardwhen the intersection is
< 2, render the three panels, andsavefig(fig, "shrinkage_analysis_linear_models", FIGURES_DIR, FORMATS, DPI).Reads cache-boundary CSVs only; never
fit_collection.pkl.3.6
tests/test_linear_baseline.pyPhase 4 shipped
tests/test_naive_baseline.py(8 tests) and its review stillfound a vacuous test — one that passed because the regularizers were never
forwarded at all. Mirror the file and make V3 genuinely falsifiable: a test that
fails if
fusionreg/l2reg/ge_typeare dropped on the way intomultidms.Model. Add a test thatge_typeis"Identity"in the constructedparams dict, since that is the entire point of the arm.
3.7 Docs
docs/spike_linear_baseline.nblink:{ "path": "../experiments/scv2-spike/results/linear_baseline.ipynb" }spike_linear_baselineto theSpike Analysistoctree indocs/index.rst, betweenspike_naive_baselineandspike_manuscript_figures.4. Validation
V1 — α drift under Identity is measured and reported (not asserted).
Record every fitted α against the arm's own
alpha_initand report thespread, for both α arms (§D1). Do
not fail the run on drift: per the §D1 correction, the penalties break the
degeneracy and α is expected to move somewhat. What the run must surface is
the magnitude, because a large drift means the reported β scale — and hence
the meaning of the arm's λ ladder — moved with it.
V2 — the arm did not disturb the sigmoid cache. After a full run,
md5offit_collection.pklandmutations_df.csvis unchanged, andsnakemake -nreports nothing to be done for the fit-tier rules.V3 — regularizers are genuinely forwarded. A test that would fail if
fusionreg/l2regwere dropped on the way intomultidms.Model— thePhase 4 review found exactly this class of vacuous test, so it is called out.
V4 — convergence is recorded, not asserted. Report converged/total per
rung. A rung that fails to converge is reported and excluded from S10 with a
visible marker, following the
excluded_fusionregprecedent — never silentlyplotted.
V5 — shared mutation index. Panels comparing linear against sigmoid use
one explicit intersected index, with the count printed. The Phase 4 review
found panels silently using two different indices; this makes it impossible.
V7 — no result is reported from a void rung. Any rung where the β clip
binds (C1) is excluded from every cross-arm comparison and labelled void in
the figure, following the
excluded_fusionregmarking precedent. Silentinclusion is the failure mode this guards.
V8 — the null outcome is a first-class result. The notebook must be able
to conclude "the data do not separate the arms" and say so in-figure (B3).
A run producing that conclusion is complete and correct, and must not be
re-tuned to produce a difference.
V9 — pre-flight cache guard. Run
snakemake -nand confirm no fit-tierrule is scheduled before any real run. Phase 4 destroyed the 1.76 GB
cache with
--forcerunand no dry-run; V2's md5 check is after-the-fact andwould not have prevented it.
V6 — test profile runs. The whole rule completes under the
testprofile(subsampled), with thin-coverage guards so a correlation over
n < 2cannotcrash the notebook (the Phase 4 run hit exactly this).
4b. Scope: this may want to be two PRs (open)
A fresh completeness review rated this unit oversized for one PR. The work is:
a ladder-calibration probe requiring its own fit; a multi-condition ladder fit
with cross-validation (structurally comparable to
cross_validation.ipynb,the pipeline's second-most-expensive rule); two α arms for the sensitivity;
six CSV exports; a Snakemake rule; a three-panel figure; tests; docs. For
calibration, Phase 4 — explicitly lighter, with no ladder and no CV — still
landed with seven numbered deviations and a spun-out follow-up issue.
Recommended split, left open for the implementer to confirm or reject:
_downstream.pyhelpers, the ladder probe, the configblocks, the Snakemake rule, the CSVs, tests, docs. Deliverable: a committed
ladder with its probe table, plus
linear_*.csvon disk and the C1/C2/C3instrument checks reported.
helpers,
FIGURE_NAMESregistration, and panel B's B1–B5 obligations.The split also de-risks the serial dependency: 3b cannot be written until the
probe has run, so bundling them parks the figure work behind a fit.
4c. Open questions — deliberately unresolved
These are recorded as open because resolving them by fiat is how a baseline arm
turns into a rigged comparison. Each must be answered from the run's own
evidence and the answer recorded in the PR's
## Deviations from spec.alpha_initdoes the primary arm use?ge_params.csv, don't hard-code from this spec.l2reg: 0.0?beta_clip_rangewidened for this arm?5. Out of scope
config/config.yaml,_common.py, or the sigmoid fit.ge_typeinsidebuild_fit_params— that doubles the joint grid andinvalidates the cached fit.
now ships without S10. This issue still owns S10's own number-diff (§D2).
S10 was not regenerated; landing this issue later produces a follow-up note,
not a revision of Phase 7 of EPIC #290: Written handoff package for manuscript writing #297.