Skip to content

reservations

reservations #

Per-user time reservations — spread a crowd across candidate capture times.

If every user is handed the same schedule they all capture at once, so the dataset oversamples one moment and undersamples the rest. Reservations steers each user toward a distinct candidate time, and makes that choice stable (a user polling repeatedly is handed the same time) and self-expiring (a reservation is released once its time is more than grace in the past, or once a capture lands within grace of it), so an abandoned or fulfilled slot frees up for someone else.

A reservation is just a time: the work of choosing which times are worth offering — void-and-cluster selection over solar angle, weather, and so on — happens before reserve and hands it a plain list of datetimes.

Reservations live in a Scope. Each instant is persisted as a tz-faithful ISO-8601 string; the candidate list is re-supplied on every call, so a held reservation re-snaps onto the freshest candidates and survives the list being regenerated between requests (cgsh.forecast.times yields different exact instants each call).

Example
book = cgsh.Reservations(state.scope("schedule"), grace=timedelta(minutes=30))
when = book.reserve(user_id, among=candidate_times, captured=last_capture)

Reservations #

Hands each user a distinct, stable, self-expiring time from a candidate set.

Construction claims scope: it is stamped with this class's persisted format version, and any state written in another shape — including by an unrelated consumer — is cleared and rebuilt rather than misread (ensure_format).

Parameters:

Name Type Description Default
scope Scope

Where reservations persist, isolated to this scheduler's use (e.g. state.scope("schedule")).

required
grace timedelta

How long a reservation outlives its time. A time more than grace in the past is released, and a reservation snaps onto a candidate only when one lies within grace of it.

timedelta(minutes=30)
Source code in capturegraph-lib/capturegraph/scheduling/organize/reservations.py
class Reservations:
    """Hands each user a distinct, stable, self-expiring time from a candidate set.

    Construction claims ``scope``: it is stamped with this class's persisted
    format version, and any state written in another shape — including by an
    unrelated consumer — is cleared and rebuilt rather than misread
    ([ensure_format][capturegraph.scheduling.organize.scope.ensure_format]).

    Args:
        scope: Where reservations persist, isolated to this scheduler's use
            (e.g. ``state.scope("schedule")``).
        grace: How long a reservation outlives its time. A time more than
            ``grace`` in the past is released, and a reservation snaps onto a
            candidate only when one lies within ``grace`` of it.
    """

    def __init__(
        self,
        scope: Scope,
        *,
        grace: timedelta = timedelta(minutes=30),
    ) -> None:
        """Claim ``scope``, resetting it if its stamped format has changed."""
        ensure_format(scope, _FORMAT)
        self._held = scope.scope("held")
        self._grace = grace

    def reserve(
        self,
        user_id: str,
        among: Iterable[datetime],
        *,
        captured: datetime | None = None,
        now: datetime | None = None,
    ) -> datetime | None:
        """The time this user holds, leasing a fresh distinct one if they hold none.

        Args:
            user_id: The user asking for a time.
            among: The current candidate times (regenerated per call is fine).
            captured: The target's most recent capture (by any user), if known.
                Every reservation at or before ``captured + grace`` is released
                first, whoever holds it: the capture just sampled that moment's
                conditions, so those slots are spent and their holders are
                steered to fresh times.
            now: The reference time; defaults to ``datetime.now()``. Pass it
                explicitly to make scheduling deterministic under test.

        Returns:
            The candidate this user holds (a member of ``among``), or ``None``
            when ``among`` is empty. When every candidate is already held the
            earliest is returned without recording a hold, so the answer in
            the oversubscribed case may drift between polls.
        """
        now = now if now is not None else datetime.now()
        held = self._prune(now=now, captured=captured)

        candidates = sorted(among)
        if not candidates:
            return None

        if user_id in held:
            snapped = self._snap(candidates, held[user_id])
            if snapped is not None:
                return snapped
            # The reservation drifted out of the candidate window — re-lease it.
            del self._held[user_id]
            held.pop(user_id, None)

        taken = {
            snapped
            for when in held.values()
            if (snapped := self._snap(candidates, when)) is not None
        }
        for candidate in candidates:
            if candidate not in taken:
                self._held[user_id] = candidate.isoformat()
                return candidate

        return candidates[0]

    def release(self, user_id: str) -> None:
        """Release a user's reservation, freeing their time immediately."""
        if user_id in self._held:
            del self._held[user_id]

    def reserved(self, now: datetime | None = None) -> dict[str, datetime]:
        """The live reservations as ``{user_id: reserved time}`` after pruning."""
        return self._prune(now=now if now is not None else datetime.now())

    def _prune(
        self,
        *,
        now: datetime,
        captured: datetime | None = None,
    ) -> dict[str, datetime]:
        """Drop reservations whose time has passed (or precedes a capture).

        Returns the survivors. Mutates the scope so released times stay freed.
        """
        threshold = now - self._grace
        if captured is not None:
            threshold = max(threshold, captured + self._grace)

        live: dict[str, datetime] = {}
        for user_id, value in self._held.items():
            if not isinstance(value, str):
                raise TypeError(
                    f"expected an ISO-8601 reservation in scope, got {type(value).__name__}"
                )
            when = datetime.fromisoformat(value)
            if when > threshold:
                live[user_id] = when
            else:
                del self._held[user_id]
        return live

    def _snap(self, candidates: Sequence[datetime], when: datetime) -> datetime | None:
        """The candidate nearest ``when``, or ``None`` if none lies within ``grace``."""
        nearest = min(candidates, key=lambda candidate: abs(candidate - when))
        return nearest if abs(nearest - when) <= self._grace else None

