Skip to content

Fit cssLasso()'s second lasso path over an anchored, padded grid (#125) - #200

Merged
gregfaletto merged 6 commits into
mainfrom
issue-125-csslasso-anchored-grid
Sep 4, 2026
Merged

gregfaletto merged 6 commits into
mainfrom
issue-125-csslasso-anchored-grid

Conversation

@gregfaletto

@gregfaletto gregfaletto commented Sep 2, 2026 •

Copy link
Copy Markdown
Owner

cssLasso() runs 2 * B times per css() call and fit the full lasso path twice on every
subsample: predict.glmnet(..., exact = TRUE) discards the first fit's coefficients, keeps only
its $lambda, and refits the whole augmented path. This keeps the first fit — the only way to
learn glmnet's truncated grid — and shortens the second.

What changed

A new internal anchoredLambdaGrid() builds the second fit's grid: the path down to the target
penalty plus one, five geometrically spaced padding points, and the original path's final
penalty re-appended
. That last element is load-bearing. glmnet:::lambda.interp normalises by
lambda[1] - lambda[k]. That factor cancels algebraically — the exact weight is
(λ_right − s)/(λ_right − λ_left) — but it does not cancel in floating point, so the last
element moves the weight by a couple of ULP. Tiny, and material anyway: nonzeroCoef() tests
abs(x) > 0 with no tolerance, so a wobble around zero flips membership, and the unanchored
version agrees on only 99.6–99.9% of selected sets. Warm-started coordinate descent cannot
depend on smaller penalties, so the points between are pure waste. exact = TRUE, the do.call idiom and three arguments that existed only
to serve it are removed; the branch for a target penalty already on the fitted grid is a
correctness requirement, not an optimisation.

No signature, export or man/ change. This PR also starts a NEWS.md — edited at
source-files/NEWS.md, copied in by the build — with a bullet for this change.

Evidence

Output is bit-identical, and that is the whole promise. Across review: 170,784 comparisons in
the feasibility sweep, 2,178 corner cases, and 2,592 fresh calls against main — 0
mismatches
, maximum absolute coefficient difference exactly 0.

Measured at the final commit, median of 3, B = 50:

setup before after
n=400, p=800 2.063 s 1.724 s 16.4%
n=200, p=100 2.226 s 1.699 s 23.7%

identical(feat_sel_mat) and identical(clus_sel_mat) TRUE in both.

On the gap from this issue's headline: #125 advertises 35–45%. That figure is the ceiling for
dropping the second fit entirely, which is not output-preserving — the feasibility
spike
ruled
that out.
16–24% is what a byte-identical route buys.

Gate: 0 errors | 0 warnings | 0 notes, spell_check 0 rows, url_check the recorded
403s. Suite [ FAIL 0 | WARN 0 | SKIP 0 | PASS 4272 ], from 4194.

Resolves #125.

🤖 Generated with Claude Code

gregfaletto and others added 2 commits September 2, 2026 07:48
cssLasso() is css()'s innermost hot path -- 2 * B calls per css() -- and it
paid for two complete coordinate-descent path fits where one would do. The
first fit produced the default path; predict.glmnet(exact = TRUE) then threw
that fit's coefficients away, kept only its $lambda, and refit the whole path
augmented with the target penalty, including every grid point BELOW it. Warm
starts run in decreasing-lambda order, so those lower points are solved after
the target and cannot influence it.

The second fit now runs over the grid a new internal helper,
anchoredLambdaGrid(), builds: the augmented grid's prefix through one element
past the target, five geometrically spaced interior points, and the augmented
grid's own final element re-appended. The re-append is the load-bearing part --
glmnet:::lambda.interp() normalises by lambda[1] - lambda[k], so the blend
weight at the target depends on the last supplied grid point as well as the
first, and truncating the grid changes the answer in its last bits even when
both fitted columns are bit-identical. The selection is then read off with an
ordinary exact = FALSE call.

Output is unchanged bit for bit; the saving is wall-clock only. exact = TRUE,
the do.call idiom that existed solely to make update() re-evaluable, and the
now-unused newx / x / y arguments are removed, along with the comments that
asserted they were load-bearing. The do.call fact survives in the one place
that still exercises it, the identity block's reference fit.

