"""The date-view tree primitive (D5).
A `DateView` is the single source of truth for a *subjective view-window*
— the analyst-facing "look at the latest day," "show me the last 30
days," "today's statement." It bundles the owned `AsOfFrame` (D1; the
anchor) with the view's own definition (empty-behavior + the
required-coverage contract). Promoted from the AP.1 spike's
`BalanceDateView` (`tests/unit/test_ap1_view_primitive.py`).
Why this exists — the C1 release-blocker shape: a subjective view today
is split across THREE independently-authored encodings — the analysis-
param default (`RollingDate`), the dataset-param default (`StaticValues`
sentinel), and the App2 binding. Each is authored in a different place,
and they can disagree (that disagreement IS C1: QS reads "yesterday off
wall-clock," App2 reads "latest day," the dataset KPI summary lands at a
day with no rows, the KPI tile reads `0`). The `DateView` inverts the
derivation: ONE typed object is the source of truth; every renderer
binding *derives* from it. AR.2 wires those emissions onto this
primitive; AR.1 lands the primitive itself.
What is intentionally NOT here (deferred to AR.2):
- `DateTimeParam.default` emission for QS analysis param defaults.
- Dataset `StaticValues` emission for QS dataset param defaults.
- `ParameterDateTimePicker` control emission for the picker widget.
- App2 `date_from` / `date_to` binding emission.
AR.1 is the *authoring abstraction* — what app authors construct. AR.2
adds the wired emissions that make the derivation inversion concrete.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from enum import Enum, auto
from recon_gen.common.as_of_frame import AsOfFrame
from recon_gen.common.models import (
DateTimeDatasetParameterDefaultValues,
DateTimeDefaultValues,
)
[docs]
class EmptyBehavior(Enum):
"""How a view resolves when its anchor day has no rows.
Captures the implicit precondition every QS view carries today
(audit §5 residual tension): a view "knows" what to do when its
declared anchor has no data, but that knowledge lives in
developer-memory. Making it explicit on the View object turns the
precondition into a property of the type.
"""
#: If `anchor_day` has no data, fall back to the latest day with
#: data ≤ anchor. The default for KPI-style "latest statement"
#: views — renders something useful instead of going blank.
LATEST_ON_EMPTY = auto()
#: Honor `anchor_day` literally even if it has no rows. The view
#: shows empty. Right for "as-of date is a hard precondition"
#: surfaces where blank IS the correct answer (e.g., a regulator
#: snapshot at an unsettled date).
SHOW_EMPTY = auto()
[docs]
@dataclass(frozen=True)
class DateView:
"""A typed view over a date range / single date, owning its own
definition (anchor, span, empty-behavior, required-coverage).
The renderer bindings (analysis-param default, dataset-param
default, picker widget, App2 binding) all *derive* from this one
object — AR.2's emission layer. ONE source of truth; the C1
dual-default split becomes unrepresentable.
The frame carries the anchor + window: `frame.as_of` is the
right-edge anchor, `frame.window` is the typed `DateInterval` the
view queries against (single-day when `frame.window.days == 1`;
rolling N-day when `frame.window.days > 1`). BD.1 replaced the v1
`frame.window_days: int` field with `frame.window: DateInterval`
so the "span > 0 means rolling" convention is now a closed-closed
days count, not a separate scalar.
Authoring abstraction — not end-user config. App authors construct
one of these per surface; the operator never picks one.
"""
frame: AsOfFrame
empty_behavior: EmptyBehavior = EmptyBehavior.LATEST_ON_EMPTY
@property
def anchor_day(self) -> date:
"""The single date the view points at — `frame.as_of`. For
single-date views this is the only day; for range views this
is the right edge."""
return self.frame.as_of
@property
def window_start(self) -> date:
"""The look-back's lower bound — `frame.window_start`. Equal
to `anchor_day` when the view is single-date (span=0)."""
return self.frame.window_start
@property
def required_coverage(self) -> tuple[date, date]:
"""The date range this view needs data inside to be
meaningful. For range views: `[window_start, anchor_day]`. For
single-date views with `LATEST_ON_EMPTY`: `[date.min, anchor]`
(any prior day will do). For single-date `SHOW_EMPTY`:
`[anchor, anchor]` (exact-match-or-blank is the contract).
The seed-coverage assertion in AR.3 calls
`is_satisfied_by(available_days)` to make the plant ⟷
query-window contract a test, not developer-memory.
"""
if self.frame.window.days > 1:
return (self.window_start, self.anchor_day)
if self.empty_behavior is EmptyBehavior.LATEST_ON_EMPTY:
return (date.min, self.anchor_day)
return (self.anchor_day, self.anchor_day)
[docs]
def is_satisfied_by(self, available_days: list[date]) -> bool:
"""Does at least one available day fall inside
`required_coverage`? The view's stated limit, checkable
BEFORE render."""
lo, hi = self.required_coverage
return any(lo <= d <= hi for d in available_days)
[docs]
def resolve_day(self, available_days: list[date]) -> date | None:
"""Apply `empty_behavior` to pick the day this view actually
renders.
Returns the `anchor_day` if it has data, else (for
`LATEST_ON_EMPTY`) the latest day ≤ anchor that does, else
`None` (the view can't satisfy itself — under `SHOW_EMPTY`
means "render blank for the anchor"; under `LATEST_ON_EMPTY`
means "no data anywhere ≤ anchor"). Range views resolve to
their right-edge day the same way.
"""
if self.empty_behavior is EmptyBehavior.SHOW_EMPTY:
return self.anchor_day
if self.anchor_day in available_days:
return self.anchor_day
earlier = [d for d in available_days if d <= self.anchor_day]
return max(earlier) if earlier else None
# ---- Renderer emissions (AR.2) ----------------------------------------
#
# ONE source — `anchor_day` — drives every renderer binding. The three
# emissions below return the SAME concrete day; QS's analysis default
# ⋈ MappedDataSetParameters bridge drops the same value into the
# dataset param, and App2 reads the same day from the dataset default.
# The C1 dual-default split becomes unrepresentable: there's nothing
# to keep in sync because there's only one source.
def _anchor_iso(self) -> str:
"""QS-shape datetime literal — `YYYY-MM-DDT00:00:00` (no offset).
Matches the existing dataset StaticValues serialization."""
return f"{self.anchor_day.isoformat()}T00:00:00"
[docs]
def emit_qs_analysis_default(self) -> DateTimeDefaultValues:
"""Alias for `emit_qs_analysis_default_end()` — kept for the
single-date case where the "default" is unambiguously the
anchor (the AR.2 balance-date wiring's caller name)."""
return self.emit_qs_analysis_default_end()
[docs]
def emit_qs_analysis_default_end(self) -> DateTimeDefaultValues:
"""The QS analysis-param default for the END of a range view (or
the single-date case) — a `StaticValues` literal day, NOT a
`RollingDate` expression. Strict-collapse: the anchor is the
owned `as_of`, baked at deploy."""
return DateTimeDefaultValues(StaticValues=[self._anchor_iso()])
[docs]
def emit_qs_analysis_default_start(self) -> DateTimeDefaultValues:
"""The QS analysis-param default for the START of a range view —
`window_start` as `StaticValues`. AR.4 wires this onto the L1
universal-range start param + the Exec 30-day start param,
replacing the per-app `RollingDate(addDateTime(-N, ...))`
expressions."""
start_iso = f"{self.window_start.isoformat()}T00:00:00"
return DateTimeDefaultValues(StaticValues=[start_iso])
[docs]
def emit_qs_dataset_default(self) -> DateTimeDatasetParameterDefaultValues:
"""The QS dataset-param default — the SAME literal day as the
analysis default. App2 reads this directly; QS receives it via
`MappedDataSetParameters` from the analysis side. Both renderers
land on the same day.
For single-date views (Daily Statement) this is unambiguously
the anchor. Range views use the start / end variants below;
this is kept as an alias of the END for symmetry with
``emit_qs_analysis_default``.
"""
return self.emit_qs_dataset_default_end()
[docs]
def emit_qs_dataset_default_end(self) -> DateTimeDatasetParameterDefaultValues:
"""Phase BM — dataset-param default for the END of a range view
(the anchor day). Mirrors ``emit_qs_analysis_default_end``."""
return DateTimeDatasetParameterDefaultValues(
StaticValues=[self._anchor_iso()],
)
[docs]
def emit_qs_dataset_default_start(self) -> DateTimeDatasetParameterDefaultValues:
"""Phase BM — dataset-param default for the START of a range view
(``window_start``). Mirrors ``emit_qs_analysis_default_start``;
used by L1 + Exec date-pushdown datasets so the BM-shape
``DateTimeDatasetParameter`` for the start picks up the same
7-day / 30-day window the analysis-level picker shows."""
start_iso = f"{self.window_start.isoformat()}T00:00:00"
return DateTimeDatasetParameterDefaultValues(StaticValues=[start_iso])
# Phase BM — ``emit_app2_date_from`` / ``emit_app2_date_to`` removed.
# Pre-BM they emitted ``YYYY-MM-DD`` bind values for App2's
# ``:date_from`` / ``:date_to`` bind layer (the BL.2 prepop). BM
# routes App2's date narrowing through the same
# ``DateTimeDatasetParameter.DefaultValues`` defaults QS uses, via
# the existing ``apply_dataset_param_defaults`` substitution path
# — so the App2-shape helpers became dead code.