# ------------------------------------------------------------------------
# Trackers
# Copyright (c) 2026 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Timestamp plumbing and time-based pruning tests.

``_predict_timing`` unit tests run on SORT only (shared ``BaseTracker`` impl). Integration behaviour is parametrized
over timestamp-aware trackers.
"""

from __future__ import annotations

import warnings
from typing import Any

import numpy as np
import pytest
import supervision as sv

from trackers.core.base import BaseTracker
from trackers.core.botsort.tracker import BoTSORTTracker
from trackers.core.botsort.tracklet import BoTSORTTracklet
from trackers.core.bytetrack.tracker import ByteTrackTracker
from trackers.core.bytetrack.tracklet import ByteTrackTracklet
from trackers.core.cbiou.tracker import CBIoUTracker
from trackers.core.mcbyte.tracker import McByteTracker
from trackers.core.mcbyte.tracklet import McByteTracklet
from trackers.core.ocsort.tracker import OCSORTTracker
from trackers.core.ocsort.tracklet import OCSORTTracklet
from trackers.core.sort.tracker import SORTTracker
from trackers.core.sort.tracklet import SORTTracklet
from trackers.utils.base_tracklet import BaseTracklet
from trackers.utils.predict_timing import PredictTiming
from trackers.utils.state_representations import XCYCSRStateEstimator

TIMESTAMP_AWARE_TRACKERS: list[Any] = [
    pytest.param(SORTTracker, SORTTracklet, {}, id="sort"),
    pytest.param(
        ByteTrackTracker,
        ByteTrackTracklet,
        {"track_activation_threshold": 0.5},
        id="bytetrack",
    ),
    pytest.param(
        OCSORTTracker,
        OCSORTTracklet,
        {"high_conf_det_threshold": 0.5},
        id="ocsort",
    ),
    pytest.param(
        BoTSORTTracker,
        BoTSORTTracklet,
        {"enable_cmc": False, "track_activation_threshold": 0.5},
        id="botsort",
    ),
    pytest.param(
        CBIoUTracker,
        BoTSORTTracklet,
        {"track_activation_threshold": 0.5},
        id="cbiou",
    ),
    pytest.param(
        McByteTracker,
        McByteTracklet,
        {"enable_cmc": False, "track_activation_threshold": 0.5},
        id="mcbyte",
    ),
]

PREDICT_TIMING_TRACKER: list[Any] = [
    pytest.param(SORTTracker, SORTTracklet, {}, id="sort"),
]

FRAME_BUDGET_TRACKERS: list[Any] = [
    pytest.param(SORTTracker, SORTTracklet, {}, id="sort"),
    pytest.param(
        BoTSORTTracker,
        BoTSORTTracklet,
        {"enable_cmc": False, "track_activation_threshold": 0.5},
        id="botsort",
    ),
    pytest.param(
        CBIoUTracker,
        BoTSORTTracklet,
        {"track_activation_threshold": 0.5},
        id="cbiou",
    ),
    pytest.param(
        McByteTracker,
        McByteTracklet,
        {"enable_cmc": False, "track_activation_threshold": 0.5},
        id="mcbyte",
    ),
]


def _make_detections(boxes: list[list[float]], confidences: list[float] | None = None) -> sv.Detections:
    if not boxes:
        empty = sv.Detections.empty()
        if confidences is not None:
            empty.confidence = np.array([], dtype=np.float32)
        return empty
    xyxy = np.array(boxes, dtype=np.float32)
    det = sv.Detections(xyxy=xyxy)
    if confidences is not None:
        det.confidence = np.array(confidences, dtype=np.float32)
    return det


def _make_timestamp_aware_tracker(
    tracker_cls: type[BaseTracker],
    _tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
    **kwargs: Any,
) -> BaseTracker:
    params = {
        "minimum_consecutive_frames": 1,
        "frame_rate": 30.0,
        "lost_track_buffer": 30,
        **extra_kwargs,
        **kwargs,
    }
    return tracker_cls(**params)


_BOX = [[100.0, 100.0, 200.0, 200.0]]
_DET = _make_detections(_BOX, [0.9])
_EMPTY = _make_detections([])


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    TIMESTAMP_AWARE_TRACKERS,
)
def test_explicit_none_timestamp_matches_omitted(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    frame_rate = 30.0
    t1 = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs, frame_rate=frame_rate)
    r1 = t1.update(_DET)

    t2 = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs, frame_rate=frame_rate)
    r2 = t2.update(_DET, timestamp=None)

    assert r1.tracker_id is not None and r2.tracker_id is not None and list(r1.tracker_id) == list(r2.tracker_id)


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    PREDICT_TIMING_TRACKER,
)
def test_predict_timing_no_timestamp_uses_one_frame_step(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs)
    timing = tracker._predict_timing(None)
    assert timing.frame_step == 1.0
    assert timing.elapsed_seconds is None
    assert not timing.skip_predict


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    PREDICT_TIMING_TRACKER,
)
def test_predict_timing_first_timestamp_bootstraps(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs, frame_rate=30.0)
    timing = tracker._predict_timing(1000.0)
    assert timing.elapsed_seconds == pytest.approx(1.0 / 30.0)
    assert timing.frame_step == pytest.approx(1.0)
    assert not timing.skip_predict


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    PREDICT_TIMING_TRACKER,
)
def test_predict_timing_second_timestamp_returns_gap(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs, frame_rate=30.0)
    tracker._predict_timing(1000.0)
    timing = tracker._predict_timing(1000.1)
    assert timing.elapsed_seconds == pytest.approx(0.1, abs=1e-9)
    assert timing.frame_step == pytest.approx(3.0)
    assert not timing.skip_predict


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    PREDICT_TIMING_TRACKER,
)
def test_predict_timing_constant_fps_maps_to_one_frame_step(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs, frame_rate=25.0)
    timing = tracker._predict_timing(0.04)
    assert timing.elapsed_seconds == pytest.approx(0.04)
    assert timing.frame_step == pytest.approx(1.0)


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    PREDICT_TIMING_TRACKER,
)
def test_predict_timing_frame_gap_scales_frame_step(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs, frame_rate=25.0)
    tracker._predict_timing(0.04)
    timing = tracker._predict_timing(0.24)
    assert timing.elapsed_seconds == pytest.approx(0.20)
    assert timing.frame_step == pytest.approx(5.0)


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    PREDICT_TIMING_TRACKER,
)
def test_predict_timing_earlier_timestamp_skips_update_and_warns(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs)
    tracker._predict_timing(1000.0)
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        timing = tracker._predict_timing(999.9)
    assert timing.skip_update
    assert timing.skip_predict
    assert len(caught) == 1
    assert issubclass(caught[0].category, UserWarning)
    assert "earlier than the previous timestamp" in str(caught[0].message)
    assert tracker._last_timestamp == 1000.0


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    PREDICT_TIMING_TRACKER,
)
def test_predict_timing_duplicate_timestamp_skips_predict_and_warns(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs)
    tracker._predict_timing(1000.0)
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        timing = tracker._predict_timing(1000.0)
    assert timing.skip_predict
    assert not timing.skip_update
    assert timing.elapsed_seconds == 0.0
    assert len(caught) == 1
    assert issubclass(caught[0].category, UserWarning)
    assert "duplicate" in str(caught[0].message).lower()


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    PREDICT_TIMING_TRACKER,
)
def test_update_earlier_timestamp_skips_without_mutating_tracks(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs)
    tracker.update(_DET, timestamp=0.0)
    x_before = tracker.tracks[0].state_estimator.kf.state.copy()
    hits_before = tracker.tracks[0].time_since_update

    with warnings.catch_warnings():
        warnings.simplefilter("ignore", UserWarning)
        result = tracker.update(_DET, timestamp=-1.0)

    assert result.tracker_id is not None
    assert (result.tracker_id == -1).all()
    assert tracker.tracks[0].time_since_update == hits_before
    np.testing.assert_array_equal(tracker.tracks[0].state_estimator.kf.state, x_before)


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    PREDICT_TIMING_TRACKER,
)
def test_predict_timing_reset_clears_timestamp_state(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs)
    tracker._predict_timing(1000.0)
    tracker._predict_timing(1000.1)
    tracker.reset()
    timing = tracker._predict_timing(500.0)
    assert timing.elapsed_seconds == pytest.approx(1.0 / 30.0)
    assert timing.frame_step == pytest.approx(1.0)


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    TIMESTAMP_AWARE_TRACKERS,
)
def test_fixed_mode_does_not_accumulate_seconds(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs)
    tracker.update(_DET)
    tracker.update(_EMPTY)
    assert tracker.tracks[0].time_since_update_seconds == 0.0


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    TIMESTAMP_AWARE_TRACKERS,
)
def test_seconds_reset_on_match(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs)
    tracker.update(_DET)
    tracker.update(_EMPTY)
    tracker.update(_DET)
    assert len(tracker.tracks) == 1
    assert tracker.tracks[0].time_since_update_seconds == 0.0


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    TIMESTAMP_AWARE_TRACKERS,
)
def test_timestamp_mode_accumulates_elapsed_seconds_on_miss(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(tracker_cls, tracklet_cls, extra_kwargs)
    tracker.update(_DET, timestamp=0.0)
    assert tracker.tracks[0].time_since_update_seconds == 0.0
    tracker.update(_EMPTY, timestamp=0.5)
    assert tracker.tracks[0].time_since_update_seconds == pytest.approx(0.5)


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    TIMESTAMP_AWARE_TRACKERS,
)
def test_time_pruning_keeps_track_within_budget(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(
        tracker_cls,
        tracklet_cls,
        extra_kwargs,
        lost_track_buffer=30,
    )
    assert tracker.maximum_time_without_update == pytest.approx(1.0)
    tracker.update(_DET, timestamp=0.0)
    tracker.update(_make_detections([], []), timestamp=0.9)
    assert len(tracker.tracks) == 1


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    TIMESTAMP_AWARE_TRACKERS,
)
def test_time_pruning_removes_track_past_budget(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(
        tracker_cls,
        tracklet_cls,
        extra_kwargs,
        lost_track_buffer=30,
    )
    tracker.update(_DET, timestamp=0.0)
    tracker.update(_make_detections([], []), timestamp=1.5)
    assert len(tracker.tracks) == 0


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    FRAME_BUDGET_TRACKERS,
)
def test_no_timestamp_uses_frame_budget(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(
        tracker_cls,
        tracklet_cls,
        extra_kwargs,
        lost_track_buffer=5,
    )
    tracker.update(_DET)
    for _ in range(5):
        tracker.update(_make_detections([], []))
    assert len(tracker.tracks) == 1
    tracker.update(_make_detections([], []))
    assert len(tracker.tracks) == 0


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    FRAME_BUDGET_TRACKERS,
)
def test_mixed_timestamp_then_fixed_uses_frame_budget(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    tracker = _make_timestamp_aware_tracker(
        tracker_cls,
        tracklet_cls,
        extra_kwargs,
        lost_track_buffer=3,
    )
    tracker.update(_DET, timestamp=0.0)
    assert tracker.tracks[0].time_since_update_seconds == 0.0
    for _ in range(3):
        tracker.update(_make_detections([], []))
    assert len(tracker.tracks) == 1
    assert tracker.tracks[0].time_since_update_seconds == 0.0
    tracker.update(_make_detections([], []))
    assert len(tracker.tracks) == 0


def _confirmation_pattern(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
    *,
    use_timestamps: bool,
    n_frames: int = 10,
    frame_rate: float = 30.0,
) -> list[bool]:
    tracker = _make_timestamp_aware_tracker(
        tracker_cls,
        tracklet_cls,
        extra_kwargs,
        frame_rate=frame_rate,
        lost_track_buffer=30,
    )
    confirmed: list[bool] = []
    for i in range(n_frames):
        ts = i / frame_rate if use_timestamps else None
        result = tracker.update(_DET, timestamp=ts)
        if result.tracker_id is not None and len(result.tracker_id):
            confirmed.append(int(result.tracker_id[0]) >= 0)
        else:
            confirmed.append(False)
    return confirmed


@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    TIMESTAMP_AWARE_TRACKERS,
)
def test_same_confirmation_pattern_at_reference_fps(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
) -> None:
    fixed_pattern = _confirmation_pattern(tracker_cls, tracklet_cls, extra_kwargs, use_timestamps=False)
    dynamic_pattern = _confirmation_pattern(tracker_cls, tracklet_cls, extra_kwargs, use_timestamps=True)
    assert fixed_pattern == dynamic_pattern


def _process_noise_after_a_few_frames(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
    *,
    clock: str,
    frame_rate: float = 30.0,
) -> np.ndarray:
    """Return the ``Q`` a tracklet ends up running with under the given clock."""
    tracker = _make_timestamp_aware_tracker(
        tracker_cls,
        tracklet_cls,
        extra_kwargs,
        frame_rate=frame_rate,
        lost_track_buffer=30,
    )
    for i in range(6):
        if clock == "fixed":
            timestamp = None
        elif clock == "float":
            timestamp = i / frame_rate
        elif clock == "milliseconds":
            # cv2.CAP_PROP_POS_MSEC reports whole milliseconds.
            timestamp = round(i / frame_rate * 1000.0) / 1000.0
        else:
            raise ValueError(clock)
        x = 100.0 + 6.0 * i
        tracker.update(_make_detections([[x, 100.0, x + 50.0, 200.0]], [0.9]), timestamp=timestamp)
    return np.asarray(tracker.tracks[0].state_estimator.kf.process_noise, dtype=np.float64).copy()


@pytest.mark.parametrize("clock", ["float", "milliseconds"])
@pytest.mark.parametrize(
    "tracker_cls, tracklet_cls, extra_kwargs",
    TIMESTAMP_AWARE_TRACKERS,
)
def test_same_process_noise_at_reference_fps(
    tracker_cls: type[BaseTracker],
    tracklet_cls: type[BaseTracklet],
    extra_kwargs: dict[str, Any],
    clock: str,
) -> None:
    """A steady stream at the reference FPS runs the tracker's configured ``Q``.

    ``frame_step`` is ``(t - t_prev) * frame_rate``, which lands near ``1.0``
    but never on it, so this is the case an exact ``== 1.0`` comparison misses:
    every step took the gap path and ran a ``Q`` rebuilt from the velocity
    diagonal instead of the configured one.

    ``Q`` is asserted rather than the output boxes on purpose. ``Q`` does not
    enter the predicted state (``x = F @ x``) — it enters the covariance, which
    sets the Kalman gain, so its effect on the boxes only becomes measurable
    once detections compete for association in a crowded scene. Asserting the
    matrix keeps the regression observable in a unit test.
    """
    fixed = _process_noise_after_a_few_frames(tracker_cls, tracklet_cls, extra_kwargs, clock="fixed")
    dynamic = _process_noise_after_a_few_frames(tracker_cls, tracklet_cls, extra_kwargs, clock=clock)

    np.testing.assert_array_equal(dynamic, fixed)


def test_mcbyte_predict_forwards_frame_rate_for_mid_band_gap() -> None:
    """McByte's ``predict()`` must forward ``frame_rate`` for a mid-band gap.

    ``frame_step=1.15`` sits outside the fixed 0.1 frame-unit fallback tolerance but inside the fps-scaled ``0.004 *
    frame_rate`` band at 60 FPS (0.24) — the exact divergence zone a dropped ``frame_rate`` falls out of.

    Asserted directly on the tracklet, not through ``McByteTracker.update()``: McByte's ``update()`` unconditionally
    recalibrates ``Q`` from the current bbox scale via ``_refresh_noise_from_state``, which overwrites whatever
    ``predict()`` computed before the next frame — the only place this regression is observable is the tracklet's state
    right after ``predict()``.
    """
    box = np.array([100.0, 100.0, 150.0, 200.0], dtype=np.float32)
    nominal = McByteTracklet(initial_bbox=box)
    nominal.predict(PredictTiming(frame_step=1.0, elapsed_seconds=1.0 / 60.0, frame_rate=60.0))

    gapped = McByteTracklet(initial_bbox=box)
    gapped.predict(PredictTiming(frame_step=1.15, elapsed_seconds=1.15 / 60.0, frame_rate=60.0))

    np.testing.assert_array_equal(
        np.asarray(gapped.state_estimator.kf.process_noise, dtype=np.float64),
        np.asarray(nominal.state_estimator.kf.process_noise, dtype=np.float64),
    )


@pytest.mark.parametrize(
    ("tracker_cls", "extra_kwargs"),
    [
        pytest.param(OCSORTTracker, {}, id="ocsort-default-xcycsr"),
        pytest.param(
            SORTTracker,
            {"state_estimator_class": XCYCSRStateEstimator},
            id="sort-xcycsr",
        ),
        pytest.param(
            ByteTrackTracker,
            {"state_estimator_class": XCYCSRStateEstimator},
            id="bytetrack-xcycsr",
        ),
        pytest.param(
            BoTSORTTracker,
            {"state_estimator_class": XCYCSRStateEstimator, "enable_cmc": False},
            id="botsort-xcycsr",
        ),
        pytest.param(
            CBIoUTracker,
            {"state_estimator_class": XCYCSRStateEstimator},
            id="cbiou-xcycsr",
        ),
        pytest.param(
            McByteTracker,
            {"state_estimator_class": XCYCSRStateEstimator, "enable_cmc": False},
            id="mcbyte-xcycsr",
        ),
    ],
)
def test_xcycsr_shrinking_box_survives_timestamp_gap_without_nan(
    tracker_cls: type[BaseTracker], extra_kwargs: dict[str, Any]
) -> None:
    """A shrinking box plus a sub-second timestamp gap must not crash XCYCSR trackers.

    The XCYCSR filter tracks scale (area) with a velocity term. A box that
    shrinks each frame builds a negative scale velocity ``vs`` that still passes
    the one-frame guard (``s + vs > 0``). In timestamp mode a gap produces
    ``frame_step = elapsed * frame_rate > 1``, and ``predict`` extrapolates scale
    as ``s + frame_step * vs``. Guarding only the one-frame case let the scale go
    negative, so ``xcycsr_to_xyxy`` took ``sqrt`` of it and emitted NaN boxes,
    crashing association with ``ValueError: boxes_1 contains non-finite values``.
    The clamp now guards the projected scale over ``frame_step``. OC-SORT uses
    XCYCSR by default; SORT and ByteTrack hit the same path when configured
    with it.
    """
    params: dict[str, Any] = {"frame_rate": 30.0, **extra_kwargs}  # defaults otherwise
    tracker = tracker_cls(**params)
    fps = 30.0
    t = 0.0
    w, h = 120.0, 260.0
    for _ in range(8):  # establish a shrinking track -> negative scale velocity
        x1, y1 = 500 - w / 2, 300 - h / 2
        tracker.update(_make_detections([[x1, y1, x1 + w, y1 + h]], [0.9]), timestamp=t)
        t += 1.0 / fps
        w *= 0.97
        h *= 0.97

    # NOTE: Large frame-step gaps may trigger pre-existing Kalman covariance
    # RuntimeWarnings in dt^4 propagation; this is intentionally out of scope.
    # Half-second gap: frame_step = 15, well within the lost-track horizon.
    result = tracker.update(sv.Detections.empty(), timestamp=t + 0.5)

    assert result is not None
    assert len(tracker.tracks) > 0, "Tracker lost all tracks after timestamp gap"
    for tracklet in tracker.tracks:
        assert np.all(np.isfinite(tracklet.get_state_bbox()))
