diff --git a/backend/app/services/worker_control.py b/backend/app/services/worker_control.py index 97e70f8..022abee 100644 --- a/backend/app/services/worker_control.py +++ b/backend/app/services/worker_control.py @@ -298,6 +298,20 @@ async def lane_view(session: AsyncSession) -> list[dict]: "ceiling": derived_ceiling(lane), "enabled": row.enabled, "memory_bound": lane.memory_bound, + "optional": lane.optional, + # What enabling this lane will download, so the UI can say WHICH + # model and how big BEFORE the switch is thrown rather than after + # a multi-GB fetch has started. `measured` travels with the + # numbers: the card must not present an estimate as a fact. + "models": [ + { + "repo": m.repo, + "download_bytes": m.approx_download_bytes, + "resident_bytes": m.approx_resident_bytes, + "measured": m.measured, + } + for m in lane.models + ], "live": { "present": state.present, "replicas": state.replicas, diff --git a/backend/app/services/worker_lanes.py b/backend/app/services/worker_lanes.py index cdd639f..66521a2 100644 --- a/backend/app/services/worker_lanes.py +++ b/backend/app/services/worker_lanes.py @@ -58,6 +58,58 @@ log = logging.getLogger(__name__) # --- the lanes --------------------------------------------------------------- +GIB = 1024 ** 3 + + +@dataclass(frozen=True) +class ModelRequirement: + """A model a lane must download before it can do anything. + + Surfaced to the UI so the operator is told WHICH model, how big, and what + it costs to hold — before they turn the lane on, not after a multi-GB + download has already started. The lane is optional and its cost is not + obvious from its name, which is the whole reason this is structured data + rather than a sentence in a component. + + `measured=False` means the numbers are ESTIMATES and the UI must say so. + They come from the checkpoint's parameter count and dtype, not from a + build — and a number presented as fact decides whether someone's server + survives, so it is labelled rather than rounded confidently. + """ + + # The Hugging Face repo id, which is the honest answer to "which model". + repo: str + # Roughly what the download costs, for the operator's bandwidth and disk. + approx_download_bytes: int + # Roughly what ONE slot holds while running. Prefork forks a child per + # slot and each loads its own copy, so this multiplies. + approx_resident_bytes: int + measured: bool = False + + +# SigLIP so400m — the only model FabledCurator itself downloads. +# +# What it is for, which is NOT obvious from the lane's name: it produces the +# image embeddings that back similarity search, duplicate grouping and the +# tag heads. WD14 tagging is the GPU AGENT's job, not this lane's — the +# comment in celery_app.py naming both is stale since B3 (#1238), when the +# agent took over and this lane was left as the CPU embed fallback for stacks +# running no agent at all (see MLSettings.cpu_embed_enabled). +# +# Both numbers are ESTIMATES, derived from the checkpoint rather than from a +# build: ~877M parameters at fp32 is ~3.5GB of weights, and holding them plus +# activations and the torch runtime is what the resident figure covers. They +# err high. Replace them with measurements — download the repo and read its +# size; run one embed and read the worker child's VmHWM — and set +# `measured=True` when you do. +SIGLIP_MODEL = ModelRequirement( + repo="google/siglip-so400m-patch14-384", + approx_download_bytes=3_500_000_000, + approx_resident_bytes=4 * GIB, + measured=False, +) + + @dataclass(frozen=True) class Lane: """A worker lane. `name` is the stable key the settings row is keyed on. @@ -87,6 +139,12 @@ class Lane: # True when a slot costs a copy of the ML model rather than just a process. # The only lane whose ceiling is decided by memory instead of by cores. memory_bound: bool = False + # Models this lane downloads the first time it is enabled. Empty for every + # lane that needs none, which is how the UI knows whether to warn at all. + models: tuple[ModelRequirement, ...] = () + # An optional lane is one the product works without. Shown as such, so + # nobody turns on a multi-GB download believing it is required. + optional: bool = False @property def queue_key(self) -> tuple[str, ...]: @@ -142,6 +200,8 @@ LANES: tuple[Lane, ...] = ( default_slots_cap=1, default_enabled=False, memory_bound=True, + models=(SIGLIP_MODEL,), + optional=True, ), ) @@ -167,20 +227,10 @@ _CGROUP_V1_CPU_PERIOD = Path("/sys/fs/cgroup/cpu/cpu.cfs_period_us") # a petabyte is a sentinel, not a machine. _UNLIMITED_ABOVE = 1 << 50 -GIB = 1024 ** 3 - -# Memory one ML slot needs: the SigLIP so400m weights plus the runtime holding -# them. Prefork forks a child per slot and each child loads its own copy, so -# this multiplies — it is not a one-off cost. -# -# UNMEASURED AND DELIBERATELY CONSERVATIVE. This number decides whether a -# stranger's server survives enabling tagging, so it errs toward refusing a -# slot that would have fitted rather than granting one that will not. To -# replace it with a real figure: enable the lane on a container with a known -# limit, run one tagging task, and read the worker child's peak RSS -# (`grep VmHWM /proc//status`). Put the measurement in the commit -# message when you do. -ML_BYTES_PER_SLOT = 4 * GIB +# DERIVED from the model requirement above, never restated. The ceiling and +# the number shown to the operator before they enable the lane have to be the +# same figure, or the UI promises something the cap will then refuse. +ML_BYTES_PER_SLOT = SIGLIP_MODEL.approx_resident_bytes # Held back for hypercorn and the non-ML lanes before any ML slot is offered. # In the consolidated container these share one cgroup with ML, and they are diff --git a/frontend/src/components/settings/WorkerLanesCard.vue b/frontend/src/components/settings/WorkerLanesCard.vue index ca0d09e..f60a6e3 100644 --- a/frontend/src/components/settings/WorkerLanesCard.vue +++ b/frontend/src/components/settings/WorkerLanesCard.vue @@ -43,7 +43,15 @@ -
{{ lane.display_name }}
+
+ {{ lane.display_name }} + + + optional + +
+ +
+ {{ lane.display_name }} is optional and currently off +
+

