Follow-up to #876 / #919 (merged as b4d3a2d). Four items, none behavioural, all
inside src/lib/webllm/preserve-numbers.ts and its test. They were the surviving
non-blocking nits on that PR's approval plus one measured simplification found
afterwards.
Nothing here changes what the gate does. This is a drive-by: fold it into
whatever PR is next in src/lib/webllm/, rather than opening a PR of its own.
1. Tiers 1b and 1c in isYearContext are dead — delete them and their two regexes
isYearContext (preserve-numbers.ts:438) ends with two "verb" tiers:
// 1b. Bare past-tense verb followed by a function word, punctuation, or end of bullet (#876)
if (YEAR_PREFIX_VERB_CUE.test(before) && FUNCTION_WORD_FOLLOWS.test(after)) {
return "verb";
}
// 1c. Weak prepositions, qualifiers, or bullet-initial attributive years (#876)
if (YEAR_PREFIX_WEAK_CUE.test(before) || leadingText.length === 0) {
return "verb";
}
Both are shadowed by the inverted gate's own fallback in bareIntegerClaim
(preserve-numbers.ts:510), which already returns year_verb for every 4-digit
integer in 1900–2099 that nothing else claimed. Anything 1b or 1c catches, the
fallback catches one branch later with the same answer.
The only way they could matter is ordering: bareIntegerClaim consults
isRangeEndpoint before the fallback, so a 4-digit range endpoint carrying a
weak cue gets year_verb from 1c where it would otherwise get range — and
range is claimed while year_verb is counted as unclaimed by
countUnclaimedByKey. That is a real difference in principle. It does not show
up in practice.
Measured, not assumed. Replacing both tiers with return "none":
npx vitest run src/lib/webllm/preserve-numbers.test.ts → 97/97 still pass.
- Differential against the submitted implementation over 136 cases — 16 bullets
covering 4-digit range endpoints ("Shipped 2000-3000 units per quarter.",
"Handled 1900 to 2100 requests per second.", "Reduced errors from 2000 to 1900 per week."), weak-cue shapes (from / by / for / of / over / the /
our / early / mid-), bare past-tense verbs, and bullet-initial attributive
years, each × identity / year-dropped / year-dropped-with-same-value-decoy /
appended-clause, each × with and without an unrelated decoy bullet on the input
side — comparing ok, dropped and added: 0 diffs.
Deleting both tiers also removes YEAR_PREFIX_WEAK_CUE (:362) and
YEAR_PREFIX_VERB_CUE (:371), which have no other reference. That is roughly
4 cyclomatic off isYearContext, which fallow currently reports at CRAP 63.6 /
15 cyclomatic / 13 cognitive / 52 lines — the highest in the module and the
reason it shows up in fallow audit at all.
Before deleting, re-run the two checks above rather than trusting this issue:
the claim is "no observable difference", and it is only as good as the corpus it
was measured on.
2. bareIntegerClaim's docblock still says "Three ways to qualify"
preserve-numbers.ts:495:
Three ways to qualify — a headcount, a year, or one endpoint of a tight range.
There are four readings now that year_verb exists. One word, but the docblock is
the thing a reader trusts to know how many claim kinds there are, and the
lenient/strict split between year and year_verb is the whole point of #876.
(If item 1 lands, the count is still four — year_verb comes from the fallback,
not from tiers 1b/1c.)
3. The residual false-revert families are not disclosed in code
#919's PR description listed the accepted residual, and the squash commit body
carries it, but nothing in the module does — and a PR description is not where
someone debugging a surprise revert will look.
The module docblock should name the mechanism and all four shapes. A strict year
cue immediately before the digit — , / ( / a leading dash / a month name —
means that if an unrelated quantity of the same value is merged away in another
bullet, strict count parity reports a drop. Confirmed shapes:
| Input |
Output |
Result |
["Shipped in March 2000 units.", "Counted 2000 units."] |
["Counted 2000 units shipped in March."] |
{ ok: false, dropped: ["2000"] } |
["Speaker (2019) at the internal summit.", "Handled 2019 support tickets."] |
["Handled 2019 support tickets; spoke at the internal summit."] |
{ ok: false, dropped: ["2019"] } |
["B.S. CS, 2019.", "Closed 2019 issues."] |
["Closed 2019 issues after earning a B.S. in CS."] |
{ ok: false, dropped: ["2019"] } |
All three were ok: true before #919. The trade is deliberate — the cost is one
discarded rewrite with a visible warning, and the alternative is losing the
degree/talk/award year defence — so this is disclosure, not a bug. The month-name
shape in particular was never written down anywhere.
4. The residual test's title is wider than its body
preserve-numbers.test.ts:1121:
it("pins accepted residual: comma/parenthesis cues across two occurrences (#876 review)", ...)
The body exercises only the comma ("Cut infra spend, 2000 servers decommissioned.").
Either narrow the title to what it pins, or add the parenthesis case from the table
above so the title becomes true.
Why these were not fixed in #919
#919 was approved after six review rounds by an outside contributor. main has
dismiss_stale_reviews on, so any push to the branch would have dismissed the
approval and bought a seventh round over comment wording. Landing it and folding
these in later was the cheaper trade.
Decisions (2026-09-27)
- Ship as its own PR. Do not wait to fold this into another webllm change.
- Item 4: add the parenthesis case from the table above to the test body so the existing title becomes true. Do not narrow the title.
- Item 5 (from the comments) is in scope: delete the dead
|${OPEN_ENDED_WORDS} alternation and correct the "six sites" docblock count.
- The 136-case / 432-case differential harnesses mentioned above are not committed. The existing
preserve-numbers.test.ts suite passing is the gate; say so in the PR body.
Follow-up to #876 / #919 (merged as
b4d3a2d). Four items, none behavioural, allinside
src/lib/webllm/preserve-numbers.tsand its test. They were the survivingnon-blocking nits on that PR's approval plus one measured simplification found
afterwards.
Nothing here changes what the gate does. This is a drive-by: fold it into
whatever PR is next in
src/lib/webllm/, rather than opening a PR of its own.1. Tiers 1b and 1c in
isYearContextare dead — delete them and their two regexesisYearContext(preserve-numbers.ts:438) ends with two "verb" tiers:Both are shadowed by the inverted gate's own fallback in
bareIntegerClaim(
preserve-numbers.ts:510), which already returnsyear_verbfor every 4-digitinteger in 1900–2099 that nothing else claimed. Anything 1b or 1c catches, the
fallback catches one branch later with the same answer.
The only way they could matter is ordering:
bareIntegerClaimconsultsisRangeEndpointbefore the fallback, so a 4-digit range endpoint carrying aweak cue gets
year_verbfrom 1c where it would otherwise getrange— andrangeis claimed whileyear_verbis counted as unclaimed bycountUnclaimedByKey. That is a real difference in principle. It does not showup in practice.
Measured, not assumed. Replacing both tiers with
return "none":npx vitest run src/lib/webllm/preserve-numbers.test.ts→ 97/97 still pass.covering 4-digit range endpoints (
"Shipped 2000-3000 units per quarter.","Handled 1900 to 2100 requests per second.","Reduced errors from 2000 to 1900 per week."), weak-cue shapes (from/by/for/of/over/the/our/early/mid-), bare past-tense verbs, and bullet-initial attributiveyears, each × identity / year-dropped / year-dropped-with-same-value-decoy /
appended-clause, each × with and without an unrelated decoy bullet on the input
side — comparing
ok,droppedandadded: 0 diffs.Deleting both tiers also removes
YEAR_PREFIX_WEAK_CUE(:362) andYEAR_PREFIX_VERB_CUE(:371), which have no other reference. That is roughly4 cyclomatic off
isYearContext, which fallow currently reports at CRAP 63.6 /15 cyclomatic / 13 cognitive / 52 lines — the highest in the module and the
reason it shows up in
fallow auditat all.Before deleting, re-run the two checks above rather than trusting this issue:
the claim is "no observable difference", and it is only as good as the corpus it
was measured on.
2.
bareIntegerClaim's docblock still says "Three ways to qualify"preserve-numbers.ts:495:There are four readings now that
year_verbexists. One word, but the docblock isthe thing a reader trusts to know how many claim kinds there are, and the
lenient/strict split between
yearandyear_verbis the whole point of #876.(If item 1 lands, the count is still four —
year_verbcomes from the fallback,not from tiers 1b/1c.)
3. The residual false-revert families are not disclosed in code
#919's PR description listed the accepted residual, and the squash commit body
carries it, but nothing in the module does — and a PR description is not where
someone debugging a surprise revert will look.
The module docblock should name the mechanism and all four shapes. A strict year
cue immediately before the digit —
,/(/ a leading dash / a month name —means that if an unrelated quantity of the same value is merged away in another
bullet, strict count parity reports a drop. Confirmed shapes:
["Shipped in March 2000 units.", "Counted 2000 units."]["Counted 2000 units shipped in March."]{ ok: false, dropped: ["2000"] }["Speaker (2019) at the internal summit.", "Handled 2019 support tickets."]["Handled 2019 support tickets; spoke at the internal summit."]{ ok: false, dropped: ["2019"] }["B.S. CS, 2019.", "Closed 2019 issues."]["Closed 2019 issues after earning a B.S. in CS."]{ ok: false, dropped: ["2019"] }All three were
ok: truebefore #919. The trade is deliberate — the cost is onediscarded rewrite with a visible warning, and the alternative is losing the
degree/talk/award year defence — so this is disclosure, not a bug. The month-name
shape in particular was never written down anywhere.
4. The residual test's title is wider than its body
preserve-numbers.test.ts:1121:The body exercises only the comma (
"Cut infra spend, 2000 servers decommissioned.").Either narrow the title to what it pins, or add the parenthesis case from the table
above so the title becomes true.
Why these were not fixed in #919
#919 was approved after six review rounds by an outside contributor.
mainhasdismiss_stale_reviewson, so any push to the branch would have dismissed theapproval and bought a seventh round over comment wording. Landing it and folding
these in later was the cheaper trade.
Decisions (2026-09-27)
|${OPEN_ENDED_WORDS}alternation and correct the "six sites" docblock count.preserve-numbers.test.tssuite passing is the gate; say so in the PR body.