A short-circuit predicts straight off the default fit when the target penalty
is already one of the fitted penalties. That is a correctness requirement, not
an optimisation: removing it changes the selected set on the on-grid route,
concentrated at the top of the path. css() never reaches it; it keeps the
exported direct-call route byte-identical.

Four new test blocks, with the division of labour spelled out in each:

- "cssLasso is byte-identical to the exact refit (#125)" -- a pin, green on the
  pre-change source, over four fixtures (iid, correlated, n < p, bundled
  alpha), each asserting its own fixture rules: at least 1e-14 relative from
  every fitted penalty, and more than one feature selected.
- "cssLasso fits the anchored grid rather than refitting exactly (#125)" -- the
  regression test. Both assertions are red on the pre-change source.
- "anchoredLambdaGrid holds its invariants (#125)" -- what actually enforces the
  construction, including a mandatory ULP-spaced fixture without which the
  strictly-decreasing and no-duplicates pair cannot fail.
- "cssLasso short-circuits when lambda is already on the path (#125)" -- with
  the rank-1 penalty in the fixture set and the mock assertion load-bearing.

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

Both stage-6 passes read 96f02cf and converged on three comment defects; the
drift sentinel found four more. None touches the code -- the gate, the suite and
the bit-identity claim are unchanged.

The three both passes found:

- The identity pin's failure-diagnosis comment sent a reader to audit
  anchoredLambdaGrid() first. Measured, every mutation of the helper tried in
  review leaves that block green; only the on-grid branch predicate reddens it,
  and deleting the short-circuit cannot redden it at all, because all four of
  the block's fixtures assert themselves off-grid. The comment now ranks the
  two causes instead of conjoining them.
- "both of the helper mutants" named a block that names three. Replaced with
  the predicate rather than a corrected count.
- "both sides discard non-finite path entries": sort() drops NA and NaN and
  keeps +-Inf.

The four the sentinel found alone:

- Two published narrative sentences said the anchored grid solves only the part
  of the path that can influence the target. It deliberately does not -- it
  retains the padding points and the path's final penalty, which the
  interpolation reads and which the helper's own comment calls load-bearing.
  Shipped as written, the site would have argued for the one simplification the
  identity pin cannot detect.
- The ULP fixture's detection threshold is n_interior = 5, not 2 as the comment
  implied; a reader lowering it would have silently restored the unfalsifiable
  assertions the fixture exists to fix.
- The hi <= lo guard fires whenever s is the smallest OR second smallest
  element, a wider set than the comment enumerated.
- @PARAM lambda_path's precondition was neither sufficient nor doing any work.
  The replacement was verified exact against the function's actual behaviour
  across 55 zero-bearing path and s combinations.

Also: a half-line saying the second do.call is habit rather than necessity,
since exact=TRUE at an on-grid s never reaches update().

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

Copy link
Copy Markdown
Owner Author

Review — post-execution pass + drift sentinel, independent contexts, at 6da0a53

Verdict: LGTM, no blockers. The bit-identity promise is verified about as thoroughly as a claim like this can be, and the speedup is real and understated. The gap is the mirror image of the one on #193: correctness is heavily pinned, and the optimisation itself is pinned by nothing — deleting it entirely leaves the suite byte-identical.


The identity holds

Two independent harnesses, neither the PR's own:

battery comparisons mismatches
cssLasso() randomized sweep (250 designs × up to 18 penalties; n 12–120, p 2–200; iid / collinear / block-correlated / orthonormal / constant-column / near-orthogonal; α ∈ {1, 0.2, 0.5, 0.9}; s from 0 to 1e6, including exactly min, between the two smallest, and above λ_max) 4,500 0
on-grid penalties (short-circuit route) 1,939 0
coefficient vectors vs do.call + exact = TRUE 360 0
css() end-to-end, feat_sel_mat + clus_sel_mat (48 runs, 70,884 cells) 96 0
n_interior ∈ {0,1,2,5,20,100} invariance, coefficients and selected sets 5,400 0
second pass, off-grid configurations 480 0

Maximum absolute coefficient difference exactly 0 throughout. n_interior really is a speed constant: varying it 0→100 changes nothing, so the roxygen claim holds.