+ It computes image embeddings on the CPU — what similarity search, + duplicate grouping and tag suggestions are built on. You only need it + if you are not running the GPU agent, which does the same + work faster. +

+

Turning it on downloads, once:

+ +

+ Each slot loads its own copy, which is why this container caps the + lane at {{ lane.ceiling }}. + +

+
@@ -134,10 +174,25 @@ const busy = ref(null) const notice = ref(null) const lanesList = computed(() => store.lanes?.lanes ?? []) -const mlOff = computed(() => - lanesList.value.some((l) => l.name === 'ml' && !l.enabled), + +// Optional lanes that are OFF — the only ones whose cost the operator has not +// already accepted. Keyed on the lane's own `optional` flag rather than on the +// name 'ml', so a second optional lane gets the same treatment without anyone +// remembering to add it here. +const offOptionalLanes = computed(() => + lanesList.value.filter((l) => l.optional && !l.enabled && l.models?.length), ) +function gb(bytes) { + return `${(bytes / 1024 ** 3).toFixed(1)} GB` +} + +// An unmeasured figure says so. It decides whether someone's server survives, +// and presenting an estimate as a measurement is the failure this guards. +function approx(measured) { + return measured ? '' : 'about ' +} + async function apply(lane, fields) { busy.value = lane.name notice.value = null diff --git a/tests/test_worker_lanes.py b/tests/test_worker_lanes.py index 7958676..4627ad6 100644 --- a/tests/test_worker_lanes.py +++ b/tests/test_worker_lanes.py @@ -266,3 +266,44 @@ def test_worker_lane_check_constraints(slots, cap, ok): # documents what is accepted rather than restating the SQL. satisfied = slots >= 0 and cap >= 0 and slots <= cap assert satisfied is ok + + +# --- what an optional lane tells the operator before it is enabled ----------- + + +def test_the_ml_lane_is_marked_optional_and_declares_its_model(): + """The operator's ask, 2026-09-22: an optional lane must SAY it is + optional, and say what enabling it downloads — which model, how big, and + what it costs to hold — before the switch is thrown rather than after a + multi-GB fetch has begun.""" + ml = wl.LANES_BY_NAME["ml"] + assert ml.optional is True + assert len(ml.models) == 1 + assert ml.models[0].repo == "google/siglip-so400m-patch14-384" + assert ml.models[0].approx_download_bytes > 0 + assert ml.models[0].approx_resident_bytes > 0 + + +def test_no_required_lane_claims_a_model(): + """A lane the product cannot work without must not be gated behind a + download. If one ever needs a model, it stops being required.""" + for lane in wl.LANES: + if lane.models: + assert lane.optional, f"{lane.name} needs a model but is not optional" + + +def test_the_per_slot_ceiling_is_the_same_number_the_ui_shows(): + """DERIVED, not restated. The figure quoted to the operator before they + enable the lane and the figure the cap enforces have to be one number, or + the UI promises a slot the cap will then refuse.""" + ml = wl.LANES_BY_NAME["ml"] + assert wl.ML_BYTES_PER_SLOT == ml.models[0].approx_resident_bytes + + +def test_estimated_numbers_are_flagged_as_estimates(): + """`measured` travels with the figures so the card can say "about". An + estimate presented as a measurement is what decides whether someone's + server survives — it must not be rounded into something that reads like a + fact. Flip this to True in the same commit that records a real + measurement.""" + assert wl.SIGLIP_MODEL.measured is False