Release: dev → main (first public release) #258

Merged
bvandeusen merged 94 commits from dev into main 2026-09-25 10:02:40 -04:00
4 changed files with 181 additions and 21 deletions
Showing only changes of commit ecbd325437 - Show all commits
+14
View File
@@ -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,
+64 -14
View File
@@ -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/<child pid>/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
@@ -43,7 +43,15 @@
<tbody>
<tr v-for="lane in lanesList" :key="lane.name">
<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".
Saying "stopped" here would be a verdict drawn from an
unswept read, and the operator would go looking for a crash
@@ -111,10 +119,42 @@
</tbody>
</v-table>
<p v-if="mlOff" class="fc-section__hint mt-3">
ML tagging is off. Turning it on downloads the tagging model the first
time it runs — a few GB, once.
</p>
<!-- What an optional, off lane will cost BEFORE it is switched on.
Every number here comes from the lane payload, and `measured`
travels with them — an estimate is labelled rather than rounded
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>
</template>
@@ -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
+41
View File
@@ -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