Performance is understated, not overstated. Re-measured with 12 interleaved timings per cell: 18.8% (n=400/p=800) and 27.0% (n=200/p=100), Wilcoxon p ≤ 3.3e-05, against the body's 16.4% / 23.7%.

And the CI anomaly is not this change. Two Ubuntu jobs ran 16–21 minutes against ~3 for the rest, which I flagged at the outset. It's runner network variance in apt-get: Fetched 47.3 MB in 10min 10s (77.6 kB/s), where the same 47.3 MB took 20 s on the devel job in the same run. testthat.R times are normal and in line with the last main run.


1. Robustness — the speedup, which is the entire point of this PR, is unpinned

Replacing anchoredLambdaGrid()'s body with return(g_full) — no shortening at all, i.e. exactly the augmented grid exact = TRUE used to refit over — gives:

tree blocks failed error WARN passed
6da0a53 178 0 0 0 4262
no-shortening mutant 178 0 0 0 4262

Byte-identical. I measured what that mutant costs on css() at B = 50, n=200/p=100: 0.848 s → 0.980 s, about 13.5% of the benefit gone, silently. A second pass measured 8–12% on its own fixtures.

This is not a correctness hole — every grid meeting the five invariants reproduces the refit's columns and weight, and the invariants block enforces those (see below). It is that a future refactor could delete the optimisation, or quietly neuter it, and nothing anywhere would fail. The routing block that looks like it pins shortening is self-referential by its own admission — index.Rmd:8690 calls the same helper production calls, so it compares the implementation to itself.

The cheap repair is one assertion: expect_lt(length(g), k_full) on the mid-path fixture in the invariants block at index.Rmd:8760, where that block's stated objection to expect_lt doesn't apply.

2. Robustness — the short-circuit block's comment is false about its own fixtures, and contradicts itself

index.Rmd:8806-8810 reads "THIS IS THE LOAD-BEARING ASSERTION OF THE BLOCK … Deleting the short-circuit leaves the identity assertions above green." Measured by mutation against a 0/0/4262 control: deleting the short-circuit reddens 3 of that block's 4 assertions, including the identity assertion at fA$lambda[1].

The block's own comment 16 lines earlier (:8790) says rank 1 is in on_grid precisely because breaks concentrate at the top of the path — the opposite conclusion. The concrete risk is a reader trimming the rank-1 fixture as redundant and removing the only value-level detector for that route.

3. Clarity — the anchor's necessity is floating-point, not mathematical, and the body's "1.4e-05" reads as though it were material

Two related things here, and the second explains the first.

The body says the unanchored variant shows "coefficient differences up to 1.4e-05", in the same sentence as "in its last bits". That absolute figure is scale-proportional: measured, the relative difference never exceeds ~2 ULP (at y-scale 1, 2.2e-16; at y-scale 1e3, 6.1e-13 absolute but 4.2e-16 relative). plan.md:136's "far too large to be last-bit noise" isn't supported. The shipped comment is right; only the body and the plan overstate. (The 99.6–99.9% agreement figure does reproduce — 479/480.)

The reason it is last-bit noise is worth putting in the source, because it also protects the anchor. lambda.interp does:

sfrac  <- (lambda[1] - s)      / (lambda[1] - lambda[k])
lambda <- (lambda[1] - lambda) / (lambda[1] - lambda[k])
...
sfrac  <- (sfrac - lambda[right]) / (lambda[left] - lambda[right])

The lambda[1] - lambda[k] factor cancels algebraically in that final ratio — the exact weight is just (λ_right − s)/(λ_right − λ_left), with no dependence on the grid's last element at all. In floating point it does not cancel:

grid ending 0.0625 → left=2 right=3 frac=0.20000000000000042744
grid ending 0.001  → left=2 right=3 frac=0.20000000000000000111
                                    difference: 4.16e-16

Same neighbours, weight differing by one ULP — and neither equals the algebraic value (0.19999999999999995559). So the source comment's "the blend weight at s depends on the last element of the supplied grid as much as on the first" is true numerically and false algebraically. That matters: a maintainer who reasons it through symbolically will correctly conclude the anchor is unnecessary and delete it, reintroducing the drift. One clause — that the normaliser cancels in exact arithmetic and the anchor exists to make the rounding identical — makes the anchor's necessity legible instead of looking like an error.

4. Clarity — "on an exactly orthogonal design" names the wrong condition

