3333import logging
3434from collections .abc import Callable , Iterator
3535from dataclasses import dataclass , field
36- from datetime import timedelta
36+ from datetime import date , datetime , time , timedelta
3737from typing import Any
3838
3939import polars as pl
4949 _promote_dates_to_datetimes ,
5050 convert_expr_to_datetime_range ,
5151)
52+ from .restrict_visitor import restrict_expr_to_columns
5253from .set_visitor import convert_expr_to_valid_values
5354from .util import collect_lf_in_io_source , register_io_source_with_is_pure
5455
55- __all__ = ("FilterSpec" , "pushdown_combine" )
56+ __all__ = ("FilterSpec" , "IntervalFilterSpec" , " pushdown_combine" )
5657
5758log = logging .getLogger (__name__ )
5859
@@ -112,6 +113,81 @@ class FilterSpec:
112113 value_mapping : dict [Any , Any ] | Callable [[Any ], Any ] | None = None
113114
114115
116+ @dataclass
117+ class IntervalFilterSpec :
118+ """
119+ Specification for pushing a filter into a validity-interval (history-of-change) source.
120+
121+ Where :class:`FilterSpec` maps one output column to one source column, this maps a single
122+ output (request-date) column to a pair of source columns ``[start_col, end_col]`` that
123+ describe the window over which each source row is valid. When the user filters the output
124+ column to a range ``[lo, hi]``, the correct predicate is an *overlap*::
125+
126+ start_col <= hi AND end_col >= lo # closed="both"
127+
128+ which cannot be expressed with a single-column ``FilterSpec``. This spec pushes that overlap
129+ to the source (so SQL/TickStore range-predicate pushdown is preserved) and, because the
130+ overlap is fully determined by each source row, resolves membership exactly at the source --
131+ consumers no longer re-implement ``start <= d <= end`` in their ``combine``.
132+
133+ Args:
134+ start_col (str): Source column holding the window's inclusive lower bound.
135+
136+ end_col (str): Source column holding the window's upper bound (see ``closed``).
137+
138+ closed (str): Interval endpoint semantics -- one of ``"both"``, ``"left"``, ``"right"``,
139+ ``"none"``. The half-open variants flip the boundary comparison at the exact endpoints:
140+
141+ - ``"both"`` (``[start, end]``): ``end >= lo`` and ``start <= hi``
142+ - ``"left"`` (``[start, end)``): ``end > lo`` and ``start <= hi``
143+ - ``"right"`` (``(start, end]``): ``end >= lo`` and ``start < hi``
144+ - ``"none"`` (``(start, end)``): ``end > lo`` and ``start < hi``
145+
146+ value_mapping (dict[Any, Any] | Callable[[Any], Any] | None): Transform the request
147+ bounds ``lo``/``hi`` before pushdown, exactly as :class:`FilterSpec` does for discrete
148+ values (e.g. a business-day offset applied to the request date). A callable is applied to
149+ each finite bound; a dict maps finite bounds present in it and leaves absent bounds
150+ unmapped (that bound's predicate is then not pushed). ``None`` passes bounds through.
151+
152+ Notes:
153+ The pushdown is **conservative** -- it may keep non-overlapping rows, but never drops an
154+ overlapping one. The request column is virtual (produced by another source in ``combine``,
155+ not by the interval source), so unlike :class:`FilterSpec` there is no post-combine filter on
156+ it to trim the surplus; exact per-row membership is the consumer's ``combine``. Two cases
157+ over-select at the source:
158+
159+ - **Disjoint requests** (e.g. ``date in [Jan1..Jan5] OR [Feb1..Feb5]``) collapse to their
160+ outer hull ``[Jan1, Feb5]``, so windows overlapping only the gap survive the pushdown.
161+ - **A dict ``value_mapping`` missing a bound** skips that side's predicate entirely, so that
162+ side is left unconstrained.
163+ """
164+
165+ start_col : str
166+ end_col : str
167+ closed : str = "both"
168+ value_mapping : dict [Any , Any ] | Callable [[Any ], Any ] | None = None
169+
170+ def __post_init__ (self ) -> None :
171+ if self .closed not in _CLOSED_MODES :
172+ raise ValueError (f"closed must be one of { sorted (_CLOSED_MODES )} , got { self .closed !r} " )
173+
174+
175+ def _map_bound (value : Any , mapping : dict | Callable | None ) -> tuple [Any , bool ]:
176+ """Map a single interval request bound through ``value_mapping``.
177+
178+ Returns ``(mapped_value, ok)``. ``ok`` is False only for a dict mapping that lacks the key,
179+ signalling the caller not to push that bound's predicate (mirrors the ``FilterSpec`` dict-miss
180+ behavior of deferring rather than pushing an incomplete filter).
181+ """
182+ if mapping is None :
183+ return value , True
184+ if callable (mapping ):
185+ return mapping (value ), True
186+ if value in mapping :
187+ return mapping [value ], True
188+ return value , False
189+
190+
115191def _apply_value_mapping (values : set [Any ], mapping : dict | Callable | None ) -> tuple [set [Any ], set [Any ]]:
116192 """Transform filter values using the provided mapping.
117193
@@ -143,6 +219,96 @@ def _apply_value_mapping(values: set[Any], mapping: dict | Callable | None) -> t
143219 return mapped , unmapped
144220
145221
222+ # Endpoint comparison operators per ``closed`` mode. ``lower`` is the ``end_col >= lo`` side (left
223+ # endpoint of the request overlap); ``upper`` is the ``start_col <= hi`` side (right endpoint).
224+ _CLOSED_TO_LEFT = {"both" : portion .CLOSED , "left" : portion .OPEN , "right" : portion .CLOSED , "none" : portion .OPEN }
225+ _CLOSED_TO_RIGHT = {"both" : portion .CLOSED , "left" : portion .CLOSED , "right" : portion .OPEN , "none" : portion .OPEN }
226+ _CLOSED_MODES = frozenset (_CLOSED_TO_LEFT )
227+
228+
229+ def _bound_expr (value : Any , source_col : str , source_dtype : pl .DataType , * , is_lower : bool , boundary ) -> pl .Expr | None :
230+ """Build a single-sided comparison predicate for an interval bound.
231+
232+ ``is_lower=True`` emits ``end_col >= / > value`` (closed / open ``boundary``); ``is_lower=False``
233+ emits ``start_col <= / < value``.
234+
235+ When ``value`` is a ``date`` (not ``datetime``) but the source column is ``Datetime``, the request
236+ is a whole *day*, so the comparison point is resolved to a datetime that keeps full-day overlap
237+ semantics -- independently of the endpoint open/closed-ness, which only governs the operator:
238+
239+ - lower side (``end_col`` vs the request-day start): compare against ``datetime(day, 00:00)``.
240+ End-closed (``both``/``right``) -> ``>=``; end-open (``left``/``none``) -> ``>`` so a window
241+ ending exactly at that midnight is dropped.
242+ - upper side (``start_col`` vs the request-day end): a window starting anywhere within the day
243+ overlaps it, so always compare ``start < datetime(day + 1, 00:00)`` regardless of ``boundary``.
244+
245+ Passing an open-boundary *date* interval through the day-granular widener would instead shift the
246+ value a full day (reading "open" as "strictly after day d"), silently dropping boundary-day rows.
247+ """
248+ is_date_on_datetime = isinstance (source_dtype , pl .Datetime ) and isinstance (value , date ) and not isinstance (value , datetime )
249+ if is_date_on_datetime :
250+ if is_lower :
251+ point = datetime .combine (value , time .min )
252+ interval = portion .Interval .from_atomic (boundary , point , portion .inf , portion .OPEN )
253+ else :
254+ # Full-day upper: start strictly before the day after ``value`` (endpoint closed-ness is
255+ # immaterial for a day-wide request).
256+ point = datetime .combine (value + timedelta (days = 1 ), time .min )
257+ interval = portion .Interval .from_atomic (portion .OPEN , - portion .inf , point , portion .OPEN )
258+ elif is_lower :
259+ interval = portion .Interval .from_atomic (boundary , value , portion .inf , portion .OPEN )
260+ else :
261+ interval = portion .Interval .from_atomic (portion .OPEN , - portion .inf , value , boundary )
262+ expr = _convert_interval_to_polars_expr (interval , source_col )
263+ # A one-sided finite interval is never empty/universe here, but stay defensive.
264+ return expr if isinstance (expr , pl .Expr ) else None
265+
266+
267+ def _apply_interval_filter (
268+ filtered_lf : pl .LazyFrame ,
269+ spec : IntervalFilterSpec ,
270+ date_range : portion .Interval ,
271+ source_schema : dict [str , pl .DataType ],
272+ ) -> pl .LazyFrame :
273+ """Apply an interval-overlap filter to a source LazyFrame.
274+
275+ Given the request range ``date_range`` extracted for the output column, push the overlap
276+ ``start_col <= hi AND end_col >= lo`` (adjusted for ``spec.closed``) onto the source, mapping the
277+ request bounds through ``spec.value_mapping`` first. The overlap is fully determined per source
278+ row, so the surviving rows are exactly the overlapping windows -- no post-combine membership
279+ predicate is needed.
280+ """
281+ if spec .start_col not in source_schema or spec .end_col not in source_schema :
282+ log .debug (f"Interval columns { spec .start_col !r} /{ spec .end_col !r} not both in source, skipping interval pushdown" )
283+ return filtered_lf
284+
285+ enclosure = date_range .enclosure
286+ lo , hi = enclosure .lower , enclosure .upper
287+
288+ conditions : list [pl .Expr ] = []
289+
290+ # end_col >= lo (drops expired/seed rows). Skipped for an unbounded lower request bound.
291+ if lo != - portion .inf :
292+ lo_mapped , ok = _map_bound (lo , spec .value_mapping )
293+ if ok :
294+ expr = _bound_expr (lo_mapped , spec .end_col , source_schema [spec .end_col ], is_lower = True , boundary = _CLOSED_TO_LEFT [spec .closed ])
295+ if expr is not None :
296+ conditions .append (expr )
297+
298+ # start_col <= hi (drops windows starting after the request). Skipped for an unbounded upper bound.
299+ if hi != portion .inf :
300+ hi_mapped , ok = _map_bound (hi , spec .value_mapping )
301+ if ok :
302+ expr = _bound_expr (hi_mapped , spec .start_col , source_schema [spec .start_col ], is_lower = False , boundary = _CLOSED_TO_RIGHT [spec .closed ])
303+ if expr is not None :
304+ conditions .append (expr )
305+
306+ for cond in conditions :
307+ filtered_lf = filtered_lf .filter (cond )
308+
309+ return filtered_lf
310+
311+
146312def _call_combine (
147313 combine : Callable [..., pl .LazyFrame ],
148314 filtered_sources : dict [str , pl .LazyFrame ],
@@ -170,7 +336,7 @@ def _call_combine(
170336
171337
172338def _compute_output_schema (
173- sources : dict [str , tuple [pl .LazyFrame , dict [str , FilterSpec ]]],
339+ sources : dict [str , tuple [pl .LazyFrame , dict [str , FilterSpec | IntervalFilterSpec ]]],
174340 combine : Callable [..., pl .LazyFrame ],
175341 combine_kwargs : dict [str , Any ] | None = None ,
176342 sources_as_kwargs : bool = False ,
@@ -182,7 +348,7 @@ def _compute_output_schema(
182348 actually processing any data.
183349
184350 Args:
185- sources (dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec]]]): The source specifications
351+ sources (dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec | IntervalFilterSpec ]]]): The source specifications
186352 combine (Callable[..., pl.LazyFrame]): The combine function
187353 combine_kwargs (dict[str, Any] | None): Additional keyword arguments to pass to combine
188354 sources_as_kwargs (bool): If True, pass sources as individual kwargs; if False, pass as a dict
@@ -205,7 +371,7 @@ def _is_temporal_dtype(dtype: pl.DataType) -> bool:
205371
206372
207373def pushdown_combine (
208- sources : dict [str , tuple [pl .LazyFrame , dict [str , FilterSpec ]]],
374+ sources : dict [str , tuple [pl .LazyFrame , dict [str , FilterSpec | IntervalFilterSpec ]]],
209375 combine : Callable [..., pl .LazyFrame ],
210376 * ,
211377 combine_kwargs : dict [str , Any ] | None = None ,
@@ -220,7 +386,7 @@ def pushdown_combine(
220386 the combine function is called.
221387
222388 Args:
223- sources (dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec]]]): Dictionary mapping source names to (LazyFrame, filter_specs) tuples.
389+ sources (dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec | IntervalFilterSpec ]]]): Dictionary mapping source names to (LazyFrame, filter_specs) tuples.
224390
225391 The filter_specs dict maps OUTPUT column names to FilterSpec objects
226392 that describe how to transform filters on that column for this source.
@@ -340,6 +506,19 @@ def combine_with_region_mapping(sources, region_to_code):
340506 combine=combine_with_region_mapping,
341507 combine_kwargs={"region_to_code": REGION_TO_CODE},
342508 )
509+
510+ Validity-interval source via :class:`IntervalFilterSpec`::
511+
512+ lf = pushdown_combine(
513+ sources={
514+ # window rows valid over [valid_from, valid_to]
515+ "schedule": (schedule_lf, {"date": IntervalFilterSpec(start_col="valid_from", end_col="valid_to")}),
516+ },
517+ combine=lambda s: s["schedule"],
518+ )
519+
520+ # Pushes the overlap start<=hi AND end>=lo to the source; only covering windows survive.
521+ result = lf.filter(pl.col("date").is_between(start, end)).collect()
343522 """
344523 # Compute output schema for the IO source.
345524 # Wrap in a lambda so that the schema (and the per-source `lf.collect_schema()`
@@ -408,6 +587,13 @@ def source_generator(
408587 empty_temporal_range = False
409588
410589 for output_col , spec in specs .items ():
590+ # Interval specs reference two source columns and push an overlap predicate; handle them
591+ # separately from the single-column FilterSpec path below.
592+ if isinstance (spec , IntervalFilterSpec ):
593+ if output_col in extracted_ranges :
594+ filtered_lf = _apply_interval_filter (filtered_lf , spec , extracted_ranges [output_col ], source_schema )
595+ continue
596+
411597 source_col = _get_source_col (output_col , spec )
412598
413599 # Skip if source column doesn't exist in this source
@@ -511,8 +697,17 @@ def source_generator(
511697 # rolling calculations, and handles any filters we couldn't push down.
512698 # Example: if user filters date == Jan 5 with 3-day lookback, the source fetched
513699 # Jan 2-5, combine ran (e.g., computed lag values), and now we filter to just Jan 5.
700+ #
701+ # An IntervalFilterSpec output column is a *virtual* request axis (e.g. "date") that maps to
702+ # source [start_col, end_col] and is not produced by combine, so it is absent from the output.
703+ # Its overlap was already resolved exactly at the source, so restrict the final predicate to
704+ # columns that actually exist in the output before applying it (a no-op for FilterSpec columns,
705+ # which combine does produce).
514706 if predicate is not None :
515- result_lf = result_lf .filter (predicate )
707+ output_cols = result_lf .collect_schema ().names ()
708+ restricted_predicate = restrict_expr_to_columns (predicate , output_cols )
709+ if restricted_predicate is not None :
710+ result_lf = result_lf .filter (restricted_predicate )
516711
517712 # Select requested columns
518713 if with_columns is not None :
0 commit comments