Design
The Introduction covers what MedOtter’s planned SDK is and how its pieces fit together. This page covers why three of its more surprising properties are the way they are: the dependency-light import contract, the two-package split, and the deliberate absence of a submission call.
The dependency-light import contract
import medotter is a contract, not an accident: it must cost only numpy.
From the package’s own docstring:
Importing this package is torch-free AND scoring-stack-free: the catalog
(list_datasets, describe), the Sample dataclass, and the label semantics are
pure-Python/numpy. Heavy machinery is pulled only on use — load_dataset pulls
torch + the dataloader package, and `medotter.eval` lazily imports the scoring
stack (medotter_bench -> metrics.hd95 -> scipy) on first access, so catalog-only
users never pay for scipy.
The reason is the audience for the cheapest operations in the SDK — listing
and filtering the 130-dataset catalog, reading a DatasetInfo — is not the
same audience that needs a GPU-capable torch install or scipy. A script
that only calls mo.list_datasets(modality="CT") to build a report, or a CI
job that validates catalog metadata, shouldn’t need to resolve, download, and
import the ML stack just to do that. Three lazy boundaries enforce this:
-
mo.load_dataset(...)importstorchand thedataloaderpackage the first time it’s called, not atimport medottertime. -
mo.load_model(...)andmo.train(...)importtorch(and, for SAM2, the model backbone) only when called. -
mo.eval— themedotter_benchscoring stack — is exposed through a module-level__getattr__rather than a normal import:def __getattr__(name): if name == "eval": import importlib mod = importlib.import_module("medotter.eval") globals()["eval"] = mod return mod raise AttributeError(f"module {__name__!r} has no attribute {name!r}")evalis also deliberately left out ofmedotter.__all__, even though__dir__reports it. If it were in__all__,from medotter import *and any__all__-walking tool (Sphinx autosummary and similar) would resolve it eagerly on import — silently reintroducing thescipycost the lazy attribute exists to avoid. Keeping it out of__all__but reachable by attribute access gets both: discoverable, but never resolved unless something actually touchesmo.evaldirectly. The firstmo.score/mo.evaluatecall pulls in the samescipydependency too — regardless of whichmetrics=you ask for, not onlyhd95— but through a separate, torch-free import of the scoring stack, not through this attribute.
One nuance worth being precise about: the scipy-free half of the promise is
scoped to import medotter itself, not to medotter_bench in isolation.
medotter_bench’s own docstring calls it “torch-free,” not “scipy-free” —
its __init__.py imports metrics.hd95 (which needs scipy.ndimage) at
module load time. Once the package is released, a submitter who imports
medotter_bench directly will pay the scipy cost immediately; a
researcher who only ever does import medotter and calls the catalog
functions pays for neither scipy nor torch, because that import path
never reaches medotter_bench at all.
The two-package split
The development source is split into medotter (the researcher-facing facade:
catalog, data, inference, training, evaluation) and medotter_bench (the
narrower benchmark, prompt-free inference, and scoring surface). See
Introduction — The two packages
for the audience-facing summary; the design reason for keeping them separate
packages, rather than one package with two audiences, is what medotter_bench
has to run as.
medotter_bench is what backs the leaderboard’s GPU evaluation worker on
every submission — including model code the worker has never seen before.
That makes it infrastructure with a narrow, auditable job: load a frozen
bundle, run a prompt-free predictor, score the result. Keeping it torch-free
and free of medotter’s much larger research surface (the dataset registry,
training loops, SAM2 backbones) keeps that infrastructure’s dependency
footprint small and its behavior easy to reason about — a submitter or
reviewer auditing what code runs against untrusted model output only has to
read medotter_bench, not all of medotter.
The split also isn’t a fork: medotter.eval re-exports medotter_bench’s
public names byte-identically (mo.eval.Bundle is medotter_bench.Bundle is
True) rather than wrapping or duplicating them. Local evaluation in
medotter and the worker’s evaluation both run the same scoring code — one
source of truth, imported lazily by the facade and directly by the worker.
Why there is no mo.submit
The planned SDK boundary ends at local mo.evaluate(...): scoring a model against a
dataset, on your own machine, with no network calls and nothing left behind.
Submitting a model to the public leaderboard is a different kind of
operation — it validates a Hugging Face repo, POSTs to a Supabase Edge
Function, schedules work on a GPU worker, and persists run/case scores — and
that is stateful, server-owned behavior outside the package surface. See
Submitting — There is no mo.submit
for the current maintenance state and planned public submission paths.
Keeping submission out of medotter keeps the boundary from the two sections
above intact: medotter’s heaviest verb (evaluate) is still a pure local
call, and the code that talks to the leaderboard’s infrastructure is the same
medotter_bench-adjacent tooling (the CLI’s local predictor.py preflight,
the GPU worker itself) already described above — not a new surface bolted
onto the facade.
Next: Versioning covers what stability guarantee each of these surfaces actually carries.