Skip to content

Recurring schedules: total a series with "<amount> <period> for <duration>" - #116

Merged
LiamRiddell merged 5 commits into
mainfrom
feat/95-recurring-schedules
Aug 21, 2026
Merged

Recurring schedules: total a series with "<amount> <period> for <duration>"#116
LiamRiddell merged 5 commits into
mainfrom
feat/95-recurring-schedules

Conversation

@LiamRiddell

Copy link
Copy Markdown
Owner

What this changes

A recurring schedule now adds itself up. <amount> <period> for <duration> gives the total, so a subscription, salary or instalment plan does not have to be worked out elsewhere and typed back in as a number.

450 monthly for 18 months        8,100
12.99 monthly for 2 years        311.76
2000 every 2 weeks for 6 months  26,000
£450 monthly for 18 months       £8100.00

Periods are daily, weekly, monthly, yearly (also annually), and every N days/weeks/months/years. Money rides along, and where the per-payment amount is exact so is the total ($12.99 monthly for 2 years is exactly $311.76).

Why

Subscriptions, salaries and instalments are the most common thing anyone adds up in a note, and there was no way to express the series: the total had to be computed separately and typed in as a number, which is the part worth checking. Multiplication covered the whole-number case (450 * 18) and nothing else. The date and finance packages both exist and neither models a series, so this sat between them.

Closes #95

Tests

  • A test covers the behaviour that changed, and fails without the change
  • A regression test named after the defect, if this is a fix (see __tests__/bugs)
  • Existing tests that assert the old behaviour are updated rather than deleted
  • npm run test:full passes

packages/engine/__tests__/hardening/RecurringSchedules.spec.ts is the new hardening spec (34 tests): the headline totals, money riding along and staying exact where the amount is exact ($0.10 weekly for 3 weeks is 0.3, not the drifted double, with the bare-decimal boundary asserted alongside), every named period plus the every-N form, the whole-period floor on a part period, and the zero-interval structured error being contained to its line. Its "what must keep working" block guards the investment for (1000 for 3 years at 7%), the loan-repayment phrase, the rate $24 a day for a year, the period words as ordinary variable names, and plain arithmetic. packages/engine/__tests__/docs/DocExamples.spec.ts executes the four new documentation examples.

Documentation

  • Syntax reference updated, if an expression now evaluates differently
  • Examples in docs/ still pass, and any new ones are in a solve block so they run
  • npm run lint:units passes, if a unit was added or renamed (no unit added)
  • npm run lint:sidebar passes, if a page was added (no page added)
  • A changeset describes the change from the reader's side, if the published package changed

A "Recurring schedules" section was added to docs/src/content/docs/syntax/money-and-finance.md with runnable solve examples, and .changeset/recurring-schedules.md (minor) describes it in the house voice.

Checks

  • npm run verify
  • npm run lint
  • node scripts/check-comment-style.mjs --all

All green. npm run test:full also passes (292 suites, 7023 tests).

Anything worth a closer look

This touches the token normalizer, which runs on every keystroke, so a note on the approach and the two design decisions the issue asked for:

  • Result shape. The primary result is the total, a plain number or a currency amount. The number of payments is the secondary detail that produces it (total = per-payment amount times the whole payment count). The engine has no channel for a note beside a number, so it is the total that shows; the count is documented rather than rendered. Because the feature is a rewrite to a plain amount * count, currency and exactness come for free from the existing money-multiply path (£12.99 * 24 is exactly £311.76), and a bare decimal stays an ordinary float as it does everywhere else in the engine.

  • Partial periods. The count is one payment per whole period, floored, on a scheduling year where a month is one of twelve, a week one of fifty-two and a day one of 365 (independent of the second-based unit table, which is what makes every 2 weeks for 6 months a clean 13: twenty-six a year over half a year). A final part-period has not come due, so every 2 weeks for 5 weeks is two payments, not three, and a span shorter than one whole period is zero. A count that lands on a whole number through floating point is snapped to it before the floor, so the clean cases stay clean. A non-positive every N interval is a structured RECURRING_INTERVAL_NOT_POSITIVE parse error rather than a division by zero.

  • Collision with for. The word is shared with the investment grammar ($1,000 for 3 years at 7%) and the uom rate grammar ($24 a day for a year). The rule matches at the amount (the token before the period word) so it fires ahead of implicit multiplication at that position, rather than depending on that rule's word-by-word suppression (which fires before weekly but not before monthly, since monthly can start the fused phrase monthly repayment on). It claims the period word only when the whole <amount> <period> for <N> <unit> shape is present, so a bare monthly/weekly stays a variable name and both for grammars are untouched, all asserted in the "what must keep working" block.

  • Performance. The rule is O(1) per token position (a few type checks, returning null immediately for anything that is not a value ender followed by a period word), and each application strictly shrinks the token stream, so it cannot loop.

