Repository navigation
Predict at the penalty glmnet fitted, so cssLasso() stops selecting interpolation debris (Closes #199) - #201
Conversation
cssLasso() handed predict.glmnet() the penalty it requested. glmnet does not echo a supplied penalty back verbatim -- it reports the grid divided by the response scale and multiplied back, two correctly-rounded scalar operations -- so the requested value can sit an ULP away from the entry the fit holds. lambda.interp() then reports frac a hair short of 1 and predict.glmnet() returns a blend of two adjacent path columns, leaking the neighbour's coefficients in at around 1e-17. nonzeroCoef()'s membership test is abs(x) > 0 with no tolerance, so those leaks are SELECTED and enter feat_sel_mat as ordinary selections. snapLambdaToGrid() recovers the fitted entry within a few ULP of the requested penalty; cssLasso() predicts there, on both the short-circuit and the refit routes, so the coefficient vector it reads is a solved column with exact zeros. Three new blocks: the routing block (the red-green test, mocking both glmnet bindings so the nudge is deterministic), the helper invariants block (the arithmetic, including the two fixtures that make the relative tolerance load-bearing), and the end-to-end block (the solved column's nonzero set). The #125 identity pin keeps its assertions and narrows its stated property. The snap changes one selection in the #156 block's fixture, which drops a cluster's selection proportion into a max_num_clusts tie; both of that block's wrapper calls get PR #186's targeted muffle so the suite stays at WARN 0. Suite 4276 -> 4297, FAIL 0 | WARN 0 | SKIP 0. Gate: 0 errors | 0 warnings | 0 notes (with _R_CHECK_SYSTEM_CLOCK_=FALSE; the recorded timestamp NOTE fired). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"The four blocks below" was true when four blocks followed it. The #199 blocks land after them, so the sentence now names what it introduces without counting what comes after, and points at the two narratives further down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both were re-derived against the final tree rather than carried from the plan. The helper's body said adjacent glmnet penalties sit "a relative 0.08 or so" apart; measured on default paths at n=80/p=40 and n=40/p=120 the median relative gap is 0.0975 and 0.0476, so the comment now quotes the range. The order-of-magnitude conclusion (1e14 ULP) is unchanged. The end-to-end block named three #125 blocks as independent cover for the routing it is blind to. Measured, the never-refit mutant reddens a fourth: this cycle's own routing block. Naming it matters, because the bullet exists to stop a later reader trimming one of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The helper's comment said "a positivity OR FINITENESS stopifnot() would turn
the degenerate path ... from an empty selection into an error", carrying over
a claim that is true of cssLasso()'s FIRST fit and not of the vector this
helper receives.
Measured on the design test_that("cssLasso survives a degenerate lasso path
(#125)") drives: the first fit's $lambda is NaN followed by zeros,
anchoredLambdaGrid() reduces it to c(0.01, 0), and the second fit returns
exactly that. So what reaches snapLambdaToGrid() is finite and NaN-free but
carries a zero. A positivity check therefore reddens that block (confirmed by
mutation -- it errors there and in the s = 0 fixtures of the invariants block);
a finiteness check does not.
The invariants block's NaN comment carried the same slip and is corrected the
same way: none of NaN-mixed, all-NaN or empty is reachable through cssLasso()
today, which is why the fixtures state a contract rather than a live path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The routing block said an on-grid penalty "reads 10 there". Re-derived: 10 is just the index of whichever on-grid penalty is substituted -- m0$lambda[12] reads 12 -- so the number was a property of one arbitrary substitution rather than of the fixture. Restated as what is determinate: it reads L's own index in the first fit's path rather than 0. The inert-mock reading of 27 does reproduce on this fixture and is kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ts (#199) The post-execution review and the sentinel's post-implementation pass each found comment prose asserting things the tree contradicts. Both passes' findings are applied here as one revision. The no-validation comment said a finiteness check would not redden the suite. Measured, it does -- the #199 invariants block drives a NaN-bearing grid straight into the helper. Its enumeration of which check reddens which block has now been wrong twice, so this states the rule in the positive instead: any validation added here reddens the suite, with no enumeration left to get wrong. The end-to-end block said that if the nudge stopped applying, the requested penalty would be in the grid and the block would stop distinguishing snapped from unsnapped. Measured with both nudges disarmed, the guard still reads 0 and the block still separates 1 3 4 from 1 4, because this fixture's natural round trip is already inexact here. Rewritten to say what the guard is for -- the platform where neither source of inexactness is present -- and to say plainly that it cannot see the nudge alone go away. Both mocking blocks now name each other as the other holder of the same mock and the same firing condition, since that condition is a claim about how cssLasso() spells its second glmnet() call and a refactor of it disarms both at once. Two figures are deleted rather than corrected: the muffle's jittered-draw counts, which carried no scheme and did not reproduce under a plausible one, and the adjacent-penalty spacing range, whose low end was outside it. What survives in each case is the claim that is load-bearing and cannot rot. NEWS.md is reconciled. Its #125 bullet claimed the anchored grid returns "exactly the same selected features", which this change falsifies; it now says the same features as the refit it replaced, which was true then and stays true. The #199 bullet is one sentence plus a scope clause, and says it supersedes that identity rather than sitting beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A narrow review round checked 55 claims in the comment prose this branch adds and found ten that do not hold. Every one is the same move: a measurement taken over a coarse or convenient sample, then written down as a law. The repair in each case is a narrower claim, not a better number. The largest reached users. "Near the top of the path, which is the only place a blend can materialise at all" came from a sample whose minimum was 0.643 of lambda_max. Measured over 30 designs, blends occur down to 0.0533, and of the selections that change most sit below half of lambda_max. The mechanism is a gradient rather than a threshold: lambda.interp()'s numerator lambda[1] - s absorbs more of an ULP-scale perturbation the further s sits below lambda[1], which makes a blend progressively less likely, never impossible. Corrected in the fixture comment and in NEWS.md, whose bullet renders on the public changelog and now says the change is rare, arises anywhere on the path, and was in every observed case a feature no longer being selected. "ANY validation added here reddens the suite" was written last round to replace an enumeration that had been wrong twice, and is itself wrong: stopifnot(is.numeric(lambda_fitted)) leaves the suite byte-identically green. The comment now names the two shapes that do redden, names the one that does not, and says outright that this is not a rule about validation in general. Seven smaller ones: the red-green block is not the only one that reddens under its own mutation; an inexact round trip is not on its own enough to redden the byte-identity pin (measured, 97% of such fixtures stay green); the roxygen described an insensitivity the body calls one-sided; lambda.interp()'s normaliser was named as the numerator where the rest of the file uses that phrase for the denominator; the n_ulp lower bound is the measured 2 rather than the non-operative 0.5; the muffle comment contradicted itself three sentences apart, and is rewritten whole because a previous edit had left it saying the same thing three times; and the absolute-tolerance mutant first diverges at magnitude 6, not 8 -- the earlier figure came from testing 4, 8 and 1e6 and never 5, 6, 7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review — post-execution pass + drift sentinel, independent contexts, at
|
| mutant | base | head |
|---|---|---|
drop the re-appended lo |
5 fails | 5 fails |
prefix through kt not kt+1 |
6 fails + 1 error | same |
drop the mid filter |
3 | 3 |
| reverse the interior sort | 4 | 4 |
| no shortening at all | 4 | 4 |
| never short-circuit | 3 | 3 |
| never refit | 3 blocks | 4 blocks |
Identical everywhere except the last, where the guard set is one block stronger. All four identity fixtures round-trip exactly, which is why their assertions did not need to move — the snap is a no-op on them. The pin was re-aimed with prose, not relaxed.
The block's name, "cssLasso is byte-identical to the exact refit (#125)", does now overstate what it pins — but its first comment line is WHAT THIS PINS, since #199 narrowed it, so a reader lands on the correction immediately. I'd leave it; renaming breaks ten cross-references that quote it verbatim.
The crossed-call hazard is covered
snapLambdaToGrid(lambda_fitted, s, n_ulp = 4L) sits directly below anchoredLambdaGrid(lambda_path, s, n_interior = 5L) — same arity, same second formal, both taking a numeric vector then a scalar, both defaulting to a small integer. The source comment says the differing first-formal name is "the one signal at a call site," which would be thin protection. It turns out not to be the protection: all five crossings redden, none silently — including the confusion the comment is actually about (handing the helper the first fit's path), which reddens the same two assertions as deliberate un-wiring.
One caveat worth acting on: for the crossing that puts snapLambdaToGrid at the anchored site, the entire margin is a single incidental #125 assertion, and the byte-identity pin stays green. stopifnot(length(s) == 1L) closes it, is about s rather than lambda_fitted so it doesn't conflict with the documented no-validation decision, and was measured to cost nothing (PASS 4297, byte-identical).
1. The direction claim is guaranteed at cssLasso() and only observed at css() — and the sentence covers both
source-files/NEWS.md:11-14 and the PR body say the change is rare and that "every change observed was a feature no longer being selected", with the subject being "which features cssLasso() and css() return."
At cssLasso() that is stronger than observed — it is structural, and worth saying so, because it turns a "we looked and didn't see any" into a guarantee. predict.glmnet() returns beta[,left]*frac + beta[,right]*(1-frac), and within the snapping tolerance frac is within a few ULP of 1, so the snapped column is the dominant one. For any feature: present in the solved column only → survives as frac*x; present in the neighbour only → leaks in at (1-frac)*x ≈ 1e-17 and is selected, because nonzeroCoef()'s test is abs(x) > 0. So the blend's support is a superset of the solved column's, and snapping can only remove. The one escape is exact cancellation between two opposite-signed contributions — a measure-zero tie. I checked it over 2,400 penalty reads: 0 additions, 0 cases where the snapped set was not a subset. A separate 3,000-run sweep found 297 inexact round trips, 17 changed, 17/17 removals.
At css() it is not structural, and this is the real point. The aggregation step is not order-preserving: per-subsample selections feed cluster selection proportions, and getSelectedClusters() under an auto max_num_clusts resolves a tie by returning more clusters. So a removal upstream can lower a proportion into a tie and widen the returned set. One pass measured exactly that — 400 default-path runs, 14 changed, 6 pure additions, with a worked example (base 11 → head 2 11, c10 falling 0.110 → 0.080 while myclust held at 0.080) and two undisclosed consequences: the Returning more than max_num_clusts warning firing on ordinary input, and cssSelect() erroring under options(warn = 2) where it previously returned.
I could not reproduce that, and you should know it before deciding. Across two configurations — 150 runs at n=40/p=12 with a strong signal, and 300 runs at n=22/p=14 with a deliberately tie-prone weak signal — I saw 3 changes, all removals, zero additions. A 300-run warning probe fired the max_num_clusts warning less often on head (5) than on base (6), not more. So the mechanism is real a priori and one pass measured it, but I have not seen it, and the rate and the two consequences rest on that one pass's configuration.
Either way the sentence needs narrowing, independent of how the disagreement resolves: the guarantee exists one level down and is asserted one level up, where a documented tie rule can invert it. Say the direction holds for cssLasso() and that css()-level changes were only observed as removals — or attach the reproducing seed and configuration and state the css() behaviour precisely. This sentence was added by 5ac1668, after the last review round, generalising a cssLasso()-only sweep — which is exactly why it is the one thing here that hadn't been looked at.
2. Robustness — correction residue, the sixth instance of this cycle's pattern
5ac1668 corrected "is the only one that can be" in the narrative at index.Rmd:9056 and left the identical claim nine lines below at :9065 ("THE RED-GREEN TEST FOR #199, and the only block that can be one"), where it is contradicted by its own next-but-one sentence and by measurement. This is the same fix-the-quoted-sentence-not-the-claim pattern your own 3c9487b on #200 flagged as having happened five times; the sentinel's sweep of the other five corrections found no residue, so this is the only one.
3. Notes
- The
max_num_clustsmuffle is now in its third identical copy. Unlike thestopifnotfamily,withCallingHandlersdeparses nothing, so this one is safely extractable into a test helper. cssLasso()asserts the class of its first fit and nothing about its second; since cssLasso() can return a feature whose coefficient is a ~1e-17 interpolation artifact #199 the second fit's$lambdais consumed bysnapLambdaToGrid()as well aspredict.glmnet(), so it now silently assumes a numeric vector with at least one non-NaN entry. The comment names this rather than asserting it, deliberately — reasonable, and correctly disclosed.- The
#156block still tests its name after its selection moved, but itscssSelect()half is vacuous today (a disclosed trade). - "One in five hundred off-grid penalties" measured 1 in 176 in one sweep — the body is conservative in the wrong direction for a rate.
What held up
- Twenty falsifiable claims tested, twenty held, several bit-exactly: the issue reproduction (base
1 2 6with feature 1 at2.4678e-17against0.0375864; branch2 6); the round-trip mechanism reproduced underidentical()asfl(fl(s/ys)*ys);nonzeroCoef()'sabs(beta) > 0;lambda.interp()going fromleft 4 / right 5 / frac 0.99999999999999678toleft 4 / right 4 / frac 1; default-grid spacing of2.05e14–4.39e14ulp against the claimed "1e14". - The tolerance is genuinely pinned from below and unbounded above, as claimed:
n_ulp = 1reddens 3 blocks,2reddens 1, and3/5/100/1e6are all green.s = 0is reachable and safe — glmnet echoes0exactly, and the relative tolerance collapses to exact equality there, which is the right behaviour. - The no-validation decision is exactly as documented:
all(lambda_fitted > 0)errors 2 blocks,all(is.finite(...))errors 1,stopifnot(is.numeric(...))is byte-identically green.length(j) == 1Lis genuine control flow —TRUE &&in its place errors on the all-NaN case. - The sibling routes really are untouched. The only other production
predict.glmnetcall passes nosat all, and no vignette calls it. - The muffle is the house pattern already on
main, is load-bearing (WARN 1without it), and an injected unrelated warning still surfaces through it. The0 → 1 → 0WARN chain reproduces on all three legs. - Gate:
0/0/0exit 0,spell_check0 rows,url_checkthe four recorded 403s, suite4276 → 4297, CI green 5/5, and a freshRscript build.Rreproduces the committedcssr/byte-identically,LitrIdincluded.NEWS.mdparses to one entry and the generated copy was regenerated rather than hand-edited.
Action items
- Narrow the direction claim in
NEWS.mdand the PR body to what is actually guaranteed — structural forcssLasso(), observed-only forcss()(finding 1). If thecss()-level additions reproduce for you, disclose the warning and thewarn = 2behaviour too; if they don't, say what was swept. No rebuild for NEWS; the body needs none. - Fix the residue at
index.Rmd:9065(finding 2). Needs a rebuild. - Add
stopifnot(length(s) == 1L)tosnapLambdaToGrid()— measured free, and it converts the crossed-call margin from one incidental assertion into a direct failure. Same rebuild. - Optional: extract the thrice-copied muffle; consider stating the
cssLasso()direction guarantee in the source, since it is provable and currently reads as empirical.
Only (1) should gate the merge, and it is a sentence.
Two independent subagent passes plus orchestrator-run verification: a 2,400-read subset probe, a 450-run cssSelect() sweep across two configurations, and a 300-run warning probe. Full reports in .plans/issue-199-csslasso-snap-penalty/post_execution_review_v3.md and sentinel_feedback_v3.md.
…ler fixes (#199) All four review points, in one commit because every commit on this branch carries its regenerated cssr/ and docs/ tree and splitting would mean three more full rebuilds. Each point is a paragraph below. (1) THE DIRECTION CLAIM. "Every change observed was a feature no longer being selected" was measured on cssLasso() and written with css() as its subject. Both halves needed correcting. At cssLasso() the direction is not merely observed, it is structural, and NEWS.md now says so: predict.glmnet() returns beta[,left]*frac + beta[,right]*(1-frac), and within the snapping tolerance frac is within a few ULP of 1, so a feature carried only by the adjacent column leaks in at around 1e-17 and is selected on abs(x) > 0. The interpolated support is a superset of the solved column's, so snapping can only remove. At css() it can go either way, and the review was right that I had not looked. Measured over 400 seeds at n = 22, p = 14 with three named clusters and a weak signal, comparing origin/main against this branch: five returned sets changed and three were pure ADDITIONS. The mechanism is the documented tie rule -- a removal lowers a cluster's selection proportion, that proportion meets a max_num_clusts tie, and getSelectedClusters() resolves the tie by returning more clusters and warning. The three addition seeds are exactly the three where the warning newly fires, because the addition and the warning are the same event. Two consequences are now disclosed: the warning fires on ordinary input more often than before (11 runs against 9), and under options(warn = 2) cssSelect() stops with an error at those three seeds where it previously returned. An earlier attempt of mine failed to reproduce this and should not have been trusted: it re-seeded immediately before cssSelect(), changing the RNG stream from the sweep that found the divergence. (2) CORRECTION RESIDUE. The previous commit fixed "the only one that can be" in the narrative and left the identical claim nine lines below, inside the block comment, where its own next-but-one sentence contradicts it. Now corrected there too. (3) A SCALAR GUARD. snapLambdaToGrid() gains stopifnot(length(s) == 1L). It is about s rather than lambda_fitted, so it does not touch the documented no-validation decision, and it converts the crossed-call hazard with the same-arity sibling from a margin of one incidental assertion into a direct failure. (4) A RATE FIGURE DROPPED. Two sweeps measured roughly 1 in 554 and 1 in 176; the PR body quoted the first as if it were the rate. The spread is the finding, so the body now says "uncommon" and leaves each measurement where it was taken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
) The narrow post-execution round and the sentinel's second post-implementation pass both ran against 342f472. Neither blocked. Their findings are applied here as one revision. Two of the defects were introduced by 342f472 itself, which was the commit answering a review about claims made without running them. That is the seventh instance of this pattern on this branch and the fourth inside a commit correcting the previous one, so it is recorded rather than quietly repaired. THE GUARD'S COMMENT NAMED THE WRONG CROSSING. It said asserting a scalar s makes the crossing that hands snapLambdaToGrid() the anchored site's arguments fail directly. Measured, that crossing passes a scalar s, so the line never fires and the suite output is byte-identical with and without it; that crossing is caught by the two #199 red-green assertions. What the guard converts is the TRANSPOSED call, where R's cryptic coercion error becomes a named precondition at a named line. The comment now says so, and records that no legitimate caller can reach the guard at all, since checkCssLassoInputs() rejects any lambda whose length is not 1 or 2. THE GUARD HAD NO RED-GREEN COVER: deleting it reddened nothing, so it could have been weakened later in silence. It is now pinned by an expect_error() in the invariants block. fixed = TRUE is mandatory and verified rather than assumed -- grepl(m, m) on "length(s) == 1L is not TRUE" is FALSE because of the parentheses, and at edition 2 a mismatched expect_error() aborts the render. Suite 4297 -> 4298; the guard-deleted mutant now gives FAIL 1. THE DIRECTION CLAIM'S REASON ARGUED ONLY HALF THE CASE. "Can only remove" needs both that the blend's support is a superset of the solved column's and that a feature the solved column carries survives the blend. Only the first was stated. The second can fail by exact cancellation, which is why this is a well-tested empirical claim -- 0 additions in 239,004 runs, 0 sign flips in 541,750 adjacent column pairs -- rather than a theorem. NEWS.md now says both halves and says which it is. THE css() DISCLOSURE WAS INCOMPLETE IN THE WAY THAT MATTERS. selected_feats, which is what cssPredict() consumes, can widen with NO warning at all and with selected_clusts unchanged, through a within-cluster weighting tie rather than the max_num_clusts one (seeds 336 and 390 of the recorded sweep). Tying the widening to the warning would tell an options(warn = 2) reader they are covered when they are not. NEWS.md now names both routes and says which is silent. Also: a positional cross-reference added by 342f472 pointed at the wrong paragraph and is replaced by the fact it was pointing at; "frac is within a few ULP of 1" is 19 ULP at the median and near 0 rather than 1 in some runs, so the wording no longer carries a figure or an orientation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up at
|
Review follow-up at d46a431. The cssLasso() call-site comment still said an ULP miss "leaves frac a hair short of 1", an orientation d46a431 removed from NEWS.md and did not remove here. Measured over 240 interpolated reads on real glmnet() paths, perturbing s by 2 ULP in each direction at path positions 2-5: miss upward (s above the fitted entry) median frac = 0 miss downward (s below the fitted entry) median frac = 1 So the sentence was correct for downward misses and inverted for upward ones, and "near the top of the path" did not rescue it -- the split is measured at positions 2-5. The mechanism and the code are unaffected: whichever side the miss falls, snapLambdaToGrid() picks the nearer entry, which is the column carrying the weight indistinguishable from 1, so the other column's exclusive features are the ones leaking in and the blend's support is a superset either way. The comment now names both sides with the measurement, and says not to write it as "a hair short of 1", because that phrasing has propagated twice. This is the eighth instance of this branch's fix-the-quoted-copy pattern, and it survived five review rounds for a reason worth recording: every one of those rounds was scoped to what the newest commit had changed, and this line was written in c1ff287 and never re-touched, so no round had scope over it. The maintainer found it by reading the whole file. An unscoped audit of the complete branch diff is running. Also corrected, in the same class: the comment above the new expect_error() said deleting stopifnot(length(s) == 1L) "reddens nothing in the suite", which described the state before that assertion existed and reads as false now that it does. Measured, the deletion records an ERROR rather than a failure -- failed 0, error blocks 1, passed 4282 -- because the mismatched expect_error() re-raises and aborts the block, taking the assertions below it. The comment says that, and says the summary line's FAIL aggregates failures and errors, which is what made the earlier report imprecise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An audit of the COMPLETE branch diff -- unscoped by commit, unlike the five
earlier rounds -- checked 77 claims. Seven were wrong, and all seven sit in
material no earlier round had scope over:
c1ff287, original implementation, never re-touched 3 never covered
d46a431, the tip when the audit was briefed 2 not covered since
the in-flight orientation fixes 2 not yet covered
the four intermediate correction commits 0 covered once each
Every claim in a commit a scoped round did cover held. The scoped rounds were not
careless; they were correct within their scope, and the defects lived in the gaps
between scopes. That is why this branch produced findings one at a time, and why
the maintainer found the last one by reading the whole file rather than a diff.
WHAT WAS WRONG, each re-derived before correcting:
The claim that the suite is byte-identical with and without the scalar guard went
stale the moment the same commit added an expect_error() for that guard -- the
identical failure to the orientation line, one commit later, written while fixing
that line's class of error.
The transposed call's error does not come from inside which.min(), which returns
an index quite happily; it comes from the && in the tolerance test. The length in
that message was quoted as 45 and matches no fixture here -- the paths in play are
61 and 13 -- so the figure is dropped rather than replaced.
"Transposed" was used for the crossed call sixty lines below the comment that
defines transposed as the argument-swapped one, which errors. The two are now
distinguished where both are named.
A crossed call was said to produce the per-s-slot union #190 warns about. It
returns a scalar, so predict.glmnet() yields one slot; #190's union comes from
calling predict.glmnet() with no s at all, which yields one slot per fitted
penalty.
The <= boundary was called one no reachable input sits on. The block's own
s = 0 fixture sits exactly on it, since |g - s| and the relative tolerance are
both 0 there. The mutant is undetectable for a different and checkable reason:
on that boundary both branches return the same double.
Also: "a median frac of 0" overstated by a few ULP and is now stated as
indistinguishable from 0; the 1e-17 figure was attached to the complement when it
belongs to the leaked coefficients; one over-long line rewrapped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
glmnet does not echo a supplied penalty back verbatim — it returns it after dividing by the
response scale and multiplying back — so the
scssLasso()predicted at could miss the fittedentry by an ULP.
glmnet:::lambda.interp()then blended two adjacent path columns, andnonzeroCoef()selected the leak because its test isabs(x) > 0. Issue #199's reproductionreturns
1 2 6with feature 1 at2.468e-17against a next-smallest of0.0376; on this branchit returns
2 6.A new internal
snapLambdaToGrid()returns the fitted entry when one sits within a few ULP ofthe request, so
lambda.interp()givesleft == rightwithfrac == 1and the vector read backis a solved lasso column with exact zeros. No coefficient-magnitude threshold is introduced: the
tolerance sits on the penalty scale, bounded by IEEE arithmetic. The sibling routes are untouched.
What changes for users
Selected features move where glmnet's reported penalty differs from the requested one in its
last bits — uncommon, and likelier the nearer the penalty sits to the top of a subsample's own
path. At
cssLasso()the change removes features and does not add them: the blend's supportis a superset of the solved column's, and a feature the solved column carries survives a weight
indistinguishable from 1 or from 0. The only escape is exact cancellation, unobserved in 239,004
runs — so this is well tested, not proved, and
NEWS.mdsays which.At
css()it can go either way, because the aggregation abovecssLasso()is notorder-preserving: a lowered selection proportion can reach a tie resolved by returning more.
Two routes, and only one announces itself.
getSelectedClusters()wideningselected_clustswarns, and under
options(warn = 2)a call that returned can now error. A within-clusterweighting tie widens
selected_feats— whatcssPredict()consumes — silently, with thecluster set unchanged. Both are measured and both are in
NEWS.md. This supersedes #125'sidentity, and no math the paper specifies is touched.
The
#156block is one such case; its warning is muffled at both wrapper calls by the patternPR #186 introduced, leaving every other warning visible and its assertions untouched.
Verification
check0/0/0,spell_check0 rows,url_checkthe four recorded 403s,[ FAIL 0 | WARN 0 | SKIP 0 | PASS 4297 ]against a base of 4276, CI green on all five jobs.Under the mutation helper present / call site absent, exactly two assertions redden — the
routing block's wiring assertion and the end-to-end block's structural assertion — and nothing
else moves.
WARN 0is met by the muffle, not for free: baseWARN 0→ wiredWARN 1→muffled
WARN 0.🤖 Generated with Claude Code