index.Rmd:2278-2286 (generated anchoredLambdaGrid.R:63-66) attributes the degenerate NaN-then-zeros $lambda to an exactly orthogonal design. The actual driver is max(abs(crossprod(X, y))) == 0 — orthogonal and X ⊥ y. Measured: an orthonormal design with generic y returns an ordinary 63-element path, zero NaN, zero zeros. The repo's own #157 fixture (which satisfies both conditions) does reproduce NaN + 99 zeros → two-element g_full → the hi <= lo guard, exactly as described. Only the stated condition is too broad.

Cosmetic

  • The "six-figure sweep" comment points at .plans/, which is deleted at merge — cite PR Fit cssLasso()'s second lasso path over an anchored, padded grid (#125) #200 instead, so the evidence stays findable.
  • The body's two-decimal percentages overstate median-of-3 precision.
  • Check 2 (NOTE mode per profile § 9): fit_at_lambda from the else branch reaches predict.glmnet at cssLasso.R:91 without the class check applied to lasso_model at :61. Narrowed, not blinded.

What held up

  • Every other comment claim tested true, and several are load-bearing: lambda.interp's normaliser verbatim; predict.glmnet's own match(s, lambda, FALSE) being the same predicate the short-circuit uses; g_full matching glmnet's construction operator for operator; warm-start independence from smaller penalties (2,400 fits, difference 0); short-circuit removal breaking 13/1867 on-grid penalties with 12 at path position 1 and max 3.9e-04 — genuinely material, so "far larger than last-bit noise" is right there; css() never reaching the short-circuit (0 hits in 720 instrumented invocations).
  • kt alone is genuinely insufficient, which is the non-obvious half of the design: 199/360 user-supplied grids don't survive glmnet's round trip bit-for-bit, so s falls between knots and right is read. Variant scores against the exact refit: shipped 360/360, kt-only 350/360, no-lo 342/360, naive truncation 337/360.
  • The invariants block is doing the real work. Mutants M1 (drop the re-appended lo), M2 (kt instead of kt + 1), M3 (drop the mid filter) and M5 (reverse the interior sort) are each caught, all of them only by that block, with the identity pin green every time — exactly as the narrative says. The PR's claim that reverting cssLasso.R alone reddens exactly two routing assertions is also confirmed. The division of labour between the four blocks is real and correctly described.
  • The log(0) path is unreachable, and now has a number behind the disposition: across 400+ real glmnet() fits none produced a $lambda mixing an exact zero with ≥2 distinct positives; glmnet's grid stays strictly positive down to y-scale 1e-150 and errors below. The helper is @noRd and not exported.
  • Gotcha 7 does not fire, and the near-duplication is deliberate: clusterLassoCore() predicts with no s, no exact and no second fit, so there is nothing to share. And the augmented-grid derivation appearing in both production and the test oracle should not be extracted into a helper — that independent copy is what makes the kt mutant catchable.
  • Gate: 0 errors | 0 warnings | 0 notes, PASS 4262 from 4194, spell_check 0 rows, url_check the recorded four 403s, no new TODO, NAMESPACE/man//CITATION unchanged, no $call read anywhere, and the committed cssr/ is byte-identical to a fresh Rscript build.R.

Action items

  1. Pin the shortening — expect_lt(length(g), k_full) in the invariants block (finding 1). Needs a rebuild.
  2. Correct the short-circuit block's "leaves the identity assertions green" comment, which is false for its own fixtures and contradicts the comment above it (finding 2). Same rebuild.
  3. Say in the source that lambda.interp's normaliser cancels in exact arithmetic, so the anchor exists to reproduce the rounding (finding 3) — and soften "up to 1.4e-05" in the body to a relative figure. Same rebuild for the comment; the body needs none.
  4. Narrow "on an exactly orthogonal design" to orthogonal and X ⊥ y (finding 4). Same rebuild.
  5. Cosmetic: re-point the "six-figure sweep" citation at PR Fit cssLasso()'s second lasso path over an anchored, padded grid (#125) #200.

None of these blocks merge; (1) and (2) are the two I'd want in before it lands, since both concern what the suite can and cannot see.

