Source code for recon_gen.common.as_of_frame

"""The owned temporal frame (D1; BD-extended).

`AsOfFrame` is the single anchor both the generator and the views read in
place of wall-clock ``now()``. See `docs/audits/date_range_model_audit.md`
§5 ("step back — time is the unowned coordinate") and the AP.0 spike
result for the reasoning. Promoted from `tests/unit/test_ap0_as_of_frame.py`
by Phase AQ; BD ships the three-leg ``(as_of, window, seed)`` shape on
the same class (no `RunContext` rename — see `docs/audits/bd_0_asofframe_spike.md`).

Three fields:

  * ``as_of: date`` — the calendar day this run anchors to. The
    "right edge" of the system's temporal frame.
  * ``window: DateInterval`` — the closed-closed date range this run
    queries / plants into. NOT constrained to end at ``as_of`` (audit
    windows end yesterday; trainer windows span the operator's
    scenario; ``as_of`` is independent).
  * ``seed: int | None`` — RNG seed for deterministic outputs. None
    means "use the spine's internal default" (production live mode).

Four named constructors (the recommended call shapes):

  * ``AsOfFrame.locked(window_days=N)`` — demo/test binding. Anchor
    pinned at `LOCKED_ANCHOR`. ``window_days=N`` is an ergonomic
    shortcut that internally builds a closed-closed N-day window
    ending at the anchor.
  * ``AsOfFrame.live(window_days=N)`` — production binding. Anchor
    via ``date.today()`` (the single blessed wall-clock read in this
    codebase). Same ``window_days=N`` shortcut.
  * ``AsOfFrame.for_audit(today, *, lookback_days)`` — audit-CLI
    binding. Window = ``trailing_days_ending_yesterday(today,
    lookback_days)`` (today excluded, the audit-window convention).
  * ``AsOfFrame.for_test(*, window, seed, as_of=None)`` — test
    fixtures' explicit shape. ``seed`` required because tests are
    deterministic by construction; ``as_of`` defaults to
    ``window.end``.

What is intentionally NOT here: renderer-specific derivations (QS
`RollingDate` exprs, App2 sentinels, picker defaults). Those live on the
`View` primitive (Phase AR) which takes an `AsOfFrame` as its anchor; one
view object emits both QS and App2 bindings so the C1 dual-default split
becomes unrepresentable.

BD.0 D6 (locked 2026-05-25 per the no-compat-shims principle): the v1
``window_days: int`` field is GONE. Callers that read ``frame.window_days``
migrate to ``frame.window.days`` (closed-closed count) or
``frame.window.start`` (left edge — the dominant pattern, already
encapsulated by the ``window_start`` property which now derives from
``frame.window.start``). The ``window_days=N`` keyword arg stays on
``live()`` / ``locked()`` as a construction-time ergonomic shortcut —
construction-time ergonomics ≠ runtime escape hatch.

`LOCKED_ANCHOR` is the single source of truth for the demo anchor (AQ.3
funneled the value here; BD.6 retired the `cli/data.py::_CANONICAL_LOCK_ANCHOR`
alias kept for caller-compat). Every locked seed + every locked-binding
frame anchors here.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import date
from typing import Final

from recon_gen.common.intervals import DateInterval

#: The canonical demo anchor — every locked seed + every locked-binding
#: frame anchors here. Single source of truth post-BD.6
#: (`_CANONICAL_LOCK_ANCHOR` alias in `cli/data.py` retired).
LOCKED_ANCHOR: Final[date] = date(2030, 1, 1)


[docs] @dataclass(frozen=True) class AsOfFrame: """The owned temporal frame: anchor (as_of) + queried window + seed. Every temporal predicate the system reads — the generator's data-end day, a view's window bounds, "latest" semantics, RNG determinism — derives from this one object instead of wall-clock ``now()``, parallel `period: DateInterval` args, or scattered `seed: int` kwargs. The locked-vs-live split is a single binding choice rather than three independent encodings (the C1 release-blocker shape). """ as_of: date window: DateInterval seed: int | None = None # -- named-convention constructors --
[docs] @classmethod def locked( cls, *, window_days: int = 0, seed: int | None = None, ) -> "AsOfFrame": """Demo/test binding — anchor pinned at `LOCKED_ANCHOR`. ``window_days`` is an ergonomic shortcut: 0 means single-day, N>0 means a closed-closed N-day window ending at the anchor. (Internally builds the `DateInterval` — the FIELD on the resulting object is `window`, not the int.) """ window = ( DateInterval.single_day(LOCKED_ANCHOR) if window_days <= 0 else DateInterval.trailing_days_ending_today( LOCKED_ANCHOR, window_days + 1, ) ) return cls(as_of=LOCKED_ANCHOR, window=window, seed=seed)
[docs] @classmethod def live( cls, *, window_days: int = 0, seed: int | None = None, ) -> "AsOfFrame": """Production binding — anchor = today. Same derivations as `locked()`; only the bound anchor value differs (the §8 determinism story falls out of the frame for free). ``window_days`` ergonomic shortcut — see `locked` for the construction shape. """ today = date.today() # typing-smell: ignore[no-datetime-now]: AsOfFrame.live() is the SINGLE blessed wall-clock read — every other site reads frame.as_of; AQ.3 funnels the 4 ad-hoc date.today() fallbacks through this constructor window = ( DateInterval.single_day(today) if window_days <= 0 else DateInterval.trailing_days_ending_today( today, window_days + 1, ) ) return cls(as_of=today, window=window, seed=seed)
[docs] @classmethod def for_audit( cls, today: date, *, lookback_days: int, seed: int | None = None, ) -> "AsOfFrame": """Audit-CLI binding. Window = ``DateInterval.trailing_days_ending_yesterday(today, lookback_days)`` — N days ending yesterday, today EXCLUDED (the audit-window convention — "you can't audit a day that hasn't closed yet"). ``as_of = today`` (the day the audit runs). """ window = DateInterval.trailing_days_ending_yesterday( today, lookback_days, ) return cls(as_of=today, window=window, seed=seed)
[docs] @classmethod def for_test( cls, *, window: DateInterval, seed: int = 0, as_of: date | None = None, ) -> "AsOfFrame": """Test-fixture binding — explicit window + seed. ``seed`` defaults to ``0`` (a known-good deterministic seed); pass ``None`` to opt into the spine's internal default. ``as_of`` defaults to ``window.end`` (the most-recently-closed day in the window — matches the audit-window-end convention). """ anchor = as_of if as_of is not None else window.end return cls(as_of=anchor, window=window, seed=seed)
# -- queries / derived helpers -- @property def window_start(self) -> date: """The window's left edge — ``self.window.start``. Kept for backward compat with existing callers that read `frame.window_start` directly. New code prefers `frame.window.start`. """ return self.window.start
[docs] def contains(self, day: date) -> bool: """Is ``day`` inside ``self.window`` (closed-closed)? BD.1 — the semantic is now "in the window," not "in ``[window_start, as_of]``." For frames where ``window.end == as_of`` (the dominant pre-BD pattern), the two are equivalent; for audit frames where ``window`` ends yesterday and ``as_of`` is today, the new semantic is the right one — we're checking whether the day falls in the QUERIED range, not the wall-clock epoch. """ return self.window.contains(day)