feat(ml): lease announces detector config; agent builds proposers from it live (#134 step 2)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m34s

The GPU lease now carries the crop-proposer config from MLSettings in a per-job
'detectors' block (same pattern as embed_model_name). The agent's worker builds
its Proposers from the announced config via _effective_cfg (lease block overlaid
on env) + _proposers_for (rebuilds only when a config signature changes) — so an
operator's UI edit takes effect on the next lease with NO restart, and env is now
just the bootstrap fallback until the server announces. enabled-off maps to empty
weights (proposer skipped); dedupe_iou + max_regions also come from the effective
cfg. Test: lease announces the detectors block with the seeded default weights.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-05 19:42:59 -04:00
parent 62ec70b9e4
commit a4df279343
3 changed files with 102 additions and 9 deletions
+50 -9
View File
@@ -265,6 +265,8 @@ class Worker:
self._embedder = None
self._embedder_lock = threading.Lock()
self._proposers = None
self._proposers_sig = None # detector-config signature the current
# proposers were built for (#134)
self._proposers_lock = threading.Lock()
# --- held-lease bookkeeping --------------------------------------------
@@ -796,14 +798,52 @@ class Worker:
self._embedder = CropEmbedder(model_name, self.cfg.embed_dtype)
return self._embedder
def _ensure_proposers(self):
if self._proposers is not None:
return self._proposers
def _effective_cfg(self, det: dict | None):
"""The detector config for a job: the LEASE-ANNOUNCED block (the DB/UI
source of truth, #134) overlaid on the env cfg, or the env cfg unchanged
when the server announced nothing (bootstrap / older backend). An enabled
toggle off maps to empty weights — the 'weights == "" → proposer skipped'
contract in detectors.py. detector_level + ccip_model stay env (separate
settings, not part of the crop-proposer config)."""
if not det:
return self.cfg
def _w(p: str) -> str:
slot = det.get(p) or {}
return (slot.get("weights") or "") if slot.get("enabled", True) else ""
from dataclasses import replace
return replace(
self.cfg,
person_weights=_w("person"),
person_conf=float(det["person"]["conf"]),
anatomy_weights=_w("anatomy"),
anatomy_conf=float(det["anatomy"]["conf"]),
panel_weights=_w("panel"),
panel_conf=float(det["panel"]["conf"]),
max_figures=int(det["max_figures"]),
max_components=int(det["max_components"]),
max_panels=int(det["max_panels"]),
max_regions=int(det["max_regions"]),
dedupe_iou=float(det["dedupe_iou"]),
)
def _proposers_for(self, eff):
"""Proposers built from the effective detector config, rebuilt ONLY when
that config changes — so an operator's UI edit takes effect on the next
lease with no restart, and steady-state reuses the loaded YOLO models."""
sig = (
eff.person_weights, eff.person_conf,
eff.anatomy_weights, eff.anatomy_conf,
eff.panel_weights, eff.panel_conf,
eff.max_figures, eff.max_components, eff.max_panels,
)
with self._proposers_lock:
if self._proposers is None:
if self._proposers is None or self._proposers_sig != sig:
from .detectors import Proposers
self._proposers = Proposers(self.cfg)
return self._proposers
self._proposers = Proposers(eff)
self._proposers_sig = sig
return self._proposers
def _consume(self, job: dict, frames: list, stop_evt: threading.Event) -> bool:
"""Detect + embed the decoded frames and submit the result. Returns True
@@ -856,7 +896,8 @@ class Worker:
else ["figure", "face", "concept", "panel"]
)
embedder = self._ensure_embedder(model_name) if want_siglip else None
proposers = self._ensure_proposers()
eff = self._effective_cfg(job.get("detectors"))
proposers = self._proposers_for(eff)
regions = []
ccip_ev = self.cfg.ccip_model or "ccip-default"
@@ -922,7 +963,7 @@ class Worker:
# we never SigLIP the same region twice — saves GPU and a
# slot against max_regions. High-IoU + kind-aware, so
# intentional nested crops (figure ⊃ head) survive.
pending = dedupe_crops(pending, self.cfg.dedupe_iou)
pending = dedupe_crops(pending, eff.dedupe_iou)
vecs = embedder.embed_batch([c for c, _ in pending])
for (_c, tmpl), vec in zip(pending, vecs, strict=True):
tmpl["siglip_embedding"] = vec
@@ -932,7 +973,7 @@ class Worker:
# mp4, 64 sampled frames × ~32 regions) would otherwise burn
# ~38s of GPU across every frame before the submit is even
# truncated. Bounds the WORK, not just the POST body.
if len(regions) >= self.cfg.max_regions:
if len(regions) >= eff.max_regions:
break
# A Stop mid-frame-loop leaves partial regions — don't submit those;