LiamRiddell and others added 3 commits August 21, 2026 14:23
…ration>"

`450 monthly for 18 months` now answers 8,100, `2000 every 2 weeks for 6
months` 26,000, and `£450 monthly for 18 months` `£8100.00`. Implemented as
a normalizer rewrite to `amount * count`: the payment count is a whole number
known from the literal tokens, so the engine's own arithmetic carries the
currency and keeps an exact amount exact through the same money-multiply path.

The rule matches at the amount, the token before the period word, so it fires
ahead of implicit multiplication rather than fighting its word-by-word
suppression, and it claims the period word only when the full `<amount>
<period> for <N> <unit>` shape is present, leaving bare `monthly`/`weekly` and
the investment/rate `for` grammar untouched.

Periods: daily, weekly, monthly, yearly (annually), and `every N
days/weeks/months/years`. The count is one payment per whole period on a
scheduling year (12 months, 52 weeks, 365 days), so a part-period is not
counted and a zero interval is a structured error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the headline totals, money riding along and staying exact where the
amount is exact (`$0.10 weekly for 3 weeks` is 0.3, not the drifted double),
every named period plus the every-N form, the whole-period floor on a part
period, and the zero-interval error being contained to its line. A "what must
keep working" block guards the investment `for` (`1000 for 3 years at 7%`), the
loan-repayment phrase, the rate `$24 a day for a year`, the period words as
variable names, and ordinary arithmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a "Recurring schedules" section to the money-and-finance syntax page,
with runnable `solve` examples the doc test executes, and a changeset in the
house voice covering the new forms, the money-exactness guarantee, and the
partial-period rule.

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

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Benchmark comparison

