Reservations#
ReservationBook
spreads a crowd across candidate capture times: it hands each user a distinct
time, keeps that choice stable (a user who polls repeatedly is handed the same
time), and self-expires it (a reservation is released once its time is past,
or once any capture lands within grace of it), so an abandoned or fulfilled
slot frees up for someone else.
A reservation is just a datetime. Choosing which times are worth offering —
Void & Cluster selection over solar angle, weather, and so on —
happens before the book and hands it a plain list of candidates.
Basic usage#
ReservationBook is ImpureState: it evolves a
persistent book of who holds which time, so each reserve call runs the whole
load-modify-commit under the state lock. The open key shards one independent
book per coordinated crowd (e.g. per target).
from datetime import timedelta
import capturegraph.scheduling as cgsh
when = cgsh.ReservationBook.open("my-target").reserve(
user_id,
candidate_times,
captured=last_capture,
grace=timedelta(minutes=30),
)
reserve returns the datetime this user holds — a member of the
candidate list, or None when it is empty. The same user is handed the same
time on every call until it expires.
Key features#
| Feature | Description |
|---|---|
| Distinct times | Each user gets a different candidate (falls back to the earliest once all are taken, without recording a hold). |
| Stable | A user polling repeatedly keeps their time, re-snapped onto the freshest candidates. |
| Self-expiring | A time more than grace in the past is released; captured= (the target's most recent capture, by any user) releases every hold within grace of it — that moment's conditions were just sampled, so its holders are steered to fresh times. |
The book persists in the recipe scratch's object store as the
ReservationBook
itself (a cg.Map of each user's held instant). The whole load-modify-commit is one
serialized, cross-process read-modify-write, so concurrent workers never hand
two users the same time. The candidate list is re-supplied every call, so a held
reservation survives the list being regenerated between requests.
Complete example#
This interceptor lives in a target's module on the server, which calls it
whenever a client asks for its next notification time. See
Targets and Interceptors for how interceptors are
declared, and
[notification_candidates][server.notifications.candidates.notification_candidates]
for the cached void-and-cluster selection it pairs with; the
walkthrough explains
every line.
from datetime import UTC, datetime, timedelta
import capturegraph as cg
import capturegraph.scheduling as cgsh
from server import authoring as cgserver
from server.notifications import hourly_bucket, notification_candidates
@cgserver.intercept(PlantJournal, lambda s: s._metadata._next_notification)
def notification(profile, target) -> cg.Time:
now = datetime.now(UTC)
location = target._metadata._location
if cg.is_missing(location):
return cg.Time(now + timedelta(hours=1))
candidates = notification_candidates(target.sessions, location, hourly_bucket(now))
chosen = cgsh.ReservationBook.open("plant-journal").reserve(
profile.user_id,
[candidate.value for candidate in candidates],
captured=max(target.sessions.to_dict(), default=None),
grace=timedelta(minutes=30),
now=now,
)
return cg.Time(chosen if chosen is not None else now + timedelta(hours=1))
Note
reserve returns None when the candidate list is empty, so guard
before wrapping it in cg.Time.
Coverage reservations#
For spatial campaigns,
CoverageBook
is the spatial sibling of ReservationBook: where that hands out times
released by the clock, this hands out places released by proximity. A
void-and-cluster pass computes a batch of well-spread target locations once (it
is expensive); the book hands them out one per user, stably, and every location
is in exactly one of three states:
reserve() capture within threshold
in batch ─────────────▶ held ─────────────────────────────▶ retired
▲ │ (the spot is covered)
│ lease lapses │
└─────────────────────┘ (nobody captured it)
A handout is a lease (lease=, 30 minutes by default): the user is handed
the same place on every request while it holds. A capture within threshold
retires the location for good — the spot is covered, and the next user is sent
somewhere still uncovered. A lease that lapses without a capture returns the
location to the batch so someone else can be sent there.
import capturegraph as cg
import capturegraph.scheduling as cgsh
from server import authoring as cgserver
BOUNDS = cg.Array[cg.Location](
[
cg.Location(latitude=42.4440, longitude=-76.5020),
cg.Location(latitude=42.4440, longitude=-76.5000),
cg.Location(latitude=42.4425, longitude=-76.5000),
cg.Location(latitude=42.4425, longitude=-76.5020),
]
)
DISTANCE = cgsh.distance.location(sigma_m=10.0)
@cg.pure_function("survey_batch/1")
def survey_batch(covered: cg.Array[cg.Location]) -> cg.Array[cg.Location]:
return cgsh.select_sessions(
potential_sessions=cgsh.forecast.locations_area(BOUNDS, resolution_meters=10.0),
previous_sessions=covered,
distance_fn=DISTANCE,
energy_fn=lambda d: -d,
energy_mode="max",
selections=8,
)
@cgserver.intercept(ParkSurvey, lambda s: s._metadata._location)
def next_spot(profile, target) -> cg.Location:
covered = cg.Array[cg.Location](
[
session._metadata._location
for session in target.sessions.values()
if not cg.is_missing(session._metadata._location)
]
)
chosen = cgsh.CoverageBook.open("park-survey").reserve(
profile.user_id,
at=profile.location,
fulfilled_by=list(covered),
distance=DISTANCE,
threshold=1.0,
refill=list(survey_batch(covered)),
)
if chosen is None:
raise LookupError("every survey point is covered")
return chosen
distance is the same metric used to select the batch, a distance between two
bare cg.Locations, and threshold is in the units it returns (1.0 is ten
metres under sigma_m=10.0). refill= seeds the batch (kept in the order
given) when the book is exhausted, folding the if exhausted: refill idiom
into the call; reserve(user_id, at=..., fulfilled_by=...) hands out the batch
location closest to the requester's location at, falling back to insertion
order (FIFO) when no location is shared, after first retiring any reservation
or batch location within threshold of a captured location in fulfilled_by,
so a spot the crowd has already covered is never offered. release(user_id)
drops a user's hold without recycling its location. The batch selection is a
pure function keyed on the covered locations, so it is recomputed exactly when a
new capture lands; the
walkthrough shows the
whole target.
See also#
- Void & Cluster Algorithm — Computing optimal capture times
- Distance Functions — Metrics for importance sampling