DateRecurrence

DateRecurrence :: # (opaque)

Gregorian date-only series. Dates identify occurrences within this series; applications pair them with their own series ID. They are not POSIX instants. Native CalendarPattern defaults apply; this is not an RFC text adapter.

Example

A monthly rule anchored on the 31st skips months without that day. Collect with explicit work and output limits. Limited carries the cursor to resume; even reaching the exact output capacity does not prove completion.

import time.GregorianDate
import time.CalendarPattern
import time.DateRecurrence

expect {
    start = GregorianDate.from_fields({ year: 2025, month: 1, day: 31 })?
    end = GregorianDate.from_fields({ year: 2025, month: 6, day: 1 })?
    rule = DateRecurrence.new(start, {
        pattern: CalendarPattern.defaults(Monthly),
        termination: Count(3),
        by_set_pos: [], inclusions: [], exclusions: [],
    })?
    cursor = DateRecurrence.cursor(rule, { start, end })?
    batch = DateRecurrence.Cursor.collect(cursor, {
        max_steps: 1000, max_buffered: 31, max_occurrences: 4,
    })?
    months = batch.dates.map(|date| GregorianDate.to_fields(date).month)
    months == [1.U8, 3, 5] and match batch.status {
        Complete => Bool.True
        Limited(_) => Bool.False
    }
}

Examples assume a package dependency named time.

new : GregorianDate, Spec -> Try(DateRecurrence, [InvalidInterval, TooManySelectors, InvalidSelector(Str), InvalidCombination(Str), InvalidCount, InvalidUntil, UnsynchronizedStart, OutOfRange, ..])

O(366*s + n log n) construction, where s is selector count and n is the number of explicit dates. Each supplied list is limited to 4096 entries. Validate DTSTART against the whole first period, including BYSETPOS. Invalid generated dates are skipped; UNTIL is inclusive. COUNT applies before exclusions; explicit inclusions do not consume or replenish COUNT.

cursor : DateRecurrence, Window -> Try(Cursor, [EmptyWindow, ReversedWindow, ..])

Bind a fresh cursor to this exact immutable rule and a finite half-open date window. Evaluation starts at the series anchor, preserving COUNT.

fact_count : DateRecurrence -> U64

Declaration facts only: no cursor, period scan or occurrence generation.

fact_at : DateRecurrence, U64 -> [End, Item(SemanticFact)]
Spec : {
    pattern : Spec,
    termination : Termination,
    by_set_pos : List(I16),
    inclusions : List(GregorianDate),
    exclusions : List(GregorianDate),
}
Window : { start : GregorianDate, end : GregorianDate }
Limit : [WorkLimit, BufferLimit, OutputLimit]
Batch : {
    dates : List(GregorianDate),
    steps : U64,
    buffered : U64,
    status : [Complete, Limited({ cursor : Cursor, reason : Limit })],
}
FoldBatch : {
    value : acc,
    occurrences : U64,
    steps : U64,
    buffered : U64,
    status : [Complete, Limited({ cursor : Cursor, reason : Limit }), Stopped(Cursor)],
}
Next : {
    steps : U64,
    buffered : U64,
    status : [End, Item({ date : GregorianDate, cursor : Cursor }), Limited({ cursor : Cursor, reason : Limit })],
}

Cursor

DateRecurrence.Cursor :: # (opaque)

Cursor representation is private. Resume accepts no replacement rule or window, so a cursor cannot accidentally be used with stale inputs.

collect : Cursor, Limits -> Try(Batch, [OutOfRange, ..])

One step examines one date, buffered candidate, explicit date, or period/state boundary. Each step has bounded selector/calendar work. Buffer capacity counts retained candidates (at most 366); it is not an allocation-byte claim. Output and immutable input lists are separate. A zero budget is valid and can return Limited without advancing. Reducing capacity below an already retained buffer returns BufferLimit without advancing; Batch.buffered reports that existing retained count. Shared cursors may copy a period buffer; no scan hides inside one step.

fold : Cursor, Limits, acc, (acc, GregorianDate -> [Continue(acc), Stop(acc)]) -> Try(FoldBatch(acc), [OutOfRange, ..])

Fold visits ordered, deduplicated dates after exclusions and query filtering. Stop(value) consumes that date and returns its exact next cursor, without searching ahead. Stopped never claims completeness. max_occurrences limits visitor calls; steps account for engine work, not the caller's visitor. No intermediate output list is constructed. Resume with the returned cursor and value to preserve the accumulator.

next : Cursor, { max_steps : U64, max_buffered : U64 } -> Try(Next, [OutOfRange, ..])

Return one date without looking for the following date. End is proven exhaustion; Limited needs more work/capacity and is not end-of-series.

chunks : Cursor, Limits -> Iter(Try(Batch, [OutOfRange]))

Lazy Roc iterator of bounded batches. Each advance performs one collect; no series expansion happens while constructing this iterator. Complete, Limited and errors remain visible items. Iter exhaustion is transport exhaustion, not proof that the temporal query completed. Positive work/output limits continue automatically. BufferLimit or a zero work/output budget emits one terminal Limited item; its cursor can be resumed explicitly with larger limits. Errors are emitted once. Retaining chunks/rest iterators can retain their captured buffers.