ServicesChrono

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 - fixed rate_per_second up to the cap. Energy bars, cooldowns.
  • exp_decay_to_cap - fast when empty, asymptotic near the cap; configured by half_life_seconds. Comeback mechanics, accrued interest.
  • stepped - discrete grants of amount every interval_seconds. API-credit refills, content drips.
  • idle_linear - offline accrual at a fixed rate, with an optional cliff_seconds gate before accrual starts (vesting-style).
  • idle_diminishing - accrual decayed by decay_factor_per_hour per 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:

  • stepped grants floor(elapsed / interval) - partial progress toward the next step is forfeited on every touch, so frequent polling starves the resource entirely. Against a +1 every 5s curve, polling every 2s for 8s stayed at 0.0; six quiet seconds then granted 1.0.
  • cliff_seconds restarts per window. A 60s cliff at 1/s read once at t=90s returned 30.0, but reading at t=30s first made the t=90s read return 0.0.
  • idle_diminishing decays per hour since the last touch, not since the user left. Two hours at 1/s with decay_factor_per_hour: 0.5 returned 5400.0 in a single read, but 7200.0 when 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.json

scenario.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 default floor so users see whole units).
  • Cooldown: linear on 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 (add cliff_seconds for vesting), idle_diminishing to soft-cap long absences, idle_capped to hard-stop.

Exact request and trace shapes are in the Chrono API reference.

On this page