This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 164ac2e8ce [python] Support linear interpolation in temporal alignment 
(#9747)
164ac2e8ce is described below

commit 164ac2e8ce78bbd71cfc1e7f6ebe0c92c9642c27
Author: XiaoHongbo <[email protected]>
AuthorDate: Sat Sep 12 21:39:37 2026 +0800

    [python] Support linear interpolation in temporal alignment (#9747)
---
 docs/docs/pypaimon/multimodal-reading.md           |  13 +
 paimon-python/pypaimon/multimodal/__init__.py      |   6 +-
 paimon-python/pypaimon/multimodal/temporal.py      | 288 ++++++++++++++--
 .../pypaimon/tests/multimodal_temporal_test.py     | 368 ++++++++++++++++++++-
 4 files changed, 639 insertions(+), 36 deletions(-)

diff --git a/docs/docs/pypaimon/multimodal-reading.md 
b/docs/docs/pypaimon/multimodal-reading.md
index 2611154413..adb32b75b8 100644
--- a/docs/docs/pypaimon/multimodal-reading.md
+++ b/docs/docs/pypaimon/multimodal-reading.md
@@ -89,6 +89,19 @@ match delta.
 Inputs are snapshot-pinned (`resolved_snapshots`). Left rows stream, right join
 keys stay in memory, and BLOBs remain descriptors.
 
+Use `interpolate(left, right, ...)` directly or chain 
`aligned.interpolate(...)`.
+It supports integer or floating-point scalars and fixed-size lists. Exact
+timestamps use the exact right row; otherwise it requires surrounding rows
+in the same `by` group and never extrapolates. When set, `tolerance` must
+include both surrounding rows.
+
+```python
+states_at_steps = aligned.interpolate(
+    states.scan().select(["joint_position", "velocity"]),
+    tolerance=timedelta(milliseconds=50),
+)
+```
+
 ### Reading BLOB columns
 
 `scan().read_blobs(column)` bulk-fetches a BLOB column's bytes for the filtered
diff --git a/paimon-python/pypaimon/multimodal/__init__.py 
b/paimon-python/pypaimon/multimodal/__init__.py
index eec6b9afb6..1807ba4d6f 100644
--- a/paimon-python/pypaimon/multimodal/__init__.py
+++ b/paimon-python/pypaimon/multimodal/__init__.py
@@ -43,7 +43,8 @@ from pypaimon.multimodal.table import (
     vector_route,
 )
 from pypaimon.multimodal.temporal import (
-    AsOfJoin,
+    TemporalAlignment,
+    interpolate,
     join_asof,
 )
 from pypaimon.multimodal.video import VideoFrameCollator
@@ -55,7 +56,6 @@ from pypaimon.table.data_evolution_merge_into import (
 )
 
 __all__ = [
-    "AsOfJoin",
     "Blob",
     "BlobDescriptor",
     "BlobObject",
@@ -72,10 +72,12 @@ __all__ = [
     "RosbagSource",
     "RosbagStagingConfig",
     "TextRoute",
+    "TemporalAlignment",
     "VectorRoute",
     "VideoFrameCollator",
     "VideoFrameDescriptor",
     "connect",
+    "interpolate",
     "join_asof",
     "lit",
     "source_col",
diff --git a/paimon-python/pypaimon/multimodal/temporal.py 
b/paimon-python/pypaimon/multimodal/temporal.py
index 808e0fee53..43de0a8ed1 100644
--- a/paimon-python/pypaimon/multimodal/temporal.py
+++ b/paimon-python/pypaimon/multimodal/temporal.py
@@ -52,8 +52,29 @@ _TEMPORAL_ROW_GROUP_CACHE_MAX_SIZE = 64 * 1024 * 1024
 
 
 def join_asof(left, right, *, on, by, direction="backward", tolerance=None,
-              right_on=None, suffix="_right") -> "AsOfJoin":
+              right_on=None, suffix="_right") -> "TemporalAlignment":
     """Join each left row with at most one time-aligned right row."""
+    return TemporalAlignment(left, on=on, by=by).join_asof(
+        right,
+        direction=direction,
+        tolerance=tolerance,
+        right_on=right_on,
+        suffix=suffix,
+    )
+
+
+def interpolate(left, right, *, on, by, tolerance=None,
+                right_on=None, suffix="_right") -> "TemporalAlignment":
+    """Linearly interpolate numeric right values at each left timestamp."""
+    return TemporalAlignment(left, on=on, by=by).interpolate(
+        right,
+        tolerance=tolerance,
+        right_on=right_on,
+        suffix=suffix,
+    )
+
+
+def _normalize_temporal_keys(on, by):
     if not isinstance(on, str) or not on:
         raise ValueError("on must be a non-empty column name.")
     if isinstance(by, str):
@@ -66,23 +87,18 @@ def join_asof(left, right, *, on, by, direction="backward", 
tolerance=None,
                 "by must be a column name or sequence.") from error
     if not by:
         raise ValueError(
-            "join_asof requires at least one grouping column in by.")
+            "Temporal alignment requires a grouping column in by.")
     if (any(not isinstance(name, str) or not name for name in by)
             or len(set(by)) != len(by)):
         raise ValueError("by must contain unique, non-empty column names.")
-    return AsOfJoin(left, on, by).join_asof(
-        right,
-        direction=direction,
-        tolerance=tolerance,
-        right_on=right_on,
-        suffix=suffix,
-    )
+    return on, by
 
 
-class AsOfJoin:
-    """Lazy, chainable result of :func:`join_asof`."""
+class TemporalAlignment:
+    """Lazy, chainable alignment of table scans by time."""
 
-    def __init__(self, left, on, by):
+    def __init__(self, left, *, on, by):
+        on, by = _normalize_temporal_keys(on, by)
         self._anchor = _pin_scan_to_snapshot(_require_scan(left, "left"))
         self._on = on
         self._by = by
@@ -93,7 +109,7 @@ class AsOfJoin:
         self.schema = self._output_schema()
 
     def join_asof(self, right, *, direction="backward", tolerance=None,
-                  right_on=None, suffix="_right") -> "AsOfJoin":
+                  right_on=None, suffix="_right") -> "TemporalAlignment":
         """Append a right-side as-of join without materializing this scan."""
         position = len(self._sources) + 1
         label = "right source %d" % position
@@ -107,8 +123,25 @@ class AsOfJoin:
             right_on,
             suffix,
         )
