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
23 changes: 21 additions & 2 deletions .github/workflows/integration_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,29 @@ jobs:
run: |
uv venv && uv pip install -e ".[serve,dev]"

- name: Run integration tests
- name: Run integration tests with coverage
# Invoked via the venv's python, NOT `uv run`: under `uv run`,
# Ray launches its worker processes through uv, which re-syncs
# the project env from the lockfile (dev group only -- no
# ray[serve]) and every replica dies with "No module named
# 'ray'". Observed on CI run 33260341506.
run: .venv/bin/python -m pytest tests/integration -q
#
# serve/app.py needs a live Ray Serve cluster (fake backend, no
# GPU) -- it's the one module these tests exercise that the
# unit-test job structurally can't (no ray[serve] there by
# design, see unit_tests.yml). Measuring coverage here and
# uploading it as its own codecov flag lets the "overall" number
# reflect that real coverage instead of unit_tests.yml's 0% for
# this file alone.
run: |
.venv/bin/python -m pytest tests/integration \
--cov tabctx --cov-report=xml --cov-report=term-missing -q

- name: Upload coverage to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: VectorInstitute/tabctx
flags: integration
fail_ci_if_error: false
verbose: true
1 change: 1 addition & 0 deletions .github/workflows/unit_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,6 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: VectorInstitute/tabctx
flags: unit
fail_ci_if_error: false
verbose: true
2 changes: 1 addition & 1 deletion benchmarks/baselines/v0.9.1-research-1replica.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,4 @@
}
],
"feature_count_sweep": null
}
}
16 changes: 15 additions & 1 deletion codecov.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
# Two flags feed the "overall" project number: `unit` (tests/unit,
# unit_tests.yml -- fast, no GPU/ray) and `integration` (tests/integration,
# integration_tests.yml -- boots a real local Ray Serve cluster, which is
# what actually exercises serve/app.py). Both must land before the merged
# report is complete.
flags:
unit:
paths:
- src/tabctx/
carryforward: false
integration:
paths:
- src/tabctx/
carryforward: false
codecov:
require_ci_to_pass: true
notify:
after_n_builds: 1
after_n_builds: 2
wait_for_ci: yes
comment:
behavior: default
Expand Down
16 changes: 15 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ tabpfn = ["tabpfn", "torch"]
# jinja2 is imported unconditionally by ray.serve's haproxy module but
# missing from some ray[serve] distributions (seen on a clean CI install).
serve = ["ray[serve]>=2.58.0", "fastapi", "uvicorn[standard]", "jinja2"]
dev = ["pytest>=7.0", "httpx"]
dev = ["pytest>=7.0", "pytest-cov", "httpx"]

