Curves, Resets, and Simulation
A curve is a pure function of elapsed time - the whole service reduces to
f(last_timestamp, curve, cap) -> current_value. Six curve types cover
regenerating meters, drips, and idle accrual; resets bound them to a
calendar; and the simulator previews any of it against a synthetic
timeline without touching user state. All traces below were captured live.
The six curve types
linear- fixedrate_per_secondup to the cap. Energy bars, cooldowns.exp_decay_to_cap- fast when empty, asymptotic near the cap; configured byhalf_life_seconds. Comeback mechanics, accrued interest.stepped- discrete grants ofamounteveryinterval_seconds. API-credit refills, content drips.idle_linear- offline accrual at a fixed rate, with an optionalcliff_secondsgate before accrual starts (vesting-style).idle_diminishing- accrual decayed bydecay_factor_per_hourper elapsed hour, to discourage indefinite absence.idle_capped- accrual that hard-stops at the cap.
Rate-based curves take an optional round mode - floor (default),
ceil, round, or none. Rounding is display-only: responses show the
rounded value, but stored state keeps the exact fraction, which is why an
insufficient-funds error can report available: 7.5 on a resource whose
reads show whole numbers.
Every touch re-anchors the clock
Elapsed time is measured since the last materialization, and every read,
spend, refill, or materialize persists a fresh last_timestamp. Three
curve features measure their trigger against that per-window elapsed time,
so any touch restarts them:
steppedgrantsfloor(elapsed / interval)- partial progress toward the next step is forfeited on every touch, so frequent polling starves the resource entirely. Against a+1 every 5scurve, polling every 2s for 8s stayed at0.0; six quiet seconds then granted1.0.cliff_secondsrestarts per window. A 60s cliff at 1/s read once at t=90s returned30.0, but reading at t=30s first made the t=90s read return0.0.idle_diminishingdecays per hour since the last touch, not since the user left. Two hours at 1/s withdecay_factor_per_hour: 0.5returned5400.0in a single read, but7200.0when also read at the first hour boundary - frequent touches defeat the decay.
Use these shapes for resources touched much less often than their horizon,
and prefer linear (default round: floor) when clients poll - it
accrues continuously and survives re-anchoring because the exact fraction
is persisted.
Timezone-aware resets
A definition may reset daily or weekly at a local wall-clock time,
applied lazily during materialization before regen - no scheduled job:
"reset": { "schedule": "daily", "at_local_time": "04:00",
"fallback_offset_minutes": -300, "reset_to": "cap" }The caller's UTC offset comes from the tz_offset_minutes query parameter
(CLI --tz-offset-minutes), falling back to the definition's
fallback_offset_minutes. reset_to is cap, initial, or zero.
A 3-cap resource spent down to 1 returned 3.0 on the
first read after its daily boundary passed.
Previewing with the simulator
POST /api/v1/chrono/definitions/{id}/simulate replays synthetic events -
spend, refill, and read (record a snapshot) at second offsets -
against a stored definition and returns a value/bank trace. Any
authenticated user may simulate; nothing is persisted. Each event
materializes, so re-anchoring behaves exactly as in production - the
cliff and diminishing traces above were captured this way.
snug chrono definitions simulate --resource-id res_energy --timeline ./scenario.jsonscenario.json against the 10-cap, 0.5/s, banked-overflow energy
definition from the README:
{
"initial_value": 5,
"events": [
{ "at": 0, "spend": 4 },
{ "at": 10, "read": true },
{ "at": 20, "refill": 3 },
{ "at": 30, "read": true }
]
}Live trace - watch the regen between events and the overflow landing in the bank once the value passes the cap:
{
"resource_id": "res_energy",
"trace": [
{ "at": 0, "event": "spend:4", "value": 1.0, "bank_value": 0.0 },
{ "at": 10, "event": "read", "value": 6.0, "bank_value": 0.0 },
{ "at": 20, "event": "refill:3", "value": 10.0, "bank_value": 1.0 },
{ "at": 30, "event": "read", "value": 10.0, "bank_value": 5.0 }
]
}A half-life curve (exp_decay_to_cap, half_life_seconds: 60, cap 100)
closes half the remaining distance every 60 seconds - live trace of reads
at 60/120/240s from empty: 50.0, 75.0, 93.75.
Choosing a curve
- Client-visible meter with a countdown:
linear(keep the defaultfloorso users see whole units). - Cooldown:
linearon a cap-1 definition,rate_per_second = 1 / cooldown_seconds. - "Refills fast when drained":
exp_decay_to_cap. - N credits per window, touched rarely:
stepped. - Offline production:
idle_linear(addcliff_secondsfor vesting),idle_diminishingto soft-cap long absences,idle_cappedto hard-stop.
Exact request and trace shapes are in the Chrono API reference.