Two independent subagent passes plus orchestrator-run mutation, timing and a lambda.interp cancellation analysis; full reports in .plans/issue-125-csslasso-anchored-grid/post_execution_review_v2.md and sentinel_feedback_v3.md.

gregfaletto and others added 2 commits September 2, 2026 18:19
Answers the maintainer's review round on PR #200. All five action items; each
verified here before applying, and all four findings held up.

(1) THE OPTIMISATION WAS PINNED BY NOTHING. Replacing anchoredLambdaGrid()'s
body with return(g_full) -- the change deleted outright -- left the suite
byte-identical at [ FAIL 0 | PASS 4262 ]. The invariants block could not see it
because g_full satisfies all five invariants trivially: it IS the reference
grid. Root cause is mine and traceable: plan review round 2 swapped
expect_lt(seen$k, k_full) for a self-referential expect_identical to remove a
coupling to the padding constant, and that swap deleted the only assertion
pinning that the grid shrinks at all.

inv() now takes `shortens`, set from measured behaviour at every fixture. Both
directions are pinned, since returning the full grid is correct on the guard
branch. Re-derived: the mutant now reddens 4 assertions where it reddened 0.

(2) The short-circuit block claimed deleting the short-circuit "leaves the
identity assertions above green". Measured, it reddens 3 of that block's
assertions -- and the comment 16 lines above already said rank 1 was in the
fixture set precisely because it does. The two contradicted each other; the
first was written before the rank-1 fixture that round 1 asked for was added.

(3) lambda.interp()'s lambda[1]-lambda[k] normaliser CANCELS algebraically --
the exact weight is (lam_right-s)/(lam_right-lam_left), independent of the last
element. It does not cancel in floating point: over 1000 s values on two grids
differing only in their last element, the weight differed in 74.3% of them by up
to 6 ULP. The comment said the weight depends on the last element "as much as on
the first", which is true numerically and false algebraically -- so a maintainer
reasoning symbolically would correctly conclude the anchor is unnecessary and
delete it. It now says the anchor reproduces the ROUNDING.

(4) The degenerate-$lambda condition is max(abs(crossprod(X, y))) == 0 exactly,
not "an exactly orthogonal design": measured, orthonormal X with generic y gives
an ordinary path, and so does one with max|X'y| at 1e-16.

(5) The "six-figure sweep" comment cited .plans/, which is gitignored and
deleted at merge. Now cites PR #200.

Gate: 0 errors | 0 warnings | 0 notes; spell_check 0 rows; url_check the
recorded four 403s; suite [ FAIL 0 | WARN 0 | SKIP 0 | PASS 4272 ] from 4262.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A narrow, suspicion-scoped round on the one question this cycle keeps getting
wrong: does any comment claim a block catches something it does not? It does not
re-review the design. Sixteen mutants, each grep-verified as applied before
measuring, against a [ FAIL 0 | PASS 4272 ] control.

Two of the three are residue of the commit before this one, which is the finding
that matters. 1589024 corrected "both of the natural simplifications" at
index.Rmd:2342 and left the identical sentence at :8569 -- the fifth time this
cycle a fix landed on the sentence a finding quoted rather than on the claim it
was about, and this time the finding being answered was itself about that
pattern.

- :8569 now names the six mutations measured to leave the identity pin green,
  rather than carrying a count. The number has been wrong at two and at three
  and would have been wrong again at six, so the fix is to stop counting.

- :2304 claimed the #157 fixture reaches the degenerate-path guard branch.
  Instrumented, that block makes ZERO calls to anchoredLambdaGrid(): protolasso
  and clusterRepLasso route through clusterLassoCore(), which never calls
  cssLasso(). Across the whole suite, 5376 helper calls and none with a
  degenerate path. The mechanism claim is true and was verified by direct call;
  the coverage claim was false. The comment now records the gap rather than
  papering over it -- no test here exercises that branch.

- :8619 blamed the identity pin's weak detection on its fixture COUNT. Measured,
  naive truncation breaks bit-identity on ~2% of off-grid penalties while
  reproducing the reference's selected set in 600 of 600 comparisons: the pin
  compares type="nonzero", so the limit is granularity and more fixtures buy
  nothing. The comment was telling a reader to do the one thing that cannot
  help.

Comment-only; the suite is unchanged. The round's other question -- can every
assertion in the four new blocks fail? -- came back clean: all fourteen sites
were made to fire.

