feat: an optional lane says it is optional, and what enabling it costs (4296)
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / extension-version (push) Successful in 2s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m21s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m12s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m39s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / extension-version (push) Successful in 2s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m21s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m12s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m39s
Operator, 2026-09-22: "since the ml-worker is optional it should be shown as such in the UI and have a warning about what it does and that it pulls the models and what models and their projected size and ram requirements to run." The card previously said "a few GB, once" — a number sourced from nothing, which is exactly the hand-wave I had flagged in this step's own survey log as something that should be measured rather than asserted. ONE FACT CORRECTED WHILE WRITING THE COPY. I had named the lane "ML tagging". It downloads an EMBEDDER: google/siglip-so400m-patch14-384. WD14 tagging is the GPU agent's job — celery_app.py:5 still names both, but that has been stale since B3 (#1238), when the agent took over and this lane was left as the CPU embed fallback for stacks running no agent (see MLSettings.cpu_embed_enabled). Telling someone the lane "does tagging" would have been wrong in exactly the way this request exists to prevent. The facts are structured data on the lane, not prose in a component: ModelRequirement(repo, approx_download_bytes, approx_resident_bytes, measured). The API carries them; the card renders them. Numbers come from the system, wording from the UI. ML_BYTES_PER_SLOT IS NOW DERIVED from that requirement rather than stated separately. They have to be one number: the figure quoted to the operator before they enable the lane and the figure the cap enforces. Two copies could disagree, and the UI would promise a slot the cap then refuses. `measured=False` travels with the numbers and the card renders "about". They are estimates from the checkpoint's parameter count and dtype — ~877M params at fp32 is ~3.5GB of weights — not from a build. This decides whether someone's server survives, so it is labelled rather than rounded into something that reads like a fact. A test asserts the flag is false, to be flipped in the same commit that records a real measurement. The card now shows: an "optional" chip in the row itself (someone scanning the table should not have to enable a lane to learn it was never required), and before the switch, what the lane does, that you only need it if you are NOT running the GPU agent, the repo id, the download size, the per-slot RAM, and why the ceiling is what it is — including saying plainly when a box has too little memory to run it at all. Keyed on the lane's own `optional` flag, not on the name 'ml', so a second optional lane gets the same treatment without anyone remembering to add it. A test asserts no REQUIRED lane declares a model: if one ever needs a download, it stops being required. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -298,6 +298,20 @@ async def lane_view(session: AsyncSession) -> list[dict]:
|
|||||||
"ceiling": derived_ceiling(lane),
|
"ceiling": derived_ceiling(lane),
|
||||||
"enabled": row.enabled,
|
"enabled": row.enabled,
|
||||||
"memory_bound": lane.memory_bound,
|
"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": {
|
"live": {
|
||||||
"present": state.present,
|
"present": state.present,
|
||||||
"replicas": state.replicas,
|
"replicas": state.replicas,
|
||||||
|
|||||||
@@ -58,6 +58,58 @@ log = logging.getLogger(__name__)
|
|||||||
# --- the lanes ---------------------------------------------------------------
|
# --- 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)
|
@dataclass(frozen=True)
|
||||||
class Lane:
|
class Lane:
|
||||||
"""A worker lane. `name` is the stable key the settings row is keyed on.
|
"""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.
|
# 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.
|
# The only lane whose ceiling is decided by memory instead of by cores.
|
||||||
memory_bound: bool = False
|
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
|
@property
|
||||||
def queue_key(self) -> tuple[str, ...]:
|
def queue_key(self) -> tuple[str, ...]:
|
||||||
@@ -142,6 +200,8 @@ LANES: tuple[Lane, ...] = (
|
|||||||
default_slots_cap=1,
|
default_slots_cap=1,
|
||||||
default_enabled=False,
|
default_enabled=False,
|
||||||
memory_bound=True,
|
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.
|
# a petabyte is a sentinel, not a machine.
|
||||||
_UNLIMITED_ABOVE = 1 << 50
|
_UNLIMITED_ABOVE = 1 << 50
|
||||||
|
|
||||||
GIB = 1024 ** 3
|
# 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
|
||||||
# Memory one ML slot needs: the SigLIP so400m weights plus the runtime holding
|
# same figure, or the UI promises something the cap will then refuse.
|
||||||
# them. Prefork forks a child per slot and each child loads its own copy, so
|
ML_BYTES_PER_SLOT = SIGLIP_MODEL.approx_resident_bytes
|
||||||
# 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/<child pid>/status`). Put the measurement in the commit
|
|
||||||
# message when you do.
|
|
||||||
ML_BYTES_PER_SLOT = 4 * GIB
|
|
||||||
|
|
||||||
# Held back for hypercorn and the non-ML lanes before any ML slot is offered.
|
# 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
|
# In the consolidated container these share one cgroup with ML, and they are
|
||||||
|
|||||||
@@ -43,7 +43,15 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="lane in lanesList" :key="lane.name">
|
<tr v-for="lane in lanesList" :key="lane.name">
|
||||||
<td>
|
<td>
|
||||||
<div>{{ lane.display_name }}</div>
|
<div class="d-flex align-center" style="gap: 6px;">
|
||||||
|
<span>{{ lane.display_name }}</span>
|
||||||
|
<!-- Said in the row, not only in the warning below. Someone
|
||||||
|
scanning the table to work out why a lane is off should
|
||||||
|
not have to enable it to find out it was never required. -->
|
||||||
|
<v-chip v-if="lane.optional" size="x-small" variant="tonal">
|
||||||
|
optional
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
<!-- Not present is NOT zero slots — it is "nothing answered".
|
<!-- Not present is NOT zero slots — it is "nothing answered".
|
||||||
Saying "stopped" here would be a verdict drawn from an
|
Saying "stopped" here would be a verdict drawn from an
|
||||||
unswept read, and the operator would go looking for a crash
|
unswept read, and the operator would go looking for a crash
|
||||||
@@ -111,10 +119,42 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</v-table>
|
</v-table>
|
||||||
|
|
||||||
<p v-if="mlOff" class="fc-section__hint mt-3">
|
<!-- What an optional, off lane will cost BEFORE it is switched on.
|
||||||
ML tagging is off. Turning it on downloads the tagging model the first
|
Every number here comes from the lane payload, and `measured`
|
||||||
time it runs — a few GB, once.
|
travels with them — an estimate is labelled rather than rounded
|
||||||
</p>
|
into something that reads like a fact. The previous version of this
|
||||||
|
said "a few GB", which told the operator nothing they could plan
|
||||||
|
with and was not sourced from anything. -->
|
||||||
|
<v-alert
|
||||||
|
v-for="lane in offOptionalLanes" :key="`advisory-${lane.name}`"
|
||||||
|
type="info" variant="tonal" density="compact" class="mt-3"
|
||||||
|
>
|
||||||
|
<div class="font-weight-medium mb-1">
|
||||||
|
{{ lane.display_name }} is optional and currently off
|
||||||
|
</div>
|
||||||
|
<p class="mb-2">
|
||||||
|
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 <em>not</em> running the GPU agent, which does the same
|
||||||
|
work faster.
|
||||||
|
</p>
|
||||||
|
<p class="mb-1">Turning it on downloads, once:</p>
|
||||||
|
<ul class="mb-2">
|
||||||
|
<li v-for="m in lane.models" :key="m.repo">
|
||||||
|
<code>{{ m.repo }}</code> —
|
||||||
|
{{ approx(m.measured) }}{{ gb(m.download_bytes) }} to download,
|
||||||
|
and about {{ gb(m.resident_bytes) }} of RAM for
|
||||||
|
<strong>each</strong> slot while it runs.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mb-0 text-caption">
|
||||||
|
Each slot loads its own copy, which is why this container caps the
|
||||||
|
lane at {{ lane.ceiling }}.
|
||||||
|
<template v-if="lane.ceiling === 0">
|
||||||
|
It has too little memory to run this at all.
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</v-alert>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -134,10 +174,25 @@ const busy = ref(null)
|
|||||||
const notice = ref(null)
|
const notice = ref(null)
|
||||||
|
|
||||||
const lanesList = computed(() => store.lanes?.lanes ?? [])
|
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) {
|
async function apply(lane, fields) {
|
||||||
busy.value = lane.name
|
busy.value = lane.name
|
||||||
notice.value = null
|
notice.value = null
|
||||||
|
|||||||
@@ -266,3 +266,44 @@ def test_worker_lane_check_constraints(slots, cap, ok):
|
|||||||
# documents what is accepted rather than restating the SQL.
|
# documents what is accepted rather than restating the SQL.
|
||||||
satisfied = slots >= 0 and cap >= 0 and slots <= cap
|
satisfied = slots >= 0 and cap >= 0 and slots <= cap
|
||||||
assert satisfied is ok
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user