Suite Case Reference Current Ratio
cancellation-overhead 50 keystroke cycles (total) 293.95µs 293.03µs 1.00x
cancellation-overhead addEventListener + removeEventListener 0.32µs 0.34µs 1.05x
cancellation-overhead addEventListener on aborted signal 92.63µs 75.81µs 0.82x
cancellation-overhead addEventListener only 0.18µs 0.23µs 1.30x slower
cancellation-overhead engine.evaluateLine (no signal) 6.03µs 5.73µs 0.95x
cancellation-overhead engine.evaluateLine (with signal, raw) 5.96µs 5.91µs 0.99x
cancellation-overhead executeCached (no signal) 0.43µs 0.46µs 1.07x
cancellation-overhead executeCached (with signal, raw) 1.24µs 1.38µs 1.11x
cancellation-overhead full create+link+unlink cycle 0.40µs 0.39µs 0.98x
cancellation-overhead keystroke abort → 50 local aborts 255.23µs 244.66µs 0.96x
cancellation-overhead keystroke wrapper overhead 0.44µs 0.42µs 0.95x
cancellation-overhead new AbortController() 0.12µs 0.13µs 1.05x
cancellation-overhead overhead delta (with - without - wrapper) 0.37µs 0.50µs 1.36x slower
cancellation-overhead per keystroke cycle 5.88µs 5.86µs 1.00x
cancellation-overhead signal.aborted property access 0.00µs 0.00µs - too small to compare
diagnostic-pipeline DIAG_10k_warm 51.93µs 51.03µs 0.98x
diagnostic-pipeline DIAG_50_line_doc 694.84µs 740.09µs 1.07x
diagnostic-pipeline DIAG_single_eval_cold 474.44µs 477.82µs 1.01x
diagnostic-pipeline DIAG_single_eval_warm 41.88µs 44.23µs 1.06x
diagnostic-pipeline DIAG_variable_chain 325.83µs 330.62µs 1.01x
diagnostic-pipeline PROD_10k_warm 11.19µs 11.08µs 0.99x
diagnostic-pipeline PROD_50_line_doc 848.53µs 869.58µs 1.02x
diagnostic-pipeline PROD_function_plus_literal 304.79µs 324.57µs 1.06x
diagnostic-pipeline PROD_single_eval_cold 394.60µs 415.27µs 1.05x
diagnostic-pipeline PROD_single_eval_warm 4.66µs 4.68µs 1.00x
diagnostic-pipeline PROD_variable_chain 332.75µs 358.47µs 1.08x
language-service completions_cold_first_call 544.85µs 532.35µs 0.98x
language-service completions_warm_500_variables 51.50µs 51.30µs 1.00x
language-service completions_warm_no_match 0.55µs 0.59µs 1.07x
language-service completions_warm_short_prefix 4.65µs 4.63µs 1.00x
language-service completions_warm_specific_prefix 0.78µs 0.85µs 1.08x
language-service highlight_fusing_line_normalized 12.49µs 12.56µs 1.01x
language-service highlight_inline_solve_in_text 6.02µs 6.31µs 1.05x
language-service highlight_inline_solve_in_text_normalized 11.44µs 11.94µs 1.04x
language-service highlight_long_expression 256.47µs 272.39µs 1.06x
language-service highlight_long_expression_normalized 523.40µs 523.39µs 1.00x
language-service highlight_mixed_expression 10.55µs 10.75µs 1.02x
language-service highlight_mixed_expression_normalized 17.17µs 17.96µs 1.05x
language-service highlight_simple_arithmetic 7.90µs 8.38µs 1.06x
language-service highlight_simple_arithmetic_normalized 10.27µs 10.44µs 1.02x
language-service highlight_unrecognized_prose 22.32µs 22.86µs 1.02x
language-service highlight_unrecognized_prose_normalized 29.35µs 30.05µs 1.02x
language-service highlight_viewport_100_lines_cold 684.82µs 653.48µs 0.95x
language-service highlight_viewport_200_lines_cold 1349.48µs 1336.86µs 0.99x
language-service highlight_viewport_50_lines_cold 348.35µs 344.04µs 0.99x
language-service highlight_warm_cache_hit 0.01µs 0.01µs - too small to compare
parser complex_expression 8.80µs 8.70µs 0.99x
parser datetime 7.12µs 7.47µs 1.05x
parser dice 7.78µs 8.12µs 1.04x
parser function_call 7.55µs 7.54µs 1.00x
parser mixed 8.66µs 9.03µs 1.04x
parser percentage 7.52µs 7.55µs 1.00x
parser simple_arithmetic 8.31µs 9.84µs 1.18x
parser unit_conversion 7.31µs 7.57µs 1.04x
parser variable 7.01µs 7.45µs 1.06x
parser vector 7.81µs 8.66µs 1.11x
pipeline 200_line_doc 1946.32µs 2157.13µs 1.11x
pipeline 20_inline_solves 511.41µs 530.55µs 1.04x
pipeline 50_line_doc 1069.56µs 1059.07µs 0.99x
pipeline function_plus_literal 335.54µs 347.55µs 1.04x
pipeline mixed_complex 363.81µs 376.49µs 1.03x
pipeline re_eval_dirty 344.45µs 353.30µs 1.03x
pipeline single_eval_cold 408.17µs 438.82µs 1.08x
pipeline single_eval_warm 5.14µs 5.15µs 1.00x
pipeline variable_chain 348.03µs 358.21µs 1.03x
vm dice_roll 1452.37µs 1367.29µs 0.94x
vm percentage 601.66µs 601.84µs 1.00x
vm simple_add 482.27µs 511.25µs 1.06x
vm unit_conversion 999.22µs 956.61µs 0.96x
vm variable_access 667.73µs 659.19µs 0.99x
vm vector_creation 1395.17µs 1376.13µs 0.99x

Per suite

Suite Cases Geometric mean
cancellation-overhead 14 1.033x
diagnostic-pipeline 11 1.030x
language-service 19 1.021x
parser 10 1.050x
pipeline 9 1.037x
vm 6 0.988x

Warnings

  • cancellation-overhead / addEventListener only: 1.30x slower
  • cancellation-overhead / overhead delta (with - without - wrapper): 1.36x slower

No regression over threshold.

LiamRiddell and others added 2 commits August 21, 2026 23:09
@LiamRiddell
LiamRiddell merged commit f024778 into main Aug 21, 2026
11 checks passed
@LiamRiddell
LiamRiddell deleted the feat/95-recurring-schedules branch August 21, 2026 22:15
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.

[feature] Recurring schedules

1 participant