Gate: 0 errors | 0 warnings | 0 notes; spell_check 0 rows; url_check the
recorded four 403s; suite [ FAIL 0 | WARN 0 | SKIP 0 | PASS 4272 ].

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

Copy link
Copy Markdown
Owner Author

All five action items are in, across two commits (1589024, 3c9487b). Every finding was re-derived here before I acted on it, and all four held up. Two were stronger than stated.

1 — the speedup was pinned by nothing

Confirmed: return(g_full) left the suite byte-identical at PASS 4262. The invariants block couldn't see it because g_full satisfies all five invariants trivially — it is the reference grid.

The root cause is mine and traceable. Plan review round 2 objected that expect_lt(seen$k, k_full) was coupled to the padding constant (it falsely reddens at n_interior = 60), so I swapped it for a self-referential expect_identical — and documented that the replacement can't see a wrong construction. What I never noticed is that expect_lt was the only assertion pinning that the grid shrinks at all. A correct fix to one property deleted the coverage for another.

I tested your proposed assertion against all ten fixtures before adopting it: it holds on the four that shorten and fails on four that legitimately don't (guard branch — s below the path, s = 0, length-1, ULP). So I pinned both directions via a shortens argument set from measured behaviour at each call site. Re-derived: the mutant now reddens 4 assertions where it reddened 0. If you'd rather have just the single mid-path assertion, that's a two-line trim.

2 — the short-circuit comment

Confirmed: deletion reddens 3 assertions, not 0. Same provenance — round 1 asked for the rank-1 fixture so that the identity assertions would fire; I added it, wrote the :8790 comment explaining why, and left the :8806 sentence describing the world before it.

3 — the algebraic cancellation

This is the sharpest finding and I had the mechanism wrong. Verified both halves: the normaliser cancels to (λ_right − s)/(λ_right − λ_left), and in floating point it doesn't — over 1000 s values on grids differing only in their last element, with left/right identical throughout, the weight differed in 74.3%, up to 6 ULP. The comment now says the anchor reproduces the rounding.

On 1.4e-05: applied, with one refinement. The selection consequence stays material even when the coefficient difference is last-bit, because nonzeroCoef() uses abs(x) > 0 with no tolerance. The body now states the cancellation, the relative magnitude, and that consequence.

4 — the orthogonality condition

