Skip to content

Commit e143a91

Browse files
authored
Merge pull request #40 from Point72/feat/interval-filter-spec
Add IntervalFilterSpec for validity-interval pushdown
2 parents 9218944 + 308e717 commit e143a91

3 files changed

Lines changed: 364 additions & 7 deletions

File tree

docs/wiki/API-Reference.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,15 @@ Describes how a filter on an output column maps to a source: rename to `source_c
320320
expand temporal ranges by `lookback`/`lookahead`, and remap values with `value_mapping` (a
321321
dict or callable).
322322

323+
### `IntervalFilterSpec`
324+
325+
```python
326+
IntervalFilterSpec(start_col, end_col, closed="both", value_mapping=None)
327+
```
328+
329+
Maps a filter on a request-date column onto a validity-interval source whose rows are valid
330+
over `[start_col, end_col]`. A range `[lo, hi]` pushes the overlap `start_col <= hi AND end_col >= lo` (adjusted for `closed`), remapping bounds with `value_mapping` first.
331+
323332
### `concat_named`
324333

325334
```python

polars_io_tools/io_sources/pushdown_combine.py

Lines changed: 202 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
import logging
3434
from collections.abc import Callable, Iterator
3535
from dataclasses import dataclass, field
36-
from datetime import timedelta
36+
from datetime import date, datetime, time, timedelta
3737
from typing import Any
3838

3939
import polars as pl
@@ -49,10 +49,11 @@
4949
_promote_dates_to_datetimes,
5050
convert_expr_to_datetime_range,
5151
)
52+
from .restrict_visitor import restrict_expr_to_columns
5253
from .set_visitor import convert_expr_to_valid_values
5354
from .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

5758
log = 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+
115191
def _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+
146312
def _call_combine(
147313
combine: Callable[..., pl.LazyFrame],
148314
filtered_sources: dict[str, pl.LazyFrame],
@@ -170,7 +336,7 @@ def _call_combine(
170336

171337

172338
def _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

207373
def 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

Comments
 (0)