[dependency-groups]
# uv-managed dev environment (CI uses `uv sync --dev`); the [dev] extra
Expand Down Expand Up @@ -76,3 +76,17 @@ packages = ["src/tabctx"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.coverage.run]
# tabicl/tabpfn need a real GPU or PriorLabs-licensed weights and are
# never installed by any CI job (unit_tests.yml runs `uv sync --dev`;
# integration_tests.yml installs `.[serve,dev]` -- neither pulls in
# tabicl, tabpfn, or torch). They're tested locally/on GPU rigs instead
# (see tests/integration/test_tabpfn_backend.py's docstring). Measuring
# coverage on code that structurally cannot run in CI would just be a
# permanent, meaningless 0% rather than a signal -- omitted here for
# the same reason.
omit = [
"src/tabctx/backends/tabicl.py",
"src/tabctx/backends/tabpfn.py",
]
7 changes: 6 additions & 1 deletion src/tabctx/serve/csv_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,12 @@ def parse_train_csv(
raise InvalidInputError(f"train target: unreadable CSV column ({e})") from e
y = [str(v).strip() for v in y_arr[:, 0]]

if len(y) != X.shape[0]:
if len(y) != X.shape[0]: # pragma: no cover
# Defensive: X and y are parsed independently (same file, same
# skiprows) so a real CSV can't make them disagree -- found no
# way to construct one in testing. Kept as a guard rather than an
# assert so a future parsing change that could break this
# invariant fails as a clean 422, not a silent shape mismatch.
raise InvalidInputError(
f"target column has {len(y)} values but features have {X.shape[0]} rows"
)
Expand Down
29 changes: 20 additions & 9 deletions src/tabctx/serve/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,16 +224,15 @@ def build_estimator(
)


def _build_backend(
def _build_real_backend(
kind: BackendKind, settings: ServeSettings
) -> tuple[TabularICLBackend, str]:
"""Returns (backend, device). Imports torch/tabicl/tabpfn only on the
paths that need them, so the fake backend runs with core deps alone."""
if kind == "fake":
from tabctx.backends.fake import FakeBackend

return FakeBackend(), "cpu (fake backend)"

) -> tuple[TabularICLBackend, str]: # pragma: no cover
# Requires torch + tabicl/tabpfn (GPU or licensed weights), which --
# like backends/tabicl.py and backends/tabpfn.py themselves -- are not
# part of any unit- or integration-test CI extra; see those modules'
# docstrings. Isolated in its own function so the pragma doesn't also
# exclude the (fully unit-tested) fake-backend and dispatch logic in
# _build_backend below.
import torch

device = "cuda" if torch.cuda.is_available() else "cpu"
Expand All @@ -249,6 +248,18 @@ def _build_backend(
return TabICLBackend(device=device, kv_cache=kv_cache), device


def _build_backend(
kind: BackendKind, settings: ServeSettings
) -> tuple[TabularICLBackend, str]:
"""Returns (backend, device). Imports torch/tabicl/tabpfn only on the
paths that need them, so the fake backend runs with core deps alone."""
if kind == "fake":
from tabctx.backends.fake import FakeBackend

return FakeBackend(), "cpu (fake backend)"
return _build_real_backend(kind, settings)


def build_engine(settings: ServeSettings | None = None) -> BuiltEngine:
settings = settings or ServeSettings.from_env()
backends: dict[str, TabularICLBackend] = {}
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/test_adaptive_estimator.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,32 @@ def test_confidence_reports_observation_count():
assert "0 real operational" in est.confidence()
est.record_observation(n_train=1000, n_features=10, real_bytes=1_000_000)
assert "1 real operational" in est.confidence()


def test_headroom_falls_back_to_static_when_fallback_has_no_gpu_capacity():
# A fallback estimator that doesn't expose gpu_capacity_bytes (i.e.
# isn't PowerLawMemoryEstimator) can't support usage-aware headroom --
# AdaptiveMemoryEstimator must defer to the fallback's own headroom
# rather than crash on the missing attribute.
class StubEstimator:
def estimate_bytes(self, n_train, n_test, n_features):
return 1

def admit(self, n_train, n_test, n_features):
return True

def ceiling_bytes(self):
return 1000

def admission_headroom_bytes(self, used_bytes):
del used_bytes
return 42

def confidence(self):
return "stub"

def record_observation(self, n_train, n_features, real_bytes):
del n_train, n_features, real_bytes

est = AdaptiveMemoryEstimator(fallback=StubEstimator())
assert est.admission_headroom_bytes(999) == 42
70 changes: 70 additions & 0 deletions tests/unit/test_batching.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,73 @@ def bad():
f"innocent request {name} was poisoned by another member's "
"malformed input"
)

def test_context_level_failure_propagates_to_every_member(self):
# Unlike a per-member InvalidInputError, a context-level failure
# (dataset evicted mid-flight, backend compute error) would have
# hit every member individually too -- all of them must see it.
engine, _ = _make_engine(predict_delay_s=0.01)
_fit(engine, "ds")
predictor = CoalescingPredictor(engine, window_s=0.08)
predictor._engine.evict("ds") # context vanishes before the batch runs

barrier = threading.Barrier(2)
errors: dict[str, Exception] = {}

def call(name):
barrier.wait()
try:
predictor.predict("ds", [[1.0, 2.0]])
except DatasetNotFoundError as e:
errors[name] = e

threads = [
threading.Thread(target=call, args=("m1",)),
threading.Thread(target=call, args=("m2",)),
]
for t in threads:
t.start()
for t in threads:
t.join()

assert set(errors) == {"m1", "m2"}

def test_leader_bug_still_unblocks_followers(self):
# Defensive path: if the leader's _execute() itself raises (a bug
# in the coalescing machinery, not a normal predict failure --
# normal failures are already caught inside _execute), followers
# must still be released with a clear error instead of hanging.
engine, _ = _make_engine(predict_delay_s=0.01)
_fit(engine, "ds")
predictor = CoalescingPredictor(engine, window_s=0.08)

def broken_execute(key, batch):
raise RuntimeError("bug in coalescing internals")

predictor._execute = broken_execute

barrier = threading.Barrier(2)
outcomes: dict[str, object] = {}

def call(name):
barrier.wait()
try:
predictor.predict("ds", [[1.0, 2.0]])
except Exception as e: # noqa: BLE001 -- capturing either exception type
outcomes[name] = e

threads = [
threading.Thread(target=call, args=("m1",)),
threading.Thread(target=call, args=("m2",)),
]
for t in threads:
t.start()
for t in threads:
t.join()

# Which of the two becomes the batch leader is a race (both block
# on the barrier and then contend for the batching lock), so
# assert on the two DISTINCT outcomes rather than on identity.
messages = {str(e) for e in outcomes.values()}
assert "bug in coalescing internals" in messages # the leader's own error
assert any("batch leader failed" in m for m in messages) # follower's
36 changes: 36 additions & 0 deletions tests/unit/test_cache_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,39 @@ def test_evict_removes_entry():
def test_evict_missing_id_is_a_noop():
cache = ContextCacheManager(capacity_bytes=1000)
cache.evict("does-not-exist") # must not raise


def test_make_room_stops_when_nothing_left_to_evict():
# Calling make_room directly (bypassing put()'s own-size guard) with a
# target larger than total capacity must stop cleanly once the cache is
# empty, rather than looping forever.
cache = ContextCacheManager(capacity_bytes=100)
cache.put(make_context("a", 100))
evicted = cache.make_room(1000)
assert evicted == ["a"]
assert cache.stats().n_cached_contexts == 0


def test_evict_one_on_empty_cache_returns_none():
cache = ContextCacheManager(capacity_bytes=1000)
assert cache.evict_one() is None


def test_evict_one_evicts_lru_victim():
cache = ContextCacheManager(capacity_bytes=1000)
cache.put(make_context("old", 100, last_accessed_at=1.0))
cache.put(make_context("new", 100, last_accessed_at=2.0))
victim = cache.evict_one()
assert victim == "old"
assert cache.get("old") is None
assert cache.get("new") is not None


def test_evict_one_spills_when_a_spill_tier_is_attached(tmp_path):
from tabctx.cache.spill import DiskSpillStore

spill = DiskSpillStore(tmp_path)
cache = ContextCacheManager(capacity_bytes=1000, spill_store=spill)
cache.put(make_context("a", 100))
assert cache.evict_one() == "a"
assert spill.stats()["n_spilled_contexts"] == 1
27 changes: 27 additions & 0 deletions tests/unit/test_cache_policies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Unit tests for eviction policies (cache/policies.py)."""

from tabctx.cache.manager import CachedContext
from tabctx.cache.policies import LRUEvictionPolicy


def _ctx(dataset_id: str, last_accessed_at: float) -> CachedContext:
ctx = CachedContext(
dataset_id=dataset_id,
backend_name="fake",
task="classification",
n_train=1,
n_features=1,
payload=None,
est_bytes=1,
)
ctx.last_accessed_at = last_accessed_at
return ctx


def test_select_victim_on_empty_entries_returns_none():
assert LRUEvictionPolicy().select_victim([]) is None


def test_select_victim_picks_oldest_last_accessed():
entries = [_ctx("a", 3.0), _ctx("b", 1.0), _ctx("c", 2.0)]
assert LRUEvictionPolicy().select_victim(entries) == "b"
Loading
Loading