__init__(scope, *, grace=timedelta(minutes=30)) #

Claim scope, resetting it if its stamped format has changed.

Source code in capturegraph-lib/capturegraph/scheduling/organize/reservations.py
def __init__(
    self,
    scope: Scope,
    *,
    grace: timedelta = timedelta(minutes=30),
) -> None:
    """Claim ``scope``, resetting it if its stamped format has changed."""
    ensure_format(scope, _FORMAT)
    self._held = scope.scope("held")
    self._grace = grace

release(user_id) #

Release a user's reservation, freeing their time immediately.

Source code in capturegraph-lib/capturegraph/scheduling/organize/reservations.py
def release(self, user_id: str) -> None:
    """Release a user's reservation, freeing their time immediately."""
    if user_id in self._held:
        del self._held[user_id]

reserve(user_id, among, *, captured=None, now=None) #

The time this user holds, leasing a fresh distinct one if they hold none.

Parameters:

Name Type Description Default
user_id str

The user asking for a time.

required
among Iterable[datetime]

The current candidate times (regenerated per call is fine).

required
captured datetime | None

The target's most recent capture (by any user), if known. Every reservation at or before captured + grace is released first, whoever holds it: the capture just sampled that moment's conditions, so those slots are spent and their holders are steered to fresh times.

None
now datetime | None

The reference time; defaults to datetime.now(). Pass it explicitly to make scheduling deterministic under test.

None

Returns:

Type Description
datetime | None

The candidate this user holds (a member of among), or None

datetime | None

when among is empty. When every candidate is already held the

datetime | None

earliest is returned without recording a hold, so the answer in

datetime | None

the oversubscribed case may drift between polls.

Source code in capturegraph-lib/capturegraph/scheduling/organize/reservations.py
def reserve(
    self,
    user_id: str,
    among: Iterable[datetime],
    *,
    captured: datetime | None = None,
    now: datetime | None = None,
) -> datetime | None:
    """The time this user holds, leasing a fresh distinct one if they hold none.

    Args:
        user_id: The user asking for a time.
        among: The current candidate times (regenerated per call is fine).
        captured: The target's most recent capture (by any user), if known.
            Every reservation at or before ``captured + grace`` is released
            first, whoever holds it: the capture just sampled that moment's
            conditions, so those slots are spent and their holders are
            steered to fresh times.
        now: The reference time; defaults to ``datetime.now()``. Pass it
            explicitly to make scheduling deterministic under test.

    Returns:
        The candidate this user holds (a member of ``among``), or ``None``
        when ``among`` is empty. When every candidate is already held the
        earliest is returned without recording a hold, so the answer in
        the oversubscribed case may drift between polls.
    """
    now = now if now is not None else datetime.now()
    held = self._prune(now=now, captured=captured)

    candidates = sorted(among)
    if not candidates:
        return None

    if user_id in held:
        snapped = self._snap(candidates, held[user_id])
        if snapped is not None:
            return snapped
        # The reservation drifted out of the candidate window — re-lease it.
        del self._held[user_id]
        held.pop(user_id, None)

    taken = {
        snapped
        for when in held.values()
        if (snapped := self._snap(candidates, when)) is not None
    }
    for candidate in candidates:
        if candidate not in taken:
            self._held[user_id] = candidate.isoformat()
            return candidate

    return candidates[0]

reserved(now=None) #

The live reservations as {user_id: reserved time} after pruning.

Source code in capturegraph-lib/capturegraph/scheduling/organize/reservations.py
def reserved(self, now: datetime | None = None) -> dict[str, datetime]:
    """The live reservations as ``{user_id: reserved time}`` after pruning."""
    return self._prune(now=now if now is not None else datetime.now())