+        return self._append(source)
+
+    def interpolate(self, right, *, tolerance=None, right_on=None,
+                    suffix="_right") -> "TemporalAlignment":
+        """Append linear interpolation of numeric right-side values."""
+        position = len(self._sources) + 1
+        source = _LinearInterpolationRight(
+            "right source %d" % position,
+            right,
+            self._on,
+            self._by,
+            tolerance,
+            right_on,
+            suffix,
+        )
+        return self._append(source)
 
-        result = object.__new__(AsOfJoin)
+    def _append(self, source):
+        result = object.__new__(TemporalAlignment)
         result._anchor = self._anchor
         result._on = self._on
         result._by = self._by
@@ -218,7 +251,10 @@ class AsOfJoin:
                 else source_fetchers[position].schema
             )
             for name in source.payload_schema.names:
-                field = payload_schema.field(name)
+                field = source.output_field(
+                    payload_schema.field(name),
+                    effective=source_fetchers is not None,
+                )
                 output_name = field.name
                 if output_name in names:
                     output_name += source.suffix
@@ -242,21 +278,7 @@ class AsOfJoin:
         arrays = [anchor[name] for name in self._anchor_schema.names]
 
         for source, fetcher in zip(self._sources, source_fetchers):
-            matches = [source.match(row) for row in anchor_rows]
-            matched_ids = [match for match in matches if match is not None]
-            unique_ids = list(dict.fromkeys(matched_ids))
-            values = fetcher.fetch(unique_ids)
-            positions = {
-                row_id: index for index, row_id in enumerate(unique_ids)
-            }
-            take = pa.array([
-                None if match is None else positions[match]
-                for match in matches
-            ], type=pa.int64())
-            for field in source.payload_schema:
-                array = pc.take(values[field.name], take)
-                array.validate()
-                arrays.append(array)
+            arrays.extend(source.build_arrays(anchor_rows, fetcher))
 
         if arrays:
             table = pa.Table.from_arrays(
@@ -347,6 +369,120 @@ class _AsOfJoinRight:
             return None
         return self._row_ids[index].as_py()
 
+    @staticmethod
+    def output_field(field, effective=True):
+        return field
+
+    def build_arrays(self, anchor_rows, fetcher):
+        matches = [self.match(row) for row in anchor_rows]
+        matched_ids = [match for match in matches if match is not None]
+        unique_ids = list(dict.fromkeys(matched_ids))
+        values = fetcher.fetch(unique_ids)
+        positions = {
+            row_id: index for index, row_id in enumerate(unique_ids)
+        }
+        take = pa.array([
+            None if match is None else positions[match]
+            for match in matches
+        ], type=pa.int64())
+        arrays = []
+        for field in self.payload_schema:
+            array = pc.take(values[field.name], take)
+            array.validate()
+            arrays.append(array)
+        return arrays
+
+
+class _LinearInterpolationRight(_AsOfJoinRight):
+
+    def __init__(self, label, query, anchor_on, by, tolerance,
+                 right_on, suffix):
+        super().__init__(
+            label, query, anchor_on, by, "nearest", tolerance,
+            right_on, suffix)
+
+    @staticmethod
+    def output_field(field, effective=True):
+        try:
+            output_type = _linear_output_type(field.type)
+        except TypeError:
+            if effective:
+                raise
+            output_type = field.type
+        return pa.field(
+            field.name, output_type, nullable=True,
+            metadata=field.metadata)
+
+    def match(self, anchor_row):
+        key = tuple(anchor_row[name] for name in self.by)
+        bounds = self._index.get(key)
+        if bounds is None:
+            return None
+        start, end = bounds
+        target = anchor_row[_TIME_KEY]
+        position = bisect_left(self._time_keys, target, start, end)
+        if position < end and self._time_keys[position] == target:
+            exact = bisect_right(
+                self._time_keys, target, position, end) - 1
+            row_id = self._row_ids[exact].as_py()
+            return row_id, row_id, 0.0, 0, 1
+        if position == start or position == end:
+            return None
+
+        before = position - 1
+        after = position
+        before_time = _python_scalar(self._time_keys[before])
+        after_time = _python_scalar(self._time_keys[after])
+        if (self._tolerance_key is not None
+                and max(target - before_time, after_time - target)
+                > self._tolerance_key):
+            return None
+        return (
+            self._row_ids[before].as_py(),
+            self._row_ids[after].as_py(),
+            *_linear_weight(target, before_time, after_time, self.time_type),
+        )
+
+    def build_arrays(self, anchor_rows, fetcher):
+        matches = [self.match(row) for row in anchor_rows]
+        matched_ids = []
+        for match in matches:
+            if match is not None:
+                matched_ids.extend(match[:2])
+        unique_ids = list(dict.fromkeys(matched_ids))
+        values = fetcher.fetch(unique_ids)
+        positions = {
+            row_id: index for index, row_id in enumerate(unique_ids)
+        }
+        before = pa.array([
+            None if match is None else positions[match[0]]
+            for match in matches
+        ], type=pa.int64())
+        after = pa.array([
+            None if match is None else positions[match[1]]
+            for match in matches
+        ], type=pa.int64())
+        weights = pa.array([
+            None if match is None else match[2]
+            for match in matches
+        ], type=pa.float64())
+        ratios = [
+            None if match is None else match[3:5]
+            for match in matches
+        ]
+
+        arrays = []
+        for field in self.payload_schema:
+            array = _interpolate_array(
+                pc.take(values[field.name], before),
+                pc.take(values[field.name], after),
+                weights,
+                ratios,
+            )
+            array.validate()
+            arrays.append(array)
+        return arrays
+
 
 def _validate_join_options(direction, tolerance, right_on, suffix):
     if direction not in ("backward", "forward", "nearest"):
@@ -370,6 +506,93 @@ def _validate_join_options(direction, tolerance, right_on, 
suffix):
             raise ValueError("tolerance must be non-negative.")
 
 
+def _linear_output_type(data_type):
+    if pa.types.is_integer(data_type):
+        return pa.float64()
+    if pa.types.is_floating(data_type):
+        return data_type
+    if pa.types.is_fixed_size_list(data_type):
+        return pa.list_(
+            _linear_output_type(data_type.value_type), data_type.list_size)
+    raise TypeError(
+        "Linear interpolation requires integer or floating-point scalars "
+        "or fixed-size lists; got %s." % data_type)
+
+
+def _linear_weight(target, before, after, data_type):
+    if pa.types.is_integer(data_type) or pa.types.is_timestamp(data_type):
+        numerator = target - before
+        denominator = after - before
+        return numerator / denominator, numerator, denominator
+    if pa.types.is_floating(data_type):
+        ratios = [float(value).as_integer_ratio()
+                  for value in (target, before, after)]
+        common_denominator = max(
+            denominator for unused, denominator in ratios)
+        target, before, after = [
+            numerator * (common_denominator // denominator)
+            for numerator, denominator in ratios
+        ]
+        numerator = target - before
+        denominator = after - before
+        weight = numerator / denominator
+        return weight, numerator, denominator
+    weight = float(target - before) / (after - before)
+    numerator, denominator = weight.as_integer_ratio()
+    return weight, numerator, denominator
+
+
+def _interpolate_array(before, after, weights, ratios):
+    if isinstance(before, pa.ChunkedArray):
+        before = before.combine_chunks()
+    if isinstance(after, pa.ChunkedArray):
+        after = after.combine_chunks()
+    data_type = before.type
+    output_type = _linear_output_type(data_type)
+    if pa.types.is_fixed_size_list(data_type):
+        size = data_type.list_size
+        repeated = pa.array([
+            weight for weight in weights.to_pylist() for unused in range(size)
+        ], type=pa.float64())
+        repeated_ratios = [
+            ratio for ratio in ratios for unused in range(size)
+        ]
+        values = _interpolate_array(
+            before.values.slice(before.offset * size, len(before) * size),
+            after.values.slice(after.offset * size, len(after) * size),
+            repeated,
+            repeated_ratios,
+        )
+        mask = pc.or_(before.is_null(), after.is_null())
+        result = pa.FixedSizeListArray.from_arrays(values, size)
+        return pc.if_else(mask, pa.scalar(None, type=result.type), result)
+
+    if pa.types.is_integer(data_type):
+        result = []
+        for start, end, ratio in zip(
+                before.to_pylist(), after.to_pylist(), ratios):
+            if start is None or end is None or ratio is None:
+                result.append(None)
+                continue
+            numerator, denominator = ratio
+            result.append((
+                start * (denominator - numerator) + end * numerator
+            ) / denominator)
+        return pa.array(result, type=pa.float64())
+
+    start = pc.cast(before, pa.float64())
+    end = pc.cast(after, pa.float64())
+    result = pc.add(
+        pc.multiply(start, pc.subtract(1.0, weights)),
+        pc.multiply(end, weights),
+    )
+    result = pc.if_else(pc.equal(start, end), start, result)
+    result = pc.if_else(pc.equal(weights, 0.0), start, result)
+    if result.type != output_type:
+        result = pc.cast(result, output_type)
+    return result
+
+
 def _require_scan(query, label):
     if (type(query) is not ScanQuery
             or getattr(query, "_result_factory", None) is not None):
@@ -382,12 +605,13 @@ def _pin_scan_to_snapshot(query):
     options = table.options
     if not options.row_tracking_enabled(False):
         raise ValueError(
-            "join_asof requires 'row-tracking.enabled' = 'true'.")
+            "Temporal alignment requires 'row-tracking.enabled' = 'true'.")
     if (options.scan_mode() == StartupMode.INCREMENTAL
             or options.options.contains(
                 CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP)):
         raise ValueError(
-            "join_asof does not support incremental scans; inputs must "
+            "Temporal alignment does not support incremental scans; "
+            "inputs must "
             "represent a complete point-in-time snapshot.")
     # Validate the original scan configuration before replacing it with a
     # pinned snapshot. Otherwise an invalid or unsupported scan mode can be
diff --git a/paimon-python/pypaimon/tests/multimodal_temporal_test.py 
b/paimon-python/pypaimon/tests/multimodal_temporal_test.py
index 2cb0fe7c40..174352bf42 100644
--- a/paimon-python/pypaimon/tests/multimodal_temporal_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_temporal_test.py
@@ -17,6 +17,7 @@
 import json
 import os
 import shutil
+import sys
 import tempfile
 import unittest
 from datetime import datetime, timedelta
@@ -148,6 +149,369 @@ class MultimodalTemporalTest(unittest.TestCase):
             {row["event_time"]: row["value"] for row in rows},
         )
 
