Repository navigation
Normalise predict.glmnet(type = "nonzero") to a list at the cluster-lasso boundary (Closes #190) - #195
Conversation
) clusterLassoCore() read glmnet::predict.glmnet(fit, type="nonzero") and called unique() on it; getClusterSelsFromGlmnet() then called lengths(). Both operations mean something different for a data.frame than for the list the code assumes, and nothing asserted which one had arrived. The single-column data.frame layout -- what glmnet 4.x produces when every penalty selects exactly one feature, so apply() collapses to a vector -- inverts the column/penalty convention and yields a confidently wrong set of model sizes rather than an error. A bare as.list() would not fix it: it turns the single column into one slot of length n_pen, a third wrong answer. The normalisation therefore disambiguates the two data.frame layouts with n_pen <- ncol(fit$beta), and asserts one entry per penalty afterwards. getClusterSelsFromGlmnet() gains a matching entry guard so a future caller that bypasses the boundary stops instead of fabricating a model size. It needs its own !is.data.frame() test, because a data.frame is also a list. No user-visible change: on glmnet 4.1.9 and 5.0 the call already returns a list, because this fit passes no lambda and the auto-generated sequence starts at lambda_max where nothing is selected. The guarantee is real but accidental, which is what this makes unnecessary. Six new mocked test blocks pin all three container shapes end to end through protolasso() and clusterRepLasso(), plus the three new assertions. Each fixture is sized from ncol(object$beta) inside the mock, because glmnet() is not mocked and truncates its own path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…190) All five are text; no executable code changes, and the suite, the gate and the generated function bodies are unchanged. - The narrative said "the last three pin the three new assertions" while the diff adds six stopifnot() calls. Names the predicate instead, and records that THREE assertions are deliberately unpinned -- the boundary's is.list() / !is.data.frame() pair and the inner nrow() check in the row-major branch. All three are unreachable once the reshaping has run. The post-execution reviewer found two of them; the drift sentinel's wider battery found the third. - @PARAM lasso_sets claimed the function "stops if it is handed anything else". The guards check the container only: a list of characters passes. - The nr == 1 clause said that branch "returns a bare numeric vector". It does so only when every penalty is nonzero, and returns a list otherwise (measured). The unreachability half is correct and is what the clause needs. - The cross-reference said cssLasso()'s comment "describes the scalar-s case only". It states the general rule and already warns against copying its idiom here. - "nlambda = 100 yields 66 penalties" is the protolasso design's figure; the clusterRepLasso design block B uses gives 61 (both re-derived here). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sweep item from the post-implementation drift sentinel, taken now rather than deferred: this PR made the neighbouring narrative incomplete. The #188 block's prose stated flatly that "glmnet 4.x returns a data.frame on a non-empty selection and a list on an empty one". That is the scalar-s consequence of the general rule, not the rule -- 4.x coerces whenever apply() could simplify, which for one s value means whenever anything was selected. The paragraph is published, carried no qualifier, and after this PR sits next to blocks describing the no-s case, where the same rule yields two different data.frame layouts. Adds the qualifier and a pointer to the new blocks. cssLasso()'s code and its source comment are untouched, per the confirmed scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review — post-execution pass + drift sentinel, independent contexts, at
|
| patterns | data.frame returns |
both predicates true | neither true | wrong answers | |
|---|---|---|---|---|---|
| pass 1 | 1,157,324 | 26,300 | 1,851 | 0 | 0 |
| pass 2 | 28,000 classifications across p 2–6 × n_pen 1–7 × both glmnet versions |
2,019 | — | 0 | 0 |
The ambiguity resolves structurally, which is the part worth recording. In the bystep = TRUE, nr >= 2 branch:
which = apply(beta, 2, nzel, which)
if (!is.list(which)) which = data.frame(which)apply() simplifies to a matrix only when every penalty selects the same number of features ≥ 2 (→ n_pen columns), and to a vector only when every penalty selects exactly one (→ exactly one column, n_pen rows). Ragged or NULL-containing results stay lists. So a row-major frame always has ncol == 1, and ncol == n_pen can only misfire at n_pen == 1 — where the frame is 1×1 and both readings coincide. The nr == 1 branch is genuinely unreachable: if (ncol(X_glmnet) < 2) returns before the fit, so nrow(beta) >= 2.
stopifnot(identical(length(nonzero), n_pen)) — flagged in the body as a new bound verified only on 4.1.9 — is a source-supported invariant, not an observation: apply() yields one result per column of fit$beta in both versions, the empty branch's length(dn) is safe because getcoef.R sets Dimnames on both the sparse and zeromat() paths, and dfmax/pmax/early stopping cannot desync the two because both sides read the same truncated beta. Confirmed across 9 fit configurations × 2 versions and 500 random fits. CI green 5/5 on glmnet 5.0 corroborates it.
Behaviour is unchanged on the installed glmnet across 1,152 configurations, 0 identical() mismatches — structurally expected, since 500/500 real fits return a list and the reshaping is dead code locally. The red side reproduces exactly: [ FAIL 5 | WARN 0 | SKIP 0 | PASS 4161 ], and all six new blocks kill at least one mutant by per-block attribution rather than suite totals.
1. Robustness — the predicate order is load-bearing, unstated, and untested
On a square input (k == n_pen, k ≥ 2) both branch predicates are true, and the two readings are completely different:
ncol == n_pen : TRUE
nrow == n_pen : TRUE <- both true
column-major (as shipped): 1,2,3 | 1,2,4 | 1,3,4
row-major (if reordered) : 1,1,1 | 2,2,3 | 3,4,4
The shipped order is the correct one — multi-column is column-major, so testing ncol first is right. But the symmetric refactor (test nrow == n_pen first, move the readings to match) is wrong only on square input, and it scores 0 failed | 0 error | 4166 passed — identical to the control. Neither fixture is square: the single-column block is 66×1 and the multi-column block is 2×61. The existing mutant that transposes the branches is killed by an error; a predicate reorder sails through.
This matters more than a typical unpinned-guard note because the failure mode it admits is exactly #190's — confidently wrong model sizes behind a guard that reads as if it had settled the question.
The fix is cheap: a square fixture is directly constructible, since nlambda is honoured exactly at small values — measured, nlambda = 2 → n_pen = 2 and nlambda = 3 → n_pen = 3. Size the multi-column block's fixture to nrow = ncol(object$beta), or at minimum state in the comment that the order is load-bearing.
2. Robustness — the "loud direction" claim is true for the block it annotates and false for its unmentioned sibling
index.Rmd:16575 keeps the raw unique(predict.glmnet(...)) idiom deliberately, and argues that on a glmnet returning a data.frame those fixtures "would stop at the consumer's new guard rather than silently misreading it." That holds for getClusterSelsFromGlmnet works, whose five fixtures do reach the guard.
It does not hold for getSelectedSets works at index.Rmd:16252, which the comment doesn't mention. That block has six raw-idiom fixtures (:16267, :16306, :16356, :16416, :16479, :16525) and indexes lasso_sets[[5]] directly at :16269 and :16308 — it never calls getClusterSelsFromGlmnet(), so the guard cannot fire. On a data.frame, [[5]] is column 5: a well-formed integer vector, and the assertions pass silently. That is the quiet direction, in the one place the PR claims there isn't one.
Not a live defect — no glmnet in play returns a data.frame on this path — but it's the sibling-duplication pattern one level down, in the fixtures. One expect_true(is.list(lasso_sets)) after each fixture in both blocks closes it and makes the comment true as written.
3. Clarity — "the only block that catches that" is false
The list block's comment justifies keeping a block that is green pre-fix by claiming it uniquely catches the unconditional-reshape mutant. Measured: that mutant errors seven blocks, five of them pre-existing (protolasso works, clusterRepLasso works, #157, #158a, #158b). The rest of that comment is exact, down to the error string 'list' object cannot be coerced to type 'integer'. The block is still worth keeping; the justification just overstates its uniqueness.
4. Clarity — a dangling positional reference, in published prose
index.Rmd:16956 says "the clusterRepLasso() design block B uses." There is no block lettering anywhere in index.Rmd — grep -n "block A\|block B\|block C" returns only this line. It renders to docs/create/tests-for-competitor-methods.html, so it ships to the public site. Both figures it cites (66 and 61 penalties) are correct. git log -S attributes the line to d5f075d — the commit whose stated purpose was correcting inaccurate claims. Suggest "the clusterRepLasso() design the multi-column block uses."
5. Clarity — the multi-column trigger is stated too narrowly
index.Rmd:6112 says the layout arises when "every penalty selected the same k >= 2 features." The real condition is the same number of features — verified: columns {1,2} and {2,3}, different sets with uniform count, also produce the multi-column frame. As written it understates how easily that layout arrives.
6. Note — one of the three "deliberately unpinned" guards is different in kind
The PR is candid that deleting is.list(), !is.data.frame() (boundary) or the inner nrow() check leaves every block green, and calls this defence-in-depth rather than a coverage gap. Verified, and for two of the three the framing is right: they are post-conditions on a total normalisation, unpinnable except by mocking a container glmnet cannot emit. (The consumer's pair, by contrast, is pinned one-to-one — three separate mutants map to exactly one block each.)
The inner nrow(nonzero_mat) == n_pen is different: it is the else branch's own disambiguation predicate, its unreachability is empirical rather than structural, and it is trivially pinnable — data.frame(a = 1:2, b = 3:4) against n_pen = 66 trips it with one expect_error. Worth splitting the narrative so three guards stop sharing one justification.
What held up
- Every falsifiable claim in the body checks out — twelve of them, including the two I most expected to bend. The bare-
as.list()"third wrong answer" is real (length 1, read as a size-5 model{1,1,2,2,3}, distinct from both the correct and the pre-fix answer), and the worked example is exact ({1},{1},{2},{2},{3}→ a fabricated size-3{1,2,3}with the genuine size-1 model dropped). - The two call sites' comments are accurate about each other.
cssLasso()flattens because its scalarsgives exactly one slot; this path preserves one slot per penalty. The divergence reason is stated at both ends and correct — genuinely complementary, not two folk theories. The prose is duplicated but the code isn't; a shared normaliser is now feasible but isn't this PR's job. - The in-mock
expect_gte(ncol(object$beta), 3L)fires and is load-bearing, at both editions — probed with a failing threshold. It exists to stop a shortened path from making the red-green fixture vacuously green, which is the right instinct, and the suite arithmetic reconciles exactly: 3+3+4+1+1+1 = 13 = 4166 − 4153. - Gate clean:
build.Rexit 0 (so the edition-2 weave passes), rebuiltcssr/byte-identical to the committed tree includingLitrId,checkStatus: OK0/0/0,spell_checknrow 0,url_checkexactly the four recorded 403s, no new TODO,NAMESPACE/man/empty diffs (correct — both functions are@noRd), CI green 5/5. - Nothing was blinded. On 4.1.9 the
is.data.framebranch is never entered, sounique()sees byte-identical input; all six new deparsedstopifnotmessages were checked against every otherstopifnotin the file for collisions — none.
One phrasing note: the environmental timestamp NOTE did fire during review and did not for the author, so the body's parenthetical is better as "intermittent" than as "did not fire."
Action items
- Pin the square case, or state that the predicate order is load-bearing (finding 1) —
nlambda = 2gives a 2×2 fixture directly. Needs a rebuild. - Add
expect_true(is.list(lasso_sets))to the raw-idiom fixtures in both blocks, includinggetSelectedSets works, and widen the:16575comment to cover it (finding 2). Same rebuild. - Correct "the only block that catches that" (3), the "block B" reference (4), and "the same k >= 2 features" → "the same number" (5). Same rebuild.
- Optionally pin the inner
nrow()check and split the three-guard narrative (6); soften the timestamp-NOTE phrasing in the body.
None of these blocks merge. (1) and (2) are the two I'd want in before it lands, since both concern coverage rather than wording.
Two independent subagent passes plus orchestrator-run verification of the shape rules, the predicate-order case and the sibling fixtures; full reports in .plans/issue-190-glmnet-nonzero-shape/post_execution_review_v2.md and sentinel_feedback_v3.md.
…ctions
Single commit rather than one per point: this is a litr package, so each
commit must carry a consistently regenerated cssr/ and docs/, and six commits
would mean six full rebuilds and six gate runs for interleaved edits to one
file. Findings are enumerated below instead.
1. The branch predicates' ORDER is load-bearing and nothing pinned it. On a
square input (k == n_pen) both are true and the two readings differ; the
shipped order is right, but a symmetric reorder -- swap the test and the
readings together -- is wrong only on square input, and neither fixture is
square (n_pen x 1 and 2 x n_pen). Adds a square-fixture block. Measured:
under the reorder mutant that block errors and every pre-existing block
stays green, so it was genuinely the gap the review described. Records the
ordering rationale in the source comment too.
2. The deliberate raw-idiom comment claimed the fixtures "would stop at the
consumer's new guard", true for its own block and false for the sibling
"getSelectedSets works", which indexes lasso_sets[[k]] directly and never
reaches the guard -- on a data.frame that is column k, a well-formed
integer vector whose assertions pass silently. Adds
expect_true(is.list(lasso_sets)) beside all eleven raw-idiom fixtures and
widens the comment to say which block is which.
3. "this is the only block that catches that" overstated: an unconditional
reshape reaches as.integer() on a list from every caller, so the
end-to-end blocks error too. Narrowed to "the only one of the mocked
shape pins".
4. Removed a dangling "block B" reference -- plan-local vocabulary that
leaked into published prose.
5. "the same k >= 2 features" understated the trigger: apply() simplifies on
a uniform COUNT, so {1,2} and {2,3} qualify too.
6. Split the three-guard narrative. The boundary's is.list()/!is.data.frame()
pair is structurally unpinnable, but the inner nrow() check is only
empirically unreachable and costs one assertion, so it is now pinned by its
own block. This reverses a (c) disposition made before review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Blocker from the post-execution review and the drift sentinel, which agreed independently. expect_true(is.list(lasso_sets)) passes on a data.frame, because a data.frame IS a list -- which is precisely why getClusterSelsFromGlmnet() carries two guards rather than one, as its own comment says four lines from where the assertion was added. So the canary added in bfed4c6 tested only the half that cannot see the container it was watching for, and the silent-pass hole in "getSelectedSets works" stayed open while a published comment claimed it was closed. Adds expect_false(is.data.frame(lasso_sets)) beside each of the eleven, so the pair mirrors the consumer's stopifnot() pair exactly, and says in the comment why both halves are needed. Verified: on a simulated data.frame-returning glmnet the old assertion passes and the new pair fails. The assertion applied in bfed4c6 was proposed verbatim in review; it was adopted without being checked against the guard it mirrors. Also, three claims this PR's own edits made stale: - "the same k >= 2 features" was corrected at one of its two sites only. - "the last three" pointed at a set that grew to eight blocks; now names them. - "Each mock sizes its fixture from ncol(object$beta)" is not true of the two blocks that pin a specific geometry and hard-code it deliberately. Plus two positional references replaced with names, and an inverted axis in the square block's comment ("column 1" -> "row 1"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Confirmation — blocker cleared at
|
| tree | blocks | failed | error | passed |
|---|---|---|---|---|
63b83c1 |
174 | 0 | 0 | 4194 |
| symmetric reorder | 174 | 0 | 2 | 4189 |
Both errors are the new blocks: clusterLassoCore reads a square data.frame column-major (#190) and clusterLassoCore rejects a data.frame of neither orientation (#190). Before these, that mutant was invisible — 0 failures, identical to the control.
The second block also pins the inner nrow() check, reversing the earlier (c) disposition. That was the right reversal: unlike the two boundary guards, its unreachability was only empirical, and it cost one assertion to pin.
Everything else
Gate at 63b83c1: Status: OK, 0 errors / 0 warnings / 0 notes. CI green 5/5. Suite 166 → 174 blocks, 4166 → 4194 passing. All four prose corrections landed — no remaining hits for "same k >= 2 features", "block B", or "the only block that catches".
Worth noting that 63b83c1 also caught three claims bfed4c6's own edits had made stale — a "the last three" pointer to a set that had since grown to eight blocks, a "same k >= 2 features" corrected at only one of its two sites, and an inverted axis in the new square block's comment. Correcting a claim and leaving its twin stale is the failure mode this PR has been chasing from the start; catching it inside the same branch is the right place to catch it.
Round-5 confirmation: orchestrator-run reorder mutation, canary audit, and gate at 63b83c1.
clusterLassoCore()calledunique()onglmnet::predict.glmnet(fit, type = "nonzero"), andgetClusterSelsFromGlmnet()then calledlengths()on the result. Both mean something differentfor a
data.framethan for the list the code assumed, and nothing asserted which had arrived.Not a live wrong answer today, on either glmnet version — but only by accident.
clusterLassoCore()supplies nolambda, so the auto path starts atlambda_maxwheredf == 0;nonzeroCoef()yieldsNULLthere,apply()cannot simplify, and 4.x'sdata.framecoercion is skipped. Nothing in
cssrasks for that.Where it does bite is narrower and worse than the issue supposed.
nonzeroCoef()has twodata.framelayouts with opposite conventions: multi-column is one column per penalty anddegenerates to the right answer; single-column — when every penalty selects exactly one feature,
so
apply()collapses to a vector — is one row per penalty. On the latter, penalties selecting{1},{1},{2},{2},{3}are read as a size-3 model{1,2,3}glmnet never selected, with the genuinesize-1 model dropped. A bare
as.list()produces a third wrong answer. The fix disambiguates withncol(fit$beta)and guards the consumer.cssLasso()'ssort(unique(unlist(pred)))from #188 is deliberately untouched — it flattens,which is right there and wrong here.
Evidence
Gate:
0 errors ✔ | 0 warnings ✔ | 0 notes ✔(the recorded timestamp NOTE is intermittent anddid not fire on this run);
spell_checknrow = 0;url_checkexactly the four recordedpublisher 403s. Suite
[ FAIL 0 | WARN 0 | SKIP 0 | PASS 4194 ]against the 4153 baseline.Red side, base tree with the final
tests.R:[ FAIL 6 | WARN 0 | SKIP 0 | PASS 4188 ]— fiveof the eight new blocks, six named assertions. Behaviour unchanged:
identical()→TRUEthroughboth exported entry points. Every new block kills at least one mutant, and the square-fixture
block is the only thing in the suite catching a symmetric reorder of the branch predicates —
verified by mutation, every pre-existing block staying green under it.
Notes for review
stopifnot(identical(length(nonzero), n_pen))is a new bound on the default path, verifiedlocally only on glmnet 4.1.9. CI installs 5.0 and is its cross-version test — a red job naming
that assertion is information, not flake.
No
NEWS.mdhere and no user-visible change, so no bullet and no version bump;inst/CITATIONcarries no version string. Two of the six new
stopifnot()calls are deliberately unpinned —post-conditions on a total normalisation, unreachable once the reshaping has run.