Confirmed, and narrower than stated: an orthonormal X with y made numerically orthogonal (max|X'y| = 1.1e-16) also gives an ordinary 63-element path. Only exact zero produces the degenerate one.

A narrow round after these, and it was not clean

Since coverage claims had been corrected four times, I ran a round scoped to two falsifiable questions rather than another full pass. It found three more — and two are residue of 1589024 itself: I fixed "both of the natural simplifications" at one of the two sites it appears. Fifth instance this cycle of fixing the sentence a finding quoted rather than the claim it was about, and this time the finding being answered was about that very pattern.

The substantive one: the comment claimed the #157 fixture reaches the degenerate-path guard branch. Instrumented, that block makes zero calls to anchoredLambdaGrid() — protolasso/clusterRepLasso go through clusterLassoCore(), which never calls cssLasso(). Suite-wide: 5,376 helper calls, none with a degenerate path. Mechanism true, coverage false. The comment now records the gap rather than papering over it; I did not add a fixture to close it — that's new test design answering no finding, and it's your call.

Third: I blamed the pin's weak detection on fixture count. Naive truncation breaks bit-identity on ~2% of penalties but reproduced the reference's selected set 600/600 — the limit is granularity, so my comment was telling a reader to do the one thing that can't help.

Its other question came back clean: all fourteen assertion sites in the four new blocks were made to fire.

On your re-measurement

Your 18.8% / 27.0% is methodologically better than mine. You interleaved 12 timings per cell; I ran all "before" then all "after", which is exposed to drift between the halves. I've kept 16.4% / 23.7% in the body as the conservative pair I can attest to from this tree — happy to quote yours or a range instead.

One caution about my own verification

My first mutation battery reported both mutants green — a clean false null. A $lambda in a Perl pattern interpolated as a variable, so it measured an unmutated tree twice. I caught it only because the result contradicted your finding 2. Every number above comes from a re-run that asserts the mutation is present in the file before measuring.

Gate at 3c9487b: 0 errors | 0 warnings | 0 notes, spell_check 0 rows, url_check the recorded four 403s, suite [ FAIL 0 | WARN 0 | SKIP 0 | PASS 4272 ] from 4194. CI watching now. Not merging — yours to land.

Adds the package's first NEWS.md, with a bullet for this branch's change.

WHERE IT LIVES. cssr/ is generated and build.R deletes it wholesale, so a
hand-written cssr/NEWS.md would not survive a build. The file is edited at
source-files/NEWS.md and copied into the package by the inst/ chunk in
index.Rmd, alongside CITATION and WORDLIST. The copy sits ahead of
litr::add_pkgdown() in document order because that chunk builds the pkgdown
Changelog page, and `news` is added to the navbar in source-files/_pkgdown.yml
so the page is linked rather than orphaned -- the failure mode #53 fixed for
reference pages.

THE HEADER CARRIES THE VERSION, AND HAS TO. The skill's prescribed
`# <pkg> (development version)` does not parse: measured with
tools:::.build_news_db_from_package_NEWS_md(), it yields NULL, so R CMD check
reports "Problems with news in 'NEWS.md': No news entries found" as a NOTE --
and under this repo's error_on = "note" gate, and the matching CI
error-on: '"note"', a NOTE fails all five jobs. Measured alternatives:

    # cssr (development version)                 NULL entries  -> NOTE
    # cssr 0.4.7.9000                            1 entry       -> OK
    # cssr 0.4.7.9000 (development version)      1 entry       -> OK

The third form is used: it parses, prints cleanly through news(), and keeps the
development-version signal. Prose outside a version section is harmless; the
header alone decides it.

The cost is that the version now lives in two places, index.Rmd and this
header, so a release bump edits both. .workflow/PROFILE.md section 4 previously
recorded "Other files that must match: none" and now records the duplication,
the measurement above, and that source-files/NEWS.md is the file to edit.

The version itself is untouched at 0.4.7.9000, already a development version.
spell_check reads root news markdown and passes with no new WORDLIST entries.

Gate: 0 errors | 0 warnings | 0 notes; spell_check 0 rows; url_check the
recorded four 403s; suite [ FAIL 0 | WARN 0 | SKIP 0 | PASS 4272 ].

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

Copy link
Copy Markdown
Owner Author

Added a NEWS.md (0a60003). Two things about it are worth flagging rather than burying, because both are decisions rather than mechanics.

Where it lives. cssr/ is generated and build.R deletes it wholesale, so a hand-written cssr/NEWS.md would not survive a build. The file is edited at source-files/NEWS.md and copied in by the inst/ chunk in index.Rmd, alongside CITATION and WORDLIST. The copy sits ahead of litr::add_pkgdown() because that chunk builds the Changelog page, and news is in the navbar so the page is linked rather than orphaned — the same failure mode #53 fixed for reference pages.

The header has to carry the version, and that has a cost. The r-package-dev skill prescribes # <pkg> (development version). That form does not parse. Measured with R's own parser:

# cssr (development version)                 NULL entries  -> NOTE
# cssr 0.4.7.9000                            1 entry       -> OK
# cssr 0.4.7.9000 (development version)      1 entry       -> OK   <- used

R CMD check reports Problems with news in 'NEWS.md': No news entries found, and under this repo's error_on = "note" gate — and the matching CI error-on: '"note"' — a NOTE fails all five jobs. I hit it on the first build, which is the gate doing its job.

The consequence: the version now lives in two places, index.Rmd and this header, so a release bump edits both. PROFILE.md § 4 previously said "Other files that must match: none" — a line I had re-derived an hour earlier — and now records the duplication with the measurement behind it. If you would rather avoid the duplication, the alternative is .Rbuildignore-ing NEWS.md so R CMD check never sees it, but then it does not ship and news(package = "cssr") returns nothing, which seemed the worse trade. Say the word and I will flip it.

The version itself is untouched at 0.4.7.9000, already a development version. spell_check() reads root news markdown and passes with no new WORDLIST entries.

Gate at 0a60003: 0 errors | 0 warnings | 0 notes, spell_check 0 rows, url_check the recorded four 403s, suite [ FAIL 0 | WARN 0 | SKIP 0 | PASS 4272 ]. CI watching now.

…#125)

Adds test_that("cssLasso survives a degenerate lasso path (#125)"), at the
maintainer's request.

WHAT IT HOLDS. The Decision Log records that cssLasso() adds no runtime
validation of lasso_model$lambda, justified by a measurement: a positivity
check would abort on a design whose glmnet path is degenerate, where the
function currently returns integer(0) -- the shape #157 was filed to stop
crashing. Nothing in the suite held that measurement. Now something does.
Measured, against a [ FAIL 0 | PASS 4276 ] control before and after:

    stopifnot(all(lasso_model$lambda > 0))        -> ERR 1, this block only
    stopifnot(all(is.finite(lasso_model$lambda))) -> ERR 1, this block only

Both clauses of the contract the pre-implementation sentinel proposed are false
on this path, so either spelling of the guard is caught.

WHAT IT DOES NOT HOLD, contrary to my first reasoning. I expected the helper's
`hi <= lo` guard clause to be unpinned. It is not: removing `| hi <= lo`
already reddens the invariants block with 3 failures and 1 error, because the
shortens=FALSE fixtures added in the review round take that branch on ordinary
paths and the interior points then collapse onto the anchor. That hole was
closed as a side effect of the review round. This fixture is narrower than it
first looked -- it pins a decision, not the guard.

The two path-shape assertions are an upstream-drift detector in the same spirit
as the identity pin: if a future glmnet stops returning NaN-then-zeros for an
exactly-orthogonal design, they fail and the helper's comment about that case
needs re-deriving rather than quietly becoming decorative.

The fixture uses a strictly positive penalty. At lambda = 0 this design raises a
PRE-EXISTING interpolation error, on this branch and on origin/main alike, which
is not what the block is about.

Also corrected in the same edit: the helper's comment said "no test in this
suite exercises this branch with a degenerate path", true when written two
commits ago and false the moment this block lands.

Gate: 0 errors | 0 warnings | 0 notes; spell_check 0 rows; url_check the
recorded four 403s; suite [ FAIL 0 | WARN 0 | SKIP 0 | PASS 4276 ] from 4272.

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

Copy link
Copy Markdown
Owner Author

Added the degenerate-path fixture (921444b) — test_that("cssLasso survives a degenerate lasso path (#125)").

It holds a decision, not the guard — and my first reasoning about it was wrong. I pitched this as closing a hole in the hi <= lo guard's coverage. There is no such hole: removing | hi <= lo already reddens the invariants block with 3 failures and 1 error, because the shortens=FALSE fixtures your review round produced take that branch on ordinary paths, and the interior points then collapse onto the anchor. Your review closed it as a side effect nobody predicted.

What was unheld is the Decision Log entry declining the sentinel's proposed runtime validation of $lambda. I justified it with a hand measurement — a positivity check aborts on a design where cssLasso() currently returns integer(0), the shape #157 was filed to stop crashing — and nothing in the suite held that. Now something does. Against a [ FAIL 0 | PASS 4276 ] control before and after, each mutation grep-verified present first:

stopifnot(all(lasso_model$lambda > 0))          ERR 1   this block only
stopifnot(all(is.finite(lasso_model$lambda)))   ERR 1   this block only

Both clauses of the contract the sentinel proposed are false on this path, so either spelling of the guard is caught.

The two path-shape assertions are an upstream-drift detector in the same spirit as the identity pin: if a future glmnet stops returning NaN-then-zeros for an exactly-orthogonal design, they fail, and the helper's comment about that case gets re-derived rather than quietly becoming decorative.

Two details worth knowing. The fixture uses a strictly positive penalty — at lambda = 0 this design raises a pre-existing interpolation error, on this branch and on origin/main alike, which is not what the block is about. And the helper's comment previously said "no test in this suite exercises this branch with a degenerate path", true when written two commits earlier and false the moment this block lands; corrected in the same edit.

Gate at 921444b: 0 errors | 0 warnings | 0 notes, spell_check 0 rows, url_check the recorded four 403s, suite [ FAIL 0 | WARN 0 | SKIP 0 | PASS 4276 ] from 4272.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cssLasso() refits glmnet twice per subsample (redundant fit ~35-45% of css() runtime)

1 participant