+    def test_linear_interpolation_stays_in_group_without_extrapolation(self):
+        anchors = self._table("linear_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+        })
+        states = self._table("linear_states", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+            "value": pa.int32(),
+        })
+        anchors.add([
+            {"episode_id": 1, "event_time": 5},
+            {"episode_id": 1, "event_time": 10},
+            {"episode_id": 1, "event_time": 20},
+            {"episode_id": 2, "event_time": 5},
+        ])
+        states.add([
+            {"episode_id": 1, "event_time": 0, "value": 0},
+            {"episode_id": 1, "event_time": 10, "value": 20},
+            {"episode_id": 1, "event_time": 10, "value": 30},
+            {"episode_id": 2, "event_time": 0, "value": 100},
+            {"episode_id": 2, "event_time": 10, "value": 120},
+        ])
+
+        result = pmm.interpolate(
+            anchors.scan(), states.scan().select("value"),
+            on="event_time", by="episode_id", tolerance=5,
+        )
+        rows = sorted(
+            result.to_list(),
+            key=lambda row: (row["episode_id"], row["event_time"]),
+        )
+
+        self.assertIsInstance(result, pmm.TemporalAlignment)
+        self.assertEqual(pa.float64(), result.schema.field("value").type)
+        self.assertEqual([10.0, 30.0, None, 110.0], [
+            row["value"] for row in rows
+        ])
+
+    def test_linear_interpolation_preserves_an_exact_infinite_float(self):
+        anchors = self._table("linear_exact_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+        })
+        states = self._table("linear_exact_states", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+            "value": pa.float64(),
+        })
+        anchors.add([{"episode_id": 1, "event_time": 10}])
+        states.add([{
+            "episode_id": 1, "event_time": 10, "value": float("inf")
+        }])
+
+        row = pmm.interpolate(
+            anchors.scan(), states.scan().select("value"),
+            on="event_time", by="episode_id",
+        ).to_list()[0]
+
+        self.assertEqual(float("inf"), row["value"])
+
+    def test_linear_interpolation_requires_both_neighbors_in_tolerance(self):
+        anchors = self._table("linear_tolerance_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+        })
+        states = self._table("linear_tolerance_states", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+            "value": pa.float64(),
+        })
+        anchors.add([{"episode_id": 1, "event_time": 9}])
+        states.add([
+            {"episode_id": 1, "event_time": 0, "value": 0.0},
+            {"episode_id": 1, "event_time": 10, "value": 10.0},
+        ])
+
+        row = pmm.interpolate(
+            anchors.scan(), states.scan().select("value"),
+            on="event_time", by="episode_id", tolerance=8,
+        ).to_list()[0]
+
+        self.assertIsNone(row["value"])
+
+    def test_linear_interpolation_supports_fixed_size_numeric_lists(self):
+        vector = pa.list_(pa.float32(), 2)
+        anchors = self._table("linear_vector_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+        })
+        states = self._table("linear_vector_states", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+            "state": vector,
+        })
+        anchors.add([{"episode_id": 1, "event_time": 5}])
+        states.add([
+            {"episode_id": 1, "event_time": 0, "state": [0.0, 10.0]},
+            {"episode_id": 1, "event_time": 10, "state": [10.0, 20.0]},
+        ])
+
+        result = pmm.interpolate(
+            anchors.scan(), states.scan().select("state"),
+            on="event_time", by="episode_id",
+        )
+
+        self.assertEqual(vector, result.schema.field("state").type)
+        self.assertEqual([5.0, 15.0], result.to_list()[0]["state"])
+
+    def test_linear_interpolation_preserves_bigint_precision(self):
+        anchors = self._table("linear_bigint_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+        })
+        states = self._table("linear_bigint_states", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+            "value": pa.int64(),
+            "extreme": pa.int64(),
+        })
+        anchors.add([
+            {"episode_id": 1, "event_time": 1},
+            {"episode_id": 1, "event_time": 2},
+        ])
+        states.add([
+            {"episode_id": 1, "event_time": 0,
+             "value": (1 << 53) + 1, "extreme": -(1 << 63)},
+            {"episode_id": 1, "event_time": 4,
+             "value": (1 << 53) + 3, "extreme": (1 << 63) - 1},
+        ])
+
+        rows = pmm.interpolate(
+            anchors.scan(), states.scan().select(["value", "extreme"]),
+            on="event_time", by="episode_id",
+        ).to_list()
+
+        by_time = {row["event_time"]: row for row in rows}
+        self.assertEqual(float((1 << 53) + 2), by_time[1]["value"])
+        self.assertEqual(-0.5, by_time[2]["extreme"])
+
+    def test_linear_interpolation_scales_extreme_float_time_axis(self):
+        anchors = self._table("linear_extreme_time_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.float64(),
+        })
+        states = self._table("linear_extreme_time_states", {
+            "episode_id": pa.int32(),
+            "event_time": pa.float64(),
+            "value": pa.float64(),
+        })
+        anchors.add([{"episode_id": 1, "event_time": 0.0}])
+        states.add([
+            {"episode_id": 1, "event_time": -sys.float_info.max,
+             "value": 0.0},
+            {"episode_id": 1, "event_time": sys.float_info.max,
+             "value": 10.0},
+        ])
+
+        row = pmm.interpolate(
+            anchors.scan(), states.scan().select("value"),
+            on="event_time", by="episode_id",
+        ).to_list()[0]
+
+        self.assertEqual(5.0, row["value"])
+
+    def test_linear_interpolation_preserves_float_time_differences(self):
+        anchors = self._table("linear_nearby_time_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.float64(),
+        })
+        states = self._table("linear_nearby_time_states", {
+            "episode_id": pa.int32(),
+            "event_time": pa.float64(),
+            "value": pa.float64(),
+        })
+        base = 1_700_000_000.0
+        step = 2 ** -21
+        anchors.add([{"episode_id": 1, "event_time": base + step}])
+        states.add([
+            {"episode_id": 1, "event_time": base, "value": 0.0},
+            {"episode_id": 1, "event_time": base + 2 * step,
+             "value": 10.0},
+        ])
+
+        row = pmm.interpolate(
+            anchors.scan(), states.scan().select("value"),
+            on="event_time", by="episode_id",
+        ).to_list()[0]
+
+        self.assertEqual(5.0, row["value"])
+
+    def test_float_time_preserves_bigint_interpolation_precision(self):
+        anchors = self._table("linear_float_bigint_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.float64(),
+        })
+        states = self._table("linear_float_bigint_states", {
+            "episode_id": pa.int32(),
+            "event_time": pa.float64(),
+            "value": pa.int64(),
+        })
+        anchors.add([{"episode_id": 1, "event_time": 2.0}])
+        states.add([
+            {"episode_id": 1, "event_time": 0.0,
+             "value": -(1 << 63)},
+            {"episode_id": 1, "event_time": 3.0,
+             "value": 1 << 62},
+        ])
+
+        row = pmm.interpolate(
+            anchors.scan(), states.scan().select("value"),
+            on="event_time", by="episode_id",
+        ).to_list()[0]
+
+        self.assertEqual(0.0, row["value"])
+
+    def test_linear_interpolation_handles_extreme_float_payloads(self):
+        anchors = self._table("linear_extreme_value_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+        })
+        states = self._table("linear_extreme_value_states", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+            "value": pa.float64(),
+            "infinite": pa.float64(),
+        })
+        anchors.add([{"episode_id": 1, "event_time": 5}])
+        states.add([
+            {"episode_id": 1, "event_time": 0,
+             "value": -sys.float_info.max, "infinite": float("inf")},
+            {"episode_id": 1, "event_time": 10,
+             "value": sys.float_info.max, "infinite": float("inf")},
+        ])
+
+        row = pmm.interpolate(
+            anchors.scan(), states.scan().select(["value", "infinite"]),
+            on="event_time", by="episode_id",
+        ).to_list()[0]
+
+        self.assertEqual(0.0, row["value"])
+        self.assertEqual(float("inf"), row["infinite"])
+
+    def test_linear_interpolation_uses_effective_masked_payload_type(self):
+        anchors = self._table("linear_masked_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+        })
+        states = self._table("linear_masked_states", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+            "value": pa.string(),
+        })
+        anchors.add([{"episode_id": 1, "event_time": 5}])
+        states.add([
+            {"episode_id": 1, "event_time": 0, "value": "0"},
+            {"episode_id": 1, "event_time": 10, "value": "10"},
+        ])
+        auth = TableQueryAuthResult(
+            filter=None,
+            column_masking={"value": json.dumps({
+                "name": "CAST",
+                "fieldRef": {
+                    "index": 2, "name": "value", "type": "STRING",
+                },
+                "type": "DOUBLE",
+            })},
+        )
+        states.raw_table.catalog_environment.table_query_auth = (
+            lambda options, identifier: lambda select: auth)
+
+        row = pmm.interpolate(
+            anchors.scan(), states.scan().select("value"),
+            on="event_time", by="episode_id",
+        ).to_list()[0]
+
+        self.assertEqual(5.0, row["value"])
+
+    def test_linear_interpolation_rejects_masked_non_numeric_payload(self):
+        anchors = self._table("linear_invalid_mask_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+        })
+        states = self._table("linear_invalid_mask_states", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+            "value": pa.int32(),
+        })
+        anchors.add([{"episode_id": 1, "event_time": 5}])
+        states.add([
+            {"episode_id": 1, "event_time": 0, "value": 0},
+            {"episode_id": 1, "event_time": 10, "value": 10},
+        ])
+        auth = TableQueryAuthResult(
+            filter=None,
+            column_masking={"value": json.dumps({
+                "name": "CAST",
+                "fieldRef": {
+                    "index": 2, "name": "value", "type": "INT",
+                },
+                "type": "STRING",
+            })},
+        )
+        states.raw_table.catalog_environment.table_query_auth = (
+            lambda options, identifier: lambda select: auth)
+
+        aligned = pmm.interpolate(
+            anchors.scan(), states.scan().select("value"),
+            on="event_time", by="episode_id",
+        )
+        with self.assertRaisesRegex(TypeError, "requires integer or floating"):
+            aligned.to_arrow()
+
+    def test_linear_interpolation_rejects_decimal_payloads(self):
+        anchors = self._table("linear_invalid_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+        })
+        decimals = self._table("linear_invalid_decimals", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+            "value": pa.decimal128(10, 2),
+        })
+
+        with self.assertRaisesRegex(TypeError, "requires integer or floating"):
+            pmm.interpolate(
+                anchors.scan(), decimals.scan().select("value"),
+                on="event_time", by="episode_id",
+            ).to_arrow()
+
+    def test_linear_interpolation_can_follow_an_asof_join(self):
+        anchors = self._table("linear_chain_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+        })
+        images = self._table("linear_chain_images", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+            "image": pa.string(),
+        })
+        states = self._table("linear_chain_states", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+            "state": pa.float32(),
+        })
+        anchors.add([{"episode_id": 1, "event_time": 5}])
+        images.add([{"episode_id": 1, "event_time": 4, "image": "frame"}])
+        states.add([
+            {"episode_id": 1, "event_time": 0, "state": 0.0},
+            {"episode_id": 1, "event_time": 10, "state": 10.0},
+        ])
+
+        row = pmm.join_asof(
+            anchors.scan(), images.scan().select("image"),
+            on="event_time", by="episode_id",
+            direction="nearest", tolerance=2,
+        ).interpolate(
+            states.scan().select("state"), tolerance=5,
+        ).to_list()[0]
+
+        self.assertEqual("frame", row["image"])
+        self.assertEqual(5.0, row["state"])
+
     def test_alignment_can_return_matched_timestamp(self):
         anchors = self._table("timestamp_output_anchors", {
             "episode_id": pa.int32(),
@@ -919,7 +1283,7 @@ class MultimodalTemporalTest(unittest.TestCase):
         }])
 
         with self.assertRaisesRegex(
-                ValueError, "join_asof.*incremental"):
+                ValueError, "Temporal alignment.*incremental"):
             pmm.join_asof(
                 anchors.scan(), source.scan().select("value"),
                 on="event_time", by="episode_id",
@@ -1256,7 +1620,7 @@ class MultimodalTemporalTest(unittest.TestCase):
                     pa.chunked_array([chunk for _ in row_ids])
                 ], schema=schema)
 
-        aligned = object.__new__(temporal.AsOfJoin)
+        aligned = object.__new__(temporal.TemporalAlignment)
         aligned._anchor_schema = schema
         aligned._sources = ()
         rows = [{temporal._ROW_ID: value} for value in range(2)]

Reply via email to