diff --git a/CHANGELOG.md b/CHANGELOG.md index 15f1f5b2..7631294a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ adheres to [Semantic Versioning](https://semver.org/). ### Fixed +- Model time budgets now stop at their deadline, including a zero-second + budget when the clock has not advanced. This prevents optional model calls + from starting after the time allowance has been consumed. + - The Polars engine no longer silently loses rows from a zero-column pandas input. It uses the recorded pandas fallback, preserving the input index and row counts with pandas output; `fallback_policy="error"` rejects the run diff --git a/docs/semantic-models.md b/docs/semantic-models.md index 9f6ca9ea..18f32609 100644 --- a/docs/semantic-models.md +++ b/docs/semantic-models.md @@ -174,6 +174,10 @@ An exhausted budget stops the embedding backend cleanly (proposals already made stand; nothing is partially applied) and records a report fallback event. Deterministic and memory backends are never metered. +The time budget expires when elapsed time reaches `max_seconds`. A value of +`0` refuses model work immediately, even when the clock has not advanced since +the budget was created. Leaving `max_seconds` unset imposes no time ceiling. + ## Graceful degradation Requesting `"embedding"` without the extra or the model **never crashes**: diff --git a/src/freshdata/semantic/backends/base.py b/src/freshdata/semantic/backends/base.py index 892a28da..75812df1 100644 --- a/src/freshdata/semantic/backends/base.py +++ b/src/freshdata/semantic/backends/base.py @@ -68,7 +68,7 @@ def _check_time(self) -> None: if ( not self.exhausted_reason and self.max_seconds is not None - and time.monotonic() - self.started_at > self.max_seconds + and time.monotonic() - self.started_at >= self.max_seconds ): self.exhausted_reason = f"max_seconds={self.max_seconds} exceeded" diff --git a/tests/test_semantic_backends.py b/tests/test_semantic_backends.py index a6c084d2..d8a51ce5 100644 --- a/tests/test_semantic_backends.py +++ b/tests/test_semantic_backends.py @@ -3,6 +3,8 @@ from __future__ import annotations +import time + import numpy as np import pandas as pd import pytest @@ -357,6 +359,38 @@ def test_budget_from_config_parses_fields(): assert Budget.from_config({"max_columns": -1}).max_columns is None +@pytest.mark.parametrize("max_seconds", [0.0, 0.5]) +def test_time_budget_refuses_work_at_its_deadline(monkeypatch, max_seconds): + budget = Budget(max_seconds=max_seconds, started_at=100.0) + monkeypatch.setattr(time, "monotonic", lambda: 100.0 + max_seconds) + + assert not budget.try_column(3) + assert not budget.try_model_call() + assert budget.columns_used == budget.distinct_values_used == budget.model_calls_used == 0 + assert "max_seconds" in budget.exhausted_reason + + +def test_time_budget_reservations_stop_at_the_deadline(monkeypatch): + now = [100.499] + monkeypatch.setattr(time, "monotonic", lambda: now[0]) + budget = Budget(max_seconds=0.5, started_at=100.0) + assert budget.try_column(3) + assert budget.try_model_call() + + now[0] = 100.5 + assert not budget.try_column(2) + assert not budget.try_model_call() + assert (budget.columns_used, budget.distinct_values_used, budget.model_calls_used) == (1, 3, 1) + + +def test_unset_time_budget_remains_unbounded(monkeypatch): + monkeypatch.setattr(time, "monotonic", lambda: 10000.0) + budget = Budget(started_at=100.0) + assert budget.try_column(3) + assert budget.try_model_call() + assert not budget.exhausted + + @pytest.mark.parametrize( "budget_dict, expected_reason", [ @@ -367,12 +401,13 @@ def test_budget_from_config_parses_fields(): ], ) def test_budget_exhaustion_stops_cleanly(encoder_factory, budget_dict, expected_reason): - encoder_factory(_status_vectors()) + encoder = encoder_factory(_status_vectors()) df = _status_frame() out, report = fd.clean( df, config=_status_config(semantic_budget=budget_dict), return_report=True ) assert not [a for a in report.actions if a.model_id.endswith(":embedding")] + assert encoder.calls == [] events = [e for e in report.fallback_events if e["backend"] == "embedding"] assert events and expected_reason in events[0]["fallback_reason"] # Deterministic experts still ran despite the exhausted model budget.