Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/semantic-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
2 changes: 1 addition & 1 deletion src/freshdata/semantic/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
37 changes: 36 additions & 1 deletion tests/test_semantic_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

from __future__ import annotations

import time

import numpy as np
import pandas as pd
import pytest
Expand Down Expand Up @@ -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",
[
Expand All @@ -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.
Expand Down
Loading