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,65 @@ 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+
153+ start_col : str
154+ end_col : str
155+ closed : str = "both"
156+ value_mapping : dict [Any , Any ] | Callable [[Any ], Any ] | None = None
157+
158+
159+ def _map_bound (value : Any , mapping : dict | Callable | None ) -> tuple [Any , bool ]:
160+ """Map a single interval request bound through ``value_mapping``.
161+
162+ Returns ``(mapped_value, ok)``. ``ok`` is False only for a dict mapping that lacks the key,
163+ signalling the caller not to push that bound's predicate (mirrors the ``FilterSpec`` dict-miss
164+ behavior of deferring rather than pushing an incomplete filter).
165+ """
166+ if mapping is None :
167+ return value , True
168+ if callable (mapping ):
169+ return mapping (value ), True
170+ if value in mapping :
171+ return mapping [value ], True
172+ return value , False
173+
174+
115175def _apply_value_mapping (values : set [Any ], mapping : dict | Callable | None ) -> tuple [set [Any ], set [Any ]]:
116176 """Transform filter values using the provided mapping.
117177
@@ -143,6 +203,76 @@ def _apply_value_mapping(values: set[Any], mapping: dict | Callable | None) -> t
143203 return mapped , unmapped
144204
145205
206+ # Endpoint comparison operators per ``closed`` mode. ``lower`` is the ``end_col >= lo`` side (left
207+ # endpoint of the request overlap); ``upper`` is the ``start_col <= hi`` side (right endpoint).
208+ _CLOSED_TO_LEFT = {"both" : portion .CLOSED , "left" : portion .OPEN , "right" : portion .CLOSED , "none" : portion .OPEN }
209+ _CLOSED_TO_RIGHT = {"both" : portion .CLOSED , "left" : portion .CLOSED , "right" : portion .OPEN , "none" : portion .OPEN }
210+
211+
212+ def _bound_expr (value : Any , source_col : str , source_dtype : pl .DataType , * , is_lower : bool , boundary ) -> pl .Expr | None :
213+ """Build a single-sided comparison predicate for an interval bound.
214+
215+ ``is_lower=True`` emits ``end_col >= / > value`` (a ``[value, +inf)`` / ``(value, +inf)`` interval);
216+ ``is_lower=False`` emits ``start_col <= / < value``. Date bounds compared against a ``Datetime``
217+ source column are widened to full-day datetime ranges via the same helper the ``FilterSpec`` path
218+ uses, so intraday rows on the boundary day are not silently dropped.
219+ """
220+ if is_lower :
221+ interval = portion .Interval .from_atomic (boundary , value , portion .inf , portion .OPEN )
222+ else :
223+ interval = portion .Interval .from_atomic (portion .OPEN , - portion .inf , value , boundary )
224+ if isinstance (source_dtype , pl .Datetime ):
225+ interval = _extend_dates_to_full_datetimes (interval )
226+ expr = _convert_interval_to_polars_expr (interval , source_col )
227+ # A one-sided finite interval is never empty/universe here, but stay defensive.
228+ return expr if isinstance (expr , pl .Expr ) else None
229+
230+
231+ def _apply_interval_filter (
232+ filtered_lf : pl .LazyFrame ,
233+ spec : IntervalFilterSpec ,
234+ date_range : portion .Interval ,
235+ source_schema : dict [str , pl .DataType ],
236+ ) -> pl .LazyFrame :
237+ """Apply an interval-overlap filter to a source LazyFrame.
238+
239+ Given the request range ``date_range`` extracted for the output column, push the overlap
240+ ``start_col <= hi AND end_col >= lo`` (adjusted for ``spec.closed``) onto the source, mapping the
241+ request bounds through ``spec.value_mapping`` first. The overlap is fully determined per source
242+ row, so the surviving rows are exactly the overlapping windows -- no post-combine membership
243+ predicate is needed.
244+ """
245+ if spec .start_col not in source_schema or spec .end_col not in source_schema :
246+ log .debug (f"Interval columns { spec .start_col !r} /{ spec .end_col !r} not both in source, skipping interval pushdown" )
247+ return filtered_lf
248+
249+ enclosure = date_range .enclosure
250+ lo , hi = enclosure .lower , enclosure .upper
251+
252+ conditions : list [pl .Expr ] = []
253+
254+ # end_col >= lo (drops expired/seed rows). Skipped for an unbounded lower request bound.
255+ if lo != - portion .inf :
256+ lo_mapped , ok = _map_bound (lo , spec .value_mapping )
257+ if ok :
258+ expr = _bound_expr (lo_mapped , spec .end_col , source_schema [spec .end_col ], is_lower = True , boundary = _CLOSED_TO_LEFT [spec .closed ])
259+ if expr is not None :
260+ conditions .append (expr )
261+
262+ # start_col <= hi (drops windows starting after the request). Skipped for an unbounded upper bound.
263+ if hi != portion .inf :
264+ hi_mapped , ok = _map_bound (hi , spec .value_mapping )
265+ if ok :
266+ expr = _bound_expr (hi_mapped , spec .start_col , source_schema [spec .start_col ], is_lower = False , boundary = _CLOSED_TO_RIGHT [spec .closed ])
267+ if expr is not None :
268+ conditions .append (expr )
269+
270+ for cond in conditions :
271+ filtered_lf = filtered_lf .filter (cond )
272+
273+ return filtered_lf
274+
275+
146276def _call_combine (
147277 combine : Callable [..., pl .LazyFrame ],
148278 filtered_sources : dict [str , pl .LazyFrame ],
@@ -170,7 +300,7 @@ def _call_combine(
170300
171301
172302def _compute_output_schema (
173- sources : dict [str , tuple [pl .LazyFrame , dict [str , FilterSpec ]]],
303+ sources : dict [str , tuple [pl .LazyFrame , dict [str , FilterSpec | IntervalFilterSpec ]]],
174304 combine : Callable [..., pl .LazyFrame ],
175305 combine_kwargs : dict [str , Any ] | None = None ,
176306 sources_as_kwargs : bool = False ,
@@ -182,7 +312,7 @@ def _compute_output_schema(
182312 actually processing any data.
183313
184314 Args:
185- sources (dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec]]]): The source specifications
315+ sources (dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec | IntervalFilterSpec ]]]): The source specifications
186316 combine (Callable[..., pl.LazyFrame]): The combine function
187317 combine_kwargs (dict[str, Any] | None): Additional keyword arguments to pass to combine
188318 sources_as_kwargs (bool): If True, pass sources as individual kwargs; if False, pass as a dict
@@ -205,7 +335,7 @@ def _is_temporal_dtype(dtype: pl.DataType) -> bool:
205335
206336
207337def pushdown_combine (
208- sources : dict [str , tuple [pl .LazyFrame , dict [str , FilterSpec ]]],
338+ sources : dict [str , tuple [pl .LazyFrame , dict [str , FilterSpec | IntervalFilterSpec ]]],
209339 combine : Callable [..., pl .LazyFrame ],
210340 * ,
211341 combine_kwargs : dict [str , Any ] | None = None ,
@@ -220,7 +350,7 @@ def pushdown_combine(
220350 the combine function is called.
221351
222352 Args:
223- sources (dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec]]]): Dictionary mapping source names to (LazyFrame, filter_specs) tuples.
353+ sources (dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec | IntervalFilterSpec ]]]): Dictionary mapping source names to (LazyFrame, filter_specs) tuples.
224354
225355 The filter_specs dict maps OUTPUT column names to FilterSpec objects
226356 that describe how to transform filters on that column for this source.
@@ -340,6 +470,19 @@ def combine_with_region_mapping(sources, region_to_code):
340470 combine=combine_with_region_mapping,
341471 combine_kwargs={"region_to_code": REGION_TO_CODE},
342472 )
473+
474+ Validity-interval source via :class:`IntervalFilterSpec`::
475+
476+ lf = pushdown_combine(
477+ sources={
478+ # window rows valid over [valid_from, valid_to]
479+ "schedule": (schedule_lf, {"date": IntervalFilterSpec(start_col="valid_from", end_col="valid_to")}),
480+ },
481+ combine=lambda s: s["schedule"],
482+ )
483+
484+ # Pushes the overlap start<=hi AND end>=lo to the source; only covering windows survive.
485+ result = lf.filter(pl.col("date").is_between(start, end)).collect()
343486 """
344487 # Compute output schema for the IO source.
345488 # Wrap in a lambda so that the schema (and the per-source `lf.collect_schema()`
@@ -408,6 +551,13 @@ def source_generator(
408551 empty_temporal_range = False
409552
410553 for output_col , spec in specs .items ():
554+ # Interval specs reference two source columns and push an overlap predicate; handle them
555+ # separately from the single-column FilterSpec path below.
556+ if isinstance (spec , IntervalFilterSpec ):
557+ if output_col in extracted_ranges :
558+ filtered_lf = _apply_interval_filter (filtered_lf , spec , extracted_ranges [output_col ], source_schema )
559+ continue
560+
411561 source_col = _get_source_col (output_col , spec )
412562
413563 # Skip if source column doesn't exist in this source
@@ -511,8 +661,17 @@ def source_generator(
511661 # rolling calculations, and handles any filters we couldn't push down.
512662 # Example: if user filters date == Jan 5 with 3-day lookback, the source fetched
513663 # Jan 2-5, combine ran (e.g., computed lag values), and now we filter to just Jan 5.
664+ #
665+ # An IntervalFilterSpec output column is a *virtual* request axis (e.g. "date") that maps to
666+ # source [start_col, end_col] and is not produced by combine, so it is absent from the output.
667+ # Its overlap was already resolved exactly at the source, so restrict the final predicate to
668+ # columns that actually exist in the output before applying it (a no-op for FilterSpec columns,
669+ # which combine does produce).
514670 if predicate is not None :
515- result_lf = result_lf .filter (predicate )
671+ output_cols = result_lf .collect_schema ().names ()
672+ restricted_predicate = restrict_expr_to_columns (predicate , output_cols )
673+ if restricted_predicate is not None :
674+ result_lf = result_lf .filter (restricted_predicate )
516675
517676 # Select requested columns
518677 if with_columns is not None :
0 commit comments