From 6be1f247c6835050d1f07b7fdefeea64157a83f8 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 03:10:39 +0300 Subject: [PATCH 01/17] spec: hand lobe on LFM2.5-1.2B (devague /think + /challenge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converged Announcement Frame for `hand`, a ninth Colleague role: a 1.2B LFM2.5 base hosted on every machine, carrying many LoRA adapters, each mastering a specific domain ("muscle memory"). Key decisions: hand is a first-class role, not an unnamed gear; it replaces `minor` as the cheap tier target, LoRA base and pressure floor (a repointing, not a removal); v1 serves adapters via --enable-lora but never trains them (training is owned by unsloth-cli); hand is never proxied and no shape may drop it. Verified live against the pinned nightly digest on the Jetson AGX Thor (vLLM 0.23.1rc1.dev672): Lfm2ForCausalLM is registered, and the tool parser is `lfm2` — a purpose-built parser for special-token-delimited pythonic calls, so the generic `pythonic` guess would have failed exactly as it did for gemma4. Lfm2ForCausalLM declares SupportsLoRA at source. The /challenge pass surfaced eight findings, notably that every mesh-lobe shape's GPU budget was measured with no hand co-resident, and that roles_measure.py would raise KeyError rather than degrade. Follow-ups: agentculture/lobes-cli#180 (grant repo_action once adapters exist), agentculture/unsloth-cli#16 (verify LFM2.5 fine-tuning). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- .devague/current | 2 +- .devague/frames/hand-lobe-lfm2-5-1-2b.json | 1151 +++++++++++++++++ .eidetic/memory/lobes__public.jsonl | 10 +- .../specs/2026-08-09-hand-lobe-lfm2-5-1-2b.md | 174 +++ 4 files changed, 1331 insertions(+), 6 deletions(-) create mode 100644 .devague/frames/hand-lobe-lfm2-5-1-2b.json create mode 100644 docs/specs/2026-08-09-hand-lobe-lfm2-5-1-2b.md diff --git a/.devague/current b/.devague/current index 9752f16..1bde7d8 100644 --- a/.devague/current +++ b/.devague/current @@ -1 +1 @@ -unsloth-qat-senses-first-class-orin-variation +hand-lobe-lfm2-5-1-2b diff --git a/.devague/frames/hand-lobe-lfm2-5-1-2b.json b/.devague/frames/hand-lobe-lfm2-5-1-2b.json new file mode 100644 index 0000000..29df751 --- /dev/null +++ b/.devague/frames/hand-lobe-lfm2-5-1-2b.json @@ -0,0 +1,1151 @@ +{ + "slug": "hand-lobe-lfm2-5-1-2b", + "title": "hand lobe LFM2.5-1.2B", + "schema_version": 5, + "status": "exported", + "created": "2026-08-09T23:29:28Z", + "updated": "2026-08-10T00:10:10Z", + "claims": [ + { + "id": "c1", + "kind": "announcement", + "text": "lobes serves 'hand' \u2014 a 1.2B LFM2.5 lobe cheap enough to run on every machine in the mesh, and the fleet's designated fine-tuning base", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h1", + "text": "VERIFIED on the physical Jetson AGX Thor against the exact pinned nightly digest 7c5a10e9a8b3 (vLLM 0.23.1rc1.dev672+g93d8f834d): ModelRegistry lists Lfm2ForCausalLM, and vllm/tool_parsers/__init__.py maps the key 'lfm2' -> Lfm2ToolParser. The same digest is present on the Orin. This is image-grade, not documentation-grade \u2014 the engine hand needs is already on the boxes.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Land hand as the ninth Colleague role in one PR: catalog entry + lfm2 parser rule, vllm-hand compose lane on the nightly digest with --enable-lora, the role tables in roles.py/schema.py/render.py/shapes.py/_config.py/server.py, the TIER_ROLE repoint, the pressure floor move, all 4 card TOMLs + 7 shape TOMLs, 28 regenerated goldens, and the doc set. Validate live on Thor and Orin (both ssh-reachable, both already carrying the pinned image) and commit an acceptance transcript per card under docs/evidence/ before any surface claims VALIDATED.", + "revisions": [] + }, + { + "id": "c2", + "kind": "requirement", + "text": "lobes/catalog.py gains an LFM2.5-1.2B-Instruct SupportedModel entry: 32K native (native_max_model_len=32768, reuse _CONTEXT_32K_NATIVE), shape 'hybrid conv + GQA (10 double-gated conv + 6 GQA of 16 layers)', and quantization='none' \u2014 the bf16/unquantized SENTINEL the Qwen3.5-4B minor gear already uses (catalog.py:368), asserted by tests/test_catalog.py::test_minor_gear_quantization_is_none_sentinel. The sentinel means VLLM_QUANTIZATION is NOT written on switch and the compose lane must omit --quantization outright.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h6", + "text": "The catalog entry round-trips: every existing test_catalog.py invariant still passes with it present \u2014 unique id, all fields non-empty, doc file resolves, native_max_model_len positive, generate-task models carry zero dimension and empty hf_overrides, and the 'none' quantization sentinel behaves exactly as the 4B minor's does under lobes switch.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c3", + "kind": "requirement", + "text": "lobes/runtime/_parser.py::_RULES needs an lfm2-scoped family rule, or tests/test_catalog.py:76::test_tool_parser_matches_infer_parser fails the moment the catalog entry lands \u2014 the catalog's tool_parser field is asserted equal to infer_parser(id) for every gear.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h5", + "text": "After adding the rule, infer_parser('LiquidAI/LFM2.5-1.2B-Instruct') returns exactly 'lfm2', tests/test_catalog.py::test_tool_parser_matches_infer_parser passes, and the new markers do not change infer_parser's answer for ANY existing catalog id (assert the full catalog round-trips unchanged).", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c4", + "kind": "requirement", + "text": "hand's compose lane must ride the vLLM NIGHTLY image (${VLLM_NIGHTLY_IMAGE:-vllm/vllm-openai@sha256:7c5a10e9...} = 0.23.1rc1.dev672), NOT the NGC pin. Lfm2ForCausalLM ships in vLLM >= 0.23.0, and docs/vllm-nightly-migration.md section 1 records vllm-minor/vllm-middle still pinned to nvcr.io/nvidia/vllm:26.04-py3 = vLLM 0.19.0+nv26.04 \u2014 the cheapest existing lane is precisely the one that cannot load this architecture.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h4", + "text": "VERIFIED on both target boxes: 'docker images' on Thor and Orin each list vllm/vllm-openai at digest 7c5a10e9a8b3, and python3 -c 'import vllm' inside it reports 0.23.1rc1.dev672 \u2014 above LFM2's >= 0.23.0 floor. No new image pull is required for either card.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c5", + "kind": "requirement", + "text": "lobes/templates/fleet/docker-compose.yml gains a vllm-hand service and lobes/templates/fleet/env.example gains its HAND_* keys, following the vllm-minor block (docker-compose.yml:413-463) for structure: mg-logwrap entrypoint, HF cache + /logs mounts, expose 8000 with no host port, /health healthcheck.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h10", + "text": "docker compose config renders the vllm-hand service with no unresolved variables, and 'lobes up hand' starts it and reaches /health on a real box; the lane omits --quantization entirely (not an empty flag) and carries no --language-model-only.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c6", + "kind": "requirement", + "text": "IF hand lands as a ninth first-class Colleague role, the role vocabulary must be extended in lockstep across every table that enumerates roles: lobes/roles.py (ROLES, ROLE_BACKEND, ROLE_ROLE_HINT, ROLE_PATH, ROLE_RESPONSIBILITIES, ROLE_FORBIDDEN, ROLE_MAX_MODEL_LEN_ENV), lobes/profiles/schema.py:57 ROLES, lobes/profiles/render.py:76 ROLE_ENV_PREFIX, lobes/profiles/shapes.py (SHAPE_ROLES/DEFAULT_HOSTED_ROLES), lobes/gateway/_config.py (FEASIBLE_ENV, peer origin/proxy/key tables), lobes/gateway/server.py (_PEER_SERVED_NAME_ENV, _PEER_ROLE_HINT), lobes/gateway/_pressure_policy.py, lobes/catalog.py TIER_ROLE, lobes/runtime/_compose.py (role->service name), lobes/roles_measure.py, and lobes/cli/_commands/{capabilities,up,measure,overview,init}.py.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h11", + "text": "A single parametrised test iterates lobes.roles.ROLES and asserts every per-role table has an entry for all nine roles \u2014 so a future tenth role cannot half-land either. Today that test would fail on hand; after the change it passes with no role missing from any table.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c7", + "kind": "requirement", + "text": "server.py's peer tables are the KNOWN silent-failure point for a newly added role: 0.54.8 fixed exactly this \u2014 worker was wired into _config.py's peer dicts in 0.54.6 but missing from server.py's _PEER_SERVED_NAME_ENV/_PEER_ROLE_HINT, so peer_specs_from_table silently dropped it and WORKER_PEER_PROXY=true did nothing. hand must land in BOTH files or its proxy/referral surface is inert with no error.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h9", + "text": "HAND_PEER_PROXY=true against a declared HAND_PEER_ORIGIN actually forwards and returns X-Lobes-Proxied-By \u2014 proven by the same test shape that guards worker, plus tests/test_gateway_proxy.py::test_every_proxyable_role_resolves_a_served_name extended to hand so the 0.54.8 silent-drop cannot recur.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c8", + "kind": "requirement", + "text": "'runs on all machines' means hand is declared across the whole profile+shape matrix, not one file: all four card profiles (lobes/profiles/builtin/{base,spark,thor,orin}.toml) and all seven shape TOMLs (lobes/profiles/builtin_shapes/), which in turn regenerates the four card goldens (tests/goldens/*.env) and all 24 shape x card goldens (tests/goldens/shapes/*.env) via tests/goldens/regen.py, plus tests/test_shape_contract_matrix.py.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h7", + "text": "A byte-diff of the 28 regenerated goldens shows changes ONLY on lines introducing HAND_* keys \u2014 no unrelated key, value or ordering drift in any of the 4 card or 24 shape x card files. Any other delta means the render changed something it should not have.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c9", + "kind": "requirement", + "text": "Docs that enumerate the role set must all move together: docs/colleague-stack.md (the role contract), docs/gateway-fleet.md, docs/deployment-shapes.md, docs/machine-profiles.md, docs/openai-api.md, a new per-model docs/.md (tests/test_catalog.py::test_every_doc_file_exists asserts the catalog's doc field resolves), lobes/explain/catalog.py, CLAUDE.md and README.md.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h12", + "text": "tests/test_catalog.py::test_every_doc_file_exists passes with the new per-model doc present, and a grep for the role-count phrasing ('eight', 'EIGHT') across docs/ and CLAUDE.md returns no stale occurrence describing the role set.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c10", + "kind": "boundary", + "text": "hand is NOT an OPT_IN_CORE_ROLE. muse and worker are opt-in precisely because they are too heavy to co-reside (lobes/profiles/shapes.py: OPT_IN_CORE_ROLES = muse, worker); a 1.2B bf16 (~2.4 GiB weights) is the opposite case. 'Runs on all machines' places hand in DEFAULT_HOSTED_ROLES \u2014 the first role added to the default-hosted set since issue #81.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h21", + "text": "shapes.py's OPT_IN_CORE_ROLES remains exactly ('muse','worker') after the change \u2014 hand is absent from it \u2014 and DEFAULT_HOSTED_ROLES contains hand. Assert both, so a later refactor cannot quietly demote hand to opt-in.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c11", + "kind": "boundary", + "text": "Adding hand to DEFAULT_HOSTED_ROLES deliberately BREAKS the machine-as-brain byte-identical invariant that muse/worker were designed to preserve. shapes.py documents it explicitly: DEFAULT_HOSTED_ROLES excludes the opt-in core roles so 'machine-as-brain stays byte-identical to the bare card profile', and tests/goldens/regen.py::_shape_needs_goldens is defined against that set. hand renders on machine-as-brain, so this invariant must be knowingly re-baselined, not silently regenerated.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h8", + "text": "The machine-as-brain re-baseline is explicit and reviewed: the goldens diff for machine-as-brain contains ONLY HAND_* additions, and shapes.py's docstring + tests/goldens/regen.py's _shape_needs_goldens are updated to state that the identity-shape invariant now includes a default-hosted cheap role. A silent regeneration that merely makes the tests pass does NOT satisfy this.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c12", + "kind": "boundary", + "text": "base.toml's OOM veto does NOT extend to hand. base.toml marks senses/muse/worker feasible=false because a heavy checkpoint on an unrecognised card risks an OOM crash-loop; a 1.2B bf16 is the first generate lobe cheap enough that the conservative fallback can host it honestly.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h22", + "text": "Rendering the base profile produces HAND_FEASIBLE unset (i.e. feasible) with a declared model and budget, while senses/muse/worker stay FEASIBLE=false \u2014 proven by the regenerated tests/goldens/base.env, not by reading the TOML.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c13", + "kind": "non_goal", + "text": "LoRA/QLoRA is NOT in v1. Grepping lobes/ and tests/ for enable-lora|enable_lora|lora-modules|LORA returns ZERO hits, and CLAUDE.md states plainly 'there is no lobes train verb' \u2014 adapter training, adapter storage, --enable-lora/--lora-modules serving, adapter naming in /v1/models, and adapter-to-role addressing are all greenfield. The user's own framing is 'eventually'.", + "origin": "llm", + "status": "rejected", + "honesty_conditions": [], + "hard_questions": [ + { + "id": "q1", + "text": "contradiction with q4 confirmed --enable-lora adapter serving IS in v1; this claim excludes it. Superseded \u2014 reject.?", + "resolved": false, + "blocking": true, + "resolution": "" + } + ], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c14", + "kind": "boundary", + "text": "v1 must not foreclose LoRA even though it defers it: the served checkpoint stays bf16/unquantized. This is the same reason the 4B minor is served bf16 rather than the cosmicproc NVFP4 export \u2014 unsloth LoRA needs the bf16 base (docs/specs/2026-06-26-lobes-ships-a-minor-lobe-a-cheap-warm-co-resident.md). A quantized hand would make the 'fine-tuning base' half of the announcement undeliverable later.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h23", + "text": "The rendered vllm-hand command contains no --quantization token at all, and the catalog entry's quantization field is the 'none' sentinel \u2014 checked against the rendered compose, since an empty --quantization= would break vLLM exactly as the 4B minor's comment warns.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c15", + "kind": "boundary", + "text": "hand is TEXT-ONLY. LFM2.5-1.2B-Instruct carries no ViT (LiquidAI ships separate VL/audio variants), so hand must not advertise image_understanding/video_understanding, and \u2014 unlike the 4B minor \u2014 its compose lane needs no --language-model-only flag to strip a vision tower it does not have.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h24", + "text": "hand's ROLE_RESPONSIBILITIES contains neither image_understanding nor video_understanding, the rendered lane carries no --language-model-only, and posting an image part to model=hand fails cleanly rather than being silently dropped.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c16", + "kind": "boundary", + "text": "The Qwen3.5-4B minor gear is NOT deleted (cite-don't-delete). Whether hand supersedes it in practice is a separate decision from whether its catalog entry, docs/qwen3.5-4b-minor.md, and the vllm-minor compose service stay in-tree \u2014 they do.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h25", + "text": "After the change, the Qwen3.5-4B catalog entry still exists, docs/qwen3.5-4b-minor.md still resolves, and the vllm-minor compose service is still present behind COMPOSE_PROFILES=minor \u2014 a grep proves all three survive the repointing.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c17", + "kind": "boundary", + "text": "No live/validated claim without an evidence transcript (#108). Every doc, support table, and lobes capabilities output must read hand as declared/UNVALIDATED until a transcript lands under docs/evidence/ per card actually booted. Precedent in-tree: thor-muse's budget was measured but the shape stayed UNVALIDATED because the acceptance transcript never landed.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h26", + "text": "Before any evidence transcript lands, lobes capabilities and every doc/support table describe hand as declared/UNVALIDATED on every card; after a per-card transcript is committed, only THAT card reads validated. No blanket promotion from a single box's run.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c18", + "kind": "requirement", + "text": "hand joins lobes.roles.ROLES as the NINTH role with backend name 'hand', env prefix HAND_, compose service vllm-hand, and entries in every table enumerated in c6 \u2014 including gateway/server.py's _PEER_SERVED_NAME_ENV/_PEER_ROLE_HINT so HAND_PEER_PROXY is not silently inert (the 0.54.8 trap).", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h13", + "text": "GET /capabilities returns exactly nine role keys with hand among them, and 'lobes capabilities' agrees field-for-field with the gateway \u2014 the shared-core contract in roles.py holds for the new role with no CLI/gateway divergence.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c19", + "kind": "requirement", + "text": "The tier/alias space becomes DYNAMIC for the first time. Every alias today is a compile-time constant: catalog.TIER_ROLE is a literal dict and _tier_request.py:37 freezes _KNOWN_TIERS = frozenset(TIER_ROLE) at import. A per-deployment set of domain adapters cannot live in a module-level frozen constant \u2014 alias resolution must learn to admit names that exist only in this deployment's adapter inventory.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h14", + "text": "A request for model=hand: naming an adapter present in HAND_LORA_MODULES resolves and is served, while a name absent from the inventory is refused with a clear error rather than silently falling back to the base or to another lane. Prove BOTH directions.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c20", + "kind": "requirement", + "text": "Adapter advertisement must obey the #92 honesty rule end to end: GET /v1/models lists each LOADED adapter, GET /capabilities and 'lobes capabilities' report hand's adapter inventory, and an adapter that is declared but not loaded is never advertised as usable \u2014 the same feasible/ready/loaded discipline the eight existing roles already carry, applied one level deeper (per-adapter, not just per-role).", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h15", + "text": "With HAND_LORA_MODULES declaring an adapter whose path does not exist (so vLLM cannot load it), GET /v1/models does NOT list it and GET /capabilities does NOT advertise it as usable \u2014 declared-but-unloaded must never read as available. This is the #92 rule at adapter granularity and is the single most likely place to get it wrong.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c21", + "kind": "requirement", + "text": "catalog.TIER_ROLE repoints 'minor'/'cheap' to hand and gains a 'hand' alias; the capability order becomes hand < multimodal < worker < muse < main. The dict's ORDER is load-bearing \u2014 catalog.py:776-787 documents that tier_aliases derives ascending capability order from each role's LAST occurrence position, so this is an ordered edit, not a key swap.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h16", + "text": "resolve_tier('minor') and resolve_tier('cheap') both return the hand gear, resolve_tier('hand') returns it too, and tier_aliases still derives the ascending order hand < multimodal < worker < muse < main from the reordered dict \u2014 assert the derived ORDER, not just the mapping.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c22", + "kind": "requirement", + "text": "gateway/_pressure_policy.py's servable floor moves from the 4B minor to hand: hand is never shed, always served regardless of swap/iowait pressure, and the shed set stays exactly cortex/senses/worker/muse.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h17", + "text": "Under synthetic swap>75% / iowait>50% pressure, model=hand is served 200 while model=cortex/senses/worker/muse each return 429 with Retry-After \u2014 hand never sheds, and the shed set is unchanged.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c23", + "kind": "requirement", + "text": "The vllm-hand compose lane carries --enable-lora plus its sizing knobs (max_loras / max_lora_rank / max_cpu_loras) over a bf16 base with no --quantization flag \u2014 vLLM LoRA needs the unquantized base, which is the same constraint that already forces the bf16 sentinel.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h3", + "text": "VERIFIED at source: vllm/model_executor/models/lfm2.py declares 'class Lfm2ForCausalLM(nn.Module, HasInnerState, SupportsLoRA, SupportsPP, IsHybrid, SupportsQuant)' with packed_modules_mapping (qkv_proj, w13, in_proj), embedding_modules, and an hf_to_vllm_mapper shared with quantization. --enable-lora is a supported flag for this architecture. Must still be confirmed to BOOT with the flag armed on our digest \u2014 registration is not the same as a clean start.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c24", + "kind": "assumption", + "text": "hand's role contract expresses LEARNED SPECIALIZATION rather than generic action \u2014 responsibilities along the lines of domain_mastery / learned_skill / specialized_task / structured_extraction / classification, forbidden final_decision and security_decision. This is proposed vocabulary only: the exact tokens are a build-time call (the same caveat roles.py already records for the #81 lists).", + "origin": "llm", + "status": "rejected", + "honesty_conditions": [], + "hard_questions": [ + { + "id": "q2", + "text": "contradiction with q8 added repo_action to the forbidden list; this claim omits it. Superseded by c30. Reject.?", + "resolved": false, + "blocking": true, + "resolution": "" + } + ], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c25", + "kind": "boundary", + "text": "hand REPLACING minor is a repointing, not a removal: Qwen3.5-4B's catalog entry, docs/qwen3.5-4b-minor.md and the vllm-minor compose service all stay in-tree (cite-don't-delete). What changes is what the fleet POINTS AT \u2014 the tier target, the LoRA base, and the pressure floor.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h27", + "text": "resolve_tier('minor') returns the hand gear while the Qwen3.5-4B entry remains in SUPPORTED_MODELS \u2014 proving the change is a repointing of what the fleet resolves, not a removal of what the catalog knows.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c26", + "kind": "decision", + "text": "hand's tool-call parser is 'lfm2' \u2014 vLLM's purpose-built parser for LFM2/LFM2.5's special-token-delimited pythonic calls. lobes/runtime/_parser.py::_RULES gains an lfm2-scoped rule returning 'lfm2', with markers narrow enough ('lfm2', 'lfm2.5') not to collide with other families.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h2", + "text": "VERIFIED in-image: vllm/tool_parsers/lfm2_tool_parser.py defines Lfm2ToolParser with TOOL_CALL_START='<|tool_call_start|>' / TOOL_CALL_END='<|tool_call_end|>', registered under the exact key 'lfm2'. Critically the parser resolves both delimiters via self.vocab.get() and RAISES when either is missing \u2014 so a tokenizer/chat-template mismatch fails LOUDLY at init rather than silently degrading, which is strictly better than the gemma4 failure mode. Note the upstream HF discussion 'Fixed Chat Template to Fix Tool Calls' on this very checkpoint: the served tokenizer revision must actually carry those two tokens, and that must be checked on the pulled revision.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c27", + "kind": "decision", + "text": "hand needs NO reasoning parser. LFM2.5-1.2B-Instruct has no thinking mode \u2014 LiquidAI ships reasoning as a SEPARATE checkpoint (LFM2.5-1.2B-Thinking). So hand is unlike cortex (paired with --reasoning-parser=qwen3) and unlike the Gemma 4 lanes (which require the gemma4 tool+reasoning parser PAIR or leak <|channel>thought markers into content): hand wires the tool parser ALONE, and that is correct rather than an omission.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c28", + "kind": "decision", + "text": "hand's adapter inventory is declared as HAND_LORA_MODULES=\"name=path,...\" in .env and fixed at container boot; the vllm-hand lane passes it as --lora-modules alongside --enable-lora and the --max-loras/--max-lora-rank sizing knobs. Runtime hot-loading is deliberately NOT armed in v1.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c29", + "kind": "decision", + "text": "Addressing: model=hand serves the BASE checkpoint; model=hand: serves that adapter. The bare alias never 404s, so hand's always-servable-floor guarantee holds with an empty inventory. GET /v1/models lists the base and each LOADED adapter as distinct ids.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c30", + "kind": "decision", + "text": "hand's forbidden_responsibilities are final_decision, repo_action and security_decision \u2014 mirroring senses and muse, NOT worker. Granting repo_action later is additive and contract-compatible; revoking it would be a break, so v1 withholds it.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c31", + "kind": "decision", + "text": "v1 ships ZERO adapters: the base serves, --enable-lora is armed and tested, and the adapter inventory is empty. No domain is advertised that has not been learned.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c32", + "kind": "audience", + "text": "Colleague and every mesh consumer that addresses lobes by ROLE name rather than model id, plus the operator running lobes init/up on each box. Secondary: the future fine-tuning workflow that will train domain adapters against this base.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h28", + "text": "The role is discoverable the way the audience is told to discover it: a caller resolving capabilities by ROLE NAME finds hand in GET /capabilities and lobes capabilities without parsing any model id, per the standing consumer contract that #361 punished cortex for violating.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c33", + "kind": "after_state", + "text": "Every machine in the mesh \u2014 Spark, Thor, Orin, and any unrecognised card \u2014 hosts a hand: a 1.2B LFM2.5 lobe served bf16 with --enable-lora armed, addressable as model=hand for the base and model=hand: for a learned adapter. hand is the fleet's always-servable floor (never shed under pressure) and has replaced the 4B minor as the cheap tier target and the LoRA base. Its adapter inventory is operator-declared in .env and honestly advertised: /v1/models and GET /capabilities list exactly the adapters actually loaded, and none that are not.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h18", + "text": "An acceptance transcript per validated card is committed under docs/evidence/ showing hand serving on that box; any card without one reads DECLARED/UNVALIDATED in every doc, support table and capabilities output (#108). Thor and Orin are ssh-reachable and already carry the pinned image, so both are obtainable; the Spark and the base fallback are not covered by those runs.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c34", + "kind": "why_it_matters", + "text": "Today the fleet's cheap lane does not work at all: the 4B minor gear pins vLLM 0.19.0, the image on which its own Qwen3.5 architecture hits the FLA/Gated-DeltaNet bug and emits gibberish, so 'minor' has never served correctly and no box has a working small lobe. Separately, every specialization the mesh wants must be paid for in prompt tokens against a big general model, because nothing in the tree can serve a fine-tuned adapter \u2014 grepping for LoRA returns zero hits. hand fixes both: a small lobe that actually runs on the nightly engine, and the first surface where a learned skill can be served instead of prompted.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h29", + "text": "The two motivating facts are re-verified at implementation time rather than trusted from this frame: that the 4B minor still cannot serve correctly on its pinned image, and that no LoRA surface exists in-tree. If either has changed, the justification is restated.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c35", + "kind": "success_signal", + "text": "A caller on any box in the mesh gets a 200 from model=hand with no pressure-based 429, on a card whose acceptance transcript is committed under docs/evidence/; lobes capabilities reports hand feasible/ready with lora_enabled true and an adapter list matching exactly what vLLM has loaded; a tool call through the lfm2 parser returns a structured tool_calls array rather than prose; and the full test suite passes with the 28 regenerated goldens.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h19", + "text": "Every clause of the success signal is demonstrated by committed artefacts \u2014 an evidence transcript for the live clauses and a green CI run for the test-suite clause \u2014 not asserted in prose.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c36", + "kind": "before_state", + "text": "Eight Colleague roles, all of which are either heavy (cortex/senses/muse/worker) or non-generate (embedder/reranker/stt/tts). The only small generate gear, minor, is an opt-in unnamed gear on a broken image, is not a Colleague role, and is not hosted by any shape by default. No LoRA surface exists anywhere in the tree and there is no lobes train verb.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h30", + "text": "The before-state is checked against the tree at implementation time \u2014 eight roles in lobes.roles.ROLES, zero LoRA hits, no train verb \u2014 so the spec is not describing a repo that has moved on.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c37", + "kind": "non_goal", + "text": "Adapter TRAINING is not in v1. This SUPERSEDES c13, which was written before q4 and wrongly excluded all LoRA surface: adapter SERVING (--enable-lora, --lora-modules, per-adapter addressing and advertisement) IS in v1. What stays deferred is producing adapters \u2014 lora/qlora training runs, a 'lobes train' verb, adapter storage and versioning. lobes v1 can serve a hand's muscle memory; it cannot yet build it.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c38", + "kind": "assumption", + "text": "hand's responsibilities are domain_mastery, learned_skill, specialized_task and tool_use. This SUPERSEDES c24, whose forbidden list predates q8 and omitted repo_action; the authoritative forbidden list is c30's (final_decision, repo_action, security_decision). Vocabulary remains a build-time call per the #81 caveat in roles.py.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c39", + "kind": "boundary", + "text": "Training is OWNED BY unsloth-cli, a sibling repo (/home/spark/git/unsloth-cli, dist unsloth-cli v0.5.0, console script 'sloth'), NOT by lobes. lobes never trains: there is no lobes train verb and there will not be one. The contract between the two is an ADAPTER ON DISK \u2014 unsloth-cli produces a LoRA/QLoRA adapter directory, the operator declares its path in HAND_LORA_MODULES, and lobes serves it. This is a cleaner boundary than 'training is deferred': the capability is not missing, it lives on the other side of a file-path interface.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h31", + "text": "Nothing in lobes imports, shells out to, or version-pins unsloth-cli; a grep for 'unsloth' across lobes/ returns hits only in comments and docs, never in code. The dependency is one-directional and documentary.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c40", + "kind": "assumption", + "text": "unsloth-cli's fine-tuning verbs are NOT yet built \u2014 its 0.5.0 README documents only scaffold verbs (whoami / learn / explain / overview / doctor / cli overview), with fine-tuning stated as the repo's purpose rather than its shipped surface. So the adapter-producing half of the muscle-memory loop is a cross-repo dependency that does not yet exist, which is an independent reason v1 ships zero adapters. Coordinating it is a brief/issue ON unsloth-cli, not work in this repo.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h20", + "text": "The handoff is a plain filesystem path with no lobes-side coupling to unsloth-cli: nothing in lobes imports it, depends on it, or assumes its layout beyond 'a directory vLLM accepts as a LoRA adapter'. Verify lobes can serve an adapter produced by ANY trainer, not only unsloth.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c41", + "kind": "requirement", + "text": "roles_measure.py must gain hand or 'lobes measure' CRASHES. _FAMILY_BY_ROLE (roles_measure.py:116) is indexed unguarded at line 419 while iterating lobes.roles.ROLES, and _LLM_ROLES (line 112) lists only cortex/senses/muse/worker. Adding hand to ROLES without touching this file is a KeyError, not a silent omission \u2014 louder than the 0.54.8 drop but the same class of lockstep failure, and c6's table list does not name _FAMILY_BY_ROLE or _LLM_ROLES specifically.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h32", + "text": "A test iterates lobes.roles.ROLES and asserts _FAMILY_BY_ROLE covers every role, so the KeyError class of bug cannot recur for a tenth role either; 'lobes measure' completes for hand and reports it in the generate family.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c42", + "kind": "requirement", + "text": "Every deployment shape's GPU budget must be RE-DERIVED with hand co-resident, or hand's allocation taken out of an existing lane rather than added on top. Each mesh-lobe reclaim was MEASURED on hardware with no hand present: spark-lobe cortex gpu_mem_util=0.44 (and the naive reclaim-sum was REFUSED live before 0.44 was found), thor-worker 0.45, orin 0.45 \u2014 each already sums to ~0.56-0.57 with the pooling gears. 'Runs on all machines' silently adds a tenth consumer to budgets that were measured to the edge. No claim in this frame re-derives them.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h33", + "text": "For EVERY shape that hosts hand, the committed budget is either (a) re-measured on real hardware with the hand lane running, with the transcript under docs/evidence/, or (b) explicitly re-apportioned so the total is unchanged from the pre-hand measured value. A shape whose budget is left at its pre-hand number with hand added on top does NOT satisfy this \u2014 that is the exact arithmetic that was refused live on spark-lobe, thor-lobe and thor-muse.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c43", + "kind": "assumption", + "text": "A single gpu_mem_util for hand cannot be right across the fleet. At 0.06 the same number is 7.7 GiB on a 128 GB Spark but 3.84 GiB on a 64 GB Orin \u2014 against ~2.4 GiB of bf16 weights that leaves the Orin roughly 1.4 GiB of KV at 32K context, before any LoRA adapter memory. The existing budget park treats this as tuning detail; the after_state claim asserts hand runs on the Orin, which is a stronger claim than the park covers.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h34", + "text": "hand's gpu_mem_util is declared PER CARD in each card profile rather than once globally, and the Orin value is validated by an actual boot at the served max_model_len \u2014 not inferred from the Spark's.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c44", + "kind": "boundary", + "text": "Adding hand to the role contract is effectively IRREVERSIBLE and that is accepted knowingly. The same additive/subtractive asymmetry that justified withholding repo_action (#180) applies to the role itself: once consumers resolve model=hand by name, removing it breaks them. There is no rollback path beyond a breaking change, so the decision to make hand a role rather than an unnamed gear should be treated as one-way.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h35", + "text": "The irreversibility is stated in docs/colleague-stack.md so a future maintainer reads it before proposing a tenth role or a retirement, rather than rediscovering it from the #361 consumer contract.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c45", + "kind": "requirement", + "text": "lobes/minor/governance.py and the 'lobes eval minor' verb must be reconciled with the repoint. governance.py is keyed to the ROLE NAME 'minor' (its own docstring: 'never to a specific model identifier') and carries allowed/forbidden duty lists plus a confidence threshold written for a general-purpose 4B. Once the minor tier resolves to hand, that policy silently governs a different model with different characteristics \u2014 and a DOMAIN-SPECIALIZED one, whose competence is adapter-dependent rather than uniform. Decide whether the policy transfers, is re-derived for hand, or is retired with the tier.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h36", + "text": "After the change there is exactly one answer to 'what governs the minor tier': either governance.py names hand and its duty lists are re-derived for an adapter-dependent specialist, or the module and 'lobes eval minor' are retired with the tier. A state where governance.py still says ROLE='minor' while the minor tier serves hand does not satisfy this.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c46", + "kind": "requirement", + "text": "up.py's COLLEAGUE_STACK bundle must gain hand. Its docstring defines colleague-stack as 'the FULL six-role Colleague set = the default fleet roles (cortex/senses/embedder/reranker) PLUS the audio-overlay roles (stt/tts)' and the constant's comment says 'the six roles machine-as-brain hosts'. hand is default-hosted, so both the count and the target list are wrong the moment it lands \u2014 and 'lobes up colleague-stack' would silently bring up a stack missing the role the spec claims runs everywhere.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h37", + "text": "'lobes up colleague-stack --apply' starts the hand lane along with the others, and up.py's docstring and constant comment state the correct role count \u2014 proven by running the target, not by reading the source.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c47", + "kind": "requirement", + "text": "The vllm-hand lane carries MG_LOG_NAME=hand for durable logs (the #50 convention every other lane follows via mg-logwrap), and hand is reachable by 'lobes measure' as a generate-family role. Without the log name the lane's boot output is not recoverable after a restart \u2014 which is precisely what would be needed to debug a co-residency OOM introduced by the budget gap above.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h38", + "text": "After a container restart, the hand lane's boot log is still readable under the durable log dir with the hand name \u2014 the same check the other lanes' MG_LOG_NAME wiring already satisfies.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c48", + "kind": "requirement", + "text": "--max-lora-rank has a documented default and a legible failure path. An adapter trained above the armed rank fails to LOAD at serve time, and vLLM's error for that is not self-explanatory to an operator who only knows they trained something with unsloth-cli. Since the trainer is a SEPARATE repo (unsloth-cli#16) the rank is chosen on the far side of a boundary lobes does not control, making mismatch the single most likely day-two failure.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h39", + "text": "Declaring an adapter whose rank exceeds --max-lora-rank produces an operator-legible failure that names the rank mismatch \u2014 in the lane's logs and, if the lane still starts, absent from /v1/models rather than half-advertised. The chosen default rank is documented in the per-model doc so unsloth-cli#16 can train against it.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c49", + "kind": "boundary", + "text": "hand is NEVER PROXIED and never referred: it carries no HAND_PEER_ORIGIN / _PEER_PROXY / _PEER_API_KEY, and lobes/gateway/server.py's _PEER_SERVED_NAME_ENV / _PEER_ROLE_HINT tables deliberately OMIT it. This narrows c7 and c18, which assumed hand would ride the peer tables by symmetry with the other eight roles \u2014 for hand the correct entry in those tables is no entry at all, and a test should assert its absence so a future symmetry-minded refactor does not add it back.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h41", + "text": "A test asserts hand is ABSENT from server.py's peer tables and from the FEASIBLE/peer env channels, so the never-proxied decision is enforced rather than merely documented.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c50", + "kind": "requirement", + "text": "Because hand is never proxied and never referred, NO deployment shape may drop it \u2014 hand appears in the hosts list of ALL seven built-in shapes, including the mesh-lobe shapes that drop heavy lobes. A dropped hand would have no referral and no proxy, so model=hand would 404 role_infeasible with no hosted_by and no recourse anywhere in the mesh: the one lobe state the honest-referral work (#112) exists to prevent. This is what makes 'runs on every host' a structural invariant rather than a default.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h40", + "text": "A test asserts hand is present in the hosts list of every built-in shape TOML, so adding an eighth shape that omits it fails CI rather than silently creating an unreachable role.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + } + ], + "open_vagueness": [ + { + "id": "v1", + "text": "The exact vLLM --tool-call-parser for LFM2.5. The card describes ChatML-style pythonic-or-JSON function calls, but the gemma4 precedent is explicit: 'pythonic' was a never-validated guess that the 2026-07-17 live run disproved, silently relaying well-formed calls as prose. The value must be measured against the served checkpoint, not inferred.", + "kind": "unknown_blocking", + "claim_id": null, + "resolved": true, + "resolution": "RESOLVED: the parser is 'lfm2'. vLLM ships a PURPOSE-BUILT lfm2_tool_parser (--tool-call-parser lfm2, docs.vllm.ai/en/latest/api/vllm/tool_parsers/lfm2_tool_parser/) for LFM2/LFM2.5's pythonic calls wrapped in <|tool_call_start|> / <|tool_call_end|>. The caution was warranted and the naive guess would have been WRONG for exactly the gemma4 reason: those delimiters are SPECIAL TOKENS, so the generic 'pythonic' parser (run with skip_special_tokens=True) would never see them and would silently relay well-formed calls as prose. Documentation-grade, not yet measured \u2014 a live check stays an honesty condition.", + "resolution_claim_id": null + }, + { + "id": "v2", + "text": "Per-card gpu_mem_util and max_model_len for hand on spark / thor / orin / base. ~2.4 GiB bf16 weights suggests a small util, but every reclaim/budget value in this repo has been MEASURED rather than computed, and the naive value has been refused live more than once (spark-lobe, thor-lobe, thor-muse).", + "kind": "unknown_nonblocking", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v3", + "text": "Whether the lfm1.0 license imposes any redistribution or deployment condition that the catalog/docs must record. Gemma 4 already sets a precedent for a non-Apache checkpoint in-tree, so this is likely a note rather than a blocker \u2014 but it has not been read.", + "kind": "unknown_nonblocking", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v4", + "text": "BLOCKING: whether vLLM's Lfm2ForCausalLM implements SupportsLoRA. --enable-lora only works for architectures that declare it, and vLLM refuses at startup for those that do not. A targeted search could not confirm LFM2 is on that list \u2014 the vendor card documents TRAINING adapters (Unsloth/TRL), which is a different claim from SERVING them in vLLM. v1 promises adapter serving, so this must be verified against the nightly image before the promise is made. Same class of error as the gemma4 'pythonic' guess.", + "kind": "unknown_blocking", + "claim_id": null, + "resolved": true, + "resolution": "RESOLVED POSITIVELY: LFM2 IS LoRA-servable in vLLM. vllm/model_executor/models/lfm2.py declares 'class Lfm2ForCausalLM(nn.Module, HasInnerState, SupportsLoRA, SupportsPP, IsHybrid, SupportsQuant)' and carries the LoRA plumbing \u2014 packed_modules_mapping (qkv_proj, w13, in_proj), embedding_modules (embed_tokens/lm_head), and an hf_to_vllm_mapper shared with quantization so LoRA and quant layer names stay consistent. --enable-lora is therefore a supported flag for this architecture and the v1 adapter-serving promise is safe to make. Source-grade, not yet measured on our image.", + "resolution_claim_id": null + }, + { + "id": "v5", + "text": "Granting hand repo_action once real adapters exist \u2014 deferred from q8 and tracked as agentculture/lobes-cli#180. Blocked on the deferred LoRA/QLoRA training work: until a hand can actually learn a domain there is nothing for the authority to be scoped to.", + "kind": "follow_up", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v6", + "text": "Adapter production for hand is tracked cross-repo as agentculture/unsloth-cli#16 (verify/add LFM2.5-1.2B LoRA fine-tuning). Upstream Unsloth already supports the family \u2014 official LFM2.5 tutorial, Liquid's own docs, and an unsloth/LFM2.5-1.2B-Instruct mirror \u2014 so this is wrapper verification, not new library work. Includes checking that training against the unsloth mirror produces adapters loadable against the LiquidAI-served base.", + "kind": "follow_up", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v7", + "text": "Whether any downstream mesh consumer (culture, daria, reachy-mini-cli, the colleague backend) parses model ids in a way a colon would break. lobes itself is clean, but the 'hand:' form is a new id SHAPE the mesh has never been served before \u2014 every existing id is org/name.", + "kind": "unknown_nonblocking", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + } + ], + "scope_entries": [ + { + "id": "s1", + "surface": "lobes/catalog.py + tests/test_catalog.py + lobes/runtime/_parser.py", + "finding": "a new gear is not just a catalog row: the catalog's bf16 'none' quantization sentinel (catalog.py:363-372) and the tool_parser==infer_parser invariant (test_catalog.py:76) both bind at once, so _parser.py::_RULES must grow an lfm2 rule in the same change", + "seeds": [ + "c2", + "c3" + ] + }, + { + "id": "s2", + "surface": "docs/vllm-nightly-migration.md + lobes/templates/fleet/docker-compose.yml image pins", + "finding": "the fleet runs TWO engines: primary/embed/rerank/worker on the nightly digest (0.23.1rc1.dev672) and minor/middle still on nvcr.io/nvidia/vllm:26.04-py3 (0.19.0). LFM2 needs >= 0.23.0, so hand cannot reuse the vllm-minor lane's image even though minor is the closest structural template", + "seeds": [ + "c4", + "c5" + ] + }, + { + "id": "s3", + "surface": "lobes/roles.py + profiles/schema.py + profiles/render.py + gateway/_config.py + gateway/server.py", + "finding": "the role set is enumerated in at least a dozen hand-maintained tables across five packages with no single registry; adding a ninth role is a lockstep edit, and server.py's peer tables are the proven silent-drop point (the 0.54.8 worker-proxy fix)", + "seeds": [ + "c6", + "c7" + ] + }, + { + "id": "s4", + "surface": "lobes/profiles/builtin/*.toml + builtin_shapes/*.toml + tests/goldens/", + "finding": "the profile x shape matrix is 4 cards x 7 shapes with committed .env goldens for each; 'every machine hosts hand' touches all 4 card TOMLs, all 7 shape TOMLs and regenerates 28 golden files", + "seeds": [ + "c8" + ] + }, + { + "id": "s5", + "surface": "docs/colleague-stack.md, gateway-fleet.md, deployment-shapes.md, machine-profiles.md, openai-api.md, explain/catalog.py", + "finding": "the role contract is duplicated in prose across six doc surfaces plus the in-CLI explain catalog; test_catalog.py::test_every_doc_file_exists additionally forces a new per-model doc to exist before the catalog entry can land", + "seeds": [ + "c9" + ] + }, + { + "id": "s6", + "surface": "lobes/profiles/shapes.py (OPT_IN_CORE_ROLES / DEFAULT_HOSTED_ROLES) + tests/goldens/regen.py", + "finding": "every role added since #81 was opt-in-hosted specifically to keep machine-as-brain byte-identical; a role hosted by DEFAULT is a new composition case with no in-tree precedent, and re-baselines the identity-shape golden contract", + "seeds": [ + "c10", + "c11" + ] + }, + { + "id": "s7", + "surface": "lobes/profiles/builtin/base.toml", + "finding": "base.toml vetoes senses/muse/worker with feasible=false on OOM grounds and stands cortex up on the 4B minor gear; a 1.2B is the first generate lobe small enough that the conservative fallback could host it as itself", + "seeds": [ + "c12" + ] + }, + { + "id": "s8", + "surface": "grep -rn 'enable-lora|enable_lora|lora-modules|LORA' lobes/ tests/", + "finding": "ZERO hits \u2014 there is no LoRA serving flag, no adapter store, no train verb, and no adapter naming anywhere in the tree. LoRA/QLoRA is greenfield, not an extension of an existing surface", + "seeds": [ + "c13", + "c14" + ] + }, + { + "id": "s9", + "surface": "docs/specs/2026-06-26-lobes-ships-a-minor-lobe-a-cheap-warm-co-resident.md + catalog.py minor entry", + "finding": "the 4B minor is served bf16 ON PURPOSE because unsloth LoRA needs the bf16 base; that precedent binds hand's quantization choice if hand is to remain a fine-tuning base", + "seeds": [ + "c14" + ] + }, + { + "id": "s10", + "surface": "huggingface.co/LiquidAI/LFM2.5-1.2B-Instruct model card", + "finding": "1.17B params, 16 layers (10 double-gated conv + 6 GQA), 32768 context, bf16, license lfm1.0, TEXT-ONLY (VL/audio are separate variants), ChatML-style tool calling, vLLM natively supported via Lfm2ForCausalLM with no --trust-remote-code, and the card explicitly recommends fine-tuning (Unsloth/TRL LoRA documented)", + "seeds": [ + "c15" + ] + }, + { + "id": "s11", + "surface": "lobes/gateway/_pressure_policy.py", + "finding": "the shed set is cortex/senses/worker/muse (429 + Retry-After) while an explicit minor request is 'the servable floor, always served regardless of pressure' \u2014 a 1.2B lobe is floor-class by cost, so hand's pressure posture is a real decision, not a default", + "seeds": [] + }, + { + "id": "s12", + "surface": "docs/evidence/ + the #108 UNVALIDATED rule", + "finding": "the repo enforces a hard split between DECLARED and VALIDATED; thor-muse's measured budget still reads UNVALIDATED because no acceptance transcript landed. Spark, Thor and Orin are all ssh-reachable, so per-card evidence is obtainable but must be a planned deliverable", + "seeds": [ + "c17" + ] + }, + { + "id": "s13", + "surface": "lobes/catalog.py TIER_ROLE + docs/qwen3.5-4b-minor.md + prior live measurement of Qwen3.5-4B", + "finding": "the 'minor' tier's incumbent has never served correctly: Qwen3.5-4B hits the vLLM 0.19.0 FLA/Gated-DeltaNet gibberish bug on the very image vllm-minor pins, so the cheap lane hand would join is a lane that does not currently work", + "seeds": [] + }, + { + "id": "s14", + "surface": "lobes/gateway/_tier_request.py:37 + catalog.TIER_ROLE + gateway/_routing.py tier_aliases", + "finding": "the whole alias layer is compile-time frozen (_KNOWN_TIERS = frozenset(TIER_ROLE) at import) and its dict ORDER encodes capability ranking via last-occurrence position; a per-deployment adapter set is the first dynamic member the alias space has ever had", + "seeds": [ + "c19", + "c21" + ] + }, + { + "id": "s15", + "surface": "vLLM --enable-lora / SupportsLoRA architecture gate", + "finding": "LoRA serving is per-architecture opt-in in vLLM (SupportsLoRA), not a universal flag, and LFM2's presence on that list is UNCONFIRMED \u2014 the model card documents training adapters, which does not imply vLLM can serve them", + "seeds": [ + "c24" + ] + }, + { + "id": "s16", + "surface": "lobes/profiles/builtin/base.toml honesty posture", + "finding": "base.toml today stands the CORTEX role up on a 4B checkpoint \u2014 a box advertising the 27B reasoning role while serving a small model, which is the half-honest posture #92 exists to forbid; hand as a real always-hosted role lets base.toml declare hand as itself and mark cortex infeasible instead", + "seeds": [] + }, + { + "id": "s17", + "surface": "challenge pass / adjacent-systems lens: lobes/roles_measure.py", + "finding": "_FAMILY_BY_ROLE[role] at line 419 is an unguarded dict index over ROLES; a ninth role absent from the map raises KeyError. _LLM_ROLES at line 112 separately gates which roles get generate-family probes.", + "seeds": [] + }, + { + "id": "s18", + "surface": "challenge pass / hardware + concurrency lens: builtin_shapes/{spark-lobe,thor-worker}.toml + builtin/orin.toml", + "finding": "read the committed reclaim values: spark-lobe cortex 0.44 @262144, thor-worker worker 0.45 @262144, orin 0.45 @262144, each plus embed 0.06 + rerank 0.06. Every one is a MEASURED value found after a naive value was refused, and none was measured with a hand lane co-resident.", + "seeds": [] + }, + { + "id": "s19", + "surface": "challenge pass / reversibility lens: lobes/roles.py ROLES + the #81 consumer contract", + "finding": "consumers are instructed to resolve capabilities by role name and never parse model ids (the #361 finding against cortex), which is exactly what makes role REMOVAL a breaking change; no in-tree precedent exists for retiring a role", + "seeds": [] + }, + { + "id": "s20", + "surface": "challenge pass / unstated-assumption lens: lobes/minor/{governance.py,_client.py} + cli/_commands/eval.py", + "finding": "governance.py:33 ROLE='minor' with duty allow/forbid lists and a confidence floor; eval.py exposes 'lobes eval minor' over lobes.minor.chat_text. The frame repoints the minor TIER at hand but says nothing about the governance policy or the CLI verb that ride that name.", + "seeds": [] + }, + { + "id": "s21", + "surface": "challenge pass / failure-mode lens: vLLM --enable-lora sizing knobs vs the unsloth-cli boundary", + "finding": "rank is decided by the trainer (a different repo) and enforced by the server (this repo); nothing in the frame pins or communicates it across that boundary. max_loras similarly caps how many hands can be resident at once.", + "seeds": [] + }, + { + "id": "s22", + "surface": "challenge pass / adjacent-systems lens: model-id delimiter handling for the 'hand:' form", + "finding": "CLEAN. Grepped lobes/gateway/, roles.py and cli/_commands/capabilities.py for split('/')/split(':')/partition() \u2014 the only hits are _config.py:398 partition('=') for alias pairs, server.py:246 partition(' ') for the auth scheme, and _realtime.py:197 for HTTP headers. Nothing parses a model id by delimiter, so a colon-bearing id passes through unharmed. Residual risk is OUTSIDE this repo: downstream consumers that split model ids themselves.", + "seeds": [] + }, + { + "id": "s23", + "surface": "challenge pass / migration lens: existing ~/.lobes deployments on wheel upgrade", + "finding": "compose ${VAR:-default} substitution means an upgraded box renders the hand lane with defaults and no operator action; combined with the absent-means-feasible convention this changes what a pre-hand box ADVERTISES. Seeded q10.", + "seeds": [] + } + ], + "lapses": [] +} diff --git a/.eidetic/memory/lobes__public.jsonl b/.eidetic/memory/lobes__public.jsonl index 069fa75..93590e6 100644 --- a/.eidetic/memory/lobes__public.jsonl +++ b/.eidetic/memory/lobes__public.jsonl @@ -1,10 +1,10 @@ -{"id": "qwen3.5-4b-verified-facts", "hash": "3f61d119c727d0f228e4d2e9dd8dfd71690bf0d526c8511dca023eb553c7d64f", "content": "Qwen/Qwen3.5-4B verified from HF config.json (2026-06-26): architecture is HYBRID linear-attention (Gated Delta/SSM) + full-attention (32 layers: 24 linear + 8 full), NOT MoE and NOT plain dense -> no vLLM --moe-backend. It is MULTIMODAL (full ViT, image+video tokens) so serve text-only via --language-model-only. Ships a BUILT-IN MTP draft head (mtp_num_hidden_layers=1) -> native speculative decoding, no separate draft checkpoint. 256K native (max_position_embeddings 262144), mRoPE. dtype bf16, public, Apache-2.0. Tool-call format = qwen3_coder (XML ), NOT hermes. No official nvidia/RedHatAI NVFP4 for the 4B; community cosmicproc/Qwen3.5-4B-NVFP4 exists (NVIDIA ModelOpt NVFP4 W4A4 -> vLLM --quantization modelopt_fp4; conv1d + lm_head left bf16; needs Blackwell for W4A4 kernels).", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "reference", "record_metadata": {"source": "hf-config-json", "topic": "qwen3.5-4b", "relates_to": "lobes-catalog", "date": "2026-06-26"}, "created": "2026-06-26T09:44:18.173778+00:00", "last_recall": "2026-07-31T13:22:52.104978+00:00", "recall_count": 9.0, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} -{"id": "minor-lobe-spec-issue-64", "hash": "8b97d0eb6db4a548000aa5e6d06068f6173bc562f16c2f8693f0a4ec028da4bb", "content": "lobes issue #64 ('minor lobe') was specced via /think on 2026-06-26 -> docs/specs/2026-06-26-lobes-ships-a-minor-lobe-a-cheap-warm-co-resident.md. Scope (user-confirmed) is the FULL minor-role runtime, not just a catalog entry: add Qwen3.5-4B as catalog gear role_hint=minor, served bf16 WARM CO-RESIDENT behind the gateway, + new read-only verbs lobes run/route/eval with escalation + confidence governance. Key decisions: served checkpoint = bf16 Qwen/Qwen3.5-4B (chosen because unsloth LoRA needs the bf16 base; NVFP4 cosmicproc is doc-only untested); route v1 routes ONLY across lobes GEARS (minor vs primary vs candidate) + escalate, not tools/mesh agents; run/route/eval call the model via the gateway OpenAI endpoint reusing assess.py's stdlib urllib client. Deferred (not yet): actual fine-tuning/lobes train, eval-suite contents, MTP spec-decode for minor. Two code gotchas it requires: (1) runtime/_parser.py infer_parser returns 'hermes' for a qwen3.5 id but the model needs 'qwen3_coder' -> must extend _RULES (catalog test asserts tool_parser==infer_parser(id)); (2) catalog requires non-empty quantization for generate gears, so a bf16 gear needs a quantization-field convention (sentinel that switch/compose translate to omitting --quantization).", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "project", "record_metadata": {"source": "claude-code-session", "topic": "minor-lobe", "relates_to": "issue-64", "date": "2026-06-26"}, "created": "2026-06-26T09:44:18.175114+00:00", "last_recall": "2026-07-14T17:07:59.068575+00:00", "recall_count": 6, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} +{"id": "qwen3.5-4b-verified-facts", "hash": "3f61d119c727d0f228e4d2e9dd8dfd71690bf0d526c8511dca023eb553c7d64f", "content": "Qwen/Qwen3.5-4B verified from HF config.json (2026-06-26): architecture is HYBRID linear-attention (Gated Delta/SSM) + full-attention (32 layers: 24 linear + 8 full), NOT MoE and NOT plain dense -> no vLLM --moe-backend. It is MULTIMODAL (full ViT, image+video tokens) so serve text-only via --language-model-only. Ships a BUILT-IN MTP draft head (mtp_num_hidden_layers=1) -> native speculative decoding, no separate draft checkpoint. 256K native (max_position_embeddings 262144), mRoPE. dtype bf16, public, Apache-2.0. Tool-call format = qwen3_coder (XML ), NOT hermes. No official nvidia/RedHatAI NVFP4 for the 4B; community cosmicproc/Qwen3.5-4B-NVFP4 exists (NVIDIA ModelOpt NVFP4 W4A4 -> vLLM --quantization modelopt_fp4; conv1d + lm_head left bf16; needs Blackwell for W4A4 kernels).", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "reference", "record_metadata": {"source": "hf-config-json", "topic": "qwen3.5-4b", "relates_to": "lobes-catalog", "date": "2026-06-26"}, "created": "2026-06-26T09:44:18.173778+00:00", "last_recall": "2026-08-09T22:22:34.424221+00:00", "recall_count": 10.0, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} +{"id": "minor-lobe-spec-issue-64", "hash": "8b97d0eb6db4a548000aa5e6d06068f6173bc562f16c2f8693f0a4ec028da4bb", "content": "lobes issue #64 ('minor lobe') was specced via /think on 2026-06-26 -> docs/specs/2026-06-26-lobes-ships-a-minor-lobe-a-cheap-warm-co-resident.md. Scope (user-confirmed) is the FULL minor-role runtime, not just a catalog entry: add Qwen3.5-4B as catalog gear role_hint=minor, served bf16 WARM CO-RESIDENT behind the gateway, + new read-only verbs lobes run/route/eval with escalation + confidence governance. Key decisions: served checkpoint = bf16 Qwen/Qwen3.5-4B (chosen because unsloth LoRA needs the bf16 base; NVFP4 cosmicproc is doc-only untested); route v1 routes ONLY across lobes GEARS (minor vs primary vs candidate) + escalate, not tools/mesh agents; run/route/eval call the model via the gateway OpenAI endpoint reusing assess.py's stdlib urllib client. Deferred (not yet): actual fine-tuning/lobes train, eval-suite contents, MTP spec-decode for minor. Two code gotchas it requires: (1) runtime/_parser.py infer_parser returns 'hermes' for a qwen3.5 id but the model needs 'qwen3_coder' -> must extend _RULES (catalog test asserts tool_parser==infer_parser(id)); (2) catalog requires non-empty quantization for generate gears, so a bf16 gear needs a quantization-field convention (sentinel that switch/compose translate to omitting --quantization).", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "project", "record_metadata": {"source": "claude-code-session", "topic": "minor-lobe", "relates_to": "issue-64", "date": "2026-06-26"}, "created": "2026-06-26T09:44:18.175114+00:00", "last_recall": "2026-08-09T22:22:34.424221+00:00", "recall_count": 7.0, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} {"id": "devague-blocking-hard-question-gotcha", "hash": "0b8151753dab00bb4c64218896d4cf470b2bb08e3c1a13b5ad000f6c89e13723", "content": "devague (the /think CLI) gotcha: a BLOCKING hard question (interrogate --hard-question --blocking) is NOT cleared by rejecting the claim it hangs on. convergence._missing_open_uncertainty iterates ALL frame.claims (including rejected) for q.blocking and not q.resolved. There is no CLI verb to resolve a hard question (interrogate has no --resolve; confirm/reject only take c*/h* ids; question --resolve is for the separate 'question' move's items). Workaround: edit the local working-state JSON at .devague/frames/.json and set the hard_question's resolved=true (honest when the question is actually answered). Also: the convergence gate requires a confirmed honesty condition on EVERY confirmed spec-affecting claim (audience/before_state/why_it_matters/after_state/boundary/success_signal/requirement/announcement), not just requirements; decisions/assumptions/non_goals are exempt.", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "reference", "record_metadata": {"source": "claude-code-session", "topic": "devague", "relates_to": "think-skill", "date": "2026-06-26"}, "created": "2026-06-26T09:44:18.175320+00:00", "last_recall": null, "recall_count": 0, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} -{"id": "qwen3.5-vllm-0.19-fla-gibberish-gb10", "hash": "d038d33600580542974ea8dc412cce46f97641e2f6a77edc77f9633aa1c492a2", "content": "Serving Qwen3.5 (Qwen3_5ForConditionalGeneration; Gated DeltaNet linear-attention hybrid) on the lobes GB10 fleet's vLLM image nvcr.io/nvidia/vllm:26.04-py3 (engine 0.19.0): the architecture IS registered (verified via ModelRegistry: Qwen3_5ForConditionalGeneration + Qwen3_5MTP present) so it LOADS, BUT vLLM 0.19.0 carries a known FLA/Gated-DeltaNet tensor-format bug on Blackwell (sm_120/sm_121) that produces GIBBERISH output. The fix (vLLM PR #43961) shipped only in v0.23.0. So a live Qwen3.5-4B serve on the current production image likely emits garbled text -> NOT a meaningful live test. Also: V2 model runner crashes on the mixed-attention KV cache (workaround --enforce-v1); GDN needs --max-num-batched-tokens 2096 for cache alignment; sm_121 needs forward-compat (TORCH_CUDA_ARCH_LIST=12.0). --language-model-only correctly drops the vision tower for inference of the full multimodal checkpoint. A meaningful live 4B co-resident test needs vLLM >= 0.23.0 (newer image). The minor-lobe CODE (issue #64: catalog gear, qwen3_coder parser rule, run/route/eval verbs, governance, opt-in co-resident fleet service) is independent and CI-green. Fleet facts: gateway published on host :8001 (not :8000); deployment dir ~/.model-gear; HF cache /home/spark/.cache/huggingface; image runtime=runc.", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "reference", "record_metadata": {"source": "claude-code-session", "topic": "qwen3.5-serving", "relates_to": "minor-lobe", "date": "2026-06-26"}, "created": "2026-06-26T11:11:12.839972+00:00", "last_recall": "2026-07-31T13:22:52.104978+00:00", "recall_count": 5.0, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} +{"id": "qwen3.5-vllm-0.19-fla-gibberish-gb10", "hash": "d038d33600580542974ea8dc412cce46f97641e2f6a77edc77f9633aa1c492a2", "content": "Serving Qwen3.5 (Qwen3_5ForConditionalGeneration; Gated DeltaNet linear-attention hybrid) on the lobes GB10 fleet's vLLM image nvcr.io/nvidia/vllm:26.04-py3 (engine 0.19.0): the architecture IS registered (verified via ModelRegistry: Qwen3_5ForConditionalGeneration + Qwen3_5MTP present) so it LOADS, BUT vLLM 0.19.0 carries a known FLA/Gated-DeltaNet tensor-format bug on Blackwell (sm_120/sm_121) that produces GIBBERISH output. The fix (vLLM PR #43961) shipped only in v0.23.0. So a live Qwen3.5-4B serve on the current production image likely emits garbled text -> NOT a meaningful live test. Also: V2 model runner crashes on the mixed-attention KV cache (workaround --enforce-v1); GDN needs --max-num-batched-tokens 2096 for cache alignment; sm_121 needs forward-compat (TORCH_CUDA_ARCH_LIST=12.0). --language-model-only correctly drops the vision tower for inference of the full multimodal checkpoint. A meaningful live 4B co-resident test needs vLLM >= 0.23.0 (newer image). The minor-lobe CODE (issue #64: catalog gear, qwen3_coder parser rule, run/route/eval verbs, governance, opt-in co-resident fleet service) is independent and CI-green. Fleet facts: gateway published on host :8001 (not :8000); deployment dir ~/.model-gear; HF cache /home/spark/.cache/huggingface; image runtime=runc.", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "reference", "record_metadata": {"source": "claude-code-session", "topic": "qwen3.5-serving", "relates_to": "minor-lobe", "date": "2026-06-26"}, "created": "2026-06-26T11:11:12.839972+00:00", "last_recall": "2026-08-09T22:22:34.424221+00:00", "recall_count": 6.0, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} {"id": "devague-no-resolve-park", "hash": "42eca84ddc6356f0a0d522b8953439304b28417a757f6ecab5b4f9d674a6a744", "content": "devague (the /think CLI) has NO resolve-park / edit-vagueness move. A park created with --kind unknown_blocking blocks `converge` by design, and there is no CLI verb to resolve it once the user decides the unknown \u2014 re-running `park` with the same text just creates a DUPLICATE vagueness item (v3), it does not update in place. To converge after a blocking unknown is genuinely decided, edit the frame JSON directly at .devague/frames/.json: reclassify the items \"kind\" from \"unknown_blocking\" to \"follow_up\" (or remove it) and record the resolution in its \"text\". Frame files live under .devague/frames/ (NOT .devague/*.json); spec exports land in docs/specs/-.md. Also: a capture with default origin lands as confirmed (treat as user-stated); --origin llm lands as proposed and needs user confirm. Honesty conditions are required on announcement/audience/before_state/after_state/why_it_matters/requirement/success_signal claims but NOT on decision/non_goal/assumption claims.", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "reference", "record_metadata": {"area": "devague", "topic": "think-skill-gotcha"}, "created": "2026-06-26T14:54:28.559433+00:00", "last_recall": "2026-07-21T23:49:15.141038+00:00", "recall_count": 1, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} -{"id": "minor-lobe-live-spark-fleet", "hash": "e35daba9e905594346fe19cef3be94a89f381915ae79481a3904bdef2c66b7bb", "content": "GO-LIVE (2026-06-26): the minor lobe (Qwen/Qwen3.5-4B) is LIVE on the spark DGX fleet. Deployment dir is ~/.model-gear (legacy; no ~/.lobes/$LOBES_DIR set), compose project name model-gear, composed from BOTH docker-compose.yml + docker-compose.audio.yml (audio services are NOT behind a profile). Did a full re-scaffold to 0.30.0: overwrote docker-compose.yml + Dockerfile.gateway from the repo 0.30.0 templates (gateway now pip-installs lobes-cli==0.30.0, entrypoint lobes.gateway; was 0.26.0/model_gear pre-rename), and patched .env: MODEL_GEAR_VERSION=0.30.0, added MINOR_BASE_URL=http://vllm-minor:8000, MINOR_SERVED_NAME=Qwen/Qwen3.5-4B, COMPOSE_PROFILES=minor. Applied with: docker compose -f docker-compose.yml -f docker-compose.audio.yml --profile minor up -d --build --no-deps gateway vllm-minor (only gateway recreated + vllm-minor started; primary/embed/rerank/audio untouched). GPU utils: primary 0.6 + embed 0.06 + rerank 0.06 + minor 0.10 = 0.82 of 128GB unified \u2014 fits. Gateway host port 8001; /v1/models now lists Qwen/Qwen3.5-4B. AUTH is NOT in compose \u2014 it is a HOST cloudflared tunnel process (decoupled), so compose re-scaffold does not touch it; just keep gateway on host port 8001. Rollback files: ~/.model-gear/{docker-compose.yml,Dockerfile.gateway,.env}.preminor.bak. GOTCHAS: (1) gateway reads MINOR_BASE_URL (NOT MINOR_URL like the _URL convention for PRIMARY/EMBED/RERANK); empty default => minor silently unwired. (2) chatterbox TTS hit a poisoned CUDA context (torch.AcceleratorError cudaErrorUnknown, HTTP 500) \u2014 cleared by docker compose restart chatterbox; synthesis works fine WITH the 4B co-resident (the 4B did not cause GPU exhaustion).", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "project", "record_metadata": {"area": "deployment", "host": "spark", "topic": "minor-lobe-golive"}, "created": "2026-06-26T15:35:48.071970+00:00", "last_recall": "2026-07-24T18:41:34.170386+00:00", "recall_count": 11, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} -{"id": "issue-68-tiered-routing-spec", "hash": "7d1af62c72622a18e11b0becc36982cf148e2c817a55242ebaf5bef1741673b5", "content": "Issue #68 (tiered model resource routing for Spark unified memory) specced via /think on 2026-06-29 -> docs/specs/2026-06-29-lobes-adds-a-third-middle-model-tier-and-pressure.md. User-confirmed decisions: (1) THIRD tier = a 14B-class NVFP4 checkpoint, INFERENCE-ONLY (~8GB weights) -- it is NOT a LoRA base; LoRA training stays on the existing 4B bf16 minor lobe (so 'scale work + train loras' = 3 inference tiers + 4B trainable base). (2) 27B primary served context trimmed 256K->128K (PRIMARY_MAX_MODEL_LEN=131072) to free ~half the KV cache for the co-resident middle gear. (3) Tier-request surface = gateway model-alias ONLY (caller sends model=cheap|normal|hard to the OpenAI endpoint; gateway resolves to 4B/14B/27B); NO 'lobes select --tier' verb, NO 'route --tier' field. (4) FULL pressure policy IS in scope: read-only swap%/iowait% sampling from /proc, #68 thresholds, degraded-mode state machine, model=hard downgrades with reason=pressure, manual override + downgrade reason cross the OpenAI boundary via HTTP headers, read-only 'lobes status --pressure' emits {tier,model,mode,reason,pressure}. Parked unknowns: exact 14B NVFP4 checkpoint id (same-gen Qwen3.6-14B vs dense Qwen3-14B-NVFP4); exact override/reason header names + whether a streaming response can carry the reason. Risk: 14B NVFP4 must load non-gibberish on the Blackwell vLLM image (dense Qwen3 NVFP4 lower-risk than a 3.5/3.6 hybrid w/ the pre-vLLM-0.23 FLA bug). Current fleet baseline: primary 0.6 + embed 0.06 + rerank 0.06 + minor 0.10. Next leg: /spec-to-plan.", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "project", "record_metadata": {"source": "claude-code-session", "topic": "tiered-routing", "relates_to": "issue-68", "date": "2026-06-29"}, "created": "2026-06-29T19:44:26.978729+00:00", "last_recall": "2026-07-20T16:51:00.232624+00:00", "recall_count": 8, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} +{"id": "minor-lobe-live-spark-fleet", "hash": "e35daba9e905594346fe19cef3be94a89f381915ae79481a3904bdef2c66b7bb", "content": "GO-LIVE (2026-06-26): the minor lobe (Qwen/Qwen3.5-4B) is LIVE on the spark DGX fleet. Deployment dir is ~/.model-gear (legacy; no ~/.lobes/$LOBES_DIR set), compose project name model-gear, composed from BOTH docker-compose.yml + docker-compose.audio.yml (audio services are NOT behind a profile). Did a full re-scaffold to 0.30.0: overwrote docker-compose.yml + Dockerfile.gateway from the repo 0.30.0 templates (gateway now pip-installs lobes-cli==0.30.0, entrypoint lobes.gateway; was 0.26.0/model_gear pre-rename), and patched .env: MODEL_GEAR_VERSION=0.30.0, added MINOR_BASE_URL=http://vllm-minor:8000, MINOR_SERVED_NAME=Qwen/Qwen3.5-4B, COMPOSE_PROFILES=minor. Applied with: docker compose -f docker-compose.yml -f docker-compose.audio.yml --profile minor up -d --build --no-deps gateway vllm-minor (only gateway recreated + vllm-minor started; primary/embed/rerank/audio untouched). GPU utils: primary 0.6 + embed 0.06 + rerank 0.06 + minor 0.10 = 0.82 of 128GB unified \u2014 fits. Gateway host port 8001; /v1/models now lists Qwen/Qwen3.5-4B. AUTH is NOT in compose \u2014 it is a HOST cloudflared tunnel process (decoupled), so compose re-scaffold does not touch it; just keep gateway on host port 8001. Rollback files: ~/.model-gear/{docker-compose.yml,Dockerfile.gateway,.env}.preminor.bak. GOTCHAS: (1) gateway reads MINOR_BASE_URL (NOT MINOR_URL like the _URL convention for PRIMARY/EMBED/RERANK); empty default => minor silently unwired. (2) chatterbox TTS hit a poisoned CUDA context (torch.AcceleratorError cudaErrorUnknown, HTTP 500) \u2014 cleared by docker compose restart chatterbox; synthesis works fine WITH the 4B co-resident (the 4B did not cause GPU exhaustion).", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "project", "record_metadata": {"area": "deployment", "host": "spark", "topic": "minor-lobe-golive"}, "created": "2026-06-26T15:35:48.071970+00:00", "last_recall": "2026-08-09T22:22:34.424221+00:00", "recall_count": 12.0, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} +{"id": "issue-68-tiered-routing-spec", "hash": "7d1af62c72622a18e11b0becc36982cf148e2c817a55242ebaf5bef1741673b5", "content": "Issue #68 (tiered model resource routing for Spark unified memory) specced via /think on 2026-06-29 -> docs/specs/2026-06-29-lobes-adds-a-third-middle-model-tier-and-pressure.md. User-confirmed decisions: (1) THIRD tier = a 14B-class NVFP4 checkpoint, INFERENCE-ONLY (~8GB weights) -- it is NOT a LoRA base; LoRA training stays on the existing 4B bf16 minor lobe (so 'scale work + train loras' = 3 inference tiers + 4B trainable base). (2) 27B primary served context trimmed 256K->128K (PRIMARY_MAX_MODEL_LEN=131072) to free ~half the KV cache for the co-resident middle gear. (3) Tier-request surface = gateway model-alias ONLY (caller sends model=cheap|normal|hard to the OpenAI endpoint; gateway resolves to 4B/14B/27B); NO 'lobes select --tier' verb, NO 'route --tier' field. (4) FULL pressure policy IS in scope: read-only swap%/iowait% sampling from /proc, #68 thresholds, degraded-mode state machine, model=hard downgrades with reason=pressure, manual override + downgrade reason cross the OpenAI boundary via HTTP headers, read-only 'lobes status --pressure' emits {tier,model,mode,reason,pressure}. Parked unknowns: exact 14B NVFP4 checkpoint id (same-gen Qwen3.6-14B vs dense Qwen3-14B-NVFP4); exact override/reason header names + whether a streaming response can carry the reason. Risk: 14B NVFP4 must load non-gibberish on the Blackwell vLLM image (dense Qwen3 NVFP4 lower-risk than a 3.5/3.6 hybrid w/ the pre-vLLM-0.23 FLA bug). Current fleet baseline: primary 0.6 + embed 0.06 + rerank 0.06 + minor 0.10. Next leg: /spec-to-plan.", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "project", "record_metadata": {"source": "claude-code-session", "topic": "tiered-routing", "relates_to": "issue-68", "date": "2026-06-29"}, "created": "2026-06-29T19:44:26.978729+00:00", "last_recall": "2026-08-09T22:22:34.424221+00:00", "recall_count": 9.0, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} {"id": "issue-69-gemma-duo-spec", "hash": "f0fc6928c15f0438a22ae71f7a4f8db22ed8831854c41a361db6f900b8f45b04", "content": "Issue #69 (make Spark default-serve the Qwen3.6-27B + Gemma4-12B duo) specced via /think 2026-06-30 -> docs/specs/2026-06-30-lobes-now-defaults-to-a-spark-duo-the-qwen3-6-27b.md (frame .devague/frames/lobes-now-defaults-to-a-spark-duo-the-qwen3-6-27b.json). USER-confirmed decisions: (1) 'lobes serve' STOPS being single-model -- with no flags it brings up BOTH main (27B MTP primary) + multimodal (Gemma4-12B); minor/14B/legacy need explicit profiles. (2) NEW tier vocabulary main/minor/multimodal (user reframed away from cheap/normal/hard) -- Gemma gets NEW role_hint='multimodal' in catalog.py; old 'middle' role + 'normal' alias DEPRECATED (normal->multimodal back-compat kept); nvidia/Qwen3-14B-NVFP4 demoted role_hint=middle->candidate (KEPT, cite-don't-delete, legacy profile only). (3) Default Gemma serves NVFP4 + NATIVE MTP ON; DeepSeek DSpark draft (deepseek-ai/dspark_gemma4_12b_block7, shipped 2026-06-27 via DeepSpec) = disabled-by-default experiment toggle. (4) Gemma serves FULL multimodal: vision + AUDIO (Gemma4Unified ingests image/video/audio; vLLM supports it; Gemma4-12B is first medium model with native audio-in/ASR) -- chat audio-in is DISTINCT from the /v1/audio/* Parakeet(STT)/Chatterbox(TTS) overlay (unchanged per boundary). Leading checkpoint candidate: sakamakismile/gemma-4-12B-coder-fable5-composer2.5-MTP-NVFP4 (SAME publisher as the lobes primary, NVFP4+MTP). Accepted plan risks (parked unknown_nonblocking): exact checkpoint pick; Gemma4 --tool-call-parser + runtime/_parser.py infer_parser rule (catalog test asserts tool_parser==infer_parser); loads-on-nv26.04-image (LOWER risk: Gemma4UnifiedForConditionalGeneration registered, auto-detects NVFP4, NOT the Qwen3.5 Gated-DeltaNet/FLA arch that gibbered on sm_120/121); native-MTP vs DSpark --speculative-config JSON; measured GPU util for multimodal Gemma (vision+audio embedders+KV vs 14B's 0.12). DESIGN SEAM for spec-to-plan: main/minor/multimodal breaks the linear cheap/normal/hard pressure ladder (issue #68) -- 'multimodal' is a different capability, not a capability rung, so the pressure-downgrade target needs redefining. TOOLING GOTCHA: devague spec_md exporter (render/spec_md.py:66 _follow_up) DROPS unknown_nonblocking vagueness from the exported spec.md (only renders follow_up/out_of_scope) -- had to append an 'Accepted plan risks' section by hand; the frame JSON retains all parked items, so /spec-to-plan (which reads the frame) still gets them. Next leg: /spec-to-plan.", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "project", "record_metadata": {"source": "claude-code-session", "topic": "gemma-duo", "relates_to": "issue-69", "date": "2026-06-30"}, "created": "2026-06-30T12:37:29.031340+00:00", "last_recall": "2026-07-31T13:22:52.104978+00:00", "recall_count": 10.0, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} {"id": "gemma4-unified-unsupported-released-vllm-images", "hash": "34c5ad26dedfe96cb163a6e3e4ad0348a6501f0f8d0be1450f24ebba3fa24840", "content": "t7 live validation (2026-06-30, DGX Spark) for the lobes Gemma 4 12B multimodal gear (issue #69): Gemma 4 12B's architecture is model_type=`gemma4_unified`, and ALL community NVFP4 12B checkpoints use it (sakamakismile/gemma-4-12B-coder-fable5-composer2.5-MTP-NVFP4, AxionML/Gemma-4-12B-NVFP4, coolthor/gemma-4-12B-it-NVFP4A16). NEITHER released NGC vLLM image registers gemma4_unified: nvcr.io/nvidia/vllm:26.04-py3 (vLLM 0.19.0, transformers 4.57.6) NOR nvcr.io/nvidia/vllm:26.05.post1-py3 (vLLM 0.21.0, transformers 5.6.0). vLLM crashes at config load: 'model type gemma4_unified but Transformers does not recognize this architecture ... install Transformers from source'. NOTE: 26.05/vLLM 0.21.0 DOES register Gemma4MTPModel + standard Gemma4ForConditionalGeneration (just not the Unified variant) \u2014 so a standard-arch Gemma4 would load, but the 12B unified multimodal needs nightly/source transformers. Fix path tracked in issue #71 (custom image: 26.05 base + `pip install --pre -U transformers` or git source, verify gemma4_unified registers, check vLLM 0.21.0 pin compat). The lobes catalog gear stays status=configured (correct) until a supporting image lands. Co-resident test approach = zero fleet disruption (run the multimodal container on a free host port alongside the running fleet; primary at util 0.51, +0.12 multimodal = 0.85 < 1.0, no primary bounce). Shipped in PR #72. The vLLM image's --gpus is DeviceRequests nvidia:-1 (=--gpus all), runtime=runc, HF cache /home/spark/.cache/huggingface, compose net model-gear_default, gateway host :8001, deploy dir ~/.model-gear.", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "reference", "record_metadata": {"source": "claude-code-session", "topic": "gemma4-unified-vllm", "relates_to": "issue-69", "date": "2026-06-30"}, "created": "2026-06-30T15:40:49.826739+00:00", "last_recall": "2026-07-09T05:13:18.802890+00:00", "recall_count": 7, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} {"id": "issue-71-gemma4-custom-image-spec", "hash": "167dd266f292a73237b77a2db62529cd14cb3664f363993eb7e34c3d4d1ad09e", "content": "Issue #71 (custom vLLM image for the Gemma 4 12B gemma4_unified multimodal gear) specced via /think 2026-06-30 -> docs/specs/2026-06-30-lobes-ships-a-custom-vllm-image-from-nvcr-io-nvidi.md. USER-confirmed decisions: (1) SCOPE: only vllm-multimodal moves to the custom 26.05-based image NOW; primary/embed/rerank stay on nvcr.io/nvidia/vllm:26.04-py3 (load-tested 27B primary not disturbed); if multimodal works well, open 3 follow-up issues to migrate primary/embed/rerank one-by-one. (2) DELIVERY: local compose build: directive on vllm-multimodal (matches gateway/chatterbox/parakeet/realtime Dockerfile pattern) is the DEFAULT; PLUS an optional MULTIMODAL_IMAGE env override so an operator can point at a ghcr.io/agentculture or local registry:2 tag (registry push feasible but not required). (3) TOOLING: Dockerfile.vllm-gemma4 FROM nvcr.io/nvidia/vllm:26.05.post1-py3 installs transformers with UV (uv pip install --system), NOT pip (user preference). (4) PIN: bake a PINNED transformers ref (version or git SHA) for reproducible rebuilds; exact ref discovered during validation (Q3 left to my default). (5) DELIVERABLE: validate+promote in ONE go on the Spark (spark-f8a9 IS the GB10 Spark, fleet live on 26.04, no vllm-multimodal up) -> the recipe PR merges regardless; status flips configured->load-tested + numbers in docs/gemma-4-12b-nvfp4.md ONLY if all pass (boots, image+text, audio+text, MTP accept>0, util in 0.69 budget); any fail -> stays configured, residual parked. Co-resident validation = zero fleet disruption (free host port alongside running fleet). Parked unknowns v1-v4: exact transformers ref; correct gemma4_mtp method string on vLLM 0.21.0 (r4); measured vision+audio util vs 0.12 (r5); native context (131072 default). Core risk: NO transformers ref may both register gemma4_unified AND keep vLLM 0.21.0 importing -> then await NGC release. Next leg: /spec-to-plan.", "scope": {"name": "lobes", "visibility": "public"}, "metadata": {"type": "project", "record_metadata": {"source": "claude-code-session", "topic": "gemma4-custom-image", "relates_to": "issue-71", "date": "2026-06-30"}, "created": "2026-06-30T16:19:55.749207+00:00", "last_recall": "2026-07-20T16:51:00.232624+00:00", "recall_count": 4, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} diff --git a/docs/specs/2026-08-09-hand-lobe-lfm2-5-1-2b.md b/docs/specs/2026-08-09-hand-lobe-lfm2-5-1-2b.md new file mode 100644 index 0000000..a6ca4aa --- /dev/null +++ b/docs/specs/2026-08-09-hand-lobe-lfm2-5-1-2b.md @@ -0,0 +1,174 @@ +# hand lobe LFM2.5-1.2B + +> lobes serves 'hand' — a 1.2B LFM2.5 lobe cheap enough to run on every machine in the mesh, and the fleet's designated fine-tuning base +> instruction: Land hand as the ninth Colleague role in one PR: catalog entry + lfm2 parser rule, vllm-hand compose lane on the nightly digest with --enable-lora, the role tables in roles.py/schema.py/render.py/shapes.py/`_config.py`/server.py, the `TIER_ROLE` repoint, the pressure floor move, all 4 card TOMLs + 7 shape TOMLs, 28 regenerated goldens, and the doc set. Validate live on Thor and Orin (both ssh-reachable, both already carrying the pinned image) and commit an acceptance transcript per card under docs/evidence/ before any surface claims VALIDATED. + +## Audience + +- Colleague and every mesh consumer that addresses lobes by ROLE name rather than model id, plus the operator running lobes init/up on each box. Secondary: the future fine-tuning workflow that will train domain adapters against this base. + +## Before → After + +- Before: Eight Colleague roles, all of which are either heavy (cortex/senses/muse/worker) or non-generate (embedder/reranker/stt/tts). The only small generate gear, minor, is an opt-in unnamed gear on a broken image, is not a Colleague role, and is not hosted by any shape by default. No LoRA surface exists anywhere in the tree and there is no lobes train verb. +- After: Every machine in the mesh — Spark, Thor, Orin, and any unrecognised card — hosts a hand: a 1.2B LFM2.5 lobe served bf16 with --enable-lora armed, addressable as model=hand for the base and model=hand: for a learned adapter. hand is the fleet's always-servable floor (never shed under pressure) and has replaced the 4B minor as the cheap tier target and the LoRA base. Its adapter inventory is operator-declared in .env and honestly advertised: /v1/models and GET /capabilities list exactly the adapters actually loaded, and none that are not. + +## Why it matters + +- Today the fleet's cheap lane does not work at all: the 4B minor gear pins vLLM 0.19.0, the image on which its own Qwen3.5 architecture hits the FLA/Gated-DeltaNet bug and emits gibberish, so 'minor' has never served correctly and no box has a working small lobe. Separately, every specialization the mesh wants must be paid for in prompt tokens against a big general model, because nothing in the tree can serve a fine-tuned adapter — grepping for LoRA returns zero hits. hand fixes both: a small lobe that actually runs on the nightly engine, and the first surface where a learned skill can be served instead of prompted. + +## Requirements + +- lobes/catalog.py gains an LFM2.5-1.2B-Instruct SupportedModel entry: 32K native (`native_max_model_len`=32768, reuse `_CONTEXT_32K_NATIVE`), shape 'hybrid conv + GQA (10 double-gated conv + 6 GQA of 16 layers)', and quantization='none' — the bf16/unquantized SENTINEL the Qwen3.5-4B minor gear already uses (catalog.py:368), asserted by tests/`test_catalog.py`::`test_minor_gear_quantization_is_none_sentinel`. The sentinel means `VLLM_QUANTIZATION` is NOT written on switch and the compose lane must omit --quantization outright. + - honesty: The catalog entry round-trips: every existing `test_catalog.py` invariant still passes with it present — unique id, all fields non-empty, doc file resolves, `native_max_model_len` positive, generate-task models carry zero dimension and empty `hf_overrides`, and the 'none' quantization sentinel behaves exactly as the 4B minor's does under lobes switch. +- lobes/runtime/`_parser.py`::`_RULES` needs an lfm2-scoped family rule, or tests/`test_catalog.py`:76::`test_tool_parser_matches_infer_parser` fails the moment the catalog entry lands — the catalog's `tool_parser` field is asserted equal to `infer_parser`(id) for every gear. + - honesty: After adding the rule, `infer_parser`('LiquidAI/LFM2.5-1.2B-Instruct') returns exactly 'lfm2', tests/`test_catalog.py`::`test_tool_parser_matches_infer_parser` passes, and the new markers do not change `infer_parser`'s answer for ANY existing catalog id (assert the full catalog round-trips unchanged). +- hand's compose lane must ride the vLLM NIGHTLY image (${`VLLM_NIGHTLY_IMAGE`:-vllm/vllm-openai@sha256:7c5a10e9...} = 0.23.1rc1.dev672), NOT the NGC pin. Lfm2ForCausalLM ships in vLLM >= 0.23.0, and docs/vllm-nightly-migration.md section 1 records vllm-minor/vllm-middle still pinned to nvcr.io/nvidia/vllm:26.04-py3 = vLLM 0.19.0+nv26.04 — the cheapest existing lane is precisely the one that cannot load this architecture. + - honesty: VERIFIED on both target boxes: 'docker images' on Thor and Orin each list vllm/vllm-openai at digest 7c5a10e9a8b3, and python3 -c 'import vllm' inside it reports 0.23.1rc1.dev672 — above LFM2's >= 0.23.0 floor. No new image pull is required for either card. +- lobes/templates/fleet/docker-compose.yml gains a vllm-hand service and lobes/templates/fleet/env.example gains its `HAND_`\* keys, following the vllm-minor block (docker-compose.yml:413-463) for structure: mg-logwrap entrypoint, HF cache + /logs mounts, expose 8000 with no host port, /health healthcheck. + - honesty: docker compose config renders the vllm-hand service with no unresolved variables, and 'lobes up hand' starts it and reaches /health on a real box; the lane omits --quantization entirely (not an empty flag) and carries no --language-model-only. +- IF hand lands as a ninth first-class Colleague role, the role vocabulary must be extended in lockstep across every table that enumerates roles: lobes/roles.py (ROLES, `ROLE_BACKEND`, `ROLE_ROLE_HINT`, `ROLE_PATH`, `ROLE_RESPONSIBILITIES`, `ROLE_FORBIDDEN`, `ROLE_MAX_MODEL_LEN_ENV`), lobes/profiles/schema.py:57 ROLES, lobes/profiles/render.py:76 `ROLE_ENV_PREFIX`, lobes/profiles/shapes.py (`SHAPE_ROLES`/`DEFAULT_HOSTED_ROLES`), lobes/gateway/`_config.py` (`FEASIBLE_ENV`, peer origin/proxy/key tables), lobes/gateway/server.py (`_PEER_SERVED_NAME_ENV`, `_PEER_ROLE_HINT`), lobes/gateway/`_pressure_policy.py`, lobes/catalog.py `TIER_ROLE`, lobes/runtime/`_compose.py` (role->service name), lobes/`roles_measure.py`, and lobes/cli/`_commands`/{capabilities,up,measure,overview,init}.py. + - honesty: A single parametrised test iterates lobes.roles.ROLES and asserts every per-role table has an entry for all nine roles — so a future tenth role cannot half-land either. Today that test would fail on hand; after the change it passes with no role missing from any table. +- server.py's peer tables are the KNOWN silent-failure point for a newly added role: 0.54.8 fixed exactly this — worker was wired into `_config.py`'s peer dicts in 0.54.6 but missing from server.py's `_PEER_SERVED_NAME_ENV`/`_PEER_ROLE_HINT`, so `peer_specs_from_table` silently dropped it and `WORKER_PEER_PROXY`=true did nothing. hand must land in BOTH files or its proxy/referral surface is inert with no error. + - honesty: `HAND_PEER_PROXY`=true against a declared `HAND_PEER_ORIGIN` actually forwards and returns X-Lobes-Proxied-By — proven by the same test shape that guards worker, plus tests/`test_gateway_proxy.py`::`test_every_proxyable_role_resolves_a_served_name` extended to hand so the 0.54.8 silent-drop cannot recur. +- 'runs on all machines' means hand is declared across the whole profile+shape matrix, not one file: all four card profiles (lobes/profiles/builtin/{base,spark,thor,orin}.toml) and all seven shape TOMLs (lobes/profiles/`builtin_shapes`/), which in turn regenerates the four card goldens (tests/goldens/\*.env) and all 24 shape x card goldens (tests/goldens/shapes/\*.env) via tests/goldens/regen.py, plus tests/`test_shape_contract_matrix.py`. + - honesty: A byte-diff of the 28 regenerated goldens shows changes ONLY on lines introducing `HAND_`\* keys — no unrelated key, value or ordering drift in any of the 4 card or 24 shape x card files. Any other delta means the render changed something it should not have. +- Docs that enumerate the role set must all move together: docs/colleague-stack.md (the role contract), docs/gateway-fleet.md, docs/deployment-shapes.md, docs/machine-profiles.md, docs/openai-api.md, a new per-model docs/.md (tests/`test_catalog.py`::`test_every_doc_file_exists` asserts the catalog's doc field resolves), lobes/explain/catalog.py, CLAUDE.md and README.md. + - honesty: tests/`test_catalog.py`::`test_every_doc_file_exists` passes with the new per-model doc present, and a grep for the role-count phrasing ('eight', 'EIGHT') across docs/ and CLAUDE.md returns no stale occurrence describing the role set. +- hand joins lobes.roles.ROLES as the NINTH role with backend name 'hand', env prefix `HAND_`, compose service vllm-hand, and entries in every table enumerated in c6 — including gateway/server.py's `_PEER_SERVED_NAME_ENV`/`_PEER_ROLE_HINT` so `HAND_PEER_PROXY` is not silently inert (the 0.54.8 trap). + - honesty: GET /capabilities returns exactly nine role keys with hand among them, and 'lobes capabilities' agrees field-for-field with the gateway — the shared-core contract in roles.py holds for the new role with no CLI/gateway divergence. +- The tier/alias space becomes DYNAMIC for the first time. Every alias today is a compile-time constant: catalog.`TIER_ROLE` is a literal dict and `_tier_request.py`:37 freezes `_KNOWN_TIERS` = frozenset(`TIER_ROLE`) at import. A per-deployment set of domain adapters cannot live in a module-level frozen constant — alias resolution must learn to admit names that exist only in this deployment's adapter inventory. + - honesty: A request for model=hand: naming an adapter present in `HAND_LORA_MODULES` resolves and is served, while a name absent from the inventory is refused with a clear error rather than silently falling back to the base or to another lane. Prove BOTH directions. +- Adapter advertisement must obey the #92 honesty rule end to end: GET /v1/models lists each LOADED adapter, GET /capabilities and 'lobes capabilities' report hand's adapter inventory, and an adapter that is declared but not loaded is never advertised as usable — the same feasible/ready/loaded discipline the eight existing roles already carry, applied one level deeper (per-adapter, not just per-role). + - honesty: With `HAND_LORA_MODULES` declaring an adapter whose path does not exist (so vLLM cannot load it), GET /v1/models does NOT list it and GET /capabilities does NOT advertise it as usable — declared-but-unloaded must never read as available. This is the #92 rule at adapter granularity and is the single most likely place to get it wrong. +- catalog.`TIER_ROLE` repoints 'minor'/'cheap' to hand and gains a 'hand' alias; the capability order becomes hand < multimodal < worker < muse < main. The dict's ORDER is load-bearing — catalog.py:776-787 documents that `tier_aliases` derives ascending capability order from each role's LAST occurrence position, so this is an ordered edit, not a key swap. + - honesty: `resolve_tier`('minor') and `resolve_tier`('cheap') both return the hand gear, `resolve_tier`('hand') returns it too, and `tier_aliases` still derives the ascending order hand < multimodal < worker < muse < main from the reordered dict — assert the derived ORDER, not just the mapping. +- gateway/`_pressure_policy.py`'s servable floor moves from the 4B minor to hand: hand is never shed, always served regardless of swap/iowait pressure, and the shed set stays exactly cortex/senses/worker/muse. + - honesty: Under synthetic swap>75% / iowait>50% pressure, model=hand is served 200 while model=cortex/senses/worker/muse each return 429 with Retry-After — hand never sheds, and the shed set is unchanged. +- The vllm-hand compose lane carries --enable-lora plus its sizing knobs (`max_loras` / `max_lora_rank` / `max_cpu_loras`) over a bf16 base with no --quantization flag — vLLM LoRA needs the unquantized base, which is the same constraint that already forces the bf16 sentinel. + - honesty: VERIFIED at source: vllm/`model_executor`/models/lfm2.py declares 'class Lfm2ForCausalLM(nn.Module, HasInnerState, SupportsLoRA, SupportsPP, IsHybrid, SupportsQuant)' with `packed_modules_mapping` (`qkv_proj`, w13, `in_proj`), `embedding_modules`, and an `hf_to_vllm_mapper` shared with quantization. --enable-lora is a supported flag for this architecture. Must still be confirmed to BOOT with the flag armed on our digest — registration is not the same as a clean start. +- `roles_measure.py` must gain hand or 'lobes measure' CRASHES. `_FAMILY_BY_ROLE` (`roles_measure.py`:116) is indexed unguarded at line 419 while iterating lobes.roles.ROLES, and `_LLM_ROLES` (line 112) lists only cortex/senses/muse/worker. Adding hand to ROLES without touching this file is a KeyError, not a silent omission — louder than the 0.54.8 drop but the same class of lockstep failure, and c6's table list does not name `_FAMILY_BY_ROLE` or `_LLM_ROLES` specifically. + - honesty: A test iterates lobes.roles.ROLES and asserts `_FAMILY_BY_ROLE` covers every role, so the KeyError class of bug cannot recur for a tenth role either; 'lobes measure' completes for hand and reports it in the generate family. +- Every deployment shape's GPU budget must be RE-DERIVED with hand co-resident, or hand's allocation taken out of an existing lane rather than added on top. Each mesh-lobe reclaim was MEASURED on hardware with no hand present: spark-lobe cortex `gpu_mem_util`=0.44 (and the naive reclaim-sum was REFUSED live before 0.44 was found), thor-worker 0.45, orin 0.45 — each already sums to ~0.56-0.57 with the pooling gears. 'Runs on all machines' silently adds a tenth consumer to budgets that were measured to the edge. No claim in this frame re-derives them. + - honesty: For EVERY shape that hosts hand, the committed budget is either (a) re-measured on real hardware with the hand lane running, with the transcript under docs/evidence/, or (b) explicitly re-apportioned so the total is unchanged from the pre-hand measured value. A shape whose budget is left at its pre-hand number with hand added on top does NOT satisfy this — that is the exact arithmetic that was refused live on spark-lobe, thor-lobe and thor-muse. +- lobes/minor/governance.py and the 'lobes eval minor' verb must be reconciled with the repoint. governance.py is keyed to the ROLE NAME 'minor' (its own docstring: 'never to a specific model identifier') and carries allowed/forbidden duty lists plus a confidence threshold written for a general-purpose 4B. Once the minor tier resolves to hand, that policy silently governs a different model with different characteristics — and a DOMAIN-SPECIALIZED one, whose competence is adapter-dependent rather than uniform. Decide whether the policy transfers, is re-derived for hand, or is retired with the tier. + - honesty: After the change there is exactly one answer to 'what governs the minor tier': either governance.py names hand and its duty lists are re-derived for an adapter-dependent specialist, or the module and 'lobes eval minor' are retired with the tier. A state where governance.py still says ROLE='minor' while the minor tier serves hand does not satisfy this. +- up.py's `COLLEAGUE_STACK` bundle must gain hand. Its docstring defines colleague-stack as 'the FULL six-role Colleague set = the default fleet roles (cortex/senses/embedder/reranker) PLUS the audio-overlay roles (stt/tts)' and the constant's comment says 'the six roles machine-as-brain hosts'. hand is default-hosted, so both the count and the target list are wrong the moment it lands — and 'lobes up colleague-stack' would silently bring up a stack missing the role the spec claims runs everywhere. + - honesty: 'lobes up colleague-stack --apply' starts the hand lane along with the others, and up.py's docstring and constant comment state the correct role count — proven by running the target, not by reading the source. +- The vllm-hand lane carries `MG_LOG_NAME`=hand for durable logs (the #50 convention every other lane follows via mg-logwrap), and hand is reachable by 'lobes measure' as a generate-family role. Without the log name the lane's boot output is not recoverable after a restart — which is precisely what would be needed to debug a co-residency OOM introduced by the budget gap above. + - honesty: After a container restart, the hand lane's boot log is still readable under the durable log dir with the hand name — the same check the other lanes' `MG_LOG_NAME` wiring already satisfies. +- --max-lora-rank has a documented default and a legible failure path. An adapter trained above the armed rank fails to LOAD at serve time, and vLLM's error for that is not self-explanatory to an operator who only knows they trained something with unsloth-cli. Since the trainer is a SEPARATE repo (unsloth-cli#16) the rank is chosen on the far side of a boundary lobes does not control, making mismatch the single most likely day-two failure. + - honesty: Declaring an adapter whose rank exceeds --max-lora-rank produces an operator-legible failure that names the rank mismatch — in the lane's logs and, if the lane still starts, absent from /v1/models rather than half-advertised. The chosen default rank is documented in the per-model doc so unsloth-cli#16 can train against it. +- Because hand is never proxied and never referred, NO deployment shape may drop it — hand appears in the hosts list of ALL seven built-in shapes, including the mesh-lobe shapes that drop heavy lobes. A dropped hand would have no referral and no proxy, so model=hand would 404 `role_infeasible` with no `hosted_by` and no recourse anywhere in the mesh: the one lobe state the honest-referral work (#112) exists to prevent. This is what makes 'runs on every host' a structural invariant rather than a default. + - honesty: A test asserts hand is present in the hosts list of every built-in shape TOML, so adding an eighth shape that omits it fails CI rather than silently creating an unreachable role. + +## Honesty conditions + +- VERIFIED on the physical Jetson AGX Thor against the exact pinned nightly digest 7c5a10e9a8b3 (vLLM 0.23.1rc1.dev672+g93d8f834d): ModelRegistry lists Lfm2ForCausalLM, and vllm/`tool_parsers`/`__init__.py` maps the key 'lfm2' -> Lfm2ToolParser. The same digest is present on the Orin. This is image-grade, not documentation-grade — the engine hand needs is already on the boxes. +- shapes.py's `OPT_IN_CORE_ROLES` remains exactly ('muse','worker') after the change — hand is absent from it — and `DEFAULT_HOSTED_ROLES` contains hand. Assert both, so a later refactor cannot quietly demote hand to opt-in. +- The machine-as-brain re-baseline is explicit and reviewed: the goldens diff for machine-as-brain contains ONLY `HAND_`\* additions, and shapes.py's docstring + tests/goldens/regen.py's `_shape_needs_goldens` are updated to state that the identity-shape invariant now includes a default-hosted cheap role. A silent regeneration that merely makes the tests pass does NOT satisfy this. +- Rendering the base profile produces `HAND_FEASIBLE` unset (i.e. feasible) with a declared model and budget, while senses/muse/worker stay FEASIBLE=false — proven by the regenerated tests/goldens/base.env, not by reading the TOML. +- The rendered vllm-hand command contains no --quantization token at all, and the catalog entry's quantization field is the 'none' sentinel — checked against the rendered compose, since an empty --quantization= would break vLLM exactly as the 4B minor's comment warns. +- hand's `ROLE_RESPONSIBILITIES` contains neither `image_understanding` nor `video_understanding`, the rendered lane carries no --language-model-only, and posting an image part to model=hand fails cleanly rather than being silently dropped. +- After the change, the Qwen3.5-4B catalog entry still exists, docs/qwen3.5-4b-minor.md still resolves, and the vllm-minor compose service is still present behind `COMPOSE_PROFILES`=minor — a grep proves all three survive the repointing. +- Before any evidence transcript lands, lobes capabilities and every doc/support table describe hand as declared/UNVALIDATED on every card; after a per-card transcript is committed, only THAT card reads validated. No blanket promotion from a single box's run. +- `resolve_tier`('minor') returns the hand gear while the Qwen3.5-4B entry remains in `SUPPORTED_MODELS` — proving the change is a repointing of what the fleet resolves, not a removal of what the catalog knows. +- VERIFIED in-image: vllm/`tool_parsers`/`lfm2_tool_parser.py` defines Lfm2ToolParser with `TOOL_CALL_START`='<|`tool_call_start`|>' / `TOOL_CALL_END`='<|`tool_call_end`|>', registered under the exact key 'lfm2'. Critically the parser resolves both delimiters via self.vocab.get() and RAISES when either is missing — so a tokenizer/chat-template mismatch fails LOUDLY at init rather than silently degrading, which is strictly better than the gemma4 failure mode. Note the upstream HF discussion 'Fixed Chat Template to Fix Tool Calls' on this very checkpoint: the served tokenizer revision must actually carry those two tokens, and that must be checked on the pulled revision. +- The role is discoverable the way the audience is told to discover it: a caller resolving capabilities by ROLE NAME finds hand in GET /capabilities and lobes capabilities without parsing any model id, per the standing consumer contract that #361 punished cortex for violating. +- An acceptance transcript per validated card is committed under docs/evidence/ showing hand serving on that box; any card without one reads DECLARED/UNVALIDATED in every doc, support table and capabilities output (#108). Thor and Orin are ssh-reachable and already carry the pinned image, so both are obtainable; the Spark and the base fallback are not covered by those runs. +- The two motivating facts are re-verified at implementation time rather than trusted from this frame: that the 4B minor still cannot serve correctly on its pinned image, and that no LoRA surface exists in-tree. If either has changed, the justification is restated. +- Every clause of the success signal is demonstrated by committed artefacts — an evidence transcript for the live clauses and a green CI run for the test-suite clause — not asserted in prose. +- The before-state is checked against the tree at implementation time — eight roles in lobes.roles.ROLES, zero LoRA hits, no train verb — so the spec is not describing a repo that has moved on. +- Nothing in lobes imports, shells out to, or version-pins unsloth-cli; a grep for 'unsloth' across lobes/ returns hits only in comments and docs, never in code. The dependency is one-directional and documentary. +- The handoff is a plain filesystem path with no lobes-side coupling to unsloth-cli: nothing in lobes imports it, depends on it, or assumes its layout beyond 'a directory vLLM accepts as a LoRA adapter'. Verify lobes can serve an adapter produced by ANY trainer, not only unsloth. +- hand's `gpu_mem_util` is declared PER CARD in each card profile rather than once globally, and the Orin value is validated by an actual boot at the served `max_model_len` — not inferred from the Spark's. +- The irreversibility is stated in docs/colleague-stack.md so a future maintainer reads it before proposing a tenth role or a retirement, rather than rediscovering it from the #361 consumer contract. +- A test asserts hand is ABSENT from server.py's peer tables and from the FEASIBLE/peer env channels, so the never-proxied decision is enforced rather than merely documented. + +## Success signals + +- A caller on any box in the mesh gets a 200 from model=hand with no pressure-based 429, on a card whose acceptance transcript is committed under docs/evidence/; lobes capabilities reports hand feasible/ready with `lora_enabled` true and an adapter list matching exactly what vLLM has loaded; a tool call through the lfm2 parser returns a structured `tool_calls` array rather than prose; and the full test suite passes with the 28 regenerated goldens. + +## Scope / boundaries + +- hand is NOT an `OPT_IN_CORE_ROLE`. muse and worker are opt-in precisely because they are too heavy to co-reside (lobes/profiles/shapes.py: `OPT_IN_CORE_ROLES` = muse, worker); a 1.2B bf16 (~2.4 GiB weights) is the opposite case. 'Runs on all machines' places hand in `DEFAULT_HOSTED_ROLES` — the first role added to the default-hosted set since issue #81. +- Adding hand to `DEFAULT_HOSTED_ROLES` deliberately BREAKS the machine-as-brain byte-identical invariant that muse/worker were designed to preserve. shapes.py documents it explicitly: `DEFAULT_HOSTED_ROLES` excludes the opt-in core roles so 'machine-as-brain stays byte-identical to the bare card profile', and tests/goldens/regen.py::`_shape_needs_goldens` is defined against that set. hand renders on machine-as-brain, so this invariant must be knowingly re-baselined, not silently regenerated. +- base.toml's OOM veto does NOT extend to hand. base.toml marks senses/muse/worker feasible=false because a heavy checkpoint on an unrecognised card risks an OOM crash-loop; a 1.2B bf16 is the first generate lobe cheap enough that the conservative fallback can host it honestly. +- v1 must not foreclose LoRA even though it defers it: the served checkpoint stays bf16/unquantized. This is the same reason the 4B minor is served bf16 rather than the cosmicproc NVFP4 export — unsloth LoRA needs the bf16 base (docs/specs/2026-06-26-lobes-ships-a-minor-lobe-a-cheap-warm-co-resident.md). A quantized hand would make the 'fine-tuning base' half of the announcement undeliverable later. +- hand is TEXT-ONLY. LFM2.5-1.2B-Instruct carries no ViT (LiquidAI ships separate VL/audio variants), so hand must not advertise `image_understanding`/`video_understanding`, and — unlike the 4B minor — its compose lane needs no --language-model-only flag to strip a vision tower it does not have. +- The Qwen3.5-4B minor gear is NOT deleted (cite-don't-delete). Whether hand supersedes it in practice is a separate decision from whether its catalog entry, docs/qwen3.5-4b-minor.md, and the vllm-minor compose service stay in-tree — they do. +- No live/validated claim without an evidence transcript (#108). Every doc, support table, and lobes capabilities output must read hand as declared/UNVALIDATED until a transcript lands under docs/evidence/ per card actually booted. Precedent in-tree: thor-muse's budget was measured but the shape stayed UNVALIDATED because the acceptance transcript never landed. +- hand REPLACING minor is a repointing, not a removal: Qwen3.5-4B's catalog entry, docs/qwen3.5-4b-minor.md and the vllm-minor compose service all stay in-tree (cite-don't-delete). What changes is what the fleet POINTS AT — the tier target, the LoRA base, and the pressure floor. +- Training is OWNED BY unsloth-cli, a sibling repo (/home/spark/git/unsloth-cli, dist unsloth-cli v0.5.0, console script 'sloth'), NOT by lobes. lobes never trains: there is no lobes train verb and there will not be one. The contract between the two is an ADAPTER ON DISK — unsloth-cli produces a LoRA/QLoRA adapter directory, the operator declares its path in `HAND_LORA_MODULES`, and lobes serves it. This is a cleaner boundary than 'training is deferred': the capability is not missing, it lives on the other side of a file-path interface. +- Adding hand to the role contract is effectively IRREVERSIBLE and that is accepted knowingly. The same additive/subtractive asymmetry that justified withholding `repo_action` (#180) applies to the role itself: once consumers resolve model=hand by name, removing it breaks them. There is no rollback path beyond a breaking change, so the decision to make hand a role rather than an unnamed gear should be treated as one-way. +- hand is NEVER PROXIED and never referred: it carries no `HAND_PEER_ORIGIN` / `_PEER_PROXY` / `_PEER_API_KEY`, and lobes/gateway/server.py's `_PEER_SERVED_NAME_ENV` / `_PEER_ROLE_HINT` tables deliberately OMIT it. This narrows c7 and c18, which assumed hand would ride the peer tables by symmetry with the other eight roles — for hand the correct entry in those tables is no entry at all, and a test should assert its absence so a future symmetry-minded refactor does not add it back. + +## Non-goals + +- Adapter TRAINING is not in v1. This SUPERSEDES c13, which was written before q4 and wrongly excluded all LoRA surface: adapter SERVING (--enable-lora, --lora-modules, per-adapter addressing and advertisement) IS in v1. What stays deferred is producing adapters — lora/qlora training runs, a 'lobes train' verb, adapter storage and versioning. lobes v1 can serve a hand's muscle memory; it cannot yet build it. + +## Assumptions + +- hand's responsibilities are `domain_mastery`, `learned_skill`, `specialized_task` and `tool_use`. This SUPERSEDES c24, whose forbidden list predates q8 and omitted `repo_action`; the authoritative forbidden list is c30's (`final_decision`, `repo_action`, `security_decision`). Vocabulary remains a build-time call per the #81 caveat in roles.py. +- unsloth-cli's fine-tuning verbs are NOT yet built — its 0.5.0 README documents only scaffold verbs (whoami / learn / explain / overview / doctor / cli overview), with fine-tuning stated as the repo's purpose rather than its shipped surface. So the adapter-producing half of the muscle-memory loop is a cross-repo dependency that does not yet exist, which is an independent reason v1 ships zero adapters. Coordinating it is a brief/issue ON unsloth-cli, not work in this repo. +- A single `gpu_mem_util` for hand cannot be right across the fleet. At 0.06 the same number is 7.7 GiB on a 128 GB Spark but 3.84 GiB on a 64 GB Orin — against ~2.4 GiB of bf16 weights that leaves the Orin roughly 1.4 GiB of KV at 32K context, before any LoRA adapter memory. The existing budget park treats this as tuning detail; the `after_state` claim asserts hand runs on the Orin, which is a stronger claim than the park covers. + +## Scope exploration + +- `s1` — `lobes/catalog.py + tests/test_catalog.py + lobes/runtime/_parser.py`: a new gear is not just a catalog row: the catalog's bf16 'none' quantization sentinel (catalog.py:363-372) and the `tool_parser`==`infer_parser` invariant (`test_catalog.py`:76) both bind at once, so `_parser.py`::`_RULES` must grow an lfm2 rule in the same change + - seeds: `c2`, `c3` +- `s2` — `docs/vllm-nightly-migration.md + lobes/templates/fleet/docker-compose.yml image pins`: the fleet runs TWO engines: primary/embed/rerank/worker on the nightly digest (0.23.1rc1.dev672) and minor/middle still on nvcr.io/nvidia/vllm:26.04-py3 (0.19.0). LFM2 needs >= 0.23.0, so hand cannot reuse the vllm-minor lane's image even though minor is the closest structural template + - seeds: `c4`, `c5` +- `s3` — `lobes/roles.py + profiles/schema.py + profiles/render.py + gateway/_config.py + gateway/server.py`: the role set is enumerated in at least a dozen hand-maintained tables across five packages with no single registry; adding a ninth role is a lockstep edit, and server.py's peer tables are the proven silent-drop point (the 0.54.8 worker-proxy fix) + - seeds: `c6`, `c7` +- `s4` — `lobes/profiles/builtin/*.toml + builtin_shapes/*.toml + tests/goldens/`: the profile x shape matrix is 4 cards x 7 shapes with committed .env goldens for each; 'every machine hosts hand' touches all 4 card TOMLs, all 7 shape TOMLs and regenerates 28 golden files + - seeds: `c8` +- `s5` — `docs/colleague-stack.md, gateway-fleet.md, deployment-shapes.md, machine-profiles.md, openai-api.md, explain/catalog.py`: the role contract is duplicated in prose across six doc surfaces plus the in-CLI explain catalog; `test_catalog.py`::`test_every_doc_file_exists` additionally forces a new per-model doc to exist before the catalog entry can land + - seeds: `c9` +- `s6` — `lobes/profiles/shapes.py (OPT_IN_CORE_ROLES / DEFAULT_HOSTED_ROLES) + tests/goldens/regen.py`: every role added since #81 was opt-in-hosted specifically to keep machine-as-brain byte-identical; a role hosted by DEFAULT is a new composition case with no in-tree precedent, and re-baselines the identity-shape golden contract + - seeds: `c10`, `c11` +- `s7` — `lobes/profiles/builtin/base.toml`: base.toml vetoes senses/muse/worker with feasible=false on OOM grounds and stands cortex up on the 4B minor gear; a 1.2B is the first generate lobe small enough that the conservative fallback could host it as itself + - seeds: `c12` +- `s8` — `grep -rn 'enable-lora|enable_lora|lora-modules|LORA' lobes/ tests/`: ZERO hits — there is no LoRA serving flag, no adapter store, no train verb, and no adapter naming anywhere in the tree. LoRA/QLoRA is greenfield, not an extension of an existing surface + - seeds: `c13` (rejected), `c14` +- `s9` — `docs/specs/2026-06-26-lobes-ships-a-minor-lobe-a-cheap-warm-co-resident.md + catalog.py minor entry`: the 4B minor is served bf16 ON PURPOSE because unsloth LoRA needs the bf16 base; that precedent binds hand's quantization choice if hand is to remain a fine-tuning base + - seeds: `c14` +- `s10` — `huggingface.co/LiquidAI/LFM2.5-1.2B-Instruct model card`: 1.17B params, 16 layers (10 double-gated conv + 6 GQA), 32768 context, bf16, license lfm1.0, TEXT-ONLY (VL/audio are separate variants), ChatML-style tool calling, vLLM natively supported via Lfm2ForCausalLM with no --trust-remote-code, and the card explicitly recommends fine-tuning (Unsloth/TRL LoRA documented) + - seeds: `c15` +- `s11` — `lobes/gateway/_pressure_policy.py`: the shed set is cortex/senses/worker/muse (429 + Retry-After) while an explicit minor request is 'the servable floor, always served regardless of pressure' — a 1.2B lobe is floor-class by cost, so hand's pressure posture is a real decision, not a default +- `s12` — `docs/evidence/ + the #108 UNVALIDATED rule`: the repo enforces a hard split between DECLARED and VALIDATED; thor-muse's measured budget still reads UNVALIDATED because no acceptance transcript landed. Spark, Thor and Orin are all ssh-reachable, so per-card evidence is obtainable but must be a planned deliverable + - seeds: `c17` +- `s13` — `lobes/catalog.py TIER_ROLE + docs/qwen3.5-4b-minor.md + prior live measurement of Qwen3.5-4B`: the 'minor' tier's incumbent has never served correctly: Qwen3.5-4B hits the vLLM 0.19.0 FLA/Gated-DeltaNet gibberish bug on the very image vllm-minor pins, so the cheap lane hand would join is a lane that does not currently work +- `s14` — `lobes/gateway/_tier_request.py:37 + catalog.TIER_ROLE + gateway/_routing.py tier_aliases`: the whole alias layer is compile-time frozen (`_KNOWN_TIERS` = frozenset(`TIER_ROLE`) at import) and its dict ORDER encodes capability ranking via last-occurrence position; a per-deployment adapter set is the first dynamic member the alias space has ever had + - seeds: `c19`, `c21` +- `s15` — `vLLM --enable-lora / SupportsLoRA architecture gate`: LoRA serving is per-architecture opt-in in vLLM (SupportsLoRA), not a universal flag, and LFM2's presence on that list is UNCONFIRMED — the model card documents training adapters, which does not imply vLLM can serve them + - seeds: `c24` (rejected) +- `s16` — `lobes/profiles/builtin/base.toml honesty posture`: base.toml today stands the CORTEX role up on a 4B checkpoint — a box advertising the 27B reasoning role while serving a small model, which is the half-honest posture #92 exists to forbid; hand as a real always-hosted role lets base.toml declare hand as itself and mark cortex infeasible instead +- `s17` — `challenge pass / adjacent-systems lens: lobes/roles_measure.py`: `_FAMILY_BY_ROLE`\[role\] at line 419 is an unguarded dict index over ROLES; a ninth role absent from the map raises KeyError. `_LLM_ROLES` at line 112 separately gates which roles get generate-family probes. +- `s18` — `challenge pass / hardware + concurrency lens: builtin_shapes/{spark-lobe,thor-worker}.toml + builtin/orin.toml`: read the committed reclaim values: spark-lobe cortex 0.44 @262144, thor-worker worker 0.45 @262144, orin 0.45 @262144, each plus embed 0.06 + rerank 0.06. Every one is a MEASURED value found after a naive value was refused, and none was measured with a hand lane co-resident. +- `s19` — `challenge pass / reversibility lens: lobes/roles.py ROLES + the #81 consumer contract`: consumers are instructed to resolve capabilities by role name and never parse model ids (the #361 finding against cortex), which is exactly what makes role REMOVAL a breaking change; no in-tree precedent exists for retiring a role +- `s20` — `challenge pass / unstated-assumption lens: lobes/minor/{governance.py,_client.py} + cli/_commands/eval.py`: governance.py:33 ROLE='minor' with duty allow/forbid lists and a confidence floor; eval.py exposes 'lobes eval minor' over lobes.minor.`chat_text`. The frame repoints the minor TIER at hand but says nothing about the governance policy or the CLI verb that ride that name. +- `s21` — `challenge pass / failure-mode lens: vLLM --enable-lora sizing knobs vs the unsloth-cli boundary`: rank is decided by the trainer (a different repo) and enforced by the server (this repo); nothing in the frame pins or communicates it across that boundary. `max_loras` similarly caps how many hands can be resident at once. +- `s22` — `challenge pass / adjacent-systems lens: model-id delimiter handling for the 'hand:' form`: CLEAN. Grepped lobes/gateway/, roles.py and cli/`_commands`/capabilities.py for split('/')/split(':')/partition() — the only hits are `_config.py`:398 partition('=') for alias pairs, server.py:246 partition(' ') for the auth scheme, and `_realtime.py`:197 for HTTP headers. Nothing parses a model id by delimiter, so a colon-bearing id passes through unharmed. Residual risk is OUTSIDE this repo: downstream consumers that split model ids themselves. +- `s23` — `challenge pass / migration lens: existing ~/.lobes deployments on wheel upgrade`: compose ${VAR:-default} substitution means an upgraded box renders the hand lane with defaults and no operator action; combined with the absent-means-feasible convention this changes what a pre-hand box ADVERTISES. Seeded q10. + +## Decisions + +- hand's tool-call parser is 'lfm2' — vLLM's purpose-built parser for LFM2/LFM2.5's special-token-delimited pythonic calls. lobes/runtime/`_parser.py`::`_RULES` gains an lfm2-scoped rule returning 'lfm2', with markers narrow enough ('lfm2', 'lfm2.5') not to collide with other families. +- hand needs NO reasoning parser. LFM2.5-1.2B-Instruct has no thinking mode — LiquidAI ships reasoning as a SEPARATE checkpoint (LFM2.5-1.2B-Thinking). So hand is unlike cortex (paired with --reasoning-parser=qwen3) and unlike the Gemma 4 lanes (which require the gemma4 tool+reasoning parser PAIR or leak <|channel>thought markers into content): hand wires the tool parser ALONE, and that is correct rather than an omission. +- hand's adapter inventory is declared as `HAND_LORA_MODULES`="name=path,..." in .env and fixed at container boot; the vllm-hand lane passes it as --lora-modules alongside --enable-lora and the --max-loras/--max-lora-rank sizing knobs. Runtime hot-loading is deliberately NOT armed in v1. +- Addressing: model=hand serves the BASE checkpoint; model=hand: serves that adapter. The bare alias never 404s, so hand's always-servable-floor guarantee holds with an empty inventory. GET /v1/models lists the base and each LOADED adapter as distinct ids. +- hand's `forbidden_responsibilities` are `final_decision`, `repo_action` and `security_decision` — mirroring senses and muse, NOT worker. Granting `repo_action` later is additive and contract-compatible; revoking it would be a break, so v1 withholds it. +- v1 ships ZERO adapters: the base serves, --enable-lora is armed and tested, and the adapter inventory is empty. No domain is advertised that has not been learned. + +## Open parks + +- [unknown_nonblocking] Per-card `gpu_mem_util` and `max_model_len` for hand on spark / thor / orin / base. ~2.4 GiB bf16 weights suggests a small util, but every reclaim/budget value in this repo has been MEASURED rather than computed, and the naive value has been refused live more than once (spark-lobe, thor-lobe, thor-muse). +- [unknown_nonblocking] Whether the lfm1.0 license imposes any redistribution or deployment condition that the catalog/docs must record. Gemma 4 already sets a precedent for a non-Apache checkpoint in-tree, so this is likely a note rather than a blocker — but it has not been read. +- [unknown_nonblocking] Whether any downstream mesh consumer (culture, daria, reachy-mini-cli, the colleague backend) parses model ids in a way a colon would break. lobes itself is clean, but the 'hand:' form is a new id SHAPE the mesh has never been served before — every existing id is org/name. +- [follow_up] Granting hand `repo_action` once real adapters exist — deferred from q8 and tracked as agentculture/lobes-cli#180. Blocked on the deferred LoRA/QLoRA training work: until a hand can actually learn a domain there is nothing for the authority to be scoped to. +- [follow_up] Adapter production for hand is tracked cross-repo as agentculture/unsloth-cli#16 (verify/add LFM2.5-1.2B LoRA fine-tuning). Upstream Unsloth already supports the family — official LFM2.5 tutorial, Liquid's own docs, and an unsloth/LFM2.5-1.2B-Instruct mirror — so this is wrapper verification, not new library work. Includes checking that training against the unsloth mirror produces adapters loadable against the LiquidAI-served base. + +## Resolved vagueness + +- [unknown_blocking] The exact vLLM --tool-call-parser for LFM2.5. The card describes ChatML-style pythonic-or-JSON function calls, but the gemma4 precedent is explicit: 'pythonic' was a never-validated guess that the 2026-07-17 live run disproved, silently relaying well-formed calls as prose. The value must be measured against the served checkpoint, not inferred. — resolved: RESOLVED: the parser is 'lfm2'. vLLM ships a PURPOSE-BUILT `lfm2_tool_parser` (--tool-call-parser lfm2, docs.vllm.ai/en/latest/api/vllm/`tool_parsers`/`lfm2_tool_parser`/) for LFM2/LFM2.5's pythonic calls wrapped in <|`tool_call_start`|> / <|`tool_call_end`|>. The caution was warranted and the naive guess would have been WRONG for exactly the gemma4 reason: those delimiters are SPECIAL TOKENS, so the generic 'pythonic' parser (run with `skip_special_tokens`=True) would never see them and would silently relay well-formed calls as prose. Documentation-grade, not yet measured — a live check stays an honesty condition. +- [unknown_blocking] BLOCKING: whether vLLM's Lfm2ForCausalLM implements SupportsLoRA. --enable-lora only works for architectures that declare it, and vLLM refuses at startup for those that do not. A targeted search could not confirm LFM2 is on that list — the vendor card documents TRAINING adapters (Unsloth/TRL), which is a different claim from SERVING them in vLLM. v1 promises adapter serving, so this must be verified against the nightly image before the promise is made. Same class of error as the gemma4 'pythonic' guess. — resolved: RESOLVED POSITIVELY: LFM2 IS LoRA-servable in vLLM. vllm/`model_executor`/models/lfm2.py declares 'class Lfm2ForCausalLM(nn.Module, HasInnerState, SupportsLoRA, SupportsPP, IsHybrid, SupportsQuant)' and carries the LoRA plumbing — `packed_modules_mapping` (`qkv_proj`, w13, `in_proj`), `embedding_modules` (`embed_tokens`/`lm_head`), and an `hf_to_vllm_mapper` shared with quantization so LoRA and quant layer names stay consistent. --enable-lora is therefore a supported flag for this architecture and the v1 adapter-serving promise is safe to make. Source-grade, not yet measured on our image. From 8e3ff10c1875c9961fa02678158e3e1663d3df96 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 05:14:48 +0300 Subject: [PATCH 02/17] =?UTF-8?q?plan:=20hand=20lobe=20on=20LFM2.5-1.2B=20?= =?UTF-8?q?=E2=80=94=2012=20tasks=20in=204=20waves=20(devague=20/spec-to-p?= =?UTF-8?q?lan)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeds from the converged hand-lobe-lfm2-5-1-2b frame (76 coverage targets: 38 claims + 38 honesty conditions). All 12 tasks confirmed, every target covered, every task carrying TDD-phrased acceptance criteria and an explicit FILES: scope line for merge-disjointness. Waves (devague plan waves): wave 0: t1 t2 t3 t4 t5 t6 t7 (file-disjoint, verified by hand) wave 1: t8 t9 t11 wave 2: t10 wave 3: t12 Deps: t8<-t3,t6 · t9<-t2 · t10<-t8 · t11<-t1,t2,t3,t4 · t12<-t4,t6,t8,t10 Risk r1 (unknown_blocking, budget headroom with hand co-resident) resolved: t10 is authorised to re-apportion a committed lane budget, measured under docs/evidence/ per #108; a card with no headroom honestly gets HAND_FEASIBLE=false per #92. r2/r3 stay open non-blocking, r4 tracks adapter production in agentculture/unsloth-cli#16. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- .devague/current_plan | 2 +- .devague/plans/hand-lobe-lfm2-5-1-2b.json | 868 ++++++++++++++++++ .../plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md | 131 +++ 3 files changed, 1000 insertions(+), 1 deletion(-) create mode 100644 .devague/plans/hand-lobe-lfm2-5-1-2b.json create mode 100644 docs/plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md diff --git a/.devague/current_plan b/.devague/current_plan index 9752f16..1bde7d8 100644 --- a/.devague/current_plan +++ b/.devague/current_plan @@ -1 +1 @@ -unsloth-qat-senses-first-class-orin-variation +hand-lobe-lfm2-5-1-2b diff --git a/.devague/plans/hand-lobe-lfm2-5-1-2b.json b/.devague/plans/hand-lobe-lfm2-5-1-2b.json new file mode 100644 index 0000000..d124192 --- /dev/null +++ b/.devague/plans/hand-lobe-lfm2-5-1-2b.json @@ -0,0 +1,868 @@ +{ + "slug": "hand-lobe-lfm2-5-1-2b", + "title": "hand lobe LFM2.5-1.2B", + "frame_slug": "hand-lobe-lfm2-5-1-2b", + "schema_version": 4, + "status": "exported", + "created": "2026-08-10T00:10:52Z", + "updated": "2026-08-10T02:14:18Z", + "targets": [ + { + "id": "c1", + "kind": "announcement", + "text": "lobes serves 'hand' \u2014 a 1.2B LFM2.5 lobe cheap enough to run on every machine in the mesh, and the fleet's designated fine-tuning base", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h1", + "kind": "honesty", + "text": "VERIFIED on the physical Jetson AGX Thor against the exact pinned nightly digest 7c5a10e9a8b3 (vLLM 0.23.1rc1.dev672+g93d8f834d): ModelRegistry lists Lfm2ForCausalLM, and vllm/tool_parsers/__init__.py maps the key 'lfm2' -> Lfm2ToolParser. The same digest is present on the Orin. This is image-grade, not documentation-grade \u2014 the engine hand needs is already on the boxes.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c2", + "kind": "requirement", + "text": "lobes/catalog.py gains an LFM2.5-1.2B-Instruct SupportedModel entry: 32K native (native_max_model_len=32768, reuse _CONTEXT_32K_NATIVE), shape 'hybrid conv + GQA (10 double-gated conv + 6 GQA of 16 layers)', and quantization='none' \u2014 the bf16/unquantized SENTINEL the Qwen3.5-4B minor gear already uses (catalog.py:368), asserted by tests/test_catalog.py::test_minor_gear_quantization_is_none_sentinel. The sentinel means VLLM_QUANTIZATION is NOT written on switch and the compose lane must omit --quantization outright.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h6", + "kind": "honesty", + "text": "The catalog entry round-trips: every existing test_catalog.py invariant still passes with it present \u2014 unique id, all fields non-empty, doc file resolves, native_max_model_len positive, generate-task models carry zero dimension and empty hf_overrides, and the 'none' quantization sentinel behaves exactly as the 4B minor's does under lobes switch.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c3", + "kind": "requirement", + "text": "lobes/runtime/_parser.py::_RULES needs an lfm2-scoped family rule, or tests/test_catalog.py:76::test_tool_parser_matches_infer_parser fails the moment the catalog entry lands \u2014 the catalog's tool_parser field is asserted equal to infer_parser(id) for every gear.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h5", + "kind": "honesty", + "text": "After adding the rule, infer_parser('LiquidAI/LFM2.5-1.2B-Instruct') returns exactly 'lfm2', tests/test_catalog.py::test_tool_parser_matches_infer_parser passes, and the new markers do not change infer_parser's answer for ANY existing catalog id (assert the full catalog round-trips unchanged).", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c4", + "kind": "requirement", + "text": "hand's compose lane must ride the vLLM NIGHTLY image (${VLLM_NIGHTLY_IMAGE:-vllm/vllm-openai@sha256:7c5a10e9...} = 0.23.1rc1.dev672), NOT the NGC pin. Lfm2ForCausalLM ships in vLLM >= 0.23.0, and docs/vllm-nightly-migration.md section 1 records vllm-minor/vllm-middle still pinned to nvcr.io/nvidia/vllm:26.04-py3 = vLLM 0.19.0+nv26.04 \u2014 the cheapest existing lane is precisely the one that cannot load this architecture.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h4", + "kind": "honesty", + "text": "VERIFIED on both target boxes: 'docker images' on Thor and Orin each list vllm/vllm-openai at digest 7c5a10e9a8b3, and python3 -c 'import vllm' inside it reports 0.23.1rc1.dev672 \u2014 above LFM2's >= 0.23.0 floor. No new image pull is required for either card.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c5", + "kind": "requirement", + "text": "lobes/templates/fleet/docker-compose.yml gains a vllm-hand service and lobes/templates/fleet/env.example gains its HAND_* keys, following the vllm-minor block (docker-compose.yml:413-463) for structure: mg-logwrap entrypoint, HF cache + /logs mounts, expose 8000 with no host port, /health healthcheck.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h10", + "kind": "honesty", + "text": "docker compose config renders the vllm-hand service with no unresolved variables, and 'lobes up hand' starts it and reaches /health on a real box; the lane omits --quantization entirely (not an empty flag) and carries no --language-model-only.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c6", + "kind": "requirement", + "text": "IF hand lands as a ninth first-class Colleague role, the role vocabulary must be extended in lockstep across every table that enumerates roles: lobes/roles.py (ROLES, ROLE_BACKEND, ROLE_ROLE_HINT, ROLE_PATH, ROLE_RESPONSIBILITIES, ROLE_FORBIDDEN, ROLE_MAX_MODEL_LEN_ENV), lobes/profiles/schema.py:57 ROLES, lobes/profiles/render.py:76 ROLE_ENV_PREFIX, lobes/profiles/shapes.py (SHAPE_ROLES/DEFAULT_HOSTED_ROLES), lobes/gateway/_config.py (FEASIBLE_ENV, peer origin/proxy/key tables), lobes/gateway/server.py (_PEER_SERVED_NAME_ENV, _PEER_ROLE_HINT), lobes/gateway/_pressure_policy.py, lobes/catalog.py TIER_ROLE, lobes/runtime/_compose.py (role->service name), lobes/roles_measure.py, and lobes/cli/_commands/{capabilities,up,measure,overview,init}.py.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h11", + "kind": "honesty", + "text": "A single parametrised test iterates lobes.roles.ROLES and asserts every per-role table has an entry for all nine roles \u2014 so a future tenth role cannot half-land either. Today that test would fail on hand; after the change it passes with no role missing from any table.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c7", + "kind": "requirement", + "text": "server.py's peer tables are the KNOWN silent-failure point for a newly added role: 0.54.8 fixed exactly this \u2014 worker was wired into _config.py's peer dicts in 0.54.6 but missing from server.py's _PEER_SERVED_NAME_ENV/_PEER_ROLE_HINT, so peer_specs_from_table silently dropped it and WORKER_PEER_PROXY=true did nothing. hand must land in BOTH files or its proxy/referral surface is inert with no error.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h9", + "kind": "honesty", + "text": "HAND_PEER_PROXY=true against a declared HAND_PEER_ORIGIN actually forwards and returns X-Lobes-Proxied-By \u2014 proven by the same test shape that guards worker, plus tests/test_gateway_proxy.py::test_every_proxyable_role_resolves_a_served_name extended to hand so the 0.54.8 silent-drop cannot recur.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c8", + "kind": "requirement", + "text": "'runs on all machines' means hand is declared across the whole profile+shape matrix, not one file: all four card profiles (lobes/profiles/builtin/{base,spark,thor,orin}.toml) and all seven shape TOMLs (lobes/profiles/builtin_shapes/), which in turn regenerates the four card goldens (tests/goldens/*.env) and all 24 shape x card goldens (tests/goldens/shapes/*.env) via tests/goldens/regen.py, plus tests/test_shape_contract_matrix.py.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h7", + "kind": "honesty", + "text": "A byte-diff of the 28 regenerated goldens shows changes ONLY on lines introducing HAND_* keys \u2014 no unrelated key, value or ordering drift in any of the 4 card or 24 shape x card files. Any other delta means the render changed something it should not have.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c9", + "kind": "requirement", + "text": "Docs that enumerate the role set must all move together: docs/colleague-stack.md (the role contract), docs/gateway-fleet.md, docs/deployment-shapes.md, docs/machine-profiles.md, docs/openai-api.md, a new per-model docs/.md (tests/test_catalog.py::test_every_doc_file_exists asserts the catalog's doc field resolves), lobes/explain/catalog.py, CLAUDE.md and README.md.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h12", + "kind": "honesty", + "text": "tests/test_catalog.py::test_every_doc_file_exists passes with the new per-model doc present, and a grep for the role-count phrasing ('eight', 'EIGHT') across docs/ and CLAUDE.md returns no stale occurrence describing the role set.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c10", + "kind": "boundary", + "text": "hand is NOT an OPT_IN_CORE_ROLE. muse and worker are opt-in precisely because they are too heavy to co-reside (lobes/profiles/shapes.py: OPT_IN_CORE_ROLES = muse, worker); a 1.2B bf16 (~2.4 GiB weights) is the opposite case. 'Runs on all machines' places hand in DEFAULT_HOSTED_ROLES \u2014 the first role added to the default-hosted set since issue #81.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h21", + "kind": "honesty", + "text": "shapes.py's OPT_IN_CORE_ROLES remains exactly ('muse','worker') after the change \u2014 hand is absent from it \u2014 and DEFAULT_HOSTED_ROLES contains hand. Assert both, so a later refactor cannot quietly demote hand to opt-in.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c11", + "kind": "boundary", + "text": "Adding hand to DEFAULT_HOSTED_ROLES deliberately BREAKS the machine-as-brain byte-identical invariant that muse/worker were designed to preserve. shapes.py documents it explicitly: DEFAULT_HOSTED_ROLES excludes the opt-in core roles so 'machine-as-brain stays byte-identical to the bare card profile', and tests/goldens/regen.py::_shape_needs_goldens is defined against that set. hand renders on machine-as-brain, so this invariant must be knowingly re-baselined, not silently regenerated.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h8", + "kind": "honesty", + "text": "The machine-as-brain re-baseline is explicit and reviewed: the goldens diff for machine-as-brain contains ONLY HAND_* additions, and shapes.py's docstring + tests/goldens/regen.py's _shape_needs_goldens are updated to state that the identity-shape invariant now includes a default-hosted cheap role. A silent regeneration that merely makes the tests pass does NOT satisfy this.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c12", + "kind": "boundary", + "text": "base.toml's OOM veto does NOT extend to hand. base.toml marks senses/muse/worker feasible=false because a heavy checkpoint on an unrecognised card risks an OOM crash-loop; a 1.2B bf16 is the first generate lobe cheap enough that the conservative fallback can host it honestly.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h22", + "kind": "honesty", + "text": "Rendering the base profile produces HAND_FEASIBLE unset (i.e. feasible) with a declared model and budget, while senses/muse/worker stay FEASIBLE=false \u2014 proven by the regenerated tests/goldens/base.env, not by reading the TOML.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c14", + "kind": "boundary", + "text": "v1 must not foreclose LoRA even though it defers it: the served checkpoint stays bf16/unquantized. This is the same reason the 4B minor is served bf16 rather than the cosmicproc NVFP4 export \u2014 unsloth LoRA needs the bf16 base (docs/specs/2026-06-26-lobes-ships-a-minor-lobe-a-cheap-warm-co-resident.md). A quantized hand would make the 'fine-tuning base' half of the announcement undeliverable later.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h23", + "kind": "honesty", + "text": "The rendered vllm-hand command contains no --quantization token at all, and the catalog entry's quantization field is the 'none' sentinel \u2014 checked against the rendered compose, since an empty --quantization= would break vLLM exactly as the 4B minor's comment warns.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c15", + "kind": "boundary", + "text": "hand is TEXT-ONLY. LFM2.5-1.2B-Instruct carries no ViT (LiquidAI ships separate VL/audio variants), so hand must not advertise image_understanding/video_understanding, and \u2014 unlike the 4B minor \u2014 its compose lane needs no --language-model-only flag to strip a vision tower it does not have.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h24", + "kind": "honesty", + "text": "hand's ROLE_RESPONSIBILITIES contains neither image_understanding nor video_understanding, the rendered lane carries no --language-model-only, and posting an image part to model=hand fails cleanly rather than being silently dropped.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c16", + "kind": "boundary", + "text": "The Qwen3.5-4B minor gear is NOT deleted (cite-don't-delete). Whether hand supersedes it in practice is a separate decision from whether its catalog entry, docs/qwen3.5-4b-minor.md, and the vllm-minor compose service stay in-tree \u2014 they do.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h25", + "kind": "honesty", + "text": "After the change, the Qwen3.5-4B catalog entry still exists, docs/qwen3.5-4b-minor.md still resolves, and the vllm-minor compose service is still present behind COMPOSE_PROFILES=minor \u2014 a grep proves all three survive the repointing.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c17", + "kind": "boundary", + "text": "No live/validated claim without an evidence transcript (#108). Every doc, support table, and lobes capabilities output must read hand as declared/UNVALIDATED until a transcript lands under docs/evidence/ per card actually booted. Precedent in-tree: thor-muse's budget was measured but the shape stayed UNVALIDATED because the acceptance transcript never landed.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h26", + "kind": "honesty", + "text": "Before any evidence transcript lands, lobes capabilities and every doc/support table describe hand as declared/UNVALIDATED on every card; after a per-card transcript is committed, only THAT card reads validated. No blanket promotion from a single box's run.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c18", + "kind": "requirement", + "text": "hand joins lobes.roles.ROLES as the NINTH role with backend name 'hand', env prefix HAND_, compose service vllm-hand, and entries in every table enumerated in c6 \u2014 including gateway/server.py's _PEER_SERVED_NAME_ENV/_PEER_ROLE_HINT so HAND_PEER_PROXY is not silently inert (the 0.54.8 trap).", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h13", + "kind": "honesty", + "text": "GET /capabilities returns exactly nine role keys with hand among them, and 'lobes capabilities' agrees field-for-field with the gateway \u2014 the shared-core contract in roles.py holds for the new role with no CLI/gateway divergence.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c19", + "kind": "requirement", + "text": "The tier/alias space becomes DYNAMIC for the first time. Every alias today is a compile-time constant: catalog.TIER_ROLE is a literal dict and _tier_request.py:37 freezes _KNOWN_TIERS = frozenset(TIER_ROLE) at import. A per-deployment set of domain adapters cannot live in a module-level frozen constant \u2014 alias resolution must learn to admit names that exist only in this deployment's adapter inventory.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h14", + "kind": "honesty", + "text": "A request for model=hand: naming an adapter present in HAND_LORA_MODULES resolves and is served, while a name absent from the inventory is refused with a clear error rather than silently falling back to the base or to another lane. Prove BOTH directions.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c20", + "kind": "requirement", + "text": "Adapter advertisement must obey the #92 honesty rule end to end: GET /v1/models lists each LOADED adapter, GET /capabilities and 'lobes capabilities' report hand's adapter inventory, and an adapter that is declared but not loaded is never advertised as usable \u2014 the same feasible/ready/loaded discipline the eight existing roles already carry, applied one level deeper (per-adapter, not just per-role).", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h15", + "kind": "honesty", + "text": "With HAND_LORA_MODULES declaring an adapter whose path does not exist (so vLLM cannot load it), GET /v1/models does NOT list it and GET /capabilities does NOT advertise it as usable \u2014 declared-but-unloaded must never read as available. This is the #92 rule at adapter granularity and is the single most likely place to get it wrong.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c21", + "kind": "requirement", + "text": "catalog.TIER_ROLE repoints 'minor'/'cheap' to hand and gains a 'hand' alias; the capability order becomes hand < multimodal < worker < muse < main. The dict's ORDER is load-bearing \u2014 catalog.py:776-787 documents that tier_aliases derives ascending capability order from each role's LAST occurrence position, so this is an ordered edit, not a key swap.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h16", + "kind": "honesty", + "text": "resolve_tier('minor') and resolve_tier('cheap') both return the hand gear, resolve_tier('hand') returns it too, and tier_aliases still derives the ascending order hand < multimodal < worker < muse < main from the reordered dict \u2014 assert the derived ORDER, not just the mapping.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c22", + "kind": "requirement", + "text": "gateway/_pressure_policy.py's servable floor moves from the 4B minor to hand: hand is never shed, always served regardless of swap/iowait pressure, and the shed set stays exactly cortex/senses/worker/muse.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h17", + "kind": "honesty", + "text": "Under synthetic swap>75% / iowait>50% pressure, model=hand is served 200 while model=cortex/senses/worker/muse each return 429 with Retry-After \u2014 hand never sheds, and the shed set is unchanged.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c23", + "kind": "requirement", + "text": "The vllm-hand compose lane carries --enable-lora plus its sizing knobs (max_loras / max_lora_rank / max_cpu_loras) over a bf16 base with no --quantization flag \u2014 vLLM LoRA needs the unquantized base, which is the same constraint that already forces the bf16 sentinel.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h3", + "kind": "honesty", + "text": "VERIFIED at source: vllm/model_executor/models/lfm2.py declares 'class Lfm2ForCausalLM(nn.Module, HasInnerState, SupportsLoRA, SupportsPP, IsHybrid, SupportsQuant)' with packed_modules_mapping (qkv_proj, w13, in_proj), embedding_modules, and an hf_to_vllm_mapper shared with quantization. --enable-lora is a supported flag for this architecture. Must still be confirmed to BOOT with the flag armed on our digest \u2014 registration is not the same as a clean start.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c25", + "kind": "boundary", + "text": "hand REPLACING minor is a repointing, not a removal: Qwen3.5-4B's catalog entry, docs/qwen3.5-4b-minor.md and the vllm-minor compose service all stay in-tree (cite-don't-delete). What changes is what the fleet POINTS AT \u2014 the tier target, the LoRA base, and the pressure floor.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h27", + "kind": "honesty", + "text": "resolve_tier('minor') returns the hand gear while the Qwen3.5-4B entry remains in SUPPORTED_MODELS \u2014 proving the change is a repointing of what the fleet resolves, not a removal of what the catalog knows.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c32", + "kind": "audience", + "text": "Colleague and every mesh consumer that addresses lobes by ROLE name rather than model id, plus the operator running lobes init/up on each box. Secondary: the future fine-tuning workflow that will train domain adapters against this base.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h28", + "kind": "honesty", + "text": "The role is discoverable the way the audience is told to discover it: a caller resolving capabilities by ROLE NAME finds hand in GET /capabilities and lobes capabilities without parsing any model id, per the standing consumer contract that #361 punished cortex for violating.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c33", + "kind": "after_state", + "text": "Every machine in the mesh \u2014 Spark, Thor, Orin, and any unrecognised card \u2014 hosts a hand: a 1.2B LFM2.5 lobe served bf16 with --enable-lora armed, addressable as model=hand for the base and model=hand: for a learned adapter. hand is the fleet's always-servable floor (never shed under pressure) and has replaced the 4B minor as the cheap tier target and the LoRA base. Its adapter inventory is operator-declared in .env and honestly advertised: /v1/models and GET /capabilities list exactly the adapters actually loaded, and none that are not.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h18", + "kind": "honesty", + "text": "An acceptance transcript per validated card is committed under docs/evidence/ showing hand serving on that box; any card without one reads DECLARED/UNVALIDATED in every doc, support table and capabilities output (#108). Thor and Orin are ssh-reachable and already carry the pinned image, so both are obtainable; the Spark and the base fallback are not covered by those runs.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c34", + "kind": "why_it_matters", + "text": "Today the fleet's cheap lane does not work at all: the 4B minor gear pins vLLM 0.19.0, the image on which its own Qwen3.5 architecture hits the FLA/Gated-DeltaNet bug and emits gibberish, so 'minor' has never served correctly and no box has a working small lobe. Separately, every specialization the mesh wants must be paid for in prompt tokens against a big general model, because nothing in the tree can serve a fine-tuned adapter \u2014 grepping for LoRA returns zero hits. hand fixes both: a small lobe that actually runs on the nightly engine, and the first surface where a learned skill can be served instead of prompted.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h29", + "kind": "honesty", + "text": "The two motivating facts are re-verified at implementation time rather than trusted from this frame: that the 4B minor still cannot serve correctly on its pinned image, and that no LoRA surface exists in-tree. If either has changed, the justification is restated.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c35", + "kind": "success_signal", + "text": "A caller on any box in the mesh gets a 200 from model=hand with no pressure-based 429, on a card whose acceptance transcript is committed under docs/evidence/; lobes capabilities reports hand feasible/ready with lora_enabled true and an adapter list matching exactly what vLLM has loaded; a tool call through the lfm2 parser returns a structured tool_calls array rather than prose; and the full test suite passes with the 28 regenerated goldens.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h19", + "kind": "honesty", + "text": "Every clause of the success signal is demonstrated by committed artefacts \u2014 an evidence transcript for the live clauses and a green CI run for the test-suite clause \u2014 not asserted in prose.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c36", + "kind": "before_state", + "text": "Eight Colleague roles, all of which are either heavy (cortex/senses/muse/worker) or non-generate (embedder/reranker/stt/tts). The only small generate gear, minor, is an opt-in unnamed gear on a broken image, is not a Colleague role, and is not hosted by any shape by default. No LoRA surface exists anywhere in the tree and there is no lobes train verb.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h30", + "kind": "honesty", + "text": "The before-state is checked against the tree at implementation time \u2014 eight roles in lobes.roles.ROLES, zero LoRA hits, no train verb \u2014 so the spec is not describing a repo that has moved on.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c39", + "kind": "boundary", + "text": "Training is OWNED BY unsloth-cli, a sibling repo (/home/spark/git/unsloth-cli, dist unsloth-cli v0.5.0, console script 'sloth'), NOT by lobes. lobes never trains: there is no lobes train verb and there will not be one. The contract between the two is an ADAPTER ON DISK \u2014 unsloth-cli produces a LoRA/QLoRA adapter directory, the operator declares its path in HAND_LORA_MODULES, and lobes serves it. This is a cleaner boundary than 'training is deferred': the capability is not missing, it lives on the other side of a file-path interface.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h31", + "kind": "honesty", + "text": "Nothing in lobes imports, shells out to, or version-pins unsloth-cli; a grep for 'unsloth' across lobes/ returns hits only in comments and docs, never in code. The dependency is one-directional and documentary.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c41", + "kind": "requirement", + "text": "roles_measure.py must gain hand or 'lobes measure' CRASHES. _FAMILY_BY_ROLE (roles_measure.py:116) is indexed unguarded at line 419 while iterating lobes.roles.ROLES, and _LLM_ROLES (line 112) lists only cortex/senses/muse/worker. Adding hand to ROLES without touching this file is a KeyError, not a silent omission \u2014 louder than the 0.54.8 drop but the same class of lockstep failure, and c6's table list does not name _FAMILY_BY_ROLE or _LLM_ROLES specifically.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h32", + "kind": "honesty", + "text": "A test iterates lobes.roles.ROLES and asserts _FAMILY_BY_ROLE covers every role, so the KeyError class of bug cannot recur for a tenth role either; 'lobes measure' completes for hand and reports it in the generate family.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c42", + "kind": "requirement", + "text": "Every deployment shape's GPU budget must be RE-DERIVED with hand co-resident, or hand's allocation taken out of an existing lane rather than added on top. Each mesh-lobe reclaim was MEASURED on hardware with no hand present: spark-lobe cortex gpu_mem_util=0.44 (and the naive reclaim-sum was REFUSED live before 0.44 was found), thor-worker 0.45, orin 0.45 \u2014 each already sums to ~0.56-0.57 with the pooling gears. 'Runs on all machines' silently adds a tenth consumer to budgets that were measured to the edge. No claim in this frame re-derives them.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h33", + "kind": "honesty", + "text": "For EVERY shape that hosts hand, the committed budget is either (a) re-measured on real hardware with the hand lane running, with the transcript under docs/evidence/, or (b) explicitly re-apportioned so the total is unchanged from the pre-hand measured value. A shape whose budget is left at its pre-hand number with hand added on top does NOT satisfy this \u2014 that is the exact arithmetic that was refused live on spark-lobe, thor-lobe and thor-muse.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c44", + "kind": "boundary", + "text": "Adding hand to the role contract is effectively IRREVERSIBLE and that is accepted knowingly. The same additive/subtractive asymmetry that justified withholding repo_action (#180) applies to the role itself: once consumers resolve model=hand by name, removing it breaks them. There is no rollback path beyond a breaking change, so the decision to make hand a role rather than an unnamed gear should be treated as one-way.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h35", + "kind": "honesty", + "text": "The irreversibility is stated in docs/colleague-stack.md so a future maintainer reads it before proposing a tenth role or a retirement, rather than rediscovering it from the #361 consumer contract.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c45", + "kind": "requirement", + "text": "lobes/minor/governance.py and the 'lobes eval minor' verb must be reconciled with the repoint. governance.py is keyed to the ROLE NAME 'minor' (its own docstring: 'never to a specific model identifier') and carries allowed/forbidden duty lists plus a confidence threshold written for a general-purpose 4B. Once the minor tier resolves to hand, that policy silently governs a different model with different characteristics \u2014 and a DOMAIN-SPECIALIZED one, whose competence is adapter-dependent rather than uniform. Decide whether the policy transfers, is re-derived for hand, or is retired with the tier.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h36", + "kind": "honesty", + "text": "After the change there is exactly one answer to 'what governs the minor tier': either governance.py names hand and its duty lists are re-derived for an adapter-dependent specialist, or the module and 'lobes eval minor' are retired with the tier. A state where governance.py still says ROLE='minor' while the minor tier serves hand does not satisfy this.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c46", + "kind": "requirement", + "text": "up.py's COLLEAGUE_STACK bundle must gain hand. Its docstring defines colleague-stack as 'the FULL six-role Colleague set = the default fleet roles (cortex/senses/embedder/reranker) PLUS the audio-overlay roles (stt/tts)' and the constant's comment says 'the six roles machine-as-brain hosts'. hand is default-hosted, so both the count and the target list are wrong the moment it lands \u2014 and 'lobes up colleague-stack' would silently bring up a stack missing the role the spec claims runs everywhere.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h37", + "kind": "honesty", + "text": "'lobes up colleague-stack --apply' starts the hand lane along with the others, and up.py's docstring and constant comment state the correct role count \u2014 proven by running the target, not by reading the source.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c47", + "kind": "requirement", + "text": "The vllm-hand lane carries MG_LOG_NAME=hand for durable logs (the #50 convention every other lane follows via mg-logwrap), and hand is reachable by 'lobes measure' as a generate-family role. Without the log name the lane's boot output is not recoverable after a restart \u2014 which is precisely what would be needed to debug a co-residency OOM introduced by the budget gap above.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h38", + "kind": "honesty", + "text": "After a container restart, the hand lane's boot log is still readable under the durable log dir with the hand name \u2014 the same check the other lanes' MG_LOG_NAME wiring already satisfies.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c48", + "kind": "requirement", + "text": "--max-lora-rank has a documented default and a legible failure path. An adapter trained above the armed rank fails to LOAD at serve time, and vLLM's error for that is not self-explanatory to an operator who only knows they trained something with unsloth-cli. Since the trainer is a SEPARATE repo (unsloth-cli#16) the rank is chosen on the far side of a boundary lobes does not control, making mismatch the single most likely day-two failure.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h39", + "kind": "honesty", + "text": "Declaring an adapter whose rank exceeds --max-lora-rank produces an operator-legible failure that names the rank mismatch \u2014 in the lane's logs and, if the lane still starts, absent from /v1/models rather than half-advertised. The chosen default rank is documented in the per-model doc so unsloth-cli#16 can train against it.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c49", + "kind": "boundary", + "text": "hand is NEVER PROXIED and never referred: it carries no HAND_PEER_ORIGIN / _PEER_PROXY / _PEER_API_KEY, and lobes/gateway/server.py's _PEER_SERVED_NAME_ENV / _PEER_ROLE_HINT tables deliberately OMIT it. This narrows c7 and c18, which assumed hand would ride the peer tables by symmetry with the other eight roles \u2014 for hand the correct entry in those tables is no entry at all, and a test should assert its absence so a future symmetry-minded refactor does not add it back.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h41", + "kind": "honesty", + "text": "A test asserts hand is ABSENT from server.py's peer tables and from the FEASIBLE/peer env channels, so the never-proxied decision is enforced rather than merely documented.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c50", + "kind": "requirement", + "text": "Because hand is never proxied and never referred, NO deployment shape may drop it \u2014 hand appears in the hosts list of ALL seven built-in shapes, including the mesh-lobe shapes that drop heavy lobes. A dropped hand would have no referral and no proxy, so model=hand would 404 role_infeasible with no hosted_by and no recourse anywhere in the mesh: the one lobe state the honest-referral work (#112) exists to prevent. This is what makes 'runs on every host' a structural invariant rather than a default.", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h40", + "kind": "honesty", + "text": "A test asserts hand is present in the hosts list of every built-in shape TOML, so adding an eighth shape that omits it fails CI rather than silently creating an unreachable role.", + "deferred": false, + "deferred_reason": "" + } + ], + "tasks": [ + { + "id": "t1", + "summary": "Catalog gear + lfm2 tool-parser rule + per-model doc + minor/cheap tier repoint", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "infer_parser('LiquidAI/LFM2.5-1.2B-Instruct') == 'lfm2' and the full existing catalog round-trips through infer_parser unchanged", + "the entry carries quantization='none', native_max_model_len=32768, and every existing tests/test_catalog.py invariant still passes", + "resolve_tier('minor'), resolve_tier('cheap') and resolve_tier('hand') all return the hand gear while the Qwen3.5-4B entry remains in SUPPORTED_MODELS", + "tier_aliases derives the ascending order hand < multimodal < worker < muse < main (assert the ORDER, not just the mapping)", + "docs/lfm2.5-1.2b-hand.md exists so test_every_doc_file_exists resolves; FILES: catalog.py, runtime/_parser.py, the new doc only" + ], + "deps": [], + "covers": [ + "c2", + "h6", + "c3", + "h5", + "c14", + "h23", + "c16", + "h25", + "c21", + "h16", + "c25", + "h27" + ], + "instruction": "" + }, + { + "id": "t2", + "summary": "Role registry: hand as the ninth role in lobes/roles.py", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "lobes.roles.ROLES has nine entries including hand, and every per-role table in roles.py resolves for all nine", + "a parametrised test iterates ROLES and asserts each per-role dict has an entry, so a tenth role cannot half-land", + "hand's responsibilities are domain_mastery/learned_skill/specialized_task/tool_use and its forbidden list is final_decision/repo_action/security_decision", + "hand advertises neither image_understanding nor video_understanding", + "FILES: lobes/roles.py + tests/test_roles.py only" + ], + "deps": [], + "covers": [ + "c18", + "h13", + "c15", + "h24", + "c32", + "h28" + ], + "instruction": "" + }, + { + "id": "t3", + "summary": "Profile schema, render and shape vocabulary learn hand", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "OPT_IN_CORE_ROLES remains exactly ('muse','worker') and DEFAULT_HOSTED_ROLES contains hand \u2014 both asserted", + "a test asserts hand appears in the hosts list of every built-in shape, so a future shape omitting it fails CI", + "ROLE_ENV_PREFIX maps hand -> HAND and renders HAND_MODEL/HAND_GPU_MEM_UTIL/HAND_MAX_MODEL_LEN", + "FILES: profiles/{schema,render,shapes}.py + their tests only" + ], + "deps": [], + "covers": [ + "c10", + "h21", + "c50", + "h40" + ], + "instruction": "" + }, + { + "id": "t4", + "summary": "Gateway wiring: backend, dynamic adapter aliases, adapter honesty, never-proxied", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "model=hand: naming a declared adapter resolves and is served; a name absent from the inventory is refused with a clear error and never silently falls back to the base or another lane", + "an adapter declared in HAND_LORA_MODULES whose path does not exist is absent from GET /v1/models AND from capabilities \u2014 declared-but-unloaded never reads as usable", + "a test asserts hand is ABSENT from server.py's _PEER_SERVED_NAME_ENV/_PEER_ROLE_HINT and from the peer origin/proxy/key channels, so a symmetry-minded refactor cannot add it back", + "GET /capabilities returns nine role keys and 'lobes capabilities' agrees field-for-field", + "FILES: gateway/{_config,server,_routing,_tier_request}.py + their tests only" + ], + "deps": [], + "covers": [ + "c6", + "h11", + "c7", + "h9", + "c19", + "h14", + "c20", + "h15", + "c49", + "h41" + ], + "instruction": "" + }, + { + "id": "t5", + "summary": "Pressure policy: hand becomes the servable floor", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "under synthetic swap>75%/iowait>50%, model=hand returns 200 while cortex/senses/worker/muse each return 429 with Retry-After", + "the shed set is unchanged and hand is never in it", + "FILES: gateway/_pressure_policy.py + tests/test_pressure_policy.py only" + ], + "deps": [], + "covers": [ + "c22", + "h17" + ], + "instruction": "" + }, + { + "id": "t6", + "summary": "vllm-hand compose lane on the nightly digest with --enable-lora", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "docker compose config renders vllm-hand with no unresolved variables; the lane omits --quantization entirely (not an empty flag) and carries no --language-model-only", + "the lane pins VLLM_NIGHTLY_IMAGE, carries --enable-lora, --lora-modules=${HAND_LORA_MODULES}, --max-loras and --max-lora-rank, and MG_LOG_NAME=hand", + "the chosen default --max-lora-rank is documented in the per-model doc, and a rank-exceeding adapter fails legibly rather than half-advertising", + "FILES: templates/fleet/{docker-compose.yml,env.example} + a new compose test only" + ], + "deps": [], + "covers": [ + "c4", + "h4", + "c5", + "h10", + "c23", + "h3", + "c47", + "h38", + "c48", + "h39" + ], + "instruction": "" + }, + { + "id": "t7", + "summary": "Reconcile lobes/minor governance and 'lobes eval minor' with the repoint", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "there is exactly one answer to 'what governs the minor tier': either governance.py names hand with duty lists re-derived for an adapter-dependent specialist, or the module and 'lobes eval minor' are retired with the tier", + "no state remains where governance.py says ROLE='minor' while the minor tier serves hand", + "FILES: lobes/minor/governance.py, cli/_commands/eval.py + their tests only" + ], + "deps": [], + "covers": [ + "c45", + "h36" + ], + "instruction": "" + }, + { + "id": "t8", + "summary": "Declare hand across all 4 card profiles and all 7 shape TOMLs, regenerate goldens", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "a byte-diff of the 28 regenerated goldens shows changes ONLY on lines introducing HAND_* keys \u2014 no unrelated key, value or ordering drift", + "the machine-as-brain re-baseline is explicit: shapes.py's docstring and tests/goldens/regen.py's _shape_needs_goldens are updated to state the identity-shape invariant now includes a default-hosted cheap role \u2014 a silent regeneration that merely makes tests pass does NOT satisfy this", + "the regenerated base.env shows hand feasible with a declared model and budget while senses/muse/worker stay FEASIBLE=false", + "hand's gpu_mem_util is declared PER CARD, not once globally", + "FILES: profiles/builtin/*.toml, profiles/builtin_shapes/*.toml, tests/goldens/** only" + ], + "deps": [ + "t3", + "t6" + ], + "covers": [ + "c8", + "h7", + "c11", + "h8", + "c12", + "h22" + ], + "instruction": "" + }, + { + "id": "t9", + "summary": "CLI surfaces: roles_measure family map, colleague-stack bundle, capabilities, overview", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "'lobes measure' completes for hand and reports it in the generate family \u2014 today _FAMILY_BY_ROLE[role] at roles_measure.py:419 would raise KeyError", + "a test iterates ROLES and asserts _FAMILY_BY_ROLE covers every role, so the KeyError class cannot recur", + "'lobes up colleague-stack --apply' starts the hand lane, and up.py's docstring and COLLEAGUE_STACK comment state the corrected role count", + "FILES: roles_measure.py, cli/_commands/{measure,up,capabilities,overview}.py + their tests only" + ], + "deps": [ + "t2" + ], + "covers": [ + "c41", + "h32", + "c46", + "h37" + ], + "instruction": "" + }, + { + "id": "t10", + "summary": "Re-derive every hosting shape's GPU budget with hand co-resident", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "for EVERY shape hosting hand the committed budget is either re-measured on real hardware with the hand lane running (transcript under docs/evidence/) or explicitly re-apportioned so the pre-hand measured total is unchanged", + "no shape is left at its pre-hand number with hand simply added on top \u2014 that is the arithmetic vLLM refused live on spark-lobe, thor-lobe and thor-muse", + "the Orin value is validated by an actual boot at the served max_model_len, not inferred from the Spark's" + ], + "deps": [ + "t8" + ], + "covers": [ + "c42", + "h33" + ], + "instruction": "" + }, + { + "id": "t11", + "summary": "Documentation sweep: role contract, UNVALIDATED discipline, irreversibility, unsloth boundary", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "a grep for the role-count phrasing ('eight'/'EIGHT') across docs/ and CLAUDE.md returns no stale occurrence describing the role set", + "every surface reads hand as declared/UNVALIDATED per card until that card's transcript lands; no blanket promotion from one box's run", + "docs/colleague-stack.md states that adding a role is effectively irreversible, so a future maintainer reads it before proposing a tenth", + "a grep for 'unsloth' across lobes/ returns hits only in comments and docs, never in code \u2014 the dependency is one-directional", + "the two motivating facts (minor still broken on its pinned image; no LoRA surface in-tree) and the eight-role before-state are re-verified against the tree at implementation time", + "FILES: docs/*.md except the new per-model doc, CLAUDE.md, README.md, explain/catalog.py" + ], + "deps": [ + "t1", + "t2", + "t3", + "t4" + ], + "covers": [ + "c9", + "h12", + "c17", + "h26", + "c39", + "h31", + "c44", + "h35", + "c34", + "h29", + "c36", + "h30" + ], + "instruction": "" + }, + { + "id": "t12", + "summary": "Live validation on Thor and Orin; commit acceptance transcripts", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "a caller gets 200 from model=hand on each validated box with no pressure 429, and the transcript is committed under docs/evidence/", + "a tool call through the lfm2 parser returns a structured tool_calls array rather than prose, against the actually-pulled tokenizer revision (confirm it carries <|tool_call_start|>/<|tool_call_end|>)", + "capabilities reports hand feasible/ready with lora_enabled true and an adapter list matching exactly what vLLM loaded", + "the full suite passes with the 28 regenerated goldens", + "cards without a transcript (Spark, base fallback) remain explicitly UNVALIDATED" + ], + "deps": [ + "t4", + "t6", + "t8", + "t10" + ], + "covers": [ + "c1", + "h1", + "c33", + "h18", + "c35", + "h19" + ], + "instruction": "" + } + ], + "risks": [ + { + "id": "r1", + "text": "Every mesh-lobe reclaim (spark-lobe cortex 0.44, thor-worker worker 0.45, orin 0.45, all at 262144) was measured with NO hand co-resident; t10 may find that no headroom exists on one or more cards and force a re-apportionment that changes another lane's already-committed, measured budget", + "kind": "unknown_blocking", + "task_id": null, + "resolved": true, + "resolution": "Operator accepts the re-apportionment exposure rather than deferring the plan. t10 owns re-deriving every hosting shape's GPU budget with hand co-resident, and is explicitly authorised to LOWER another lane's committed budget if a card has no headroom \u2014 the new number lands as measured evidence under docs/evidence/, never as a declared guess (#108), and t8's goldens are regenerated against it. The graph already sequences this safely: t10 follows t8 (goldens exist to regenerate) and t12 (live validation on Thor + Orin) follows t10, so any re-apportionment is measured and re-goldened BEFORE anything is validated live. If a card turns out to have no headroom at all, the honest outcome is HAND_FEASIBLE=false on that card's profile per #92 \u2014 hand is a sleeping lobe by design, so a card that cannot host it is a supported end state, not a plan failure." + }, + { + "id": "r2", + "text": "hand's per-card gpu_mem_util is unmeasured \u2014 0.06 is 7.7 GiB on a 128 GB Spark but 3.84 GiB on a 64 GB Orin against ~2.4 GiB of weights, leaving little KV at 32K", + "kind": "unknown_nonblocking", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r3", + "text": "Downstream mesh consumers (culture, daria, reachy-mini-cli, colleague) may parse model ids in a way the new 'hand:' shape breaks; every existing id is org/name and lobes itself is clean", + "kind": "unknown_nonblocking", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r4", + "text": "Adapter production is cross-repo (agentculture/unsloth-cli#16) and unsloth-cli 0.5.0 ships only scaffold verbs, so the muscle-memory loop cannot be exercised end-to-end from this plan alone", + "kind": "follow_up", + "task_id": null, + "resolved": false, + "resolution": "" + } + ] +} diff --git a/docs/plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md b/docs/plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md new file mode 100644 index 0000000..cb778e1 --- /dev/null +++ b/docs/plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md @@ -0,0 +1,131 @@ +# Build Plan — hand lobe LFM2.5-1.2B + +slug: `hand-lobe-lfm2-5-1-2b` · status: `exported` · from frame: `hand-lobe-lfm2-5-1-2b` + +> lobes serves 'hand' — a 1.2B LFM2.5 lobe cheap enough to run on every machine in the mesh, and the fleet's designated fine-tuning base + +## Tasks + +### t1 — Catalog gear + lfm2 tool-parser rule + per-model doc + minor/cheap tier repoint + +- covers: c2, h6, c3, h5, c14, h23, c16, h25, c21, h16, c25, h27 +- acceptance: + - `infer_parser`('LiquidAI/LFM2.5-1.2B-Instruct') == 'lfm2' and the full existing catalog round-trips through `infer_parser` unchanged + - the entry carries quantization='none', `native_max_model_len`=32768, and every existing tests/`test_catalog.py` invariant still passes + - `resolve_tier`('minor'), `resolve_tier`('cheap') and `resolve_tier`('hand') all return the hand gear while the Qwen3.5-4B entry remains in `SUPPORTED_MODELS` + - `tier_aliases` derives the ascending order hand < multimodal < worker < muse < main (assert the ORDER, not just the mapping) + - docs/lfm2.5-1.2b-hand.md exists so `test_every_doc_file_exists` resolves; FILES: catalog.py, runtime/`_parser.py`, the new doc only + +### t2 — Role registry: hand as the ninth role in lobes/roles.py + +- covers: c18, h13, c15, h24, c32, h28 +- acceptance: + - lobes.roles.ROLES has nine entries including hand, and every per-role table in roles.py resolves for all nine + - a parametrised test iterates ROLES and asserts each per-role dict has an entry, so a tenth role cannot half-land + - hand's responsibilities are `domain_mastery`/`learned_skill`/`specialized_task`/`tool_use` and its forbidden list is `final_decision`/`repo_action`/`security_decision` + - hand advertises neither `image_understanding` nor `video_understanding` + - FILES: lobes/roles.py + tests/`test_roles.py` only + +### t3 — Profile schema, render and shape vocabulary learn hand + +- covers: c10, h21, c50, h40 +- acceptance: + - `OPT_IN_CORE_ROLES` remains exactly ('muse','worker') and `DEFAULT_HOSTED_ROLES` contains hand — both asserted + - a test asserts hand appears in the hosts list of every built-in shape, so a future shape omitting it fails CI + - `ROLE_ENV_PREFIX` maps hand -> HAND and renders `HAND_MODEL`/`HAND_GPU_MEM_UTIL`/`HAND_MAX_MODEL_LEN` + - FILES: profiles/{schema,render,shapes}.py + their tests only + +### t4 — Gateway wiring: backend, dynamic adapter aliases, adapter honesty, never-proxied + +- covers: c6, h11, c7, h9, c19, h14, c20, h15, c49, h41 +- acceptance: + - model=hand: naming a declared adapter resolves and is served; a name absent from the inventory is refused with a clear error and never silently falls back to the base or another lane + - an adapter declared in `HAND_LORA_MODULES` whose path does not exist is absent from GET /v1/models AND from capabilities — declared-but-unloaded never reads as usable + - a test asserts hand is ABSENT from server.py's `_PEER_SERVED_NAME_ENV`/`_PEER_ROLE_HINT` and from the peer origin/proxy/key channels, so a symmetry-minded refactor cannot add it back + - GET /capabilities returns nine role keys and 'lobes capabilities' agrees field-for-field + - FILES: gateway/{`_config`,server,`_routing`,`_tier_request`}.py + their tests only + +### t5 — Pressure policy: hand becomes the servable floor + +- covers: c22, h17 +- acceptance: + - under synthetic swap>75%/iowait>50%, model=hand returns 200 while cortex/senses/worker/muse each return 429 with Retry-After + - the shed set is unchanged and hand is never in it + - FILES: gateway/`_pressure_policy.py` + tests/`test_pressure_policy.py` only + +### t6 — vllm-hand compose lane on the nightly digest with --enable-lora + +- covers: c4, h4, c5, h10, c23, h3, c47, h38, c48, h39 +- acceptance: + - docker compose config renders vllm-hand with no unresolved variables; the lane omits --quantization entirely (not an empty flag) and carries no --language-model-only + - the lane pins `VLLM_NIGHTLY_IMAGE`, carries --enable-lora, --lora-modules=${`HAND_LORA_MODULES`}, --max-loras and --max-lora-rank, and `MG_LOG_NAME`=hand + - the chosen default --max-lora-rank is documented in the per-model doc, and a rank-exceeding adapter fails legibly rather than half-advertising + - FILES: templates/fleet/{docker-compose.yml,env.example} + a new compose test only + +### t7 — Reconcile lobes/minor governance and 'lobes eval minor' with the repoint + +- covers: c45, h36 +- acceptance: + - there is exactly one answer to 'what governs the minor tier': either governance.py names hand with duty lists re-derived for an adapter-dependent specialist, or the module and 'lobes eval minor' are retired with the tier + - no state remains where governance.py says ROLE='minor' while the minor tier serves hand + - FILES: lobes/minor/governance.py, cli/`_commands`/eval.py + their tests only + +### t8 — Declare hand across all 4 card profiles and all 7 shape TOMLs, regenerate goldens + +- depends on: t3, t6 +- covers: c8, h7, c11, h8, c12, h22 +- acceptance: + - a byte-diff of the 28 regenerated goldens shows changes ONLY on lines introducing `HAND_`\* keys — no unrelated key, value or ordering drift + - the machine-as-brain re-baseline is explicit: shapes.py's docstring and tests/goldens/regen.py's `_shape_needs_goldens` are updated to state the identity-shape invariant now includes a default-hosted cheap role — a silent regeneration that merely makes tests pass does NOT satisfy this + - the regenerated base.env shows hand feasible with a declared model and budget while senses/muse/worker stay FEASIBLE=false + - hand's `gpu_mem_util` is declared PER CARD, not once globally + - FILES: profiles/builtin/\*.toml, profiles/`builtin_shapes`/\*.toml, tests/goldens/\*\* only + +### t9 — CLI surfaces: `roles_measure` family map, colleague-stack bundle, capabilities, overview + +- depends on: t2 +- covers: c41, h32, c46, h37 +- acceptance: + - 'lobes measure' completes for hand and reports it in the generate family — today `_FAMILY_BY_ROLE`\[role\] at `roles_measure.py`:419 would raise KeyError + - a test iterates ROLES and asserts `_FAMILY_BY_ROLE` covers every role, so the KeyError class cannot recur + - 'lobes up colleague-stack --apply' starts the hand lane, and up.py's docstring and `COLLEAGUE_STACK` comment state the corrected role count + - FILES: `roles_measure.py`, cli/`_commands`/{measure,up,capabilities,overview}.py + their tests only + +### t10 — Re-derive every hosting shape's GPU budget with hand co-resident + +- depends on: t8 +- covers: c42, h33 +- acceptance: + - for EVERY shape hosting hand the committed budget is either re-measured on real hardware with the hand lane running (transcript under docs/evidence/) or explicitly re-apportioned so the pre-hand measured total is unchanged + - no shape is left at its pre-hand number with hand simply added on top — that is the arithmetic vLLM refused live on spark-lobe, thor-lobe and thor-muse + - the Orin value is validated by an actual boot at the served `max_model_len`, not inferred from the Spark's + +### t11 — Documentation sweep: role contract, UNVALIDATED discipline, irreversibility, unsloth boundary + +- depends on: t1, t2, t3, t4 +- covers: c9, h12, c17, h26, c39, h31, c44, h35, c34, h29, c36, h30 +- acceptance: + - a grep for the role-count phrasing ('eight'/'EIGHT') across docs/ and CLAUDE.md returns no stale occurrence describing the role set + - every surface reads hand as declared/UNVALIDATED per card until that card's transcript lands; no blanket promotion from one box's run + - docs/colleague-stack.md states that adding a role is effectively irreversible, so a future maintainer reads it before proposing a tenth + - a grep for 'unsloth' across lobes/ returns hits only in comments and docs, never in code — the dependency is one-directional + - the two motivating facts (minor still broken on its pinned image; no LoRA surface in-tree) and the eight-role before-state are re-verified against the tree at implementation time + - FILES: docs/\*.md except the new per-model doc, CLAUDE.md, README.md, explain/catalog.py + +### t12 — Live validation on Thor and Orin; commit acceptance transcripts + +- depends on: t4, t6, t8, t10 +- covers: c1, h1, c33, h18, c35, h19 +- acceptance: + - a caller gets 200 from model=hand on each validated box with no pressure 429, and the transcript is committed under docs/evidence/ + - a tool call through the lfm2 parser returns a structured `tool_calls` array rather than prose, against the actually-pulled tokenizer revision (confirm it carries <|`tool_call_start`|>/<|`tool_call_end`|>) + - capabilities reports hand feasible/ready with `lora_enabled` true and an adapter list matching exactly what vLLM loaded + - the full suite passes with the 28 regenerated goldens + - cards without a transcript (Spark, base fallback) remain explicitly UNVALIDATED + +## Risks + +- [unknown_blocking] Every mesh-lobe reclaim (spark-lobe cortex 0.44, thor-worker worker 0.45, orin 0.45, all at 262144) was measured with NO hand co-resident; t10 may find that no headroom exists on one or more cards and force a re-apportionment that changes another lane's already-committed, measured budget +- [unknown_nonblocking] hand's per-card `gpu_mem_util` is unmeasured — 0.06 is 7.7 GiB on a 128 GB Spark but 3.84 GiB on a 64 GB Orin against ~2.4 GiB of weights, leaving little KV at 32K +- [unknown_nonblocking] Downstream mesh consumers (culture, daria, reachy-mini-cli, colleague) may parse model ids in a way the new 'hand:' shape breaks; every existing id is org/name and lobes itself is clean +- [follow_up] Adapter production is cross-repo (agentculture/unsloth-cli#16) and unsloth-cli 0.5.0 ships only scaffold verbs, so the muscle-memory loop cannot be exercised end-to-end from this plan alone From 0ebfc236a63e7d13403e148a744207e61904d638 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 06:01:24 +0300 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20hand=20=E2=80=94=20the=20ninth=20?= =?UTF-8?q?Colleague=20role=20on=20LiquidAI=20LFM2.5-1.2B=20(t1-t9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements waves 0-1 of docs/plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md. `hand` is the fleet's designated fine-tuning base: one cheap base, many LoRA adapters, each mastering a domain ("muscle memory"). Where `worker` is an untrained generalist doer, `hand` is a trained specialist. t1 catalog + parser + doc + tier repoint - LiquidAI/LFM2.5-1.2B-Instruct: role_hint="hand", 32768 native, bf16 ("none" sentinel), text-only, no thinking mode. - runtime/_parser.py learns `lfm2` — LFM2's <|tool_call_start|> delimiters are SPECIAL TOKENS, the same trap that disproved `pythonic` for Gemma 4. This parser RAISES on a tokenizer missing them rather than degrading to prose. No --reasoning-parser: there is no thinking mode to pair with. - minor/cheap tiers repoint to hand; Qwen/Qwen3.5-4B demoted to `candidate` (cite-don't-delete, still selectable via `lobes switch`). - docs/lfm2.5-1.2b-hand.md. t2 roles.py: ROLES has nine entries; six per-role tables gain hand. Responsibilities domain_mastery/learned_skill/specialized_task/tool_use; forbidden final_decision/repo_action/security_decision (v1 withholds repo_action — adding is compatible, removing is a break; issue #180). build_role_registry's hand-typed role tuple is now DERIVED from ROLES. t3 profiles: schema ROLES, ROLE_ENV_PREFIX hand->HAND. OPT_IN_CORE_ROLES stays ("muse","worker") — hand is default-hosted. t4 gateway: `hand` backend (HAND_BASE_URL), Backend.adapters, `hand:` aliases, and adapter honesty via a live probe of the lane's OWN /v1/models (probe_backend_adapters) — NOT a filesystem check, since adapter paths are mounted into vllm-hand, not the gateway. hand is deliberately absent from all three peer channels: NEVER_PROXIED_BACKENDS names that absence so a symmetry-minded refactor must delete a constant to break it. t5 pressure: hand is the servable floor (_FLOOR_TIER); shed set unchanged. t6 vllm-hand compose lane, default-on, --enable-lora armed + empty inventory. mg-logwrap now drops `--flag=` tokens with an explicitly empty value — a compose command list cannot omit an argument conditionally, and vLLM would parse `--lora-modules=` as a malformed name=path pair. t7 minor/governance.py: ROLE = "hand"; duty lists re-derived for an adapter-dependent specialist and deliberately unchanged (competence is not authority). The module still names no model id — its own invariant. t8 hand declared in all 4 card profiles (per-card util: 0.06 on the 128 GB Spark/Thor, 0.10 on the 64 GB Orin) and all 7 shapes; 28 goldens regenerated, diff is HAND_* lines only, zero deletions. t9 roles_measure family map + dispatch (fixes the documented KeyError class), colleague-stack bundle, GPU_SERVICES. Budgets are DECLARED, not measured (#108) — t10 re-derives and t12 validates. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- docs/lfm2.5-1.2b-hand.md | 184 ++++++++++++++++++ lobes/catalog.py | 85 ++++++-- lobes/cli/_commands/route.py | 27 ++- lobes/cli/_commands/run.py | 15 +- lobes/cli/_commands/up.py | 16 +- lobes/gateway/_config.py | 154 ++++++++++++++- lobes/gateway/_pressure_policy.py | 35 ++-- lobes/gateway/_readiness.py | 114 +++++++++++ lobes/gateway/_routing.py | 53 ++++- lobes/gateway/server.py | 29 ++- lobes/minor/governance.py | 73 +++++-- lobes/profiles/builtin/base.toml | 21 ++ lobes/profiles/builtin/orin.toml | 27 +++ lobes/profiles/builtin/spark.toml | 21 ++ lobes/profiles/builtin/thor.toml | 20 ++ .../builtin_shapes/machine-as-brain.toml | 10 +- lobes/profiles/builtin_shapes/orin-lobe.toml | 10 +- lobes/profiles/builtin_shapes/orin-small.toml | 10 +- lobes/profiles/builtin_shapes/spark-lobe.toml | 10 +- lobes/profiles/builtin_shapes/thor-lobe.toml | 10 +- lobes/profiles/builtin_shapes/thor-muse.toml | 10 +- .../profiles/builtin_shapes/thor-worker.toml | 10 +- lobes/profiles/render.py | 1 + lobes/profiles/schema.py | 16 +- lobes/profiles/shape_render.py | 4 + lobes/profiles/shapes.py | 18 +- lobes/roles.py | 65 ++++++- lobes/roles_measure.py | 6 +- lobes/runtime/_compose.py | 1 + lobes/runtime/_parser.py | 25 +++ lobes/templates/fleet/docker-compose.yml | 124 ++++++++++++ lobes/templates/fleet/env.example | 53 +++++ lobes/templates/mg-logwrap.sh | 27 +++ tests/goldens/base.env | 4 + tests/goldens/orin.env | 5 + tests/goldens/regen.py | 36 ++-- tests/goldens/shapes/orin-lobe__base.env | 4 + tests/goldens/shapes/orin-lobe__orin.env | 5 + tests/goldens/shapes/orin-lobe__spark.env | 4 + tests/goldens/shapes/orin-lobe__thor.env | 4 + tests/goldens/shapes/orin-small__base.env | 4 + tests/goldens/shapes/orin-small__orin.env | 5 + tests/goldens/shapes/orin-small__spark.env | 4 + tests/goldens/shapes/orin-small__thor.env | 4 + tests/goldens/shapes/spark-lobe__base.env | 4 + tests/goldens/shapes/spark-lobe__orin.env | 5 + tests/goldens/shapes/spark-lobe__spark.env | 4 + tests/goldens/shapes/spark-lobe__thor.env | 4 + tests/goldens/shapes/thor-lobe__base.env | 4 + tests/goldens/shapes/thor-lobe__orin.env | 5 + tests/goldens/shapes/thor-lobe__spark.env | 4 + tests/goldens/shapes/thor-lobe__thor.env | 4 + tests/goldens/shapes/thor-muse__base.env | 4 + tests/goldens/shapes/thor-muse__orin.env | 5 + tests/goldens/shapes/thor-muse__spark.env | 4 + tests/goldens/shapes/thor-muse__thor.env | 4 + tests/goldens/shapes/thor-worker__base.env | 4 + tests/goldens/shapes/thor-worker__orin.env | 5 + tests/goldens/shapes/thor-worker__spark.env | 4 + tests/goldens/shapes/thor-worker__thor.env | 4 + tests/goldens/spark.env | 4 + tests/goldens/template-defaults.env | 10 + tests/goldens/thor.env | 4 + tests/test_catalog.py | 85 +++++++- tests/test_catalog_tiers.py | 23 ++- tests/test_cli_capabilities.py | 10 +- tests/test_cli_measure.py | 3 +- tests/test_cli_run.py | 9 +- tests/test_cli_up.py | 39 ++-- tests/test_dropped_lobe_honesty.py | 2 + tests/test_gateway_busy_e2e.py | 19 +- tests/test_gateway_config_proxy.py | 15 +- tests/test_gateway_feasibility.py | 3 + tests/test_gateway_proxy.py | 5 +- tests/test_gateway_server.py | 25 +-- tests/test_gateway_tiers.py | 52 ++--- tests/test_init_shape.py | 9 +- tests/test_minor_governance.py | 4 +- tests/test_minor_integration.py | 34 +++- tests/test_parser.py | 12 ++ tests/test_peer_referral.py | 11 +- tests/test_pressure_policy.py | 47 ++--- tests/test_profile_render.py | 1 + tests/test_profile_schema.py | 2 +- tests/test_proxy_integration.py | 35 ++++ tests/test_roles.py | 125 +++++++++++- tests/test_shape_contract_matrix.py | 18 +- tests/test_shapes.py | 25 ++- tests/test_smoke_duo.py | 32 +-- tests/test_status_pressure.py | 15 +- tests/test_tier_request.py | 20 +- tests/test_tool_parser_plugin.py | 27 ++- 92 files changed, 1884 insertions(+), 272 deletions(-) create mode 100644 docs/lfm2.5-1.2b-hand.md diff --git a/docs/lfm2.5-1.2b-hand.md b/docs/lfm2.5-1.2b-hand.md new file mode 100644 index 0000000..d159952 --- /dev/null +++ b/docs/lfm2.5-1.2b-hand.md @@ -0,0 +1,184 @@ +# LiquidAI/LFM2.5-1.2B-Instruct — the `hand` lobe + +`hand` is the fleet's **ninth Colleague role** and its **designated fine-tuning +base**. The metaphor is muscle memory: **one cheap base, many LoRA adapters, +each mastering a domain**. Where `worker` is an untrained generalist doer, +`hand` is a trained specialist — it knows a few things extremely well because +someone taught it, and nothing else. + +At ~1.2B parameters (~2.4 GiB in bf16) it is cheap enough to co-reside on +**every card in the mesh**, which is the whole point: a specialist you cannot +afford to keep resident is not muscle memory, it is a trip to another box. + +| | | +|---|---| +| checkpoint | `LiquidAI/LFM2.5-1.2B-Instruct` | +| role | `hand` (ninth Colleague role, default-hosted) | +| tier aliases | `model=hand`, and `model=minor` / `model=cheap` (repointed) | +| architecture | `Lfm2ForCausalLM` — hybrid short-conv + GQA, **text-only** | +| context | 32768 native | +| quantization | none (bf16) | +| tool parser | `lfm2` | +| reasoning parser | **none** — no thinking mode | +| LoRA | served with `--enable-lora` | + +## Why this checkpoint + +The spec's own answer (`docs/specs/2026-08-09-hand-lobe-lfm2-5-1-2b.md`) is +that the fleet had **no LoRA surface in-tree at all** and its nominal +fine-tuning base — `Qwen/Qwen3.5-4B` — was still broken on its pinned image. +LFM2.5-1.2B is small enough to train cheaply, small enough to keep resident +everywhere, and — unlike the 4B it replaces — actually boots on the pinned +nightly. + +`Qwen/Qwen3.5-4B` stays in the catalog as a plain candidate +(cite-don't-delete): nothing about that checkpoint changed, only which gear the +`minor` tier resolves to. It is still selectable via `lobes switch`. + +## Architecture and vLLM support + +`Lfm2ForCausalLM` requires **vLLM ≥ 0.23.0**. Verified against the exact pinned +nightly digest — not inferred from the version string — on a physical Jetson +AGX Thor: + +```text +VLLM_VERSION: 0.23.1rc1.dev672+g93d8f834d +LFM2_REGISTERED: True +archs: ['Lfm2ForCausalLM', 'Lfm2MoeForCausalLM', + 'ColBERTLfm2Model', 'Lfm2VlForConditionalGeneration'] +``` + +Both Thor and Orin already carry digest `7c5a10e9a8b3`, so the `hand` lane needs +**no new image pull**. + +Note the fourth architecture in that list: `Lfm2VlForConditionalGeneration` is +LiquidAI's *vision* variant. **This checkpoint is not it.** `hand` is text-only, +carries no ViT, and advertises neither `image_understanding` nor +`video_understanding`. That also means the lane carries **no +`--language-model-only`** — there is no vision tower to switch off. + +## Tool calling — the `lfm2` parser + +LFM2 emits its own syntax, whose delimiters are **special tokens**: + +```text +<|tool_call_start|>[get_weather(city="Paris")]<|tool_call_end|> +``` + +This is the same shape of trap that made `pythonic` silently wrong for Gemma 4 +— a parser running with `skip_special_tokens=True` never sees the delimiters, +matches nothing, and vLLM relays a perfectly well-formed call as ordinary +assistant **content** with `tool_calls: null`. vLLM ships a purpose-built parser +registered as **`lfm2`** +(`vllm/tool_parsers/lfm2_tool_parser.py` → `Lfm2ToolParser`), and +`lobes.runtime._parser.infer_parser` returns it for any `lfm2`-family id. + +**This one fails loudly rather than silently.** `Lfm2ToolParser.__init__` +resolves both delimiters through `self.vocab.get()` and **raises** when either +is missing — so a tokenizer revision that dropped them kills server startup +instead of degrading to prose. That is strictly better than the Gemma 4 failure +mode, and it is why the acceptance criterion for live validation is phrased as +"confirm the actually-pulled tokenizer revision carries +`<|tool_call_start|>`/`<|tool_call_end|>`". + +### No reasoning parser + +Unlike the cortex lane (`--reasoning-parser=qwen3`) and all three Gemma 4 lanes +(which need `--tool-call-parser=gemma4` and `--reasoning-parser=gemma4` as a +**matched pair**), `hand` needs **no** `--reasoning-parser`. +`LFM2.5-1.2B-Instruct` has no thinking mode; LiquidAI ships +`LFM2.5-1.2B-Thinking` as a separate checkpoint. Enabling a reasoning parser +here would be arming a half of a pair that has no other half. + +## LoRA — the muscle-memory surface + +The lane is served with `--enable-lora` **armed and the inventory empty**. That +is a deliberate v1 shape: the *serving* half of muscle memory ships now, the +*training* half is cross-repo (see below), and an armed-but-empty lane is +honest in a way a promised-later one is not. + +Adapters are declared as a fixed list at boot: + +```bash +HAND_LORA_MODULES="legal=/models/adapters/legal,sql=/models/adapters/sql" +``` + +| knob | default | meaning | +|---|---|---| +| `HAND_LORA_MODULES` | *(empty)* | `name=path` list, comma-separated, fixed at boot | +| `HAND_MAX_LORAS` | `4` | concurrently-resident adapters | +| `HAND_MAX_LORA_RANK` | `32` | maximum adapter rank | + +**There is no runtime hot-load.** Adding an adapter is a lane restart. This is a +recorded decision, not an omission: a hot-load surface is a mutable-state API on +a lane whose whole value is being cheap and predictable. + +### Addressing an adapter + +| request | serves | +|---|---| +| `model=hand` | the **base** checkpoint | +| `model=minor` / `model=cheap` | the base (back-compat tier spellings) | +| `model=hand:` | the adapter named `` | + +The bare name **never 404s** — `hand` with no adapters declared is a working +lane, not a broken one. A `hand:` naming an adapter that is not in the +inventory is **refused with a clear error**; it never silently falls back to the +base or to another lane, because a caller who asked for the legal specialist and +got the generalist has been lied to. + +### `--max-lora-rank` and honest failure + +`HAND_MAX_LORA_RANK` defaults to **32**, which covers the ranks unsloth's +defaults produce for a model this size. An adapter whose rank exceeds the +configured maximum **fails at load** — vLLM refuses it, and the honesty rule +(#92) then applies: an adapter that did not load is absent from `GET /v1/models` +and from `/capabilities`. A declared-but-unloaded adapter must never read as +usable. If you train at a higher rank, raise the knob and restart the lane. + +## Pressure policy — the servable floor + +`hand` is the **servable floor**. Under the swap > 75 % / iowait > 50 % pressure +policy, `cortex`, `senses`, `worker` and `muse` each shed with **HTTP 429 + +`Retry-After`**; `hand` is served regardless. It inherits this from the `minor` +tier it replaced, and it is the reason the floor is worth keeping: something has +to answer when the box is under pressure. + +## Responsibilities + +| | | +|---|---| +| **responsibilities** | `domain_mastery`, `learned_skill`, `specialized_task`, `tool_use` | +| **forbidden** | `final_decision`, `repo_action`, `security_decision` | + +`hand` proposes and executes within its domain; `cortex` decides. The forbidden +list is deliberately conservative for v1 — **adding** a responsibility later is +contract-compatible, **removing** one is a break, so v1 withholds `repo_action` +rather than granting it speculatively. Granting it once adapters actually exist +is tracked as **agentculture/lobes-cli#180**. + +## Training — a one-directional boundary + +Adapters are produced by **`unsloth-cli`**, out of tree. lobes **serves** +adapters; it does not train them, and nothing under `lobes/` imports, shells out +to, or depends on unsloth — the only mentions are in comments and docs like this +one. The dependency runs one way. + +`unsloth-cli` 0.5.0 ships only scaffold verbs, so the muscle-memory loop cannot +yet be exercised end to end. Verifying/adding LFM2.5 support there is tracked as +**agentculture/unsloth-cli#16**. + +## Validation status + +Per the #108 rule, `hand` is **DECLARED** on every card and becomes **VALIDATED** +only on the cards whose acceptance transcript has landed under `docs/evidence/`. +One box's successful boot never promotes another card. See +`docs/machine-profiles.md` and `docs/deployment-shapes.md` for the per-card +picture. + +## See also + +- `docs/colleague-stack.md` — the nine-role contract +- `docs/gateway-fleet.md` — the `hand` lane and its adapter aliases +- `docs/qwen3.5-4b-minor.md` — the demoted 4B this replaced in the tier +- `lobes explain roles` / `lobes capabilities` diff --git a/lobes/catalog.py b/lobes/catalog.py index 40ac8c6..258fc7b 100644 --- a/lobes/catalog.py +++ b/lobes/catalog.py @@ -349,10 +349,56 @@ class SupportedModel: doc="qwen3-14b-nvfp4.md", task="generate", ), + SupportedModel( + id="LiquidAI/LFM2.5-1.2B-Instruct", + # The `hand` lobe — the fleet's NINTH Colleague role and its designated + # fine-tuning base (issue #81 role set; the hand-lobe spec/plan under + # docs/specs + docs/plans). "Muscle memory": one cheap base, many LoRA + # adapters, each mastering a domain. This is the gear the `minor`/`cheap` + # tier now resolves to — it REPLACES Qwen/Qwen3.5-4B below in that slot + # (which stays in the catalog as a plain candidate, cite-don't-delete). + # + # Architecture: LFM2.5 is a short-conv + GQA hybrid (Lfm2ForCausalLM), + # registered in vLLM >= 0.23.0. Verified live against the exact pinned + # nightly digest on a physical Jetson AGX Thor (2026-08-10): + # VLLM_VERSION 0.23.1rc1.dev672+g93d8f834d, LFM2_REGISTERED True. + # + # quantization="none" is the bf16/unquantized sentinel — VLLM_QUANTIZATION + # is NOT written on switch, and the `vllm-hand` fleet lane omits the + # --quantization flag entirely rather than passing it empty. At ~1.2B + # params (~2.4 GiB bf16) that is the point: cheap enough to co-reside on + # every card in the mesh. + # + # TEXT-ONLY: no ViT, so the lane carries no --language-model-only (there is + # nothing to switch off) and `hand` advertises neither image_understanding + # nor video_understanding. No thinking mode either — LiquidAI ships + # LFM2.5-1.2B-Thinking as a SEPARATE checkpoint — so unlike the cortex + # (qwen3) and Gemma 4 (gemma4) lanes this one needs NO --reasoning-parser. + # + # Tool calls use LFM2's own <|tool_call_start|>/<|tool_call_end|> delimiters, + # which are SPECIAL TOKENS — the same trap that made `pythonic` silently + # wrong for Gemma 4. vLLM ships a purpose-built "lfm2" parser + # (vllm/tool_parsers/lfm2_tool_parser.py, registered as "lfm2"); its + # __init__ resolves both delimiters via self.vocab.get() and RAISES if + # either is missing, so a tokenizer revision without them fails loudly at + # startup instead of degrading to prose. See docs/lfm2.5-1.2b-hand.md. + role_hint="hand", + shape="hybrid short-conv + GQA (text-only)", + context="32K native", + native_max_model_len=32768, + tool_parser="lfm2", + quantization="none", + status="configured", + doc="lfm2.5-1.2b-hand.md", + task="generate", + ), SupportedModel( id="Qwen/Qwen3.5-4B", - # bf16 base (the unsloth-LoRA fine-tune target): the fleet's first LoRA - # target and "minor" small-brain companion to the 27B primary. Multimodal + # DEMOTED to a plain candidate (cite-don't-delete) when the `hand` lobe + # above took over the minor/cheap tier and the LoRA-base duty. It was the + # fleet's first LoRA target and "minor" small-brain companion to the 27B + # primary; nothing about the checkpoint changed, only which gear the tier + # resolves to. Still selectable via `lobes switch`. Multimodal # (hybrid linear-attn + ViT) — serve text-only via --language-model-only. # Built-in MTP head not used in v1 (no speculative_config carried). # quantization="none" is the bf16/unquantized sentinel — VLLM_QUANTIZATION @@ -360,7 +406,7 @@ class SupportedModel: # flag from the compose command: by hand (the single-model template defaults # to --quantization=modelopt when VLLM_QUANTIZATION is absent, which would # corrupt bf16 weights). See docs/qwen3.5-4b-minor.md. - role_hint="minor", + role_hint="candidate", shape="hybrid linear-attn + ViT (multimodal)", context=_CONTEXT_256K_NATIVE, native_max_model_len=262144, @@ -764,22 +810,31 @@ def as_dicts() -> list[dict[str, str]]: #: muse → muse (Gemma 4 31B creative/ideation lobe — role IS the #: backend name; opt-in, hosted only by a muse-hosting #: deployment shape) +#: hand → hand (LiquidAI LFM2.5-1.2B fine-tuning base — role IS the +#: backend name; default-hosted on every card, and the +#: gear the minor/cheap tier resolves to since the hand +#: lobe replaced Qwen3.5-4B in that slot) TIER_ROLE: dict[str, str] = { - # Primary vocabulary. + # Primary vocabulary. ``minor`` (and its ``cheap`` alias) point at the + # ``hand`` backend: hand REPLACED the 4B in the cheap-tier slot, so the two + # spellings are the same lane, not two lanes. There is no ``minor`` backend + # role any more — the tier name survives for back-compat, the role does not. "main": "primary", - "minor": "minor", + "minor": "hand", "multimodal": "multimodal", # Back-compat aliases. - "cheap": "minor", + "cheap": "hand", "normal": "multimodal", "hard": "primary", # Capability-ROLE names (alias the same backends as main / multimodal; - # muse/worker are their own backends). Order matters: ``tier_aliases`` + # hand/muse/worker are their own backends). Order matters: ``tier_aliases`` # derives ascending capability order from each role's *last* occurrence - # position here, so a multimodal-role alias must appear before the worker - # one, worker before the muse one, and muse before a primary-role one - # (senses < worker < muse < cortex) to keep the last-occurrence sequence - # ascending (minor < multimodal < worker < muse < primary). + # position here, so the hand-role alias must appear before a multimodal one, + # multimodal before the worker one, worker before the muse one, and muse + # before a primary-role one (hand < senses < worker < muse < cortex) to keep + # the last-occurrence sequence ascending + # (hand < multimodal < worker < muse < primary). + "hand": "hand", "senses": "multimodal", "worker": "worker", "muse": "muse", @@ -794,9 +849,11 @@ def resolve_tier(tier: str) -> "SupportedModel": :param tier: A tier alias — one of the :data:`TIER_ROLE` keys. The primary vocabulary is ``"main"`` / ``"minor"`` / ``"multimodal"``; the legacy ``"cheap"`` / ``"normal"`` / ``"hard"`` names are retained as aliases. - ``"main"`` and ``"hard"`` resolve to the primary; ``"minor"`` and - ``"cheap"`` to the 4B minor; ``"multimodal"`` and ``"normal"`` to the - Gemma 4 multimodal gear. + ``"main"`` and ``"hard"`` resolve to the primary; ``"minor"``, + ``"cheap"`` and ``"hand"`` all resolve to the 1.2B ``hand`` gear (which + replaced the 4B in that slot — the 4B is still in the catalog as a + candidate, but no tier resolves to it); ``"multimodal"`` and ``"normal"`` + to the Gemma 4 multimodal gear. :raises ValueError: If *tier* is not a known key in :data:`TIER_ROLE`. """ role = TIER_ROLE.get(tier) diff --git a/lobes/cli/_commands/route.py b/lobes/cli/_commands/route.py index 943a7b0..98aad41 100644 --- a/lobes/cli/_commands/route.py +++ b/lobes/cli/_commands/route.py @@ -10,7 +10,7 @@ lobes route "" --base-url http://other:8000/v1 lobes route "" --model -The minor lobe model (``role_hint == "minor"``) is asked to classify the task +The cheap-tier lobe model (``role_hint == "hand"``) is asked to classify the task into one of the catalog gear roles. Governance is overlaid via :func:`lobes.minor.decide` so any escalation condition forces ``escalate=True`` regardless of the model's suggestion. Routing targets are **only** lobes catalog @@ -67,8 +67,9 @@ "You are a routing classifier for the lobes model fleet. " "Given a task description, classify it into the most appropriate catalog gear role.\n\n" "Available catalog gear roles:\n" - '- "minor": Small 4B model. Best for: quick formatting, validation, ' - "classification, suggestion, summarization.\n" + '- "hand": Small 1.2B fine-tuning base (LFM2.5), the cheap-tier floor. ' + "Best for: quick formatting, validation, classification, suggestion, " + "summarization, and any domain a LoRA adapter has been trained for.\n" '- "primary": Default 27B primary (text-only, MTP speculative decoding). ' "Best for: complex reasoning, generation, code, most tasks.\n" '- "candidate": Alternative 27B/32B models. Best for: vision tasks or ' @@ -103,19 +104,24 @@ def _resolve_model(args: argparse.Namespace) -> str: - """Resolve the minor model id: ``--model`` wins, else catalog lookup by role_hint. + """Resolve the cheap-tier model id: ``--model`` wins, else catalog lookup by role_hint. Raises :class:`~lobes.cli._errors.ModelGearError` when no ``--model`` was - given and the catalog has no entry with ``role_hint == "minor"``. + given and the catalog has no entry with ``role_hint == "hand"``. + + The hint is ``"hand"``, not ``"minor"``: the ``hand`` lobe replaced + ``Qwen/Qwen3.5-4B`` in the cheap-tier slot, and no catalog entry carries + ``role_hint == "minor"`` any more (the tier *name* survives as a back-compat + spelling in :data:`~lobes.catalog.TIER_ROLE`; the *role* does not). """ explicit = getattr(args, "model", None) if explicit: return explicit - models = [m for m in supported_models() if m.role_hint == "minor"] + models = [m for m in supported_models() if m.role_hint == "hand"] if not models: raise ModelGearError( code=EXIT_USER_ERROR, - message="no model with role_hint='minor' found in the catalog", + message="no model with role_hint='hand' found in the catalog", remediation="pass --model to target a specific model id", ) return models[0].id @@ -159,8 +165,11 @@ def _clamp(value: float, lo: float = 0.0, hi: float = 1.0) -> float: # Known catalog gear roles a routing decision may target. An out-of-set # suggestion from the model is clamped to "primary" (the safe default target). +# "hand" replaced "minor" as the cheap-tier role_hint; "minor" is RETAINED here +# so a model that still answers with the old spelling is honoured rather than +# silently clamped to the 27B primary — the two names mean the same lane. _KNOWN_GEARS: frozenset[str] = frozenset( - {"minor", "primary", "candidate", "fallback", "embedding", "reranker"} + {"hand", "minor", "primary", "candidate", "fallback", "embedding", "reranker"} ) @@ -312,7 +321,7 @@ def register(sub: argparse._SubParsersAction) -> None: default=None, help=( "Override the model id used for classification " - "(default: resolved from the catalog by role_hint='minor')." + "(default: resolved from the catalog by role_hint='hand')." ), ) p.add_argument( diff --git a/lobes/cli/_commands/run.py b/lobes/cli/_commands/run.py index 00a1f80..6be6ab8 100644 --- a/lobes/cli/_commands/run.py +++ b/lobes/cli/_commands/run.py @@ -11,7 +11,7 @@ lobes run minor "" --json The minor lobe model is resolved from the supported-model catalog -(``role_hint == "minor"``). Override with ``--model `` when the +(``role_hint == "hand"``). Override with ``--model `` when the catalog entry does not yet exist or you want to target a specific model id. The gateway base URL defaults to ``http://localhost:8000/v1`` (the local @@ -34,16 +34,21 @@ def _resolve_model(args: argparse.Namespace) -> str: """Resolve the model id: ``--model`` wins, else catalog lookup by role_hint. Raises :class:`~lobes.cli._errors.ModelGearError` when no ``--model`` was - given and the catalog has no entry with ``role_hint == "minor"``. + given and the catalog has no entry with ``role_hint == "hand"``. + + The hint is ``"hand"``, not ``"minor"``: the ``hand`` lobe replaced + ``Qwen/Qwen3.5-4B`` in the cheap-tier slot, and no catalog entry carries + ``role_hint == "minor"`` any more (the tier *name* survives as a back-compat + spelling in :data:`~lobes.catalog.TIER_ROLE`; the *role* does not). """ explicit = getattr(args, "model", None) if explicit: return explicit - models = [m for m in supported_models() if m.role_hint == "minor"] + models = [m for m in supported_models() if m.role_hint == "hand"] if not models: raise ModelGearError( code=EXIT_USER_ERROR, - message="no model with role_hint='minor' found in the catalog", + message="no model with role_hint='hand' found in the catalog", remediation="pass --model to target a specific model id", ) return models[0].id @@ -122,7 +127,7 @@ def register(sub: argparse._SubParsersAction) -> None: p.add_argument( "--model", default=None, - help="Override the model id (default: resolved from the catalog by role_hint='minor').", + help="Override the model id (default: resolved from the catalog by role_hint='hand').", ) p.add_argument( "--system", diff --git a/lobes/cli/_commands/up.py b/lobes/cli/_commands/up.py index 1e88846..1008ce3 100644 --- a/lobes/cli/_commands/up.py +++ b/lobes/cli/_commands/up.py @@ -65,6 +65,7 @@ "senses": "vllm-multimodal", "muse": "vllm-muse", "worker": "vllm-worker", + "hand": "vllm-hand", "embedder": "vllm-embed", "reranker": "vllm-rerank", "stt": "stt", @@ -75,14 +76,17 @@ # any target that includes one needs the ``-f`` overlay AND the file scaffolded. _AUDIO_ROLES: frozenset[str] = frozenset({"stt", "tts"}) -# The colleague-stack bundle (r4): the DEFAULT-HOSTED Colleague set — the six -# roles machine-as-brain hosts. Deliberately NOT all of :data:`lobes.roles.ROLES`: -# the opt-in ``muse`` lobe is hosted only by a muse-hosting shape and its service -# is compose-profile-gated, so bundling it here would break colleague-stack on -# every default deployment. Not a role itself — ``up``'s own composite target. +# The colleague-stack bundle (r4): the DEFAULT-HOSTED Colleague set — the SEVEN +# roles machine-as-brain hosts (cortex, senses, hand, embedder, reranker, stt, +# tts). Deliberately NOT all of :data:`lobes.roles.ROLES`: the opt-in ``muse`` +# and ``worker`` lobes are hosted only by their own hosting shapes and their +# services are compose-profile-gated, so bundling them here would break +# colleague-stack on every default deployment. `hand` IS bundled — it is +# default-hosted and its service carries no profile gate. Not a role itself — +# ``up``'s own composite target. COLLEAGUE_STACK = "colleague-stack" -# Every valid ``up`` target: the seven roles (canonical order) + the bundle. Keyed +# Every valid ``up`` target: the NINE roles (canonical order) + the bundle. Keyed # off :data:`lobes.roles.ROLES` so this and the role registry never drift. TARGETS: tuple[str, ...] = roles.ROLES + (COLLEAGUE_STACK,) diff --git a/lobes/gateway/_config.py b/lobes/gateway/_config.py index 69138cb..eac7ac7 100644 --- a/lobes/gateway/_config.py +++ b/lobes/gateway/_config.py @@ -58,6 +58,14 @@ # WORKER_BASE_URL is set — and, like muse above, it is INFEASIBLE by default # when unwired (see OPT_IN_BACKENDS). _DEFAULT_WORKER = "unsloth/Qwen3.6-35B-A3B-NVFP4" +# The `hand` gear (LiquidAI LFM2.5-1.2B-Instruct) — the NINTH Colleague role's +# backend and the fleet's designated fine-tuning base. Unlike muse/worker this +# one is DEFAULT-HOSTED on every card (~2.4 GiB bf16 is cheap enough to always +# co-reside), so it is deliberately NOT in OPT_IN_BACKENDS: an unwired hand is +# the sleeping-lobe posture (feasible:true / ready:false), not infeasible. +# It also took over the `minor`/`cheap` capability tier from Qwen/Qwen3.5-4B — +# see lobes.catalog.TIER_ROLE. +_DEFAULT_HAND = "LiquidAI/LFM2.5-1.2B-Instruct" # Per-backend "this machine's per-machine profile declares it CANNOT be served # AT ALL" signal (issue #92's "advertised implies reachable" extended to the @@ -79,6 +87,14 @@ # The opt-in worker role (thor-worker-lobe plan, t3) rides the same # channel as muse — see OPT_IN_BACKENDS below. "worker": "WORKER_FEASIBLE", + # The `hand` role (hand-lobe plan, t4) rides the same channel, but is NOT + # in OPT_IN_BACKENDS: it is default-hosted, so an ABSENT HAND_FEASIBLE + # means feasible. That is the deliberate sleeping-lobe posture for a wheel + # upgrade — a pre-hand `.env` reads hand as feasible:true / ready:false + # until the operator re-inits and the lane actually comes up, rather than + # advertising a lane that is running (it isn't) or denying a lane the card + # can obviously serve (it can). + "hand": "HAND_FEASIBLE", "embed": "EMBED_FEASIBLE", "rerank": "RERANK_FEASIBLE", # First-class audio roles (issue #129): stt/tts joined the same channel so @@ -148,6 +164,27 @@ def _as_bool(env: Mapping[str, str], key: str) -> bool: # for a name that ALSO carries the truthy ``_PEER_PROXY`` knob; origin # without that knob never gets dialed. Unset everywhere (the default) ⇒ every # response is byte-identical to the pre-referral contract. +# Backend names that carry a ``_FEASIBLE`` knob but DELIBERATELY no +# peer origin/proxy/key channel — so the peer dicts below are +# ``FEASIBLE_ENV`` minus exactly this set, and that relationship is asserted in +# tests/test_gateway_config_proxy.py rather than left to a hand-typed copy. +# +# ``hand`` (hand-lobe plan t4) is the only member. Referral and proxying exist +# because a HEAVY lobe cannot fit on every box, so a dropped role has to be +# reachable somewhere else. `hand` is ~1.2B — it runs on every host in the mesh +# by design, which is the entire reason the role exists. A box that cannot +# serve 2.4 GiB of bf16 weights is not a box that should be dialing a peer to +# find them, and a `hand:` adapter is meaningless on a peer that was +# never given that adapter's path. +# +# The absence is load-bearing, not an oversight, which is why it is named here +# instead of simply omitted: a symmetry-minded refactor ("every other role is +# in these dicts…") has to delete this constant to break the rule. The inverse +# mistake is on record — in 0.54.6 ``worker`` was wired into these dicts but +# MISSING from server.py's _PEER_SERVED_NAME_ENV/_PEER_ROLE_HINT, and +# ``WORKER_PEER_PROXY=true`` went silently inert. +NEVER_PROXIED_BACKENDS: frozenset[str] = frozenset({"hand"}) + PEER_ORIGIN_ENV: dict[str, str] = { "primary": "PRIMARY_PEER_ORIGIN", "multimodal": "MULTIMODAL_PEER_ORIGIN", @@ -440,6 +477,62 @@ def _as_int(env: Mapping[str, str], key: str, default: int) -> int: return int(default) +# The `hand` lobe's LoRA adapter inventory, declared once at boot (hand-lobe +# plan t4). Operator-typed as a comma-separated ``name=path`` list — the SAME +# string vLLM's own ``--lora-modules`` consumes verbatim on the vllm-hand lane, +# so there is exactly one place an adapter is declared and the gateway can +# never disagree with the engine about which names exist. +# +# There is deliberately NO runtime hot-load: adding an adapter is a lane +# restart. A mutable-adapter API would put changeable state on the one lane +# whose value is being cheap and predictable. +HAND_LORA_MODULES_ENV = "HAND_LORA_MODULES" + +# The separator between the role name and the adapter domain in the +# caller-facing spelling (``hand:legal``). Colon, not slash or dot: every +# existing model id in the catalog is ``org/name``, so a slash would be +# ambiguous with an HF repo path, and dots appear inside version numbers +# (``Qwen3.6``). Nothing in the gateway, roles.py or capabilities.py parses a +# model id by delimiter, so this shape passes through the whole stack unharmed. +HAND_ADAPTER_SEP = ":" + + +def _hand_adapter_names(env: Mapping[str, str]) -> tuple[str, ...]: + """Adapter NAMES declared in ``HAND_LORA_MODULES``, in declaration order. + + The value is a comma-separated ``name=path`` list. Only the names are read + here — the paths are vLLM's business, and the gateway deliberately never + stats them: an adapter path is mounted into the ``vllm-hand`` container, not + into the gateway's, so a filesystem check here would false-negative every + correctly-configured adapter. Whether an adapter actually LOADED is + answered by the live probe against the hand backend's own ``/v1/models`` + (see :func:`lobes.gateway._readiness.probe_backend_adapters`), which is the + engine's own evidence rather than the gateway's guess. + + Malformed entries are skipped rather than raising: a blank segment, a + segment with no ``=``, or one with an empty name cannot name a servable + adapter, and a typo in one entry must not take down a gateway that would + otherwise serve the base model and every other adapter fine. Duplicate + names collapse to the first occurrence, preserving order. + + ``partition("=")`` (not ``split("=")``) so an adapter path containing an + ``=`` — a query string, a padded base64 segment — keeps its full value; the + same convention this module already uses for its other list-valued knobs. + """ + raw = (env.get(HAND_LORA_MODULES_ENV) or "").strip() + if not raw: + return () + names: list[str] = [] + for segment in raw.split(","): + name, sep, path = segment.strip().partition("=") + name = name.strip() + if not sep or not name or not path.strip(): + continue + if name not in names: + names.append(name) + return tuple(names) + + def _optional_backend( env: Mapping[str, str], *, @@ -449,6 +542,7 @@ def _optional_backend( default_url: str, default_name: str, task: str = "generate", + adapters: tuple[str, ...] = (), ) -> Backend | None: """A fleet backend wired only when its ``url_key`` env var is non-empty. @@ -471,6 +565,7 @@ def _optional_backend( base_url=(env.get(url_key) or default_url).rstrip("/"), served_name=env.get(name_key) or default_name, task=task, + adapters=adapters, ) @@ -519,11 +614,34 @@ def build_config(env: Mapping[str, str] | None = None) -> tuple[RoutingTable, Se default_url="http://vllm-fallback:8000", default_name=_DEFAULT_FALLBACK, ), - # The minor co-resident generate backend (Qwen/Qwen3.5-4B, bf16). - # Wired only when MINOR_BASE_URL or MINOR_SERVED_NAME is present in - # the environment — i.e. when the operator has activated the compose - # "minor" profile and set these vars (they are absent by default so - # the routing table is unchanged on a standard fleet startup). + # The `hand` co-resident generate backend (LiquidAI LFM2.5-1.2B, bf16) + # — the ninth Colleague role, the fleet's fine-tuning base, and the + # gear the minor/cheap capability tier resolves to since it replaced + # Qwen3.5-4B in that slot (lobes.catalog.TIER_ROLE). + # + # Wired when HAND_BASE_URL or HAND_SERVED_NAME is present. It is + # default-HOSTED (every rendered card profile declares it), so on a + # freshly-inited deployment these are always set; it stays an + # _optional_backend anyway so a pre-hand `.env` — which has neither — + # simply renders no hand backend rather than pointing at a container + # that isn't running. `hand` is NOT in OPT_IN_BACKENDS, so that unwired + # state reads feasible:true / ready:false (the sleeping lobe), not + # role_infeasible. + _optional_backend( + env, + name="hand", + url_key="HAND_BASE_URL", + name_key="HAND_SERVED_NAME", + default_url="http://vllm-hand:8000", + default_name=_DEFAULT_HAND, + adapters=_hand_adapter_names(env), + ), + # The LEGACY minor co-resident generate backend (Qwen/Qwen3.5-4B, bf16). + # KEPT (cite-don't-delete) but no longer a TIER backend: the minor/cheap + # tiers now resolve to `hand` above. Like the 14B "middle" gear below, + # it stays addressable by explicit model id when its own env pair is + # set — `COMPOSE_PROFILES=minor` still works, it is simply no longer + # what `model=minor` selects. _optional_backend( env, name="minor", @@ -674,6 +792,32 @@ def build_config(env: Mapping[str, str] | None = None) -> tuple[RoutingTable, Se _opt_in_backend = next((b for b in backends if b.name == _opt_in), None) if _opt_in_backend is not None: aliases[_opt_in] = _opt_in_backend.served_name + # HAND ADAPTER aliases (hand-lobe plan t4): the caller-facing + # ``hand:`` spelling for each declared LoRA adapter, resolving to + # the bare name vLLM itself serves the adapter under (its + # ``--lora-modules =`` key). ``handle_post`` already rewrites + # the forwarded body's ``model`` field to the resolved served name, so the + # engine receives a name it knows without any adapter-specific code on the + # data path. + # + # Added only for a WIRED hand backend, mirroring the opt-in-alias contract + # directly above: an alias must never point at a served name nothing + # actually serves. Declared BEFORE the GATEWAY_ALIASES merge so an explicit + # operator override still wins. + # + # Note what is NOT here: ``hand`` itself. That comes from ``tier_aliases`` + # as a capability tier, so the bare role name resolves to the BASE + # checkpoint and never 404s just because the adapter inventory is empty — + # an armed-but-empty lane is a working lane. An UNdeclared + # ``hand:`` gets no alias, is not any backend's served name or + # adapter, and therefore takes the ``is_unknown_model`` 404 + # ``model_not_found`` — never a silent fall-back to the base weights or to + # another lane, which would hand a caller who asked for the legal + # specialist a generalist answer and call it success. + _hand_backend = next((b for b in backends if b.name == "hand"), None) + if _hand_backend is not None: + for _adapter in _hand_backend.adapters: + aliases[f"hand{HAND_ADAPTER_SEP}{_adapter}"] = _adapter # POOLING ROLE IDENTITY aliases — the stable address for the embed/rerank # lanes, mirroring what `cortex`/`senses` already give the generate lane. # diff --git a/lobes/gateway/_pressure_policy.py b/lobes/gateway/_pressure_policy.py index 6fb4515..200235b 100644 --- a/lobes/gateway/_pressure_policy.py +++ b/lobes/gateway/_pressure_policy.py @@ -96,18 +96,21 @@ #: The capability-ROLE names (``cortex``/``senses``) alias the same backends as #: ``main``/``multimodal``, so they normalize (and shed) identically. _TIER_ROLE: dict[str, str] = { - # Primary vocabulary. + # Primary vocabulary. `minor`/`cheap` name the `hand` BACKEND since the + # hand lobe replaced Qwen3.5-4B in that slot — the tier spellings survive + # for back-compat, the `minor` backend role does not. "main": "primary", - "minor": "minor", + "minor": "hand", "multimodal": "multimodal", # Back-compat aliases. - "cheap": "minor", + "cheap": "hand", "normal": "multimodal", "hard": "primary", # Capability-ROLE names (alias the same backends as main / multimodal; - # muse/worker are their own backends). Kept in the same order as - # catalog.TIER_ROLE (senses, then worker, then muse, then cortex) so the - # two dicts stay identical — the mirror guard test asserts equality. + # hand/muse/worker are their own backends). Kept in the same order as + # catalog.TIER_ROLE (hand, senses, worker, muse, cortex) so the two dicts + # stay identical — the mirror guard test asserts equality. + "hand": "hand", "senses": "multimodal", "worker": "worker", "muse": "muse", @@ -115,15 +118,23 @@ } #: Backend role → canonical new-vocabulary tier name (the inverse of the primary -#: vocabulary rows above; muse's/worker's role IS its tier name). +#: vocabulary rows above; hand's/muse's/worker's role IS its tier name). _ROLE_TO_TIER: dict[str, str] = { "primary": "main", - "minor": "minor", + "hand": "hand", "multimodal": "multimodal", "worker": "worker", "muse": "muse", } +#: The SERVABLE FLOOR tier — the one tier never shed, whatever the pressure. +#: Named once here rather than spelled inline at each comparison so the floor +#: cannot drift between the shed test and the ``servable_tier`` it reports. +#: It moved from ``minor`` to ``hand`` with the tier repoint; a caller still +#: sending ``model=minor``/``model=cheap`` normalizes to ``hand`` and is served +#: exactly as before, so the floor's PROMISE is unchanged — only its name. +_FLOOR_TIER = "hand" + _KNOWN_TIERS: frozenset[str] = frozenset(_TIER_ROLE) @@ -267,10 +278,12 @@ def decide( ) mode = "busy" if under_pressure else "warm" - # minor is the floor: never shed even under pressure - shed = under_pressure and normalized != "minor" + # `hand` is the floor: never shed even under pressure. A `minor`/`cheap` + # request normalizes to `hand` above, so the back-compat spellings keep the + # floor's protection unchanged. + shed = under_pressure and normalized != _FLOOR_TIER reason = "pressure" if shed else "default" - servable_tier = "minor" if under_pressure else normalized + servable_tier = _FLOOR_TIER if under_pressure else normalized return { "mode": mode, diff --git a/lobes/gateway/_readiness.py b/lobes/gateway/_readiness.py index f88d591..f2402bb 100644 --- a/lobes/gateway/_readiness.py +++ b/lobes/gateway/_readiness.py @@ -266,6 +266,59 @@ def probe_peer_ready( return served_name in ids +def probe_backend_adapters( + base_url: str, + declared: Iterable[str], + *, + timeout: float = _READINESS_PROBE_TIMEOUT, + opener: PeerOpener | None = None, +) -> frozenset[str]: + """Which of a backend's ``declared`` LoRA adapters its engine ACTUALLY serves. + + Reads the backend's OWN ``GET /v1/models`` and returns the intersection of + ``declared`` with the ids it lists. This is the #92 "advertised implies + reachable" rule applied to adapters, and it deliberately asks the ENGINE + rather than the filesystem: an adapter path is mounted into the vLLM + container, not the gateway's, so a ``os.path.exists`` check here would + false-negative every correctly-configured adapter while still missing the + failures that matter (an unreadable file, a rank above ``--max-lora-rank``, + a checkpoint vLLM refused). Those all end the same observable way — the + adapter is absent from the engine's model list — which is exactly what this + probe reads. + + Returns an EMPTY set on every failure mode (non-200, unreachable, timeout, + malformed/non-JSON body, unexpected shape) rather than raising or degrading + to "assume loaded". Empty means "advertise nothing", which is the honest + answer when the engine has not confirmed anything — the same fail-closed + direction :func:`probe_peer_ready` takes. It is intersected with + ``declared`` so an engine listing an id lobes never declared cannot inject + it into this box's advertised surface. + + Mirrors :func:`probe_peer_ready`'s structure and reuses its + :data:`PeerOpener` shape (status + body, since this needs the payload, not + just a status code) with no API key: this dials a co-resident fleet backend + on the internal compose network, not a cross-box peer. + """ + wanted = frozenset(declared) + if not wanted: + return frozenset() + get_models = opener or _default_peer_opener + try: + status, body = get_models(base_url.rstrip("/") + _MODELS_PATH, timeout, None) + except (OSError, http.client.HTTPException, ValueError): + return frozenset() + if status != 200: + return frozenset() + try: + payload = json.loads(body) + ids = {entry.get("id") for entry in payload.get("data", []) if isinstance(entry, dict)} + except (ValueError, TypeError, AttributeError): + # Malformed JSON or an unexpected shape — advertise nothing rather than + # crash the caller that folds this into /v1/models and /capabilities. + return frozenset() + return frozenset(wanted & ids) + + def probe_audio_peer_ready( origin: str, role: str, @@ -340,6 +393,13 @@ class PeerSpec: # unit-testable without sockets, mirroring Probe above. PeerProbe = Callable[[PeerSpec], bool] +# An adapter probe maps (base_url, declared adapter names) to the subset the +# backend's engine actually serves. Never None: an empty set IS the answer for +# "nothing confirmed", so the tri-state the readiness Probe needs does not +# apply here. Injectable so ReadinessCache stays unit-testable without sockets, +# mirroring Probe / PeerProbe above. +AdapterProbe = Callable[[str, tuple[str, ...]], frozenset[str]] + class ReadinessCache: """A non-blocking, background readiness provider for the fleet's backends. @@ -382,6 +442,8 @@ def __init__( peer_specs: Iterable[PeerSpec] | None = None, peer_probe: PeerProbe | None = None, peer_timeout: float = _PEER_PROBE_TIMEOUT, + adapter_targets: Mapping[str, tuple[str, tuple[str, ...]]] | None = None, + adapter_probe: "AdapterProbe | None" = None, start: bool = True, ) -> None: # Copy the targets so a caller mutating theirs cannot change what we probe. @@ -408,6 +470,19 @@ def __init__( # :meth:`current` for where they are merged for reading. self._value: dict[str, bool | None] = dict.fromkeys(self._targets, None) self._peer_value: dict[str, bool | None] = dict.fromkeys(self._peer_specs, None) + # LoRA adapter inventory per backend name -> (base_url, declared names). + # A THIRD independent store, seeded EMPTY rather than None: "no adapter + # confirmed" is the honest pre-probe state and the honest failure state + # alike, so this one needs no unknown sentinel — an unprobed cache + # advertises no adapters, exactly like a probe that found none. + # Refreshed on the LOCAL thread (these are co-resident fleet backends + # on the internal network, sharing the local socket budget — never the + # peer thread's cross-box budget). + self._adapter_targets: dict[str, tuple[str, tuple[str, ...]]] = dict(adapter_targets or {}) + self._adapter_probe: AdapterProbe = adapter_probe or self._default_adapter_probe + self._adapter_value: dict[str, frozenset[str]] = { + name: frozenset() for name in self._adapter_targets + } if start: self.start() @@ -441,10 +516,36 @@ def _read(self) -> dict[str, bool | None]: result[name] = None return result + def _default_adapter_probe(self, base_url: str, declared: tuple[str, ...]) -> frozenset[str]: + """The default adapter probe: :func:`probe_backend_adapters` bound to + our LOCAL ``timeout`` — these are co-resident fleet backends on the + internal compose network, so they share the local probe's budget, never + the peer thread's cross-box one.""" + return probe_backend_adapters(base_url, declared, timeout=self._timeout) + + def _read_adapters(self) -> dict[str, frozenset[str]]: + """Probe every adapter-bearing backend once, degrading a raiser to empty. + + Per-backend ``try`` so one misbehaving probe cannot abort the pass or + crash the daemon — the offending backend simply advertises no adapters, + which is the fail-closed direction (#92). Runs on the local background + thread only, never the request path. + """ + result: dict[str, frozenset[str]] = {} + for name, (base_url, declared) in self._adapter_targets.items(): + try: + result[name] = frozenset(self._adapter_probe(base_url, declared)) + except Exception: # nosec B110 — best-effort; never crash the daemon + result[name] = frozenset() + return result + def _refresh_once(self) -> None: value = self._read() + adapters = self._read_adapters() if self._adapter_targets else None with self._lock: self._value = value + if adapters is not None: + self._adapter_value = adapters def _default_peer_probe(self, spec: PeerSpec) -> bool: """The default peer probe: :func:`probe_peer_ready` bound to OUR OWN @@ -529,6 +630,19 @@ def current(self) -> dict[str, bool | None]: merged.update(self._peer_value) return merged + def current_adapters(self) -> dict[str, frozenset[str]]: + """Backend name → the LoRA adapters its engine confirmed it serves. + + Never probes, never blocks — the same O(1) read-a-copy contract as + :meth:`current`. An unprobed or failing backend maps to an empty set + (see :meth:`_read_adapters`), and a backend with no declared adapters + is absent entirely. Feeds + :func:`lobes.gateway._routing.list_models_payload`'s ``loaded_adapters`` + and the ``hand`` role's ``adapters`` field on ``/capabilities``. + """ + with self._lock: + return dict(self._adapter_value) + def start(self) -> None: """Start the background refresh thread(s) (idempotent). diff --git a/lobes/gateway/_routing.py b/lobes/gateway/_routing.py index 434e17b..e880627 100644 --- a/lobes/gateway/_routing.py +++ b/lobes/gateway/_routing.py @@ -21,6 +21,24 @@ class Backend: base_url: str # e.g. "http://vllm-primary:8000" served_name: str # the OpenAI model id this backend serves task: str = "generate" # task family: "generate" | "embed" | "score" + # EXTRA served model ids this backend owns beyond ``served_name`` — the + # LoRA adapter names a `--enable-lora` lane serves alongside its base + # checkpoint (the `hand` lobe; hand-lobe plan t4). vLLM advertises each + # ``--lora-modules =`` entry as its OWN model id on the + # backend's ``/v1/models``, so an adapter is a second served name on ONE + # backend, not a second backend. + # + # These are the DECLARED names (parsed from ``HAND_LORA_MODULES``), which + # is deliberately a WIRING fact, not a liveness one — the same distinction + # :func:`is_unknown_model` draws for backends. A declared adapter is KNOWN + # (so ``hand:`` routes to its owner and yields that owner's honest + # error if vLLM never loaded it) while an UNdeclared one is unknown (404 + # ``model_not_found``, never a silent fall-back to the base weights). + # Whether an adapter actually LOADED is a separate, live question answered + # by the readiness layer and applied in :func:`list_models_payload`. + # + # Defaults to empty so every existing backend construction is unaffected. + adapters: tuple[str, ...] = () @dataclass(frozen=True) @@ -203,7 +221,7 @@ def resolve_model(table: RoutingTable, requested: str | None) -> str: if requested in table.aliases: return table.aliases[requested] for backend in table.backends: - if backend.served_name == requested: + if requested == backend.served_name or requested in backend.adapters: return requested return table.default_model @@ -249,12 +267,15 @@ def is_unknown_model(table: RoutingTable, requested: str | None) -> bool: return False # the declared default identity is known (see docstring) if requested in table.aliases: return False - return not any(backend.served_name == requested for backend in table.backends) + return not any( + requested == backend.served_name or requested in backend.adapters + for backend in table.backends + ) def _backend_for(table: RoutingTable, served_name: str) -> Backend | None: for backend in table.backends: - if backend.served_name == served_name: + if served_name == backend.served_name or served_name in backend.adapters: return backend return None @@ -368,6 +389,7 @@ def list_models_payload( table: RoutingTable, ready: Mapping[str, "bool | None"] | None = None, peer_served: Mapping[str, str] | None = None, + loaded_adapters: Mapping[str, frozenset[str]] | None = None, ) -> dict: """OpenAI ``/v1/models`` shape listing the fleet's served models. @@ -406,6 +428,23 @@ def list_models_payload( local backend — #92 extended across the box boundary, never a hardcoded reachability claim (h2). ``peer_served=None`` (the default) changes nothing for every existing caller. + + **LoRA adapters** (hand-lobe plan t4): ``loaded_adapters`` maps a backend + NAME to the set of its declared adapters that the LIVE probe confirmed the + backend's OWN ``/v1/models`` actually lists — the same "the engine says it + serves this id" evidence ``peer_served`` demands across a box boundary, + applied here to a second served name on a local backend. An adapter is + listed IFF its owning backend survived the filters above AND the probe + confirmed it. + + Declaring an adapter in ``HAND_LORA_MODULES`` is therefore NOT enough to + advertise it: a path vLLM could not read, a rank above ``--max-lora-rank``, + or a typo'd name each leave the adapter out of the engine's own model list, + so it stays out of this payload too. That is #92 for adapters — a + declared-but-unloaded adapter must never read as usable. Omitting + ``loaded_adapters`` (the default) advertises NO adapters, which is the + honest answer for a caller holding no live evidence and keeps every + pre-adapter caller byte-identical. """ backends = table.backends if ready is not None: @@ -415,6 +454,14 @@ def list_models_payload( data = [ {"id": backend.served_name, "object": "model", "owned_by": "lobes"} for backend in backends ] + if loaded_adapters: + for backend in backends: + confirmed = loaded_adapters.get(backend.name) or frozenset() + data.extend( + {"id": adapter, "object": "model", "owned_by": "lobes"} + for adapter in backend.adapters + if adapter in confirmed + ) if peer_served and ready is not None: listed = {entry["id"] for entry in data} for name in sorted(table.peer_proxied): diff --git a/lobes/gateway/server.py b/lobes/gateway/server.py index e786d10..a3cabc3 100644 --- a/lobes/gateway/server.py +++ b/lobes/gateway/server.py @@ -2200,7 +2200,23 @@ def _get_v1_models(self) -> None: if self.peer_specs else None ) - self._send_json(200, list_models_payload(self.table, ready, peer_served)) + # LoRA adapters (hand-lobe plan t4): only those the owning engine's own + # /v1/models confirmed it loaded. A declared-but-unloaded adapter must + # never read as usable (#92 for adapters) — see + # ReadinessCache.current_adapters. + self._send_json( + 200, + list_models_payload( + self.table, + ready, + peer_served, + ( + self.readiness_cache.current_adapters() + if self.readiness_cache is not None + else None + ), + ), + ) def _get_capabilities(self) -> None: # The #81 role→endpoint contract: SEVEN first-class roles resolved to @@ -2385,8 +2401,17 @@ def serve(table: RoutingTable, cfg: ServerConfig) -> None: # pragma: no cover # dials exactly what the probe verified). No proxy config → empty specs → # no peer thread, no proxy branch, byte-identical pre-proxy behaviour. peer_specs = peer_specs_from_table(table) + # LoRA-bearing backends (hand-lobe plan t4): the cache additionally probes + # each one's OWN /v1/models to learn which declared adapters the engine + # actually loaded. Empty for every backend with no declared adapters — i.e. + # every deployment that has not declared HAND_LORA_MODULES — so this adds + # no probe traffic and changes nothing until an operator declares one. + adapter_targets = {b.name: (b.base_url, b.adapters) for b in table.backends if b.adapters} readiness_cache = ReadinessCache.from_backends( - table.backends, peer_specs=tuple(peer_specs.values()), start=False + table.backends, + peer_specs=tuple(peer_specs.values()), + adapter_targets=adapter_targets, + start=False, ) readiness_cache.refresh() readiness_cache.start() diff --git a/lobes/minor/governance.py b/lobes/minor/governance.py index 0790d6d..8d80b18 100644 --- a/lobes/minor/governance.py +++ b/lobes/minor/governance.py @@ -1,14 +1,26 @@ -"""Governance and escalation model for the **minor** role. +"""Governance and escalation model for the **hand** role (the cheap tier). -This module encodes *what* the minor lobe MAY do vs MUST escalate, keyed -entirely to the *role name* ``"minor"`` — never to a specific model identifier. -Swapping the underlying model is a catalog-only change; nothing here needs to -change. +This module encodes *what* the cheap-tier lobe MAY do vs MUST escalate, keyed +entirely to the *role name* — never to a specific model identifier. Swapping the +underlying model is a catalog-only change; nothing here needs to change. + +That claim was TESTED and held when the `hand` lobe took over this tier: the +model swap itself needed nothing here. What DID change is the role NAME — +`minor` is now a back-compat tier spelling, not a role — so :data:`ROLE` reads +``"hand"``. The duty lists below were re-derived for an adapter-dependent +specialist and came out unchanged; see :data:`ALLOWED` for why that is a +decision and not an oversight. (Which checkpoint `hand` serves is deliberately +not stated anywhere in this module — see :data:`ROLE`.) + +The module keeps its ``lobes.minor`` package path. That is deliberate +cite-don't-delete: ``lobes run minor``, ``lobes route`` and ``lobes.bench`` all +import from it, and renaming the package would break every one of them to +express a fact the ``ROLE`` constant already states. Public API ---------- ROLE : str - The role name this governance policy applies to (``"minor"``). + The role name this governance policy applies to (``"hand"``). ALLOWED : frozenset[str] Duties the minor lobe may perform locally without escalation. FORBIDDEN : frozenset[str] @@ -30,11 +42,20 @@ # Role identity # --------------------------------------------------------------------------- -ROLE: str = "minor" +ROLE: str = "hand" """The role name this governance policy applies to. Governance is role-keyed, not model-keyed, so that the underlying model can be swapped in the catalog without touching this file. + +``"hand"``, not ``"minor"``: the `hand` lobe took over the cheap tier, and +``minor``/``cheap`` survive only as back-compat TIER spellings that resolve to +this same role (see :data:`lobes.catalog.TIER_ROLE`). Leaving this at +``"minor"`` would have left exactly one authority disagreeing with every other +surface about what governs this lane. + +The catalog owns which checkpoint this role serves; this module never names +one, and ``tests/test_minor_governance.py`` asserts that. """ # --------------------------------------------------------------------------- @@ -52,7 +73,26 @@ "route", } ) -"""Duties the minor lobe may perform locally (no escalation required).""" +"""Duties the cheap-tier lobe may perform locally (no escalation required). + +RE-DERIVED for an adapter-dependent specialist and deliberately UNCHANGED. +`hand` is a trained specialist — a loaded LoRA adapter is meant to make it +genuinely good at one domain — and the tempting move is to widen this set when +an adapter is present. That is refused, for two reasons: + +* **Competence is not authority.** An adapter makes `hand` better at the duties + it already has; it does not grant it new ones. A legal-trained 1.2B that can + draft a clause well is still not the lobe that APPROVES one. +* **Governance must be decidable without asking the engine.** Keying the + allowed set to which adapters happen to be loaded would make the same duty + legal on one box and forbidden on another, and would make this pure stdlib + module depend on live serving state. + +So the policy is flat: every `hand` request is governed identically, base or +adapter. Widening a duty later is contract-compatible; narrowing one is a +break — the same asymmetry that keeps ``repo_action`` in the role's forbidden +list for v1 (see ``lobes.roles.ROLE_FORBIDDEN``, issue #180). +""" FORBIDDEN: frozenset[str] = frozenset( { @@ -63,8 +103,16 @@ "architectural_decision", } ) -"""Actions the minor lobe must NEVER perform; they always escalate to the -primary lobe (or a human reviewer), regardless of any other conditions.""" +"""Actions the cheap-tier lobe must NEVER perform; they always escalate to the +primary lobe (or a human reviewer), regardless of any other conditions. + +These are the DUTY-level spelling of ``ROLE_FORBIDDEN["hand"]`` in +``lobes.roles`` (``final_decision`` / ``repo_action`` / ``security_decision``): +``approve``/``finalize`` are the final decision, ``delete``/``deploy`` are repo +and infrastructure actions, and ``architectural_decision`` is both. The two +lists are different vocabularies — a runtime duty check here, a Colleague-facing +contract there — describing one boundary, and neither may quietly outgrow the +other.""" # --------------------------------------------------------------------------- # Escalation conditions @@ -92,8 +140,9 @@ """Confidence floor below which a routing/classification decision escalates. Mirrors issue #64's ``escalation.uncertainty_threshold``: when a caller supplies -a ``confidence`` to :func:`decide` and it falls *below* this value, the minor -lobe is too unsure to handle the task locally and the decision escalates. +a ``confidence`` to :func:`decide` and it falls *below* this value, the +cheap-tier lobe is too unsure to handle the task locally and the decision +escalates. """ # --------------------------------------------------------------------------- diff --git a/lobes/profiles/builtin/base.toml b/lobes/profiles/builtin/base.toml index b64c9b7..455ac44 100644 --- a/lobes/profiles/builtin/base.toml +++ b/lobes/profiles/builtin/base.toml @@ -54,6 +54,27 @@ feasible = false [roles.worker] feasible = false +[roles.hand] +# The `hand` lobe (LiquidAI LFM2.5-1.2B-Instruct) — the fleet's fine-tuning +# base, default-hosted on every card. ~2.4 GiB of bf16 weights, text-only, no +# ViT, no thinking mode; served with --enable-lora ARMED and (in v1) an EMPTY +# adapter inventory. See docs/lfm2.5-1.2b-hand.md. +# +# quantization is DELIBERATELY ABSENT from this block, on every card: the +# catalog's quantization="none" bf16 sentinel means the lane must omit +# --quantization ENTIRELY. Declaring it here — even as an empty string — would +# render a flag that corrupts bf16 weights. +# +# BUDGET: conservative, matching this profile's whole premise — an unrecognised +# card must not OOM on first boot. 0.06 at a 32K window on ~2.4 GiB of weights +# is the smallest generate lane lobes can serve, which is precisely why `hand` +# is FEASIBLE here while senses/muse/worker are vetoed: it is the one generate +# role whose footprint is safe to assume on unknown hardware. +feasible = true +model = "LiquidAI/LFM2.5-1.2B-Instruct" +gpu_mem_util = 0.06 +max_model_len = 32768 + [roles.embedder] feasible = true model = "Qwen/Qwen3-Embedding-0.6B" diff --git a/lobes/profiles/builtin/orin.toml b/lobes/profiles/builtin/orin.toml index 6f374ef..607e607 100644 --- a/lobes/profiles/builtin/orin.toml +++ b/lobes/profiles/builtin/orin.toml @@ -164,6 +164,33 @@ quantization = "compressed-tensors" # 2026-07-16/17) used it; FlashInfer is unvalidated here (docs/orin-profiles.md). attention_backend = "TRITON_ATTN" +[roles.hand] +# The `hand` lobe (LiquidAI LFM2.5-1.2B-Instruct) — the fleet's fine-tuning +# base, default-hosted on every card. ~2.4 GiB of bf16 weights, text-only, no +# ViT, no thinking mode; served with --enable-lora ARMED and (in v1) an EMPTY +# adapter inventory. See docs/lfm2.5-1.2b-hand.md. +# +# quantization is DELIBERATELY ABSENT from this block, on every card: the +# catalog's quantization="none" bf16 sentinel means the lane must omit +# --quantization ENTIRELY. Declaring it here — even as an empty string — would +# render a flag that corrupts bf16 weights. +# +# BUDGET: DECLARED, NOT MEASURED (#108), and deliberately HIGHER than the +# 128 GB cards' 0.06 — this is why the schema declares hand's util PER CARD +# rather than once globally. On a 64 GB Orin 0.06 is only ~3.84 GiB, which +# leaves ~1.4 GiB of KV after the ~2.4 GiB of weights: servable, but thin enough +# that a couple of concurrent 32K requests could exhaust it. 0.10 (~6.4 GiB) +# restores a comparable KV margin. Total 0.57 -> 0.67. The Orin value must be +# validated by an ACTUAL BOOT at the served max_model_len (t10/t12), never +# inferred from the Spark's. +feasible = true +model = "LiquidAI/LFM2.5-1.2B-Instruct" +gpu_mem_util = 0.10 +max_model_len = 32768 +# TRITON_ATTN: the conservative sm_87 carry-over every role that booted on this +# box uses. FlashInfer is unvalidated here (docs/orin-profiles.md). +attention_backend = "TRITON_ATTN" + [roles.embedder] feasible = true model = "Qwen/Qwen3-Embedding-0.6B" diff --git a/lobes/profiles/builtin/spark.toml b/lobes/profiles/builtin/spark.toml index 42589a9..37cb5f1 100644 --- a/lobes/profiles/builtin/spark.toml +++ b/lobes/profiles/builtin/spark.toml @@ -50,6 +50,27 @@ max_model_len = 32768 quantization = "compressed-tensors" attention_backend = "TRITON_ATTN" +[roles.hand] +# The `hand` lobe (LiquidAI LFM2.5-1.2B-Instruct) — the fleet's fine-tuning +# base, default-hosted on every card. ~2.4 GiB of bf16 weights, text-only, no +# ViT, no thinking mode; served with --enable-lora ARMED and (in v1) an EMPTY +# adapter inventory. See docs/lfm2.5-1.2b-hand.md. +# +# quantization is DELIBERATELY ABSENT from this block, on every card: the +# catalog's quantization="none" bf16 sentinel means the lane must omit +# --quantization ENTIRELY. Declaring it here — even as an empty string — would +# render a flag that corrupts bf16 weights. +# +# BUDGET: DECLARED, NOT MEASURED (#108). 0.06 of a 128 GB GB10 is ~7.7 GiB — +# ~2.4 GiB of weights leaves ~5 GiB of KV at the 32K window, comfortable. The +# arithmetic is not the evidence, though: every co-residency number on this card +# was MEASURED because vLLM refused the computed one at least once. This card's +# total goes 0.56 -> 0.62 with hand added; t10 re-derives and t12 validates. +feasible = true +model = "LiquidAI/LFM2.5-1.2B-Instruct" +gpu_mem_util = 0.06 +max_model_len = 32768 + [roles.embedder] feasible = true model = "Qwen/Qwen3-Embedding-0.6B" diff --git a/lobes/profiles/builtin/thor.toml b/lobes/profiles/builtin/thor.toml index c972c9e..363c120 100644 --- a/lobes/profiles/builtin/thor.toml +++ b/lobes/profiles/builtin/thor.toml @@ -54,6 +54,26 @@ max_model_len = 32768 quantization = "compressed-tensors" attention_backend = "TRITON_ATTN" +[roles.hand] +# The `hand` lobe (LiquidAI LFM2.5-1.2B-Instruct) — the fleet's fine-tuning +# base, default-hosted on every card. ~2.4 GiB of bf16 weights, text-only, no +# ViT, no thinking mode; served with --enable-lora ARMED and (in v1) an EMPTY +# adapter inventory. See docs/lfm2.5-1.2b-hand.md. +# +# quantization is DELIBERATELY ABSENT from this block, on every card: the +# catalog's quantization="none" bf16 sentinel means the lane must omit +# --quantization ENTIRELY. Declaring it here — even as an empty string — would +# render a flag that corrupts bf16 weights. +# +# BUDGET: DECLARED, NOT MEASURED (#108). Same 128 GB unified-memory board as the +# Spark, so the same ~7.7 GiB at 0.06 and the same caveat: this card's measured +# co-residency history (thor-muse's refused 0.40 -> measured 0.55) is exactly +# why an unmeasured number stays labelled as one. Total 0.56 -> 0.62. +feasible = true +model = "LiquidAI/LFM2.5-1.2B-Instruct" +gpu_mem_util = 0.06 +max_model_len = 32768 + [roles.embedder] feasible = true model = "Qwen/Qwen3-Embedding-0.6B" diff --git a/lobes/profiles/builtin_shapes/machine-as-brain.toml b/lobes/profiles/builtin_shapes/machine-as-brain.toml index c16fdbc..576f2ed 100644 --- a/lobes/profiles/builtin_shapes/machine-as-brain.toml +++ b/lobes/profiles/builtin_shapes/machine-as-brain.toml @@ -21,4 +21,12 @@ name = "machine-as-brain" summary = "The whole brain on one box -- every role this card can serve, hosted here" -hosts = ["cortex", "senses", "embedder", "reranker", "stt", "tts"] + +# `hand` (the ninth Colleague role, LFM2.5-1.2B) is hosted by EVERY built-in +# shape, including the mesh-lobe shapes that drop a heavy lobe. That is the +# point of the role: at ~2.4 GiB it is cheap enough to keep resident on every +# box, so a caller always has a local generate lane and a local fine-tuning +# base — and unlike muse/worker there is never a reason to refer it to a peer. +# Its budget comes from the CARD profile (per-card, see builtin/*.toml), not +# from a shape override: dropping another lobe does not change what hand needs. +hosts = ["cortex", "senses", "hand", "embedder", "reranker", "stt", "tts"] diff --git a/lobes/profiles/builtin_shapes/orin-lobe.toml b/lobes/profiles/builtin_shapes/orin-lobe.toml index e361ef3..1500d10 100644 --- a/lobes/profiles/builtin_shapes/orin-lobe.toml +++ b/lobes/profiles/builtin_shapes/orin-lobe.toml @@ -36,7 +36,15 @@ name = "orin-lobe" summary = "Jetson AGX Orin 64GB mesh-lobe (DECLARED, UNVALIDATED) -- Gemma senses + embedder + reranker, no cortex, no audio (sm_87 has no Parakeet image)" -hosts = ["senses", "embedder", "reranker"] + +# `hand` (the ninth Colleague role, LFM2.5-1.2B) is hosted by EVERY built-in +# shape, including the mesh-lobe shapes that drop a heavy lobe. That is the +# point of the role: at ~2.4 GiB it is cheap enough to keep resident on every +# box, so a caller always has a local generate lane and a local fine-tuning +# base — and unlike muse/worker there is never a reason to refer it to a peer. +# Its budget comes from the CARD profile (per-card, see builtin/*.toml), not +# from a shape override: dropping another lobe does not change what hand needs. +hosts = ["senses", "hand", "embedder", "reranker"] [overrides.senses] # These restate builtin/orin.toml's OWN senses values, on purpose — and the diff --git a/lobes/profiles/builtin_shapes/orin-small.toml b/lobes/profiles/builtin_shapes/orin-small.toml index 55acd07..5dccda1 100644 --- a/lobes/profiles/builtin_shapes/orin-small.toml +++ b/lobes/profiles/builtin_shapes/orin-small.toml @@ -56,4 +56,12 @@ name = "orin-small" summary = "Jetson AGX Orin 64GB reference (DECLARED, UNVALIDATED) -- minor + embedder + reranker + audio, no cortex/senses" -hosts = ["minor", "embedder", "reranker", "stt", "tts"] + +# `hand` (the ninth Colleague role, LFM2.5-1.2B) is hosted by EVERY built-in +# shape, including the mesh-lobe shapes that drop a heavy lobe. That is the +# point of the role: at ~2.4 GiB it is cheap enough to keep resident on every +# box, so a caller always has a local generate lane and a local fine-tuning +# base — and unlike muse/worker there is never a reason to refer it to a peer. +# Its budget comes from the CARD profile (per-card, see builtin/*.toml), not +# from a shape override: dropping another lobe does not change what hand needs. +hosts = ["minor", "hand", "embedder", "reranker", "stt", "tts"] diff --git a/lobes/profiles/builtin_shapes/spark-lobe.toml b/lobes/profiles/builtin_shapes/spark-lobe.toml index fe194dc..775fc94 100644 --- a/lobes/profiles/builtin_shapes/spark-lobe.toml +++ b/lobes/profiles/builtin_shapes/spark-lobe.toml @@ -13,7 +13,15 @@ name = "spark-lobe" summary = "DGX Spark mesh-lobe -- Qwen cortex + embedder + reranker + audio, no Gemma senses" -hosts = ["cortex", "embedder", "reranker", "stt", "tts"] + +# `hand` (the ninth Colleague role, LFM2.5-1.2B) is hosted by EVERY built-in +# shape, including the mesh-lobe shapes that drop a heavy lobe. That is the +# point of the role: at ~2.4 GiB it is cheap enough to keep resident on every +# box, so a caller always has a local generate lane and a local fine-tuning +# base — and unlike muse/worker there is never a reason to refer it to a peer. +# Its budget comes from the CARD profile (per-card, see builtin/*.toml), not +# from a shape override: dropping another lobe does not change what hand needs. +hosts = ["cortex", "hand", "embedder", "reranker", "stt", "tts"] [overrides.cortex] # gpu_mem_util = 0.44 -- the co-resident reclaim-sum: 0.30 (cortex, spark diff --git a/lobes/profiles/builtin_shapes/thor-lobe.toml b/lobes/profiles/builtin_shapes/thor-lobe.toml index c36e75c..ff47bbe 100644 --- a/lobes/profiles/builtin_shapes/thor-lobe.toml +++ b/lobes/profiles/builtin_shapes/thor-lobe.toml @@ -14,7 +14,15 @@ name = "thor-lobe" summary = "Jetson AGX Thor mesh-lobe -- Gemma senses + embedder + reranker + audio, no Qwen cortex" -hosts = ["senses", "embedder", "reranker", "stt", "tts"] + +# `hand` (the ninth Colleague role, LFM2.5-1.2B) is hosted by EVERY built-in +# shape, including the mesh-lobe shapes that drop a heavy lobe. That is the +# point of the role: at ~2.4 GiB it is cheap enough to keep resident on every +# box, so a caller always has a local generate lane and a local fine-tuning +# base — and unlike muse/worker there is never a reason to refer it to a peer. +# Its budget comes from the CARD profile (per-card, see builtin/*.toml), not +# from a shape override: dropping another lobe does not change what hand needs. +hosts = ["senses", "hand", "embedder", "reranker", "stt", "tts"] [overrides.senses] # gpu_mem_util = 0.30 -- exactly the dropped cortex's freed share (thor diff --git a/lobes/profiles/builtin_shapes/thor-muse.toml b/lobes/profiles/builtin_shapes/thor-muse.toml index 9d64570..4a276ea 100644 --- a/lobes/profiles/builtin_shapes/thor-muse.toml +++ b/lobes/profiles/builtin_shapes/thor-muse.toml @@ -24,7 +24,15 @@ name = "thor-muse" summary = "Jetson AGX Thor muse-lobe -- Gemma 4 31B muse + embedder + reranker + audio, no cortex/senses" -hosts = ["muse", "embedder", "reranker", "stt", "tts"] + +# `hand` (the ninth Colleague role, LFM2.5-1.2B) is hosted by EVERY built-in +# shape, including the mesh-lobe shapes that drop a heavy lobe. That is the +# point of the role: at ~2.4 GiB it is cheap enough to keep resident on every +# box, so a caller always has a local generate lane and a local fine-tuning +# base — and unlike muse/worker there is never a reason to refer it to a peer. +# Its budget comes from the CARD profile (per-card, see builtin/*.toml), not +# from a shape override: dropping another lobe does not change what hand needs. +hosts = ["muse", "hand", "embedder", "reranker", "stt", "tts"] [overrides.muse] # The full muse declaration lives HERE (not in a card profile) — see the diff --git a/lobes/profiles/builtin_shapes/thor-worker.toml b/lobes/profiles/builtin_shapes/thor-worker.toml index f878209..2da7c71 100644 --- a/lobes/profiles/builtin_shapes/thor-worker.toml +++ b/lobes/profiles/builtin_shapes/thor-worker.toml @@ -59,7 +59,15 @@ name = "thor-worker" summary = "Jetson AGX Thor worker-lobe -- Qwen3.6-35B-A3B multimodal worker + embedder + reranker + audio, no cortex/senses" -hosts = ["worker", "embedder", "reranker", "stt", "tts"] + +# `hand` (the ninth Colleague role, LFM2.5-1.2B) is hosted by EVERY built-in +# shape, including the mesh-lobe shapes that drop a heavy lobe. That is the +# point of the role: at ~2.4 GiB it is cheap enough to keep resident on every +# box, so a caller always has a local generate lane and a local fine-tuning +# base — and unlike muse/worker there is never a reason to refer it to a peer. +# Its budget comes from the CARD profile (per-card, see builtin/*.toml), not +# from a shape override: dropping another lobe does not change what hand needs. +hosts = ["worker", "hand", "embedder", "reranker", "stt", "tts"] [overrides.worker] # unsloth/Qwen3.6-35B-A3B-NVFP4: a MULTIMODAL (image+video, no audio) MoE with diff --git a/lobes/profiles/render.py b/lobes/profiles/render.py index 77d24f3..647e917 100644 --- a/lobes/profiles/render.py +++ b/lobes/profiles/render.py @@ -78,6 +78,7 @@ "senses": "MULTIMODAL", "muse": "MUSE", "worker": "WORKER", + "hand": "HAND", "embedder": "EMBED", "reranker": "RERANK", } diff --git a/lobes/profiles/schema.py b/lobes/profiles/schema.py index e2a2354..b78a768 100644 --- a/lobes/profiles/schema.py +++ b/lobes/profiles/schema.py @@ -54,7 +54,21 @@ # carries the full per-machine knob set — but are hosted only by an explicit # muse-/worker-hosting deployment shape, never by machine-as-brain (see # lobes.profiles.shapes.OPT_IN_CORE_ROLES). -ROLES: tuple[str, ...] = ("cortex", "senses", "muse", "worker", "embedder", "reranker") +ROLES: tuple[str, ...] = ( + "cortex", + "senses", + "muse", + "worker", + # `hand` (LiquidAI LFM2.5-1.2B, the fine-tuning base) is in scope and + # carries the full per-machine knob set like every other generate lane — + # but unlike muse/worker it is DEFAULT-HOSTED: ~2.4 GiB of bf16 weights fit + # beside any other lane on any supported card, which is the entire point of + # the role. Its per-card gpu_mem_util is declared PER CARD, never once + # globally: 0.06 is 7.7 GiB on a 128 GB Spark but 3.84 GiB on a 64 GB Orin. + "hand", + "embedder", + "reranker", +) # The machine knobs a compose template substitutes per role/gear. Order here # is the canonical field order on RoleProfile below (minus feasible/model). diff --git a/lobes/profiles/shape_render.py b/lobes/profiles/shape_render.py index 691561e..3bacca6 100644 --- a/lobes/profiles/shape_render.py +++ b/lobes/profiles/shape_render.py @@ -66,6 +66,10 @@ "senses": "vllm-multimodal", "muse": "vllm-muse", "worker": "vllm-worker", + # `hand` is DEFAULT-ON in the fleet template (no compose profile gate), so + # unlike `minor`/`muse`/`worker` it needs no OPT_IN_*_ACTIVATION_ENV entry — + # hosting it is simply naming its service. + "hand": "vllm-hand", "embedder": "vllm-embed", "reranker": "vllm-rerank", "stt": "stt", diff --git a/lobes/profiles/shapes.py b/lobes/profiles/shapes.py index 239b26f..1ed21af 100644 --- a/lobes/profiles/shapes.py +++ b/lobes/profiles/shapes.py @@ -62,7 +62,7 @@ # dependency stays lobes.profiles.schema, per the brain-shapes t1 scope. AUDIO_ROLES: tuple[str, ...] = ("stt", "tts") -# The seven first-class, Colleague-facing roles (issue #81): the five +# The nine first-class, Colleague-facing roles (issue #81): the seven # Profile-machinery core roles plus the two audio-overlay sidecars. This is # the Colleague CONTRACT set (`lobes capabilities` / GET /capabilities key # exactly these roles). @@ -86,11 +86,21 @@ # invariant (see shape_render.py's module docstring and # tests/goldens/regen.py's `_shape_needs_goldens`) is defined against THIS # set, not :data:`COLLEAGUE_ROLES` or the broader :data:`SHAPE_ROLES` below: -# the opt-in `minor` gear and the opt-in core roles (`muse`) are deliberately -# excluded from "every role this card can serve" -- machine-as-brain never -# hosts them, and a NON-hosted opt-in core role renders nothing at all (the +# the opt-in `minor` gear and the opt-in core roles (`muse`, `worker`) are +# deliberately excluded from "every role this card can serve" -- machine-as-brain +# never hosts them, and a NON-hosted opt-in core role renders nothing at all (the # gateway's OPT_IN_BACKENDS unwired-by-default rule carries the honesty), so # machine-as-brain stays byte-identical to the bare card profile. +# +# RE-BASELINED by the hand lobe (hand-lobe plan t8). This set now contains a +# DEFAULT-HOSTED CHEAP ROLE (`hand`) for the first time: every prior member was +# either a heavy lobe or a pooling/audio gear, so "the whole brain" and "the +# expensive parts of the brain" happened to coincide. They no longer do. The +# identity-shape invariant is unchanged in MEANING -- machine-as-brain still +# renders byte-identically to the bare card profile -- but the byte-identical +# baseline itself MOVED, because every card profile now declares `hand`. A +# regeneration that merely makes the goldens pass does not satisfy this: the +# 28 goldens' diff must show HAND_* keys and nothing else. DEFAULT_HOSTED_ROLES: tuple[str, ...] = tuple( role for role in COLLEAGUE_ROLES if role not in OPT_IN_CORE_ROLES ) diff --git a/lobes/roles.py b/lobes/roles.py index 1955298..6e0d2b4 100644 --- a/lobes/roles.py +++ b/lobes/roles.py @@ -1,10 +1,10 @@ -"""The role registry — the eight first-class, Colleague-facing lobes (issue #81). +"""The role registry — the nine first-class, Colleague-facing lobes (issue #81). -lobes exposes the fleet not as a bag of model ids but as EIGHT discoverable +lobes exposes the fleet not as a bag of model ids but as NINE discoverable *roles*, each resolved to a live endpoint + metadata so a caller (Colleague) can address a capability by role — ``cortex``, ``senses``, ``muse``, -``worker``, ``embedder``, ``reranker``, ``stt``, ``tts`` — without hardcoding -any single model endpoint: +``worker``, ``hand``, ``embedder``, ``reranker``, ``stt``, ``tts`` — without +hardcoding any single model endpoint: * ``cortex`` → the ``primary`` generate backend (Qwen 3.6 27B NVFP4 MTP). The authoritative reasoning/action/decision layer — the final authority. @@ -26,6 +26,16 @@ from ``senses``, which perceives but must not act. OPT-IN like ``muse``: hosted only by a worker-hosting deployment shape, never by the default ``machine-as-brain``. +* ``hand`` → the ``hand`` generate backend (LiquidAI LFM2.5-1.2B-Instruct). + The fleet's designated FINE-TUNING BASE and its trained specialist: one cheap + base, many LoRA adapters, each mastering a domain ("muscle memory"). Where + ``worker`` is an untrained generalist doer, ``hand`` knows a few things + extremely well because someone taught it. At ~1.2B it is cheap enough to + co-reside on EVERY card, so unlike ``muse``/``worker`` it is default-hosted + and never proxied to a peer. It is also the ``minor``/``cheap`` capability + tier (it replaced Qwen3.5-4B there) and the pressure-policy SERVABLE FLOOR — + the one generate lane never shed under load. Addressed as ``model=hand`` for + the base and ``model=hand:`` for an adapter. * ``embedder`` → the ``embed`` pooling backend (Qwen3-Embedding-0.6B) → ``POST /v1/embeddings``. * ``reranker`` → the ``score``/rerank backend (Qwen3-Reranker-0.6B) → @@ -63,14 +73,21 @@ from lobes.gateway._config import ServerConfig, build_config from lobes.gateway._routing import RoutingTable -# The eight first-class roles, in canonical order: generate lanes (cortex, -# senses, muse, worker), pooling lanes, then the opt-in audio overlay. +# The nine first-class roles, in canonical order: generate lanes (cortex, +# senses, muse, worker, hand), pooling lanes, then the opt-in audio overlay. # Downstream (CLI/gateway) iterate this for a stable ordering. +# +# ADDING A ROLE IS EFFECTIVELY IRREVERSIBLE. Every name here becomes a public +# address on `GET /capabilities`, `lobes capabilities`, the `model=` alias +# space and the `lobes up ` surface — and removing one later breaks every +# caller that learned to use it. Nine is the count today; read +# docs/colleague-stack.md before proposing a tenth. ROLES: tuple[str, ...] = ( "cortex", "senses", "muse", "worker", + "hand", "embedder", "reranker", "stt", @@ -92,6 +109,7 @@ "senses": "multimodal", "muse": "muse", "worker": "worker", + "hand": "hand", "embedder": "embed", "reranker": "rerank", "stt": "stt", @@ -106,6 +124,7 @@ "senses": "multimodal", "muse": "muse", "worker": "worker", + "hand": "hand", "embedder": "embedding", "reranker": "reranker", } @@ -120,6 +139,7 @@ "senses": _CHAT_PATH, "muse": _CHAT_PATH, "worker": _CHAT_PATH, + "hand": _CHAT_PATH, "embedder": "/v1/embeddings", "reranker": "/v1/rerank", "stt": "/v1/audio/transcriptions", @@ -210,6 +230,18 @@ "tool_use", "repo_action", ), + # The `hand` lobe: a TRAINED SPECIALIST, not a generalist doer. Its value + # is the LoRA adapter riding on it, so its responsibilities describe mastery + # of a taught domain rather than raw capability. Deliberately NO + # image_understanding / video_understanding — LFM2.5-1.2B-Instruct is + # text-only (LiquidAI ships the vision variant as a separate architecture, + # Lfm2VlForConditionalGeneration, which this checkpoint is not). + "hand": ( + "domain_mastery", + "learned_skill", + "specialized_task", + "tool_use", + ), "embedder": ("vectorization", "memory_retrieval_input"), "reranker": ("retrieval_ordering", "relevance_refinement"), # NOTE: this base tuple deliberately does NOT list the realtime/VAD @@ -249,6 +281,10 @@ "senses": ("final_decision", "repo_action", "security_decision"), "muse": ("final_decision", "repo_action", "security_decision"), "worker": ("final_decision", "security_decision"), + # `hand` withholds repo_action deliberately for v1: ADDING a responsibility + # later is contract-compatible, REMOVING one is a break, so the conservative + # list ships first. Granting it once adapters exist is issue #180. + "hand": ("final_decision", "repo_action", "security_decision"), "embedder": (), "reranker": (), "stt": (), @@ -265,10 +301,25 @@ "senses": "MULTIMODAL_MAX_MODEL_LEN", "muse": "MUSE_MAX_MODEL_LEN", "worker": "WORKER_MAX_MODEL_LEN", + "hand": "HAND_MAX_MODEL_LEN", "embedder": "EMBED_MAX_MODEL_LEN", "reranker": "RERANK_MAX_MODEL_LEN", } +# The roles the GATEWAY fronts as model-routed vLLM backends — every role +# except the two path-routed audio sidecars, which :func:`_resolve_audio_role` +# handles on a separate branch. +# +# DERIVED from :data:`ROLES`, deliberately: this was a hand-typed tuple inside +# ``build_role_registry`` until the ninth role landed, and a hand-typed copy of +# ROLES is precisely the thing that lets a new role half-land — it would be +# registered in every table above yet silently missing from the registry the +# CLI and ``GET /capabilities`` both read. Membership keys off +# :data:`ROLE_ROLE_HINT` (which the audio roles deliberately do not appear in, +# having no catalog entry), so adding a generate/pooling role to ROLES picks it +# up here automatically and adding an audio-style role does not. +GATEWAY_FRONTED_ROLES: tuple[str, ...] = tuple(r for r in ROLES if r in ROLE_ROLE_HINT) + @dataclass(frozen=True) class RoleInfo: @@ -707,7 +758,7 @@ def build_role_registry( gateway = (gateway_url or _gateway_base_url(server)).rstrip("/") registry: dict[str, RoleInfo] = {} - for role in ("cortex", "senses", "muse", "worker", "embedder", "reranker"): + for role in GATEWAY_FRONTED_ROLES: if backend_ready is None: # NOT SUPPLIED → back-compat: no live signal at all, so fall back to # the coarse `loaded` proxy (the original t4 behaviour). `None` here diff --git a/lobes/roles_measure.py b/lobes/roles_measure.py index 189e8ca..d3edd22 100644 --- a/lobes/roles_measure.py +++ b/lobes/roles_measure.py @@ -109,7 +109,7 @@ ALLOWED_METRIC_KEYS: frozenset[str] = LLM_METRIC_KEYS | EMBED_RERANK_METRIC_KEYS | AUDIO_METRIC_KEYS -_LLM_ROLES: tuple[str, ...] = ("cortex", "senses", "muse", "worker") +_LLM_ROLES: tuple[str, ...] = ("cortex", "senses", "muse", "worker", "hand") _EMBED_RERANK_ROLES: tuple[str, ...] = ("embedder", "reranker") _AUDIO_ROLES: tuple[str, ...] = ("stt", "tts") @@ -118,6 +118,9 @@ "senses": "llm", "muse": "llm", "worker": "llm", + # `hand` rides the llm family: it is a chat/completions generate lane + # like the other four, adapters or not. + "hand": "llm", "embedder": "embed_rerank", "reranker": "embed_rerank", "stt": "audio", @@ -395,6 +398,7 @@ def _measure_stt_role(info: RoleInfo, *, timeout: float = DEFAULT_TIMEOUT) -> di "senses": _measure_llm_role, "muse": _measure_llm_role, "worker": _measure_llm_role, + "hand": _measure_llm_role, "embedder": _measure_embed_rerank_role, "reranker": _measure_embed_rerank_role, "stt": _measure_stt_role, diff --git a/lobes/runtime/_compose.py b/lobes/runtime/_compose.py index 7411717..01efa6b 100644 --- a/lobes/runtime/_compose.py +++ b/lobes/runtime/_compose.py @@ -95,6 +95,7 @@ "vllm-embed-deep", "vllm-rerank", "vllm-minor", + "vllm-hand", "vllm-middle", "vllm-multimodal", "vllm-multimodal-coder", diff --git a/lobes/runtime/_parser.py b/lobes/runtime/_parser.py index 0789343..13fe14f 100644 --- a/lobes/runtime/_parser.py +++ b/lobes/runtime/_parser.py @@ -9,6 +9,8 @@ * Qwen3-Coder / Qwen3.5 / Qwen3.6 checkpoints emit the XML function format → ``qwen3_coder`` * Qwen3 dense models emit Hermes-style JSON tool calls → ``hermes`` * Mistral checkpoints emit the ``[TOOL_CALLS]`` format → ``mistral`` +* Gemma 4 checkpoints emit ``<|tool_call>call:name{…}`` → ``gemma4`` +* LiquidAI LFM2 / LFM2.5 emit ``<|tool_call_start|>…<|tool_call_end|>`` → ``lfm2`` * anything else → ``None`` (unknown; leave the configured parser untouched and let the caller pass ``--tool-call-parser`` explicitly) @@ -73,6 +75,29 @@ # have NOT been booted against it; they were on a parser proven wrong for the # family, so this is a strictly better default, not a validated claim. (("gemma-4", "gemma4"), "gemma4"), + # LiquidAI LFM2 / LFM2.5 (the `hand` lobe's family) emits its own + # `<|tool_call_start|>[func(arg=val)]<|tool_call_end|>` syntax, whose + # delimiters are SPECIAL TOKENS — the same shape of trap that made + # `pythonic` silently wrong for Gemma 4 above. vLLM ships a purpose-built + # parser: `lfm2` -> vllm.tool_parsers.lfm2_tool_parser.Lfm2ToolParser, + # registered under that key in vllm/tool_parsers/__init__.py. + # + # This one is strictly safer to get wrong than the gemma4 case was: the + # parser's __init__ resolves BOTH delimiters through `self.vocab.get()` and + # RAISES when either is absent, so a tokenizer revision that dropped them + # fails loudly at server startup rather than degrading to the gemma4 failure + # mode (200 OK, well-formed call relayed as prose, tool_calls=null). + # + # Markers stay scoped to the "lfm2" family spelling in all three separator + # forms, matching how the Qwen3.x rule above is written. LFM2.5-1.2B-Instruct + # has NO thinking mode (LiquidAI ships LFM2.5-1.2B-Thinking separately), so + # this lane needs no --reasoning-parser companion — unlike the qwen3 and + # gemma4 lanes, where the parsers must be enabled as a PAIR. + # VALIDATION SCOPE (#108): the parser key and its special-token requirement + # were read from the pinned nightly image's own source; the served round-trip + # is validated per card as each acceptance transcript lands under + # docs/evidence/. + (("lfm2", "lfm-2"), "lfm2"), ] diff --git a/lobes/templates/fleet/docker-compose.yml b/lobes/templates/fleet/docker-compose.yml index e02869b..4d38c7b 100644 --- a/lobes/templates/fleet/docker-compose.yml +++ b/lobes/templates/fleet/docker-compose.yml @@ -462,6 +462,115 @@ services: retries: 5 start_period: 600s + # --- `hand` co-resident generate backend (DEFAULT-ON) --------------------- + # The NINTH Colleague role: LiquidAI LFM2.5-1.2B-Instruct, the fleet's + # designated FINE-TUNING BASE. "Muscle memory" — one cheap base, many LoRA + # adapters, each mastering a domain. See docs/lfm2.5-1.2b-hand.md. + # + # DEFAULT-ON (no `profiles:` key), unlike vllm-minor above and + # vllm-muse/vllm-worker below: ~2.4 GiB of bf16 weights co-reside on every + # supported card, which is the entire reason the role exists. A card that + # genuinely cannot host it says so honestly via HAND_FEASIBLE=false in its + # own profile, not by hiding behind a compose profile. + # + # bf16 (NO --quantization flag): quantization="none" in the catalog means the + # weights are served at native precision. Do NOT add --quantization here — + # passing --quantization=modelopt to a bf16 checkpoint corrupts it. Note this + # is the flag being OMITTED ENTIRELY, not passed empty. + # + # NO --language-model-only: LFM2.5-1.2B-Instruct is text-only and ships no + # ViT, so unlike vllm-minor above there is no vision tower to drop. (LiquidAI's + # vision variant is a different architecture, Lfm2VlForConditionalGeneration.) + # + # NO --reasoning-parser: this checkpoint has no thinking mode — LiquidAI ships + # LFM2.5-1.2B-Thinking separately. The cortex (qwen3) and Gemma 4 (gemma4) + # lanes must enable their reasoning parser as a PAIR with their tool parser; + # this lane has no second half to pair with. + + vllm-hand: + image: ${HAND_IMAGE:-${VLLM_NIGHTLY_IMAGE:-vllm/vllm-openai@sha256:7c5a10e9a8b3c8642f4d0463a41215176c0dd834b4f0967287c7e3e517cf1be9}} + container_name: model-gear-vllm-hand + # Lfm2ForCausalLM needs vLLM >= 0.23.0, so this lane pins the NIGHTLY image + # (like primary/embed/rerank/worker), never the NGC 26.04 tag vllm-minor + # uses — that engine is 0.19.0 and does not know the architecture. Verified + # against the pinned digest on a physical Thor 2026-08-10: VLLM_VERSION + # 0.23.1rc1.dev672+g93d8f834d, LFM2_REGISTERED True. + restart: unless-stopped + deploy: + resources: + reservations: + devices: + - { driver: nvidia, count: all, capabilities: [gpu] } + ipc: host + ulimits: + memlock: { soft: -1, hard: -1 } + stack: { soft: 67108864, hard: 67108864 } + env_file: + - path: .env + required: false + environment: + - HF_HOME=/root/.cache/huggingface + - TOKENIZERS_PARALLELISM=false + - MG_LOG_DIR=/logs/model-gear # in-container mount point (explicit, no drift) + - MG_LOG_NAME=hand # → /logs/model-gear/hand-.log + volumes: + - ${HF_CACHE:-${HOME:-/root}/.cache/huggingface}:/root/.cache/huggingface + # Durable logs that survive restart/recreate (issue #50) — see mg-logwrap.sh. + - ${MODEL_GEAR_LOG_DIR:-./logs}:/logs/model-gear + - ./mg-logwrap.sh:/usr/local/bin/mg-logwrap:ro + # LoRA adapters, read-only. The host path is where `unsloth-cli` writes a + # trained adapter; HAND_LORA_MODULES names paths UNDER the in-container + # mount point, not host paths. Mounted unconditionally (an absent host + # directory is created empty by Docker) so declaring an adapter later + # needs no compose edit — only a lane restart. + - ${HAND_ADAPTER_DIR:-./adapters}:/adapters:ro + # No host port — reachable only as http://vllm-hand:8000 on the compose net. + expose: + - "8000" + entrypoint: ["bash", "/usr/local/bin/mg-logwrap"] + command: + - vllm + - serve + - ${HAND_MODEL:-LiquidAI/LFM2.5-1.2B-Instruct} + - --served-model-name=${HAND_SERVED_NAME:-LiquidAI/LFM2.5-1.2B-Instruct} + - --host=0.0.0.0 + - --port=8000 + # bf16 — NO --quantization flag (quantization="none" in catalog). + - --max-model-len=${HAND_MAX_MODEL_LEN:-32768} + - --gpu-memory-utilization=${HAND_GPU_MEM_UTIL:-0.06} + # LoRA serving: ARMED by default with an EMPTY inventory (v1 ships zero + # adapters). --enable-lora costs a small fixed amount of memory and makes + # the lane able to serve an adapter the moment one is declared; arming it + # later would be a restart anyway, so it is armed from the start. + - --enable-lora + # Comma-separated `name=path`. vLLM advertises each adapter as its OWN + # model id on this backend's /v1/models — which is exactly how the gateway + # verifies an adapter really loaded before advertising it (a + # declared-but-unloaded adapter is absent from /v1/models AND from + # /capabilities; see lobes/gateway/_readiness.probe_backend_adapters). + - --lora-modules=${HAND_LORA_MODULES:-} + - --max-loras=${HAND_MAX_LORAS:-4} + # Rank ceiling. 32 covers the ranks unsloth's defaults produce at this + # model size. An adapter above it FAILS TO LOAD rather than being served + # degraded — and then, per the honesty rule above, is simply not + # advertised. Raise this knob and restart the lane to serve higher ranks. + - --max-lora-rank=${HAND_MAX_LORA_RANK:-32} + # LFM2's own <|tool_call_start|>/<|tool_call_end|> delimiters are SPECIAL + # TOKENS; the purpose-built `lfm2` parser decodes with + # skip_special_tokens=False so it can see them. The generic `pythonic` + # parser cannot — that exact mistake silently broke Gemma 4 tool calling + # (see lobes/runtime/_parser.py). This parser's __init__ RAISES if the + # tokenizer lacks either delimiter, so a bad revision fails loudly here + # rather than degrading to prose at request time. + - --enable-auto-tool-choice + - --tool-call-parser=lfm2 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 600s + # --- Opt-in (legacy) middle co-resident generate backend ------------------ # LEGACY CANDIDATE: the 14B NVFP4 "middle" is no longer the fleet's "normal" # tier — that role is now filled by the default-on vllm-multimodal (Gemma 4 @@ -1129,6 +1238,21 @@ services: # upward-falling-back — see OPT_IN_BACKENDS in lobes/gateway/_config.py). - WORKER_BASE_URL=${WORKER_BASE_URL:-} - WORKER_SERVED_NAME=${WORKER_SERVED_NAME:-} + # `hand` gear — the NINTH Colleague role (LFM2.5-1.2B, the fine-tuning + # base). DEFAULT-ON, unlike muse/worker: at ~2.4 GiB bf16 it co-resides on + # every supported card, which is the whole point of the role, so it is + # wired unconditionally like the multimodal gear rather than behind a + # compose profile. It is also what `model=minor`/`model=cheap` resolve to + # (it replaced Qwen3.5-4B in that tier) and the pressure-policy servable + # floor. + - HAND_BASE_URL=${HAND_BASE_URL:-http://vllm-hand:8000} + - HAND_SERVED_NAME=${HAND_SERVED_NAME:-LiquidAI/LFM2.5-1.2B-Instruct} + # The LoRA adapter inventory, declared ONCE and read by BOTH the engine + # (vllm-hand's --lora-modules) and the gateway (which derives the + # `hand:` aliases from it) — so the two can never disagree about + # which adapters exist. Empty by default: v1 ships the lane ARMED with + # --enable-lora and the inventory EMPTY. + - HAND_LORA_MODULES=${HAND_LORA_MODULES:-} # Empty default = follow the primary gear's served name (see _config.py's # `or primary.served_name` fallback) — stays correct when a profile swaps # the cortex model instead of routing no-model requests to an unserved id. diff --git a/lobes/templates/fleet/env.example b/lobes/templates/fleet/env.example index 7d6e4b8..7c7c5c0 100644 --- a/lobes/templates/fleet/env.example +++ b/lobes/templates/fleet/env.example @@ -229,6 +229,59 @@ MIDDLE_GPU_MEM_UTIL=0.12 # A local registry tag (e.g. lobes/vllm-gemma4:custom) works the same way. # # MULTIMODAL_IMAGE=ghcr.io/agentculture/vllm-gemma4:latest # example registry tag +# --- `hand` lobe (the ninth Colleague role, DEFAULT-ON) -------------------- +# LiquidAI LFM2.5-1.2B-Instruct — the fleet's designated FINE-TUNING BASE. +# "Muscle memory": one cheap base, many LoRA adapters, each mastering a domain. +# See docs/lfm2.5-1.2b-hand.md. +# +# Uncommented (not opt-in) like the multimodal gear below: at ~2.4 GiB bf16 it +# co-resides on every supported card, which is the whole reason the role exists. +# It is ALSO what `model=minor` / `model=cheap` resolve to (it replaced +# Qwen/Qwen3.5-4B in that tier) and the pressure-policy SERVABLE FLOOR — the one +# generate lane never shed under load. +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct +HAND_BASE_URL=http://vllm-hand:8000 +# 32K native. DECLARED, not measured (#108) — see the per-card [roles.hand] +# blocks in lobes/profiles/builtin/*.toml, which is where the real per-card +# values live (0.06 on the 128 GB Spark/Thor, 0.10 on the 64 GB Orin, because +# 0.06 of 64 GB leaves too little KV after the weights). +HAND_MAX_MODEL_LEN=32768 +HAND_GPU_MEM_UTIL=0.06 +# bf16 — there is deliberately NO HAND_QUANTIZATION knob. The catalog's +# quantization="none" sentinel means the lane omits --quantization ENTIRELY; +# passing modelopt to a bf16 checkpoint corrupts the weights. +# +# --- LoRA adapters (the muscle-memory surface) ----------------------------- +# Comma-separated `name=path`, read by BOTH the engine (vllm-hand's +# --lora-modules) and the gateway (which derives the caller-facing +# `hand:` aliases from it), so the two can never disagree about which +# adapters exist. Paths are IN-CONTAINER, under the /adapters mount (see +# HAND_ADAPTER_DIR) — not host paths. +# +# EMPTY by default: v1 ships the lane ARMED (--enable-lora) with ZERO adapters. +# An armed-but-empty lane is honest; a promised-later one is not. There is NO +# runtime hot-load — adding an adapter is a lane restart. +# +# Addressing: `model=hand` serves the BASE (it never 404s just because the +# inventory is empty); `model=hand:` serves that adapter; an undeclared +# `hand:` is refused with model_not_found, never silently downgraded to +# the base. An adapter vLLM could not load is absent from /v1/models AND +# /capabilities — declared is not the same as usable (#92). +# HAND_LORA_MODULES=legal=/adapters/legal,sql=/adapters/sql +HAND_LORA_MODULES= +# HAND_ADAPTER_DIR=./adapters # host dir bind-mounted read-only at /adapters +# HAND_MAX_LORAS=4 # concurrently-resident adapters +# HAND_MAX_LORA_RANK=32 # rank ceiling; a higher-rank adapter FAILS to +# # load (and is then simply not advertised). +# # Raise and restart the lane to serve it. +# HAND_IMAGE= # override the OCI image; unset falls back to VLLM_NIGHTLY_IMAGE. +# # Lfm2ForCausalLM needs vLLM >= 0.23.0, so this lane must NOT +# # use the NGC 26.04 tag vllm-minor rides (that engine is 0.19.0). +# NOTE the deliberate ABSENCE of HAND_PEER_ORIGIN / _PEER_PROXY / _PEER_API_KEY, +# which every other role has: `hand` is NEVER proxied to a peer — it runs on +# every box, so there is nothing to refer. See NEVER_PROXIED_BACKENDS. + MULTIMODAL_MODEL=coolthor/gemma-4-12B-it-NVFP4A16 MULTIMODAL_SERVED_NAME=coolthor/gemma-4-12B-it-NVFP4A16 MULTIMODAL_BASE_URL=http://vllm-multimodal:8000 diff --git a/lobes/templates/mg-logwrap.sh b/lobes/templates/mg-logwrap.sh index bc53a00..fc29b47 100644 --- a/lobes/templates/mg-logwrap.sh +++ b/lobes/templates/mg-logwrap.sh @@ -21,6 +21,33 @@ # exec — logging can never stop the model from serving. set -u +# --- Drop explicitly-empty flags ------------------------------------------- +# A compose `command:` list is static: it cannot omit an argument conditionally. +# So a templated flag whose variable is unset renders as a bare `--flag=` — one +# argv element carrying an empty value — and an arg parser that expects a real +# value there fails at startup. The live case is the `hand` lane's +# `--lora-modules=${HAND_LORA_MODULES:-}`: v1 ships the lane ARMED +# (`--enable-lora`) with an EMPTY inventory, and vLLM would try to parse "" as a +# `name=path` pair. +# +# The rule is deliberately narrow: drop an argument ONLY when it matches +# `--=` exactly — a long flag, an `=`, and nothing after it. A flag with a +# value, a bare `--flag`, a lone `--`, a short `-x`, and every non-flag argument +# (the model id, `serve`, …) all pass through untouched. Empty is not a value +# any vLLM flag wants, so this can only ever remove an argument that would have +# errored; it can never change one that would have worked. +# +# Done here rather than per-lane because it is a property of compose's static +# arg lists, not of any one model. +_args=() +for _arg in "$@"; do + case "$_arg" in + --*=) continue ;; # e.g. `--lora-modules=` — an unset templated flag + *) _args+=("$_arg") ;; + esac +done +set -- ${_args[@]+"${_args[@]}"} + name="${MG_LOG_NAME:-server}" dir="${MG_LOG_DIR:-/logs/model-gear}" ts="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || echo boot)" diff --git a/tests/goldens/base.env b/tests/goldens/base.env index 98fea10..0a3129f 100644 --- a/tests/goldens/base.env +++ b/tests/goldens/base.env @@ -2,6 +2,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_FEASIBLE=false MUSE_FEASIBLE=false PRIMARY_GPU_MEM_UTIL=0.3 diff --git a/tests/goldens/orin.env b/tests/goldens/orin.env index a977420..a555ede 100644 --- a/tests/goldens/orin.env +++ b/tests/goldens/orin.env @@ -3,6 +3,11 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_ATTENTION_BACKEND=TRITON_ATTN +HAND_GPU_MEM_UTIL=0.1 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct LOBES_IOWAIT_DEGRADED_THRESHOLD=100 MULTIMODAL_ATTENTION_BACKEND=TRITON_ATTN MULTIMODAL_GPU_MEM_UTIL=0.45 diff --git a/tests/goldens/regen.py b/tests/goldens/regen.py index a080408..3ae5d88 100644 --- a/tests/goldens/regen.py +++ b/tests/goldens/regen.py @@ -134,18 +134,32 @@ def _shape_needs_goldens(shape) -> bool: """Whether a shape gets its own ``shapes/`` goldens. The whole-brain identity shape (hosts every :data:`DEFAULT_HOSTED_ROLES` - role -- the six default-hosted Colleague roles, NOT the broader + role -- the SEVEN default-hosted Colleague roles, NOT the broader :data:`~lobes.profiles.shapes.SHAPE_ROLES`, which also admits the opt-in - `minor` gear and the opt-in core `muse` lobe that machine-as-brain - deliberately never hosts -- with no overrides) renders identically to the - bare card profile (a non-hosted opt-in core role renders nothing at all, - see ``shape_render.compose_profile``), so it is validated against the - existing ``tests/goldens/.env`` (see ``tests/test_shape_goldens.py``) - rather than copied into a drifting duplicate. Every shape that DROPS a - role, hosts `minor`/`muse`, or carries an override diverges from the bare - profile and gets per-card goldens of its own. General by construction: a - future identity shape is auto-excluded, a future mesh-lobe (or - small-model reference shape) auto-included. + `minor` gear and the opt-in core `muse`/`worker` lobes that + machine-as-brain deliberately never hosts -- with no overrides) renders + identically to the bare card profile (a non-hosted opt-in core role renders + nothing at all, see ``shape_render.compose_profile``), so it is validated + against the existing ``tests/goldens/.env`` (see + ``tests/test_shape_goldens.py``) rather than copied into a drifting + duplicate. Every shape that DROPS a role, hosts `minor`/`muse`/`worker`, or + carries an override diverges from the bare profile and gets per-card + goldens of its own. General by construction: a future identity shape is + auto-excluded, a future mesh-lobe (or small-model reference shape) + auto-included. + + **RE-BASELINED by the `hand` lobe (hand-lobe plan t8).** + :data:`DEFAULT_HOSTED_ROLES` now contains a DEFAULT-HOSTED CHEAP ROLE for + the first time: before `hand`, every member was a heavy lobe or a + pooling/audio gear, so "the whole brain" and "the expensive parts of the + brain" happened to name the same set. They no longer do. The identity-shape + invariant is unchanged in MEANING -- machine-as-brain still renders + byte-identically to the bare card profile, and this predicate still tests + exactly that -- but the byte-identical BASELINE moved, because every card + profile now declares `hand`. Consequently the 28 shape goldens and the + per-card goldens all shifted in the same commit. A regeneration that merely + makes the tests pass does NOT satisfy that: the diff must show ``HAND_*`` + keys and nothing else -- no unrelated key, value, or ordering drift. """ return set(shape.hosts) != set(DEFAULT_HOSTED_ROLES) or bool(shape.overrides) diff --git a/tests/goldens/shapes/orin-lobe__base.env b/tests/goldens/shapes/orin-lobe__base.env index 1cf2ca0..1b90d3b 100644 --- a/tests/goldens/shapes/orin-lobe__base.env +++ b/tests/goldens/shapes/orin-lobe__base.env @@ -2,6 +2,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_FEASIBLE=false MUSE_FEASIBLE=false PRIMARY_FEASIBLE=false diff --git a/tests/goldens/shapes/orin-lobe__orin.env b/tests/goldens/shapes/orin-lobe__orin.env index a977420..a555ede 100644 --- a/tests/goldens/shapes/orin-lobe__orin.env +++ b/tests/goldens/shapes/orin-lobe__orin.env @@ -3,6 +3,11 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_ATTENTION_BACKEND=TRITON_ATTN +HAND_GPU_MEM_UTIL=0.1 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct LOBES_IOWAIT_DEGRADED_THRESHOLD=100 MULTIMODAL_ATTENTION_BACKEND=TRITON_ATTN MULTIMODAL_GPU_MEM_UTIL=0.45 diff --git a/tests/goldens/shapes/orin-lobe__spark.env b/tests/goldens/shapes/orin-lobe__spark.env index e665474..87fc822 100644 --- a/tests/goldens/shapes/orin-lobe__spark.env +++ b/tests/goldens/shapes/orin-lobe__spark.env @@ -2,6 +2,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_ATTENTION_BACKEND=TRITON_ATTN MULTIMODAL_GPU_MEM_UTIL=0.45 MULTIMODAL_MAX_MODEL_LEN=262144 diff --git a/tests/goldens/shapes/orin-lobe__thor.env b/tests/goldens/shapes/orin-lobe__thor.env index 4212bad..2b98990 100644 --- a/tests/goldens/shapes/orin-lobe__thor.env +++ b/tests/goldens/shapes/orin-lobe__thor.env @@ -3,6 +3,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_ATTENTION_BACKEND=TRITON_ATTN MULTIMODAL_GPU_MEM_UTIL=0.45 MULTIMODAL_MAX_MODEL_LEN=262144 diff --git a/tests/goldens/shapes/orin-small__base.env b/tests/goldens/shapes/orin-small__base.env index 5ce5496..28f4ed6 100644 --- a/tests/goldens/shapes/orin-small__base.env +++ b/tests/goldens/shapes/orin-small__base.env @@ -3,6 +3,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MINOR_BASE_URL=http://vllm-minor:8000 MINOR_SERVED_NAME=Qwen/Qwen3.5-4B MULTIMODAL_FEASIBLE=false diff --git a/tests/goldens/shapes/orin-small__orin.env b/tests/goldens/shapes/orin-small__orin.env index 84a4ecb..f62777e 100644 --- a/tests/goldens/shapes/orin-small__orin.env +++ b/tests/goldens/shapes/orin-small__orin.env @@ -4,6 +4,11 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_ATTENTION_BACKEND=TRITON_ATTN +HAND_GPU_MEM_UTIL=0.1 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct LOBES_IOWAIT_DEGRADED_THRESHOLD=100 MINOR_BASE_URL=http://vllm-minor:8000 MINOR_SERVED_NAME=Qwen/Qwen3.5-4B diff --git a/tests/goldens/shapes/orin-small__spark.env b/tests/goldens/shapes/orin-small__spark.env index 7e04863..36cf0be 100644 --- a/tests/goldens/shapes/orin-small__spark.env +++ b/tests/goldens/shapes/orin-small__spark.env @@ -3,6 +3,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MINOR_BASE_URL=http://vllm-minor:8000 MINOR_SERVED_NAME=Qwen/Qwen3.5-4B MULTIMODAL_FEASIBLE=false diff --git a/tests/goldens/shapes/orin-small__thor.env b/tests/goldens/shapes/orin-small__thor.env index f8bd6ae..63e6feb 100644 --- a/tests/goldens/shapes/orin-small__thor.env +++ b/tests/goldens/shapes/orin-small__thor.env @@ -4,6 +4,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MINOR_BASE_URL=http://vllm-minor:8000 MINOR_SERVED_NAME=Qwen/Qwen3.5-4B MULTIMODAL_FEASIBLE=false diff --git a/tests/goldens/shapes/spark-lobe__base.env b/tests/goldens/shapes/spark-lobe__base.env index 748a016..b72fe93 100644 --- a/tests/goldens/shapes/spark-lobe__base.env +++ b/tests/goldens/shapes/spark-lobe__base.env @@ -2,6 +2,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_FEASIBLE=false MUSE_FEASIBLE=false PRIMARY_GPU_MEM_UTIL=0.44 diff --git a/tests/goldens/shapes/spark-lobe__orin.env b/tests/goldens/shapes/spark-lobe__orin.env index 3c28ae9..d5ec28d 100644 --- a/tests/goldens/shapes/spark-lobe__orin.env +++ b/tests/goldens/shapes/spark-lobe__orin.env @@ -3,6 +3,11 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_ATTENTION_BACKEND=TRITON_ATTN +HAND_GPU_MEM_UTIL=0.1 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct LOBES_IOWAIT_DEGRADED_THRESHOLD=100 MULTIMODAL_FEASIBLE=false MUSE_FEASIBLE=false diff --git a/tests/goldens/shapes/spark-lobe__spark.env b/tests/goldens/shapes/spark-lobe__spark.env index 294ccf5..bce5269 100644 --- a/tests/goldens/shapes/spark-lobe__spark.env +++ b/tests/goldens/shapes/spark-lobe__spark.env @@ -2,6 +2,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_FEASIBLE=false PRIMARY_GPU_MEM_UTIL=0.44 PRIMARY_KV_CACHE_DTYPE=fp8 diff --git a/tests/goldens/shapes/spark-lobe__thor.env b/tests/goldens/shapes/spark-lobe__thor.env index c71750a..bd0e2fb 100644 --- a/tests/goldens/shapes/spark-lobe__thor.env +++ b/tests/goldens/shapes/spark-lobe__thor.env @@ -3,6 +3,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_FEASIBLE=false PRIMARY_GPU_MEM_UTIL=0.44 PRIMARY_KV_CACHE_DTYPE=auto diff --git a/tests/goldens/shapes/thor-lobe__base.env b/tests/goldens/shapes/thor-lobe__base.env index 1cf2ca0..1b90d3b 100644 --- a/tests/goldens/shapes/thor-lobe__base.env +++ b/tests/goldens/shapes/thor-lobe__base.env @@ -2,6 +2,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_FEASIBLE=false MUSE_FEASIBLE=false PRIMARY_FEASIBLE=false diff --git a/tests/goldens/shapes/thor-lobe__orin.env b/tests/goldens/shapes/thor-lobe__orin.env index 55028e6..e997b90 100644 --- a/tests/goldens/shapes/thor-lobe__orin.env +++ b/tests/goldens/shapes/thor-lobe__orin.env @@ -3,6 +3,11 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_ATTENTION_BACKEND=TRITON_ATTN +HAND_GPU_MEM_UTIL=0.1 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct LOBES_IOWAIT_DEGRADED_THRESHOLD=100 MULTIMODAL_ATTENTION_BACKEND=TRITON_ATTN MULTIMODAL_GPU_MEM_UTIL=0.3 diff --git a/tests/goldens/shapes/thor-lobe__spark.env b/tests/goldens/shapes/thor-lobe__spark.env index b5e838f..c584519 100644 --- a/tests/goldens/shapes/thor-lobe__spark.env +++ b/tests/goldens/shapes/thor-lobe__spark.env @@ -2,6 +2,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_ATTENTION_BACKEND=TRITON_ATTN MULTIMODAL_GPU_MEM_UTIL=0.3 MULTIMODAL_MAX_MODEL_LEN=131072 diff --git a/tests/goldens/shapes/thor-lobe__thor.env b/tests/goldens/shapes/thor-lobe__thor.env index 75ef0e9..8416407 100644 --- a/tests/goldens/shapes/thor-lobe__thor.env +++ b/tests/goldens/shapes/thor-lobe__thor.env @@ -3,6 +3,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_ATTENTION_BACKEND=TRITON_ATTN MULTIMODAL_GPU_MEM_UTIL=0.3 MULTIMODAL_MAX_MODEL_LEN=131072 diff --git a/tests/goldens/shapes/thor-muse__base.env b/tests/goldens/shapes/thor-muse__base.env index 1cf2ca0..1b90d3b 100644 --- a/tests/goldens/shapes/thor-muse__base.env +++ b/tests/goldens/shapes/thor-muse__base.env @@ -2,6 +2,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_FEASIBLE=false MUSE_FEASIBLE=false PRIMARY_FEASIBLE=false diff --git a/tests/goldens/shapes/thor-muse__orin.env b/tests/goldens/shapes/thor-muse__orin.env index 3c28ae9..d5ec28d 100644 --- a/tests/goldens/shapes/thor-muse__orin.env +++ b/tests/goldens/shapes/thor-muse__orin.env @@ -3,6 +3,11 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_ATTENTION_BACKEND=TRITON_ATTN +HAND_GPU_MEM_UTIL=0.1 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct LOBES_IOWAIT_DEGRADED_THRESHOLD=100 MULTIMODAL_FEASIBLE=false MUSE_FEASIBLE=false diff --git a/tests/goldens/shapes/thor-muse__spark.env b/tests/goldens/shapes/thor-muse__spark.env index 19f1a23..7c48331 100644 --- a/tests/goldens/shapes/thor-muse__spark.env +++ b/tests/goldens/shapes/thor-muse__spark.env @@ -3,6 +3,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_FEASIBLE=false MUSE_ATTENTION_BACKEND=TRITON_ATTN MUSE_BASE_URL=http://vllm-muse:8000 diff --git a/tests/goldens/shapes/thor-muse__thor.env b/tests/goldens/shapes/thor-muse__thor.env index 4b98041..aa86b28 100644 --- a/tests/goldens/shapes/thor-muse__thor.env +++ b/tests/goldens/shapes/thor-muse__thor.env @@ -4,6 +4,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_FEASIBLE=false MUSE_ATTENTION_BACKEND=TRITON_ATTN MUSE_BASE_URL=http://vllm-muse:8000 diff --git a/tests/goldens/shapes/thor-worker__base.env b/tests/goldens/shapes/thor-worker__base.env index 1cf2ca0..1b90d3b 100644 --- a/tests/goldens/shapes/thor-worker__base.env +++ b/tests/goldens/shapes/thor-worker__base.env @@ -2,6 +2,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_FEASIBLE=false MUSE_FEASIBLE=false PRIMARY_FEASIBLE=false diff --git a/tests/goldens/shapes/thor-worker__orin.env b/tests/goldens/shapes/thor-worker__orin.env index 3c28ae9..d5ec28d 100644 --- a/tests/goldens/shapes/thor-worker__orin.env +++ b/tests/goldens/shapes/thor-worker__orin.env @@ -3,6 +3,11 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_ATTENTION_BACKEND=TRITON_ATTN +HAND_GPU_MEM_UTIL=0.1 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct LOBES_IOWAIT_DEGRADED_THRESHOLD=100 MULTIMODAL_FEASIBLE=false MUSE_FEASIBLE=false diff --git a/tests/goldens/shapes/thor-worker__spark.env b/tests/goldens/shapes/thor-worker__spark.env index 26c4388..a8a518e 100644 --- a/tests/goldens/shapes/thor-worker__spark.env +++ b/tests/goldens/shapes/thor-worker__spark.env @@ -3,6 +3,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_FEASIBLE=false PRIMARY_FEASIBLE=false RERANK_GPU_MEM_UTIL=0.06 diff --git a/tests/goldens/shapes/thor-worker__thor.env b/tests/goldens/shapes/thor-worker__thor.env index 3bb2bef..30006a3 100644 --- a/tests/goldens/shapes/thor-worker__thor.env +++ b/tests/goldens/shapes/thor-worker__thor.env @@ -4,6 +4,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_FEASIBLE=false PRIMARY_FEASIBLE=false RERANK_ATTENTION_BACKEND=TRITON_ATTN diff --git a/tests/goldens/spark.env b/tests/goldens/spark.env index 878ee70..e79194d 100644 --- a/tests/goldens/spark.env +++ b/tests/goldens/spark.env @@ -2,6 +2,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_ATTENTION_BACKEND=TRITON_ATTN MULTIMODAL_GPU_MEM_UTIL=0.14 MULTIMODAL_MAX_MODEL_LEN=32768 diff --git a/tests/goldens/template-defaults.env b/tests/goldens/template-defaults.env index 40319de..00f01bc 100644 --- a/tests/goldens/template-defaults.env +++ b/tests/goldens/template-defaults.env @@ -23,6 +23,16 @@ GATEWAY_DEFAULT_MODEL= GATEWAY_PIP_EXTRA_INDEX_URL= GATEWAY_PUBLIC_URL= GATEWAY_READ_TIMEOUT=600 +HAND_ADAPTER_DIR=./adapters +HAND_BASE_URL=http://vllm-hand:8000 +HAND_GPU_MEM_UTIL=0.06 +HAND_IMAGE=${VLLM_NIGHTLY_IMAGE:-vllm/vllm-openai@sha256:7c5a10e9a8b3c8642f4d0463a41215176c0dd834b4f0967287c7e3e517cf1be9} +HAND_LORA_MODULES= +HAND_MAX_LORAS=4 +HAND_MAX_LORA_RANK=32 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct HF_CACHE=${HOME:-/root}/.cache/huggingface HOME=/root LOBES_IOWAIT_DEGRADED_THRESHOLD=50 diff --git a/tests/goldens/thor.env b/tests/goldens/thor.env index aa6112c..65a75c4 100644 --- a/tests/goldens/thor.env +++ b/tests/goldens/thor.env @@ -3,6 +3,10 @@ EMBED_GPU_MEM_UTIL=0.06 EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B +HAND_GPU_MEM_UTIL=0.06 +HAND_MAX_MODEL_LEN=32768 +HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct +HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct MULTIMODAL_ATTENTION_BACKEND=TRITON_ATTN MULTIMODAL_GPU_MEM_UTIL=0.14 MULTIMODAL_MAX_MODEL_LEN=32768 diff --git a/tests/test_catalog.py b/tests/test_catalog.py index b632325..396863a 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -313,11 +313,14 @@ def test_embed_score_hf_overrides_match_fleet_template() -> None: def test_minor_gear_exists_with_correct_fields() -> None: - # The minor gear must be present in the catalog with exactly the fields the - # acceptance criteria specify — any field drift is a misconfiguration bug. + # The 4B gear must STILL be present in the catalog with exactly the fields + # the original acceptance criteria specify — any field drift is a + # misconfiguration bug. Only its role_hint moved: the `hand` lobe took over + # the cheap-tier slot and the 4B was demoted to a plain candidate + # (cite-don't-delete), so the checkpoint stays selectable via `lobes switch`. minor = next((m for m in SUPPORTED_MODELS if m.id == _MINOR_ID), None) assert minor is not None, f"{_MINOR_ID} not found in catalog" - assert minor.role_hint == "minor" + assert minor.role_hint == "candidate" assert minor.shape == "hybrid linear-attn + ViT (multimodal)" assert minor.context == "256K native" assert minor.native_max_model_len == 262144 @@ -526,12 +529,18 @@ def test_14b_is_demoted_to_candidate() -> None: def test_tier_role_map_uses_new_vocabulary() -> None: # Primary vocabulary: main/minor/multimodal. Back-compat aliases retained. + # `minor`/`cheap` point at the `hand` BACKEND since the hand lobe replaced + # Qwen3.5-4B in that slot — the tier NAMES survive for back-compat, the + # `minor` backend role does not. assert TIER_ROLE["main"] == "primary" - assert TIER_ROLE["minor"] == "minor" + assert TIER_ROLE["minor"] == "hand" assert TIER_ROLE["multimodal"] == "multimodal" - assert TIER_ROLE["cheap"] == "minor" + assert TIER_ROLE["cheap"] == "hand" assert TIER_ROLE["normal"] == "multimodal" assert TIER_ROLE["hard"] == "primary" + assert TIER_ROLE["hand"] == "hand" + # No tier resolves to a "minor" backend role any more. + assert "minor" not in set(TIER_ROLE.values()) def test_resolve_tier_multimodal_and_normal_return_gemma() -> None: @@ -552,11 +561,20 @@ def test_resolve_tier_main_and_hard_return_primary() -> None: assert model.task == "generate" -def test_resolve_tier_minor_and_cheap_return_4b_minor() -> None: - for tier in ("minor", "cheap"): +def test_resolve_tier_minor_cheap_and_hand_all_return_the_hand_gear() -> None: + # The hand lobe REPLACED Qwen3.5-4B in the cheap-tier slot, so all three + # spellings resolve to the same lane. `minor`/`cheap` are back-compat names + # for it, not a second gear. + for tier in ("minor", "cheap", "hand"): model = resolve_tier(tier) - assert model.role_hint == "minor" - assert model.id == _MINOR_ID + assert model.role_hint == "hand", f"resolve_tier({tier!r}) -> {model.role_hint}" + assert model.id == _HAND_ID, f"resolve_tier({tier!r}) -> {model.id}" + + +def test_demoted_4b_remains_in_catalog_after_the_hand_repoint() -> None: + # cite-don't-delete: repointing the tier must not remove the checkpoint. + # It stays selectable via `lobes switch` even though no tier resolves to it. + assert any(m.id == _MINOR_ID for m in SUPPORTED_MODELS), f"{_MINOR_ID} was deleted, not demoted" def test_resolve_tier_unknown_still_raises_value_error() -> None: @@ -564,6 +582,55 @@ def test_resolve_tier_unknown_still_raises_value_error() -> None: resolve_tier("ultra") +# --------------------------------------------------------------------------- +# `hand` gear: LiquidAI/LFM2.5-1.2B-Instruct — the ninth Colleague role and the +# fleet's designated fine-tuning base (hand-lobe plan, t1). +# --------------------------------------------------------------------------- + +_HAND_ID = "LiquidAI/LFM2.5-1.2B-Instruct" + + +def test_hand_gear_exists_with_correct_fields() -> None: + hand = next((m for m in SUPPORTED_MODELS if m.id == _HAND_ID), None) + assert hand is not None, f"{_HAND_ID} not found in catalog" + assert hand.role_hint == "hand" + assert hand.native_max_model_len == 32768 + # bf16 sentinel: the vllm-hand lane omits --quantization entirely rather + # than passing it empty, which would corrupt the weights. + assert hand.quantization == "none" + assert hand.tool_parser == "lfm2" + assert hand.task == "generate" + assert hand.doc == "lfm2.5-1.2b-hand.md" + # Text-only: no ViT, no MoE, no speculative draft head in v1. + assert hand.dimension == 0 + assert hand.moe_backend == "" + assert hand.speculative_config == "" + assert hand.hf_overrides == "" + + +def test_hand_is_the_only_gear_carrying_the_hand_role_hint() -> None: + # `resolve_tier` returns the FIRST generate model matching the role, so a + # second hand-hinted entry would silently decide the tier by list order. + hand_hinted = [m.id for m in SUPPORTED_MODELS if m.role_hint == "hand"] + assert hand_hinted == [_HAND_ID], f"role_hint='hand' models: {hand_hinted}" + + +def test_tier_aliases_capability_order_is_hand_multimodal_worker_muse_primary() -> None: + """Assert the ORDER, not just the mapping. + + ``tier_aliases`` derives ascending capability order from each role's *last* + occurrence position in ``TIER_ROLE``'s values sequence, so the dict's key + ORDER is load-bearing: it decides which lane an unwired tier falls back + upward to. A reorder that still maps every key correctly can silently + invert the ladder, which is why this asserts the sequence. + """ + last_pos: dict[str, int] = {} + for index, role in enumerate(TIER_ROLE.values()): + last_pos[role] = index + ascending = sorted(last_pos, key=lambda role: last_pos[role]) + assert ascending == ["hand", "multimodal", "worker", "muse", "primary"] + + # --------------------------------------------------------------------------- # `worker` gear: unsloth/Qwen3.6-35B-A3B-NVFP4 (thor-worker-lobe plan, t1) # --------------------------------------------------------------------------- diff --git a/tests/test_catalog_tiers.py b/tests/test_catalog_tiers.py index 0b791c6..a4d94ea 100644 --- a/tests/test_catalog_tiers.py +++ b/tests/test_catalog_tiers.py @@ -26,6 +26,9 @@ _MIDDLE_ID = "nvidia/Qwen3-14B-NVFP4" _MINOR_ID = "Qwen/Qwen3.5-4B" +# The gear the cheap tier resolves to since the `hand` lobe replaced the 4B in +# that slot. `_MINOR_ID` above is retained: the 4B is demoted, not deleted. +_HAND_ID = "LiquidAI/LFM2.5-1.2B-Instruct" _PRIMARY_ID = "unsloth/Qwen3.6-27B-NVFP4" @@ -113,8 +116,13 @@ def test_tier_role_map_exists_and_has_three_tiers() -> None: def test_tier_role_map_values() -> None: - """Post-#69 back-compat values: cheap->minor / normal->multimodal / hard->primary.""" - assert TIER_ROLE["cheap"] == "minor" + """Back-compat values: cheap->hand / normal->multimodal / hard->primary. + + ``cheap`` pointed at a ``minor`` backend role from #69 until the `hand` lobe + replaced Qwen3.5-4B in that slot. The tier SPELLING is unchanged — callers + passing ``model=cheap`` are unaffected — but the role it names is ``hand``. + """ + assert TIER_ROLE["cheap"] == "hand" assert TIER_ROLE["normal"] == "multimodal" assert TIER_ROLE["hard"] == "primary" @@ -124,11 +132,11 @@ def test_tier_role_map_values() -> None: # --------------------------------------------------------------------------- -def test_resolve_tier_cheap_returns_minor_gear() -> None: - """resolve_tier('cheap') must return the 4B minor gear.""" +def test_resolve_tier_cheap_returns_the_hand_gear() -> None: + """resolve_tier('cheap') must return the 1.2B hand gear (it replaced the 4B).""" model = resolve_tier("cheap") - assert model.id == _MINOR_ID - assert model.role_hint == "minor" + assert model.id == _HAND_ID + assert model.role_hint == "hand" def test_resolve_tier_normal_returns_multimodal_gear() -> None: @@ -207,7 +215,8 @@ def test_tier_role_capability_order_is_ascending_with_muse_and_worker() -> None: for i, role in enumerate(TIER_ROLE.values()): last_pos[role] = i roles_asc = sorted(last_pos, key=last_pos.__getitem__) - assert roles_asc == ["minor", "multimodal", "worker", "muse", "primary"] + # `hand` replaced `minor` at the bottom rung — same position, new name. + assert roles_asc == ["hand", "multimodal", "worker", "muse", "primary"] # --------------------------------------------------------------------------- diff --git a/tests/test_cli_capabilities.py b/tests/test_cli_capabilities.py index f7f1a30..0688a34 100644 --- a/tests/test_cli_capabilities.py +++ b/tests/test_cli_capabilities.py @@ -426,16 +426,18 @@ def test_endpoint_json_shape(tmp_path, capsys) -> None: def test_endpoint_works_for_every_role(tmp_path, capsys) -> None: _scaffold_fleet(tmp_path) - # The five gateway-fronted roles resolve to the reachable gateway URL + # The seven gateway-fronted roles resolve to the reachable gateway URL # (worker is opt-in-core like muse — unwired/infeasible here, but its - # endpoint still resolves to the gateway URL, same as muse); the - # audio roles (stt/tts) are unwired here (no --audio overlay) → blank, but - # 'lobes endpoint' still exits 0 for every known role, wired or not. + # endpoint still resolves to the gateway URL, same as muse; `hand` is + # default-hosted and resolves the same way); the audio roles (stt/tts) are + # unwired here (no --audio overlay) → blank, but 'lobes endpoint' still + # exits 0 for every known role, wired or not. expected = { "cortex": "http://localhost:8000", "senses": "http://localhost:8000", "muse": "http://localhost:8000", "worker": "http://localhost:8000", + "hand": "http://localhost:8000", "embedder": "http://localhost:8000", "reranker": "http://localhost:8000", "stt": "", diff --git a/tests/test_cli_measure.py b/tests/test_cli_measure.py index 85cdf3a..db0e0c3 100644 --- a/tests/test_cli_measure.py +++ b/tests/test_cli_measure.py @@ -382,12 +382,13 @@ def test_measure_role_wraps_family_result_with_common_fields(measure_server: str assert set(out["metrics"]) == set(RM.LLM_METRIC_KEYS) -def test_measure_role_family_assignment_covers_all_eight_roles() -> None: +def test_measure_role_family_assignment_covers_all_nine_roles() -> None: assert RM._FAMILY_BY_ROLE == { "cortex": "llm", "senses": "llm", "muse": "llm", "worker": "llm", + "hand": "llm", "embedder": "embed_rerank", "reranker": "embed_rerank", "stt": "audio", diff --git a/tests/test_cli_run.py b/tests/test_cli_run.py index 583e65c..aa1777c 100644 --- a/tests/test_cli_run.py +++ b/tests/test_cli_run.py @@ -210,7 +210,12 @@ def test_run_no_catalog_minor_and_no_model_flag_raises() -> None: def test_run_minor_resolves_catalog_model_id(capsys) -> None: - """With the real catalog (t1's minor gear) and no --model, the id resolves.""" + """With the real catalog and no --model, the cheap-tier id resolves. + + ``lobes run minor`` keeps its CLI spelling for back-compat, but the gear it + resolves to is now the `hand` lobe — the 4B was demoted to a candidate when + hand took over the cheap-tier slot. + """ p = _make_parser() args = p.parse_args(["run", "minor", "hello", "--base-url", "http://localhost/v1"]) args.model = None # force catalog resolution @@ -219,7 +224,7 @@ def test_run_minor_resolves_catalog_model_id(capsys) -> None: rc = run.cmd_run_minor(args) assert rc == 0 - assert mock_ct.call_args.kwargs.get("model") == "Qwen/Qwen3.5-4B" + assert mock_ct.call_args.kwargs.get("model") == "LiquidAI/LFM2.5-1.2B-Instruct" # --------------------------------------------------------------------------- diff --git a/tests/test_cli_up.py b/tests/test_cli_up.py index 3ec0921..c3e8f40 100644 --- a/tests/test_cli_up.py +++ b/tests/test_cli_up.py @@ -23,7 +23,18 @@ from lobes.cli._commands import up as up_cmd from lobes.runtime import _compose -_SIX = ["vllm-primary", "vllm-multimodal", "vllm-embed", "vllm-rerank", "stt", "chatterbox"] +# The colleague-stack bundle: DEFAULT_HOSTED_ROLES in canonical role order. +# SEVEN since the `hand` lobe landed — it is default-hosted and its service +# carries no compose-profile gate, unlike the opt-in muse/worker lobes. +_STACK = [ + "vllm-primary", + "vllm-multimodal", + "vllm-hand", + "vllm-embed", + "vllm-rerank", + "stt", + "chatterbox", +] def _ok() -> types.SimpleNamespace: @@ -102,32 +113,32 @@ def test_up_fleet_role_ignores_audio_overlay_even_when_present(tmp_path, capsys) assert "docker-compose.audio.yml" not in payload["command"] -# --- acceptance 1: colleague-stack = all six across both compose files ----- +# --- acceptance 1: colleague-stack = every default role, both compose files - -def test_up_colleague_stack_dry_run_covers_all_six(tmp_path, capsys) -> None: +def test_up_colleague_stack_dry_run_covers_every_default_role(tmp_path, capsys) -> None: _scaffold_fleet_audio(tmp_path) rc = main(["up", "colleague-stack", "--compose-dir", str(tmp_path), "--json"]) assert rc == 0 payload = json.loads(capsys.readouterr().out) assert payload["dry_run"] is True assert payload["target"] == "colleague-stack" - # cortex+senses+embedder+reranker AND stt+tts (r4), canonical role order. - assert payload["services"] == _SIX + # cortex+senses+hand+embedder+reranker AND stt+tts, canonical role order. + assert payload["services"] == _STACK # ...across the fleet + audio compose files. assert payload["command"] == ( "docker compose -f docker-compose.yml -f docker-compose.audio.yml up -d " - "vllm-primary vllm-multimodal vllm-embed vllm-rerank stt chatterbox" + "vllm-primary vllm-multimodal vllm-hand vllm-embed vllm-rerank stt chatterbox" ) -def test_up_colleague_stack_text_plan_names_the_six(tmp_path, capsys) -> None: +def test_up_colleague_stack_text_plan_names_every_default_role(tmp_path, capsys) -> None: _scaffold_fleet_audio(tmp_path) rc = main(["up", "colleague-stack", "--compose-dir", str(tmp_path)]) assert rc == 0 out = capsys.readouterr().out assert "DRY RUN" in out - for service in _SIX: + for service in _STACK: assert service in out @@ -216,7 +227,7 @@ def test_up_apply_colleague_stack_runs_full_argv(tmp_path, monkeypatch) -> None: "docker-compose.audio.yml", "up", "-d", - *_SIX, + *_STACK, ] @@ -330,19 +341,19 @@ def test_up_muse_with_activation_targets_vllm_muse(tmp_path, capsys) -> None: def test_up_colleague_stack_excludes_the_opt_in_muse_lobe(tmp_path, capsys) -> None: - """colleague-stack stays the SIX default roles — bundling the opt-in, - profile-gated muse service would break the bundle on every default - deployment.""" + """colleague-stack stays the DEFAULT-HOSTED roles — bundling an opt-in, + profile-gated service (muse or worker) would break the bundle on every + default deployment.""" _scaffold_fleet_audio(tmp_path) rc = main(["up", "colleague-stack", "--compose-dir", str(tmp_path), "--json"]) assert rc == 0 payload = json.loads(capsys.readouterr().out) assert "vllm-muse" not in payload["services"] - assert set(payload["services"]) == set(_SIX) + assert set(payload["services"]) == set(_STACK) def test_up_colleague_stack_on_mesh_lobe_scaffold_errors(tmp_path, capsys) -> None: - """The six-role bundle needs every role — a mesh-lobe box that drops one + """The bundle needs every default role — a mesh-lobe box that drops one refuses the bundle instead of silently starting a subset.""" templates = {**_compose.FLEET_TEMPLATES, **_compose.AUDIO_TEMPLATES} _compose.write_scaffold(tmp_path, force=True, templates=templates) diff --git a/tests/test_dropped_lobe_honesty.py b/tests/test_dropped_lobe_honesty.py index fba6406..59ce79b 100644 --- a/tests/test_dropped_lobe_honesty.py +++ b/tests/test_dropped_lobe_honesty.py @@ -382,6 +382,7 @@ def test_spark_cli_and_gateway_agree_on_dropped_senses() -> None: "senses": False, "muse": False, "worker": False, + "hand": True, "embedder": True, "reranker": True, "stt": True, @@ -400,6 +401,7 @@ def test_thor_cli_and_gateway_agree_on_dropped_cortex() -> None: "senses": True, "muse": False, "worker": False, + "hand": True, "embedder": True, "reranker": True, "stt": True, diff --git a/tests/test_gateway_busy_e2e.py b/tests/test_gateway_busy_e2e.py index a5c5d84..c70c52e 100644 --- a/tests/test_gateway_busy_e2e.py +++ b/tests/test_gateway_busy_e2e.py @@ -25,8 +25,9 @@ def _fleet_cfg(): return build_config( { "PRIMARY_SERVED_NAME": "PRIMARY", - "MINOR_BASE_URL": "http://vllm-minor:8000", - "MINOR_SERVED_NAME": "MINOR", + # The cheap tier resolves to the `hand` backend now. + "HAND_BASE_URL": "http://vllm-hand:8000", + "HAND_SERVED_NAME": "HAND", "MULTIMODAL_BASE_URL": "http://vllm-multimodal:8000", "MULTIMODAL_SERVED_NAME": "MULTIMODAL", } @@ -74,7 +75,7 @@ def opener(backend, path, body, headers, *, connect_timeout, read_timeout): def test_cortex_request_under_pressure_is_shed_with_429() -> None: """model=cortex under HIGH pressure → 429 busy, no upstream dialed.""" table, cfg = _fleet_cfg() - opener, calls = _opener({"minor": 200, "multimodal": 200, "primary": 200}) + opener, calls = _opener({"hand": 200, "multimodal": 200, "primary": 200}) resp = S.handle_post( table, cfg, "/v1/chat/completions", [], b'{"model":"cortex"}', opener, pressure=_HIGH_SWAP ) @@ -95,7 +96,7 @@ def test_senses_request_under_pressure_is_shed_with_429() -> None: Busy covers both cortex AND senses (main + multimodal tiers). """ table, cfg = _fleet_cfg() - opener, calls = _opener({"minor": 200, "multimodal": 200, "primary": 200}) + opener, calls = _opener({"hand": 200, "multimodal": 200, "primary": 200}) resp = S.handle_post( table, cfg, "/v1/chat/completions", [], b'{"model":"senses"}', opener, pressure=_HIGH_SWAP ) @@ -121,7 +122,7 @@ def test_cortex_request_after_pressure_clears_gets_200_from_real_cortex() -> Non table, cfg = _fleet_cfg() # Phase 1: under pressure → shed - opener1, calls1 = _opener({"minor": 200, "multimodal": 200, "primary": 200}) + opener1, calls1 = _opener({"hand": 200, "multimodal": 200, "primary": 200}) resp1 = S.handle_post( table, cfg, "/v1/chat/completions", [], b'{"model":"cortex"}', opener1, pressure=_HIGH_SWAP ) @@ -129,7 +130,7 @@ def test_cortex_request_after_pressure_clears_gets_200_from_real_cortex() -> Non assert calls1 == [] # Phase 2: pressure clears → served from the real cortex (primary) backend - opener2, calls2 = _opener({"minor": 200, "multimodal": 200, "primary": 200}) + opener2, calls2 = _opener({"hand": 200, "multimodal": 200, "primary": 200}) resp2 = S.handle_post( table, cfg, @@ -157,13 +158,13 @@ def test_minor_request_served_even_under_pressure() -> None: The floor tier is never shed. """ table, cfg = _fleet_cfg() - opener, calls = _opener({"minor": 200, "multimodal": 200, "primary": 200}) + opener, calls = _opener({"hand": 200, "multimodal": 200, "primary": 200}) resp = S.handle_post( table, cfg, "/v1/chat/completions", [], b'{"model":"minor"}', opener, pressure=_HIGH_SWAP ) assert resp.status == 200 assert resp.upstream is not None - assert calls[0][0] == "minor" # served, not shed + assert calls[0][0] == "hand" # served, not shed headers = dict(resp.headers) - assert headers["X-Lobes-Tier"] == "minor" + assert headers["X-Lobes-Tier"] == "hand" assert headers["X-Lobes-Tier-Reason"] == "default" diff --git a/tests/test_gateway_config_proxy.py b/tests/test_gateway_config_proxy.py index 142b06a..5389326 100644 --- a/tests/test_gateway_config_proxy.py +++ b/tests/test_gateway_config_proxy.py @@ -33,6 +33,7 @@ from lobes.catalog import TIER_ROLE from lobes.gateway._config import ( FEASIBLE_ENV, + NEVER_PROXIED_BACKENDS, PEER_API_KEY_ENV, PEER_ORIGIN_ENV, PEER_PROXY_ENV, @@ -81,8 +82,16 @@ def test_peer_proxy_env_mirrors_feasible_env_prefixes() -> None: # One channel vocabulary: the proxy knob names exactly the backends the # feasibility / peer-origin channels name — the five core roles, the # opt-in worker role (thor-worker-lobe plan, t3), plus the first-class - # stt/tts audio roles (issue #129). - assert set(PEER_PROXY_ENV) == set(FEASIBLE_ENV) == set(PEER_ORIGIN_ENV) + # stt/tts audio roles (issue #129) — MINUS the never-proxied set. + # + # `hand` is feasibility-tracked but has no peer channel at all: it is cheap + # enough to run on every box, so there is never a peer to refer it to (see + # NEVER_PROXIED_BACKENDS). Asserted as a derivation, not a hand-typed copy, + # so adding a role to FEASIBLE_ENV without a peer channel fails here unless + # the omission is DECLARED. + assert set(PEER_PROXY_ENV) == set(PEER_ORIGIN_ENV) + assert set(PEER_PROXY_ENV) == set(FEASIBLE_ENV) - NEVER_PROXIED_BACKENDS + assert NEVER_PROXIED_BACKENDS == {"hand"} assert PEER_PROXY_ENV == { "primary": "PRIMARY_PEER_PROXY", "multimodal": "MULTIMODAL_PEER_PROXY", @@ -96,7 +105,7 @@ def test_peer_proxy_env_mirrors_feasible_env_prefixes() -> None: def test_peer_api_key_env_mirrors_feasible_env_prefixes() -> None: - assert set(PEER_API_KEY_ENV) == set(FEASIBLE_ENV) + assert set(PEER_API_KEY_ENV) == set(FEASIBLE_ENV) - NEVER_PROXIED_BACKENDS assert PEER_API_KEY_ENV == { "primary": "PRIMARY_PEER_API_KEY", "multimodal": "MULTIMODAL_PEER_API_KEY", diff --git a/tests/test_gateway_feasibility.py b/tests/test_gateway_feasibility.py index ded4ab0..c3f0c9e 100644 --- a/tests/test_gateway_feasibility.py +++ b/tests/test_gateway_feasibility.py @@ -92,6 +92,9 @@ def test_feasible_env_names_the_four_profile_scoped_backends() -> None: "multimodal": "MULTIMODAL_FEASIBLE", "muse": "MUSE_FEASIBLE", "worker": "WORKER_FEASIBLE", + # `hand` is feasibility-tracked but NEVER proxied — see + # NEVER_PROXIED_BACKENDS and test_gateway_config_proxy.py. + "hand": "HAND_FEASIBLE", "embed": "EMBED_FEASIBLE", "rerank": "RERANK_FEASIBLE", "stt": "STT_FEASIBLE", diff --git a/tests/test_gateway_proxy.py b/tests/test_gateway_proxy.py index da2f15e..b28ecfe 100644 --- a/tests/test_gateway_proxy.py +++ b/tests/test_gateway_proxy.py @@ -898,7 +898,10 @@ def fake_open(backend, path, body, headers, *, connect_timeout, read_timeout): monkeypatch.setattr(S, "open_upstream", fake_open) ready = {"primary": True, "embed": True, "rerank": True, "multimodal": True} - cache = SimpleNamespace(current=lambda: dict(ready)) + # Duck-typed ReadinessCache stand-in. `current_adapters` is part of the + # contract the /v1/models handler reads (hand-lobe plan t4): an empty map + # means "no adapter confirmed loaded", which is what this fixture wants. + cache = SimpleNamespace(current=lambda: dict(ready), current_adapters=dict) httpd = ThreadingHTTPServer(("127.0.0.1", 0), S._make_handler(table, cfg, None, cache, specs)) threading.Thread(target=httpd.serve_forever, daemon=True).start() host, port = httpd.server_address diff --git a/tests/test_gateway_server.py b/tests/test_gateway_server.py index 8135d13..6d7a551 100644 --- a/tests/test_gateway_server.py +++ b/tests/test_gateway_server.py @@ -663,8 +663,9 @@ def _fleet_cfg(): return build_config( { "PRIMARY_SERVED_NAME": "PRIMARY", - "MINOR_BASE_URL": "http://vllm-minor:8000", - "MINOR_SERVED_NAME": "MINOR", + # The cheap tier resolves to the `hand` backend now. + "HAND_BASE_URL": "http://vllm-hand:8000", + "HAND_SERVED_NAME": "HAND", "MULTIMODAL_BASE_URL": "http://vllm-multimodal:8000", "MULTIMODAL_SERVED_NAME": "MULTIMODAL", } @@ -680,7 +681,7 @@ def test_handle_post_sheds_main_with_429_busy_under_pressure() -> None: # with a 429 busy response; the request is NOT forwarded to any backend # (degrade-to-minor is removed; #85). No upstream is dialed (h10). table, cfg = _fleet_cfg() - opener, calls = _opener({"minor": 200, "multimodal": 200, "primary": 200}) + opener, calls = _opener({"hand": 200, "multimodal": 200, "primary": 200}) resp = S.handle_post( table, cfg, "/v1/chat/completions", [], b'{"model":"hard"}', opener, pressure=_HIGH_SWAP ) @@ -700,7 +701,7 @@ def test_handle_post_sheds_senses_with_429_busy_under_pressure() -> None: # model=normal (multimodal/senses) is ALSO shed under pressure — not degraded # to minor. Busy applies to any cross-capability substitution (cortex + senses). table, cfg = _fleet_cfg() - opener, calls = _opener({"minor": 200, "multimodal": 200, "primary": 200}) + opener, calls = _opener({"hand": 200, "multimodal": 200, "primary": 200}) resp = S.handle_post( table, cfg, "/v1/chat/completions", [], b'{"model":"normal"}', opener, pressure=_HIGH_SWAP ) @@ -714,14 +715,14 @@ def test_handle_post_sheds_senses_with_429_busy_under_pressure() -> None: def test_handle_post_minor_still_served_under_pressure() -> None: # An explicit minor request is the floor — served as requested, never shed. table, cfg = _fleet_cfg() - opener, calls = _opener({"minor": 200, "multimodal": 200, "primary": 200}) + opener, calls = _opener({"hand": 200, "multimodal": 200, "primary": 200}) resp = S.handle_post( table, cfg, "/v1/chat/completions", [], b'{"model":"minor"}', opener, pressure=_HIGH_SWAP ) assert resp.status == 200 - assert calls[0][0] == "minor" # served, not shed + assert calls[0][0] == "hand" # served, not shed headers = dict(resp.headers) - assert headers["X-Lobes-Tier"] == "minor" + assert headers["X-Lobes-Tier"] == "hand" assert headers["X-Lobes-Tier-Reason"] == "default" @@ -733,7 +734,7 @@ def test_busy_429_is_distinguishable_from_503_owner_down() -> None: # and both are distinct from the terminal 502 (malformed table) and from a # relayed upstream 404 ("model does not exist"). table, cfg = _fleet_cfg() - opener, _ = _opener({"minor": 200, "multimodal": 200, "primary": 200}) + opener, _ = _opener({"hand": 200, "multimodal": 200, "primary": 200}) busy = S.handle_post( table, cfg, "/v1/chat/completions", [], b'{"model":"hard"}', opener, pressure=_HIGH_SWAP ) @@ -754,7 +755,7 @@ def test_busy_429_is_distinguishable_from_503_owner_down() -> None: def test_handle_post_override_forces_main_under_pressure() -> None: # X-Lobes-Override forces the requested tier despite degraded pressure. table, cfg = _fleet_cfg() - opener, calls = _opener({"minor": 200, "multimodal": 200, "primary": 200}) + opener, calls = _opener({"hand": 200, "multimodal": 200, "primary": 200}) resp = S.handle_post( table, cfg, @@ -775,7 +776,7 @@ def test_handle_post_override_forces_main_under_pressure() -> None: def test_handle_post_no_pressure_keeps_main_reason_default() -> None: table, cfg = _fleet_cfg() - opener, calls = _opener({"minor": 200, "multimodal": 200, "primary": 200}) + opener, calls = _opener({"hand": 200, "multimodal": 200, "primary": 200}) resp = S.handle_post( table, cfg, "/v1/chat/completions", [], b'{"model":"hard"}', opener, pressure=_NO_PRESSURE ) @@ -789,7 +790,7 @@ def test_handle_post_plain_model_gets_no_tier_headers() -> None: # A concrete model id is never downgraded and carries no tier headers, even # under high pressure — the existing routing path is untouched. table, cfg = _fleet_cfg() - opener, calls = _opener({"minor": 200, "multimodal": 200, "primary": 200}) + opener, calls = _opener({"hand": 200, "multimodal": 200, "primary": 200}) resp = S.handle_post( table, cfg, "/v1/chat/completions", [], b'{"model":"PRIMARY"}', opener, pressure=_HIGH_SWAP ) @@ -803,7 +804,7 @@ def test_handle_post_without_pressure_skips_downgrade_layer() -> None: # pressure=None (no cache wired) → tier aliases resolve via the static table # (t5 behaviour), no tier headers, no downgrade. table, cfg = _fleet_cfg() - opener, calls = _opener({"minor": 200, "multimodal": 200, "primary": 200}) + opener, calls = _opener({"hand": 200, "multimodal": 200, "primary": 200}) resp = S.handle_post(table, cfg, "/v1/chat/completions", [], b'{"model":"hard"}', opener) assert calls[0][0] == "primary" # hard → primary via static alias assert "X-Lobes-Tier" not in dict(resp.headers) diff --git a/tests/test_gateway_tiers.py b/tests/test_gateway_tiers.py index 101b9fd..7de6723 100644 --- a/tests/test_gateway_tiers.py +++ b/tests/test_gateway_tiers.py @@ -21,8 +21,8 @@ from lobes.catalog import TIER_ROLE from lobes.gateway._config import ( + _DEFAULT_HAND, _DEFAULT_MIDDLE, - _DEFAULT_MINOR, _DEFAULT_MULTIMODAL, _DEFAULT_MULTIMODAL_CODER, _DEFAULT_PRIMARY, @@ -215,8 +215,8 @@ def test_main_alias_resolves_to_primary() -> None: def test_minor_alias_resolves_to_minor_gear() -> None: # model=minor resolves to the 4B minor gear when it is wired. - table, _ = build_config({"MINOR_BASE_URL": "http://vllm-minor:8000"}) - assert resolve_model(table, "minor") == _DEFAULT_MINOR + table, _ = build_config({"HAND_BASE_URL": "http://vllm-hand:8000"}) + assert resolve_model(table, "minor") == _DEFAULT_HAND def test_multimodal_alias_resolves_to_gemma_backend() -> None: @@ -243,8 +243,8 @@ def test_normal_resolves_to_multimodal_back_compat() -> None: def test_cheap_resolves_to_minor_back_compat() -> None: # cheap is the back-compat alias for minor. - table, _ = build_config({"MINOR_BASE_URL": "http://vllm-minor:8000"}) - assert resolve_model(table, "cheap") == _DEFAULT_MINOR + table, _ = build_config({"HAND_BASE_URL": "http://vllm-hand:8000"}) + assert resolve_model(table, "cheap") == _DEFAULT_HAND # --- tier-alias resolution: full fleet (all three tiers wired) --------------- @@ -253,16 +253,16 @@ def test_cheap_resolves_to_minor_back_compat() -> None: def test_three_tier_aliases_resolve_to_their_gears_when_all_wired() -> None: table, _ = build_config( { - "MINOR_BASE_URL": "http://vllm-minor:8000", + "HAND_BASE_URL": "http://vllm-hand:8000", "MULTIMODAL_BASE_URL": "http://vllm-multimodal:8000", } ) # Back-compat aliases: cheap → 4B minor, normal → 12B multimodal, hard → 27B primary. - assert resolve_model(table, "cheap") == _DEFAULT_MINOR + assert resolve_model(table, "cheap") == _DEFAULT_HAND assert resolve_model(table, "normal") == _DEFAULT_MULTIMODAL assert resolve_model(table, "hard") == _DEFAULT_PRIMARY # Primary vocabulary resolves the same gears. - assert resolve_model(table, "minor") == _DEFAULT_MINOR + assert resolve_model(table, "minor") == _DEFAULT_HAND assert resolve_model(table, "multimodal") == _DEFAULT_MULTIMODAL assert resolve_model(table, "main") == _DEFAULT_PRIMARY @@ -270,17 +270,17 @@ def test_three_tier_aliases_resolve_to_their_gears_when_all_wired() -> None: def test_tier_aliases_track_custom_served_names() -> None: table, _ = build_config( { - "MINOR_BASE_URL": "http://vllm-minor:8000", - "MINOR_SERVED_NAME": "my/minor", + "HAND_BASE_URL": "http://vllm-hand:8000", + "HAND_SERVED_NAME": "my/hand", "MULTIMODAL_BASE_URL": "http://vllm-multimodal:8000", "MULTIMODAL_SERVED_NAME": "my/multimodal", "PRIMARY_SERVED_NAME": "my/primary", } ) - assert resolve_model(table, "cheap") == "my/minor" + assert resolve_model(table, "cheap") == "my/hand" assert resolve_model(table, "normal") == "my/multimodal" assert resolve_model(table, "hard") == "my/primary" - assert resolve_model(table, "minor") == "my/minor" + assert resolve_model(table, "minor") == "my/hand" assert resolve_model(table, "multimodal") == "my/multimodal" assert resolve_model(table, "main") == "my/primary" @@ -291,9 +291,9 @@ def test_tier_aliases_track_custom_served_names() -> None: def test_normal_falls_back_to_primary_when_multimodal_absent() -> None: # minor wired, multimodal NOT wired → normal/multimodal escalate UPWARD to primary # (no multimodal gear → the next available higher tier is hard/primary). - table, _ = build_config({"MINOR_BASE_URL": "http://vllm-minor:8000"}) - assert resolve_model(table, "cheap") == _DEFAULT_MINOR # minor present - assert resolve_model(table, "minor") == _DEFAULT_MINOR # primary vocab + table, _ = build_config({"HAND_BASE_URL": "http://vllm-hand:8000"}) + assert resolve_model(table, "cheap") == _DEFAULT_HAND # minor present + assert resolve_model(table, "minor") == _DEFAULT_HAND # primary vocab assert resolve_model(table, "normal") == _DEFAULT_PRIMARY # multimodal absent → primary assert resolve_model(table, "multimodal") == _DEFAULT_PRIMARY # primary vocab fallback assert resolve_model(table, "hard") == _DEFAULT_PRIMARY @@ -313,7 +313,7 @@ def test_cheap_falls_back_to_multimodal_when_minor_absent() -> None: def test_multimodal_falls_back_to_primary_when_multimodal_absent() -> None: # Only minor + primary wired (no multimodal gear) → multimodal/normal → primary # (upward, skipping the lower minor tier). - table, _ = build_config({"MINOR_BASE_URL": "http://vllm-minor:8000"}) + table, _ = build_config({"HAND_BASE_URL": "http://vllm-hand:8000"}) assert resolve_model(table, "multimodal") == _DEFAULT_PRIMARY assert resolve_model(table, "normal") == _DEFAULT_PRIMARY @@ -328,7 +328,7 @@ def test_all_tiers_fall_back_to_primary_when_only_primary_wired() -> None: def test_hard_always_resolves_to_primary_even_with_full_fleet() -> None: table, _ = build_config( { - "MINOR_BASE_URL": "http://vllm-minor:8000", + "HAND_BASE_URL": "http://vllm-hand:8000", "MULTIMODAL_BASE_URL": "http://vllm-multimodal:8000", } ) @@ -346,7 +346,7 @@ def test_embed_request_never_fails_over_to_generate_with_tiers_wired() -> None: embed_name = "Qwen/Qwen3-Embedding-0.6B" table, _ = build_config( { - "MINOR_BASE_URL": "http://vllm-minor:8000", + "HAND_BASE_URL": "http://vllm-hand:8000", "MULTIMODAL_BASE_URL": "http://vllm-multimodal:8000", "EMBED_URL": "http://vllm-embed:8000", "EMBED_SERVED_NAME": embed_name, @@ -370,7 +370,7 @@ def test_generate_tier_failover_excludes_pooling_backends() -> None: embed_name = "Qwen/Qwen3-Embedding-0.6B" table, _ = build_config( { - "MINOR_BASE_URL": "http://vllm-minor:8000", + "HAND_BASE_URL": "http://vllm-hand:8000", "MULTIMODAL_BASE_URL": "http://vllm-multimodal:8000", "EMBED_URL": "http://vllm-embed:8000", "EMBED_SERVED_NAME": embed_name, @@ -388,7 +388,7 @@ def test_generate_tier_failover_excludes_pooling_backends() -> None: def test_tier_aliases_helper_is_pure_and_uses_backend_role_names() -> None: backends = ( Backend("primary", "http://p:8000", "P"), - Backend("minor", "http://m:8000", "MIN"), + Backend("hand", "http://m:8000", "HAND"), Backend("multimodal", "http://mm:8000", "MM"), # An embed backend is ignored — tier aliases are generate-only. Backend("embed", "http://e:8000", "E", task="embed"), @@ -396,10 +396,10 @@ def test_tier_aliases_helper_is_pure_and_uses_backend_role_names() -> None: aliases = tier_aliases(backends, TIER_ROLE) # Primary vocabulary. assert aliases["main"] == "P" - assert aliases["minor"] == "MIN" + assert aliases["minor"] == "HAND" assert aliases["multimodal"] == "MM" # Back-compat aliases resolve identically. - assert aliases["cheap"] == "MIN" + assert aliases["cheap"] == "HAND" assert aliases["normal"] == "MM" assert aliases["hard"] == "P" @@ -434,20 +434,20 @@ def test_tier_aliases_helper_multimodal_absent_escalates_minor_to_multimodal() - def test_explicit_gateway_aliases_coexist_with_tier_aliases() -> None: table, _ = build_config( { - "MINOR_BASE_URL": "http://vllm-minor:8000", + "HAND_BASE_URL": "http://vllm-hand:8000", "MULTIMODAL_BASE_URL": "http://vllm-multimodal:8000", - "GATEWAY_ALIASES": "fast=" + _DEFAULT_MINOR, + "GATEWAY_ALIASES": "fast=" + _DEFAULT_HAND, } ) # The hand-set alias resolves... - assert resolve_model(table, "fast") == _DEFAULT_MINOR + assert resolve_model(table, "fast") == _DEFAULT_HAND # ...and the tier aliases are still present alongside it. assert resolve_model(table, "normal") == _DEFAULT_MULTIMODAL assert resolve_model(table, "multimodal") == _DEFAULT_MULTIMODAL assert resolve_model(table, "hard") == _DEFAULT_PRIMARY assert resolve_model(table, "main") == _DEFAULT_PRIMARY # A non-tier custom alias is left untouched (no synonym expansion). - assert "fast" in table.aliases and table.aliases["fast"] == _DEFAULT_MINOR + assert "fast" in table.aliases and table.aliases["fast"] == _DEFAULT_HAND def test_legacy_keyed_override_expands_to_canonical_synonym() -> None: diff --git a/tests/test_init_shape.py b/tests/test_init_shape.py index 798c8a3..90267c5 100644 --- a/tests/test_init_shape.py +++ b/tests/test_init_shape.py @@ -307,7 +307,14 @@ def test_shape_dry_run_json_reports_shape_and_hosts(tmp_path, monkeypatch, capsy assert rc == 0 payload = json.loads(capsys.readouterr().out) assert payload["shape"] == "spark-lobe" - assert set(payload["shape_hosts"]) == {"cortex", "embedder", "reranker", "stt", "tts"} + assert set(payload["shape_hosts"]) == { + "cortex", + "hand", + "embedder", + "reranker", + "stt", + "tts", + } assert not target.exists() diff --git a/tests/test_minor_governance.py b/tests/test_minor_governance.py index 3117ca3..23b0753 100644 --- a/tests/test_minor_governance.py +++ b/tests/test_minor_governance.py @@ -29,7 +29,9 @@ def test_role_constant_is_minor(): """ROLE must be the string 'minor', not a model identifier.""" - assert ROLE == "minor" + # `hand` — the role that took over the cheap tier. `minor`/`cheap` survive + # as back-compat TIER spellings (catalog.TIER_ROLE) but name no role. + assert ROLE == "hand" def test_governance_module_has_no_model_id(): diff --git a/tests/test_minor_integration.py b/tests/test_minor_integration.py index ee56570..cb66475 100644 --- a/tests/test_minor_integration.py +++ b/tests/test_minor_integration.py @@ -87,16 +87,34 @@ def _parse(argv: list[str]): # --------------------------------------------------------------------------- -def test_catalog_has_minor_gear_qwen3_5_4b() -> None: - """The catalog has at least one role_hint='minor' entry with id='Qwen/Qwen3.5-4B'.""" - minor_models = [m for m in supported_models() if m.role_hint == "minor"] - assert minor_models, "catalog must have at least one role_hint='minor' entry" - ids = [m.id for m in minor_models] - assert "Qwen/Qwen3.5-4B" in ids, f"expected 'Qwen/Qwen3.5-4B' in minor-role models; got {ids}" +def test_catalog_has_cheap_tier_gear_with_the_hand_role_hint() -> None: + """The cheap tier's gear is the `hand` lobe, LiquidAI/LFM2.5-1.2B-Instruct. + This asserted ``role_hint='minor'`` / ``Qwen/Qwen3.5-4B`` until the hand lobe + replaced the 4B in that slot. The 4B is still in the catalog (see + ``test_demoted_4b_is_kept_as_a_candidate`` below) — it is just no longer the + gear any tier resolves to. + """ + hand_models = [m for m in supported_models() if m.role_hint == "hand"] + assert hand_models, "catalog must have at least one role_hint='hand' entry" + ids = [m.id for m in hand_models] + assert "LiquidAI/LFM2.5-1.2B-Instruct" in ids, f"expected the hand gear; got {ids}" -def test_infer_parser_minor_gear_is_qwen3_coder() -> None: - """infer_parser for 'Qwen/Qwen3.5-4B' resolves to 'qwen3_coder'.""" + +def test_demoted_4b_is_kept_as_a_candidate() -> None: + """cite-don't-delete: the 4B survives the repoint as a plain candidate.""" + four_b = next((m for m in supported_models() if m.id == "Qwen/Qwen3.5-4B"), None) + assert four_b is not None, "Qwen/Qwen3.5-4B was deleted rather than demoted" + assert four_b.role_hint == "candidate" + + +def test_infer_parser_cheap_tier_gear_is_lfm2() -> None: + """infer_parser for the hand gear resolves to the purpose-built 'lfm2' parser.""" + assert infer_parser("LiquidAI/LFM2.5-1.2B-Instruct") == "lfm2" + + +def test_infer_parser_demoted_4b_is_still_qwen3_coder() -> None: + """The demoted 4B keeps its own parser — demotion changes the tier, not the model.""" assert infer_parser("Qwen/Qwen3.5-4B") == "qwen3_coder" diff --git a/tests/test_parser.py b/tests/test_parser.py index b4a0c67..6a975f1 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -55,6 +55,18 @@ ("coolthor/gemma-4-12B-it-NVFP4A16", "gemma4"), ("some/gemma-4-27b-it", "gemma4"), ("some/gemma4-9b", "gemma4"), + # LiquidAI LFM2 / LFM2.5 (the `hand` lobe) → the purpose-built "lfm2" + # parser (Lfm2ToolParser). Its delimiters + # <|tool_call_start|>/<|tool_call_end|> are special tokens — the same + # trap that disproved "pythonic" for Gemma 4 above. + ("LiquidAI/LFM2.5-1.2B-Instruct", "lfm2"), + ("LiquidAI/LFM2-1.2B", "lfm2"), + ("some/lfm2.5-1.2b-legal-lora", "lfm2"), + ("some/lfm-2-350m", "lfm2"), + # Unrelated ids containing neither marker stay unknown — "liquid" alone + # must not match, or an unrelated checkpoint gets a parser it can't use. + ("LiquidAI/LFM1-7B", None), + ("some/liquid-neural-net", None), ], ) def test_infer_parser(model, expected) -> None: diff --git a/tests/test_peer_referral.py b/tests/test_peer_referral.py index b97f3b4..11958a7 100644 --- a/tests/test_peer_referral.py +++ b/tests/test_peer_referral.py @@ -38,7 +38,12 @@ from lobes.cli import main from lobes.gateway import server as S -from lobes.gateway._config import FEASIBLE_ENV, PEER_ORIGIN_ENV, build_config +from lobes.gateway._config import ( + FEASIBLE_ENV, + NEVER_PROXIED_BACKENDS, + PEER_ORIGIN_ENV, + build_config, +) from lobes.gateway._routing import list_models_payload from lobes.roles import ROLES, annotate_peer_referrals, build_role_registry from lobes.runtime import _compose, _env @@ -119,7 +124,9 @@ def _post(table, cfg, model: str, path: str = "/v1/chat/completions"): def test_peer_origin_env_mirrors_feasible_env_prefixes() -> None: # One "_" convention to learn: the peer-origin channel names # exactly the backends the feasibility channel names. - assert set(PEER_ORIGIN_ENV) == set(FEASIBLE_ENV) + # Minus the never-proxied set: `hand` runs on every box, so it has no peer + # channel at all (lobes.gateway._config.NEVER_PROXIED_BACKENDS). + assert set(PEER_ORIGIN_ENV) == set(FEASIBLE_ENV) - NEVER_PROXIED_BACKENDS assert PEER_ORIGIN_ENV["multimodal"] == "MULTIMODAL_PEER_ORIGIN" assert PEER_ORIGIN_ENV["primary"] == "PRIMARY_PEER_ORIGIN" diff --git a/tests/test_pressure_policy.py b/tests/test_pressure_policy.py index 514fbee..3880b33 100644 --- a/tests/test_pressure_policy.py +++ b/tests/test_pressure_policy.py @@ -96,8 +96,8 @@ def test_minor_request_stays_minor(self): r = _decide(0.0, 0.0, "minor") assert r["mode"] == "warm" assert r["shed"] is False - assert r["servable_tier"] == "minor" - assert r["requested_tier"] == "minor" + assert r["servable_tier"] == "hand" + assert r["requested_tier"] == "hand" assert r["reason"] == "default" def test_mild_pressure_below_degraded_does_not_shed(self): @@ -121,7 +121,7 @@ def test_main_shed_under_swap_pressure(self): r = _decide(80.0, 5.0, "main") assert r["mode"] == "busy" assert r["shed"] is True - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" assert r["requested_tier"] == "main" assert r["reason"] == "pressure" @@ -130,7 +130,7 @@ def test_multimodal_shed_under_swap_pressure(self): r = _decide(80.0, 5.0, "multimodal") assert r["mode"] == "busy" assert r["shed"] is True - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" assert r["requested_tier"] == "multimodal" assert r["reason"] == "pressure" @@ -139,8 +139,8 @@ def test_minor_never_shed_under_swap_pressure(self): r = _decide(80.0, 5.0, "minor") assert r["mode"] == "busy" assert r["shed"] is False - assert r["servable_tier"] == "minor" - assert r["requested_tier"] == "minor" + assert r["servable_tier"] == "hand" + assert r["requested_tier"] == "hand" assert r["reason"] == "default" @@ -156,7 +156,7 @@ def test_main_shed_under_iowait_pressure(self): r = _decide(5.0, 60.0, "main") assert r["mode"] == "busy" assert r["shed"] is True - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" assert r["requested_tier"] == "main" assert r["reason"] == "pressure" @@ -164,7 +164,7 @@ def test_multimodal_shed_under_iowait_pressure(self): r = _decide(5.0, 60.0, "multimodal") assert r["mode"] == "busy" assert r["shed"] is True - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" assert r["requested_tier"] == "multimodal" assert r["reason"] == "pressure" @@ -172,7 +172,7 @@ def test_minor_never_shed_under_iowait_pressure(self): r = _decide(5.0, 60.0, "minor") assert r["mode"] == "busy" assert r["shed"] is False - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" assert r["reason"] == "default" @@ -199,15 +199,15 @@ def test_normal_normalizes_to_multimodal_no_pressure(self): def test_cheap_normalizes_to_minor_no_pressure(self): r = _decide(0.0, 0.0, "cheap") - assert r["servable_tier"] == "minor" - assert r["requested_tier"] == "minor" + assert r["servable_tier"] == "hand" + assert r["requested_tier"] == "hand" assert r["reason"] == "default" def test_hard_shed_under_pressure(self): r = _decide(80.0, 0.0, "hard") assert r["mode"] == "busy" assert r["shed"] is True - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" assert r["requested_tier"] == "main" assert r["reason"] == "pressure" @@ -215,7 +215,7 @@ def test_normal_shed_under_pressure(self): r = _decide(80.0, 0.0, "normal") assert r["mode"] == "busy" assert r["shed"] is True - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" assert r["requested_tier"] == "multimodal" assert r["reason"] == "pressure" @@ -246,7 +246,7 @@ def test_cortex_shed_under_pressure(self): r = _decide(80.0, 0.0, "cortex") assert r["mode"] == "busy" assert r["shed"] is True - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" assert r["requested_tier"] == "main" assert r["reason"] == "pressure" @@ -256,7 +256,7 @@ def test_senses_shed_under_pressure(self): r = _decide(80.0, 0.0, "senses") assert r["mode"] == "busy" assert r["shed"] is True - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" assert r["requested_tier"] == "multimodal" assert r["reason"] == "pressure" @@ -264,7 +264,7 @@ def test_senses_shed_via_iowait(self): r = _decide(0.0, 60.0, "senses") assert r["mode"] == "busy" assert r["shed"] is True - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" assert r["reason"] == "pressure" @@ -291,7 +291,7 @@ def test_worker_shed_under_swap_pressure(self): r = _decide(80.0, 5.0, "worker") assert r["mode"] == "busy" assert r["shed"] is True - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" assert r["requested_tier"] == "worker" assert r["reason"] == "pressure" @@ -299,7 +299,7 @@ def test_worker_shed_under_iowait_pressure(self): r = _decide(5.0, 60.0, "worker") assert r["mode"] == "busy" assert r["shed"] is True - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" assert r["requested_tier"] == "worker" assert r["reason"] == "pressure" @@ -354,7 +354,7 @@ def test_swap_just_above_threshold_triggers_busy(self): r = _decide(SWAP_DEGRADED_THRESHOLD + 0.001, 0.0, "main") assert r["mode"] == "busy" assert r["shed"] is True - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" def test_iowait_exactly_at_threshold_not_busy(self): """iowait == 50 → NOT busy (50 > 50 is False) → warm.""" @@ -368,7 +368,7 @@ def test_iowait_just_above_threshold_triggers_busy(self): r = _decide(0.0, IOWAIT_DEGRADED_THRESHOLD + 0.001, "main") assert r["mode"] == "busy" assert r["shed"] is True - assert r["servable_tier"] == "minor" + assert r["servable_tier"] == "hand" # --------------------------------------------------------------------------- @@ -427,8 +427,11 @@ def test_reason_values_valid(self, tier: str): def test_tier_values_in_new_vocab(self, tier: str): """servable_tier / requested_tier are always emitted in the new vocab.""" r = decide(swap_used_percent=20.0, iowait_percent=10.0, requested_tier=tier) - assert r["servable_tier"] in ("main", "minor", "multimodal") - assert r["requested_tier"] in ("main", "minor", "multimodal") + # The OUTPUT vocabulary is the canonical role-derived tier names + # (_ROLE_TO_TIER). `minor`/`cheap` are accepted INPUTS that normalize to + # `hand` — the floor's role name — so they never appear on the way out. + assert r["servable_tier"] in ("main", "hand", "multimodal") + assert r["requested_tier"] in ("main", "hand", "multimodal") @pytest.mark.parametrize("tier", _ALL_TIERS) def test_shed_is_bool(self, tier: str): diff --git a/tests/test_profile_render.py b/tests/test_profile_render.py index 3a6c32b..9afd6b4 100644 --- a/tests/test_profile_render.py +++ b/tests/test_profile_render.py @@ -22,6 +22,7 @@ def test_role_env_prefix_covers_all_six_roles() -> None: "senses": "MULTIMODAL", "muse": "MUSE", "worker": "WORKER", + "hand": "HAND", "embedder": "EMBED", "reranker": "RERANK", } diff --git a/tests/test_profile_schema.py b/tests/test_profile_schema.py index f639881..67e6369 100644 --- a/tests/test_profile_schema.py +++ b/tests/test_profile_schema.py @@ -27,7 +27,7 @@ def test_roles_and_knob_names_are_the_expected_vocabulary() -> None: - assert ROLES == ("cortex", "senses", "muse", "worker", "embedder", "reranker") + assert ROLES == ("cortex", "senses", "muse", "worker", "hand", "embedder", "reranker") assert set(KNOB_NAMES) == { "gpu_mem_util", "max_model_len", diff --git a/tests/test_proxy_integration.py b/tests/test_proxy_integration.py index 7a5acac..dbeb75a 100644 --- a/tests/test_proxy_integration.py +++ b/tests/test_proxy_integration.py @@ -786,6 +786,41 @@ def test_sse_stream_relays_incrementally_across_both_hops(world) -> None: "ready": False, "loaded": False, }, + "hand": { + "role": "hand", + # The ninth Colleague role. DEFAULT-HOSTED, so unlike muse/worker above + # it is NOT in OPT_IN_BACKENDS — a no-knob deployment that has not been + # re-inited reads it as the SLEEPING LOBE: feasible true (this card can + # obviously serve 2.4 GiB of bf16), ready/loaded false (the lane is not + # actually up). Advertising it ready would be the #92 defect; declaring + # it infeasible would be the opposite lie. + "model": "LiquidAI/LFM2.5-1.2B-Instruct", + "runtime": "vllm", + "endpoint": _GOLDEN_ORIGIN, + "path": "/v1/chat/completions", + "context": 32768, + # bf16 — the catalog's "none" sentinel, meaning the lane omits + # --quantization entirely rather than passing it empty. + "quant": "none", + "mtp": False, + "tools": True, + "responsibilities": [ + "domain_mastery", + "learned_skill", + "specialized_task", + "tool_use", + ], + # v1 withholds repo_action: adding a responsibility later is + # contract-compatible, removing one is a break (issue #180). + "forbidden_responsibilities": [ + "final_decision", + "repo_action", + "security_decision", + ], + "feasible": True, + "ready": False, + "loaded": False, + }, "embedder": { "role": "embedder", "model": _EMBED_ID, diff --git a/tests/test_roles.py b/tests/test_roles.py index cc2661a..3e7c4dc 100644 --- a/tests/test_roles.py +++ b/tests/test_roles.py @@ -1,12 +1,13 @@ """Tests for the role registry + capability-metadata core (issue #81, task t4). -``lobes.roles`` defines the EIGHT first-class Colleague-facing roles and +``lobes.roles`` defines the NINE first-class Colleague-facing roles and resolves each to live metadata from the gateway config + the catalog: cortex → primary generate backend (Qwen 27B MTP) — reasoning/authority senses → multimodal generate backend (Gemma 4 12B) — intake/perception muse → muse generate backend (Gemma 4 31B) — creative/ideation worker → worker generate backend (Qwen3.6-35B-A3B) — fast ground-work + hand → hand generate backend (LFM2.5-1.2B) — fine-tuning base embedder → embed pooling backend (Qwen3-Embedding-0.6B, /v1/embeddings) reranker → score/rerank backend (Qwen3-Reranker-0.6B, /v1/rerank) stt → Parakeet audio sidecar (/v1/audio/transcriptions) — opt-in @@ -19,6 +20,9 @@ from __future__ import annotations +import pytest + +from lobes import roles as roles_mod from lobes.catalog import SUPPORTED_MODELS, SupportedModel from lobes.gateway._config import build_config from lobes.roles import ( @@ -44,7 +48,17 @@ _EMBED_ID = "Qwen/Qwen3-Embedding-0.6B" _RERANK_ID = "Qwen/Qwen3-Reranker-0.6B" -_EXPECTED_ROLES = {"cortex", "senses", "muse", "worker", "embedder", "reranker", "stt", "tts"} +_EXPECTED_ROLES = { + "cortex", + "senses", + "muse", + "worker", + "hand", + "embedder", + "reranker", + "stt", + "tts", +} _MUSE_ID = "nvidia/Gemma-4-31B-IT-NVFP4" _WORKER_ID = "unsloth/Qwen3.6-35B-A3B-NVFP4" @@ -104,14 +118,14 @@ def _registry(env: dict[str, str], *, audio_ready: bool | None = None, **kw) -> # --------------------------------------------------------------------------- -# Acceptance 1 — exactly the eight roles, each with the full metadata block +# Acceptance 1 — exactly the nine roles, each with the full metadata block # --------------------------------------------------------------------------- -def test_registry_returns_exactly_the_eight_roles() -> None: +def test_registry_returns_exactly_the_nine_roles() -> None: registry = _registry(_full_env()) assert set(registry) == _EXPECTED_ROLES - assert len(registry) == 8 + assert len(registry) == 9 assert set(ROLES) == _EXPECTED_ROLES @@ -367,7 +381,7 @@ def test_senses_has_tools_but_not_the_tool_use_responsibility() -> None: assert "tool_use" not in senses.responsibilities -def test_static_responsibility_maps_cover_all_eight_roles() -> None: +def test_static_responsibility_maps_cover_all_nine_roles() -> None: assert set(ROLE_RESPONSIBILITIES) == _EXPECTED_ROLES assert set(ROLE_FORBIDDEN) == _EXPECTED_ROLES assert ROLE_RESPONSIBILITIES["muse"] == ( @@ -640,6 +654,7 @@ def test_served_context_env_map_covers_only_gateway_fronted_roles() -> None: "senses", "muse", "worker", + "hand", "embedder", "reranker", } @@ -852,6 +867,7 @@ def test_role_backend_keys_match_backend_ready_vocabulary() -> None: "multimodal", "muse", "worker", + "hand", "embed", "rerank", "stt", @@ -861,15 +877,106 @@ def test_role_backend_keys_match_backend_ready_vocabulary() -> None: # --------------------------------------------------------------------------- -# All eight roles expose the identical key set +# All nine roles expose the identical key set # --------------------------------------------------------------------------- -def test_all_eight_roles_expose_identical_key_set() -> None: +def test_all_nine_roles_expose_identical_key_set() -> None: """Every role's asdict keys are identical — no role has extra or missing fields.""" import dataclasses registry = _registry(_full_env()) first_keys = set(dataclasses.asdict(registry["cortex"]).keys()) - for name in ("senses", "muse", "worker", "embedder", "reranker", "stt", "tts"): + for name in ("senses", "muse", "worker", "hand", "embedder", "reranker", "stt", "tts"): assert set(dataclasses.asdict(registry[name]).keys()) == first_keys + + +# --------------------------------------------------------------------------- +# The per-role table completeness guard (hand-lobe plan t2) +# --------------------------------------------------------------------------- +# `hand` landed as the ninth role by adding an entry to six separate per-role +# tables. Nothing structural forced all six — a role added to ROLES but missed +# in one table fails only wherever that table is indexed, which is exactly how +# `worker` shipped half-wired in 0.54.6 (present in _config.py's peer dicts, +# absent from server.py's, so its proxy went silently inert). These tests +# iterate ROLES so a TENTH role cannot half-land the same way. + +# Tables keyed by EVERY role in ROLES. +_ALL_ROLE_TABLES = { + "ROLE_BACKEND": roles_mod.ROLE_BACKEND, + "ROLE_PATH": roles_mod.ROLE_PATH, + "ROLE_RESPONSIBILITIES": roles_mod.ROLE_RESPONSIBILITIES, + "ROLE_FORBIDDEN": roles_mod.ROLE_FORBIDDEN, +} + +# Tables scoped to the gateway-fronted (non-audio) roles only: stt/tts are +# path-routed sidecars with no catalog entry and no token context, so their +# absence here is by design, not an omission. +_GATEWAY_ROLE_TABLES = { + "ROLE_ROLE_HINT": roles_mod.ROLE_ROLE_HINT, + "ROLE_MAX_MODEL_LEN_ENV": roles_mod.ROLE_MAX_MODEL_LEN_ENV, +} + +_AUDIO = ("stt", "tts") + + +@pytest.mark.parametrize("table_name", sorted(_ALL_ROLE_TABLES)) +def test_every_role_has_an_entry_in_every_all_role_table(table_name: str) -> None: + table = _ALL_ROLE_TABLES[table_name] + missing = [role for role in roles_mod.ROLES if role not in table] + assert not missing, f"{table_name} is missing: {missing}" + extra = [key for key in table if key not in roles_mod.ROLES] + assert not extra, f"{table_name} has entries for non-roles: {extra}" + + +@pytest.mark.parametrize("table_name", sorted(_GATEWAY_ROLE_TABLES)) +def test_every_gateway_role_has_an_entry_in_every_gateway_role_table(table_name: str) -> None: + table = _GATEWAY_ROLE_TABLES[table_name] + expected = [role for role in roles_mod.ROLES if role not in _AUDIO] + missing = [role for role in expected if role not in table] + assert not missing, f"{table_name} is missing: {missing}" + extra = [key for key in table if key not in expected] + assert not extra, f"{table_name} has entries for non-roles: {extra}" + + +def test_roles_has_nine_entries_including_hand() -> None: + assert len(roles_mod.ROLES) == 9 + assert "hand" in roles_mod.ROLES + + +def test_hand_responsibilities_and_forbidden_match_the_v1_contract() -> None: + assert roles_mod.ROLE_RESPONSIBILITIES["hand"] == ( + "domain_mastery", + "learned_skill", + "specialized_task", + "tool_use", + ) + # v1 withholds repo_action: ADDING a responsibility later is + # contract-compatible, REMOVING one is a break (issue #180 tracks granting + # it once adapters exist). + assert roles_mod.ROLE_FORBIDDEN["hand"] == ( + "final_decision", + "repo_action", + "security_decision", + ) + + +def test_hand_advertises_no_vision_tokens() -> None: + """LFM2.5-1.2B-Instruct is text-only — the vision variant is a different arch.""" + responsibilities = roles_mod.ROLE_RESPONSIBILITIES["hand"] + assert "image_understanding" not in responsibilities + assert "video_understanding" not in responsibilities + + +def test_measure_family_map_covers_every_role() -> None: + """Guards the documented KeyError class at roles_measure.py's family lookup. + + A role in ROLES but absent from _FAMILY_BY_ROLE raises KeyError the moment + `lobes measure` reaches it — a crash, not a degraded reading. + """ + from lobes import roles_measure + + missing = [role for role in roles_mod.ROLES if role not in roles_measure._FAMILY_BY_ROLE] + assert not missing, f"_FAMILY_BY_ROLE is missing: {missing}" + assert roles_measure._FAMILY_BY_ROLE["hand"] == "llm" + assert "hand" in roles_measure._LLM_ROLES diff --git a/tests/test_shape_contract_matrix.py b/tests/test_shape_contract_matrix.py index 377f980..1d0ff98 100644 --- a/tests/test_shape_contract_matrix.py +++ b/tests/test_shape_contract_matrix.py @@ -89,6 +89,7 @@ "senses": "coolthor/gemma-4-12B-it-NVFP4A16", "muse": "nvidia/Gemma-4-31B-IT-NVFP4", "worker": "unsloth/Qwen3.6-35B-A3B-NVFP4", + "hand": "LiquidAI/LFM2.5-1.2B-Instruct", "embedder": "Qwen/Qwen3-Embedding-0.6B", "reranker": "Qwen/Qwen3-Reranker-0.6B", } @@ -102,6 +103,7 @@ "senses": ("MULTIMODAL_BASE_URL", "MULTIMODAL_SERVED_NAME"), "muse": ("MUSE_BASE_URL", "MUSE_SERVED_NAME"), "worker": ("WORKER_BASE_URL", "WORKER_SERVED_NAME"), + "hand": ("HAND_BASE_URL", "HAND_SERVED_NAME"), "embedder": ("EMBED_URL", "EMBED_SERVED_NAME"), "reranker": ("RERANK_URL", "RERANK_SERVED_NAME"), } @@ -398,8 +400,8 @@ def test_cell_concrete_model_id_is_never_served_4xx_without_a_dial( @pytest.mark.parametrize("shape_name", sorted({shape for shape, _ in CELLS})) def test_hosted_generate_lane_still_routes_on_every_mesh_shape(shape_name: str) -> None: """Whatever generate gear a shape hosts still answers: cortex on spark-lobe, - senses on thor-lobe, and the opt-in 4B minor on orin-small (both tier - vocabularies).""" + senses on thor-lobe, and `hand` — which EVERY shape hosts — under all three + of its tier spellings.""" shape = resolve_shape(shape_name) table, cfg = build_config(_gateway_env(shape)) expected: list[tuple[str, str]] = [] @@ -415,8 +417,16 @@ def test_hosted_generate_lane_still_routes_on_every_mesh_shape(shape_name: str) expected += [("muse", "muse")] if shape.hosts_role("worker"): expected += [("worker", "worker")] - if shape.hosts_role("minor"): - expected += [("minor", "minor"), ("cheap", "minor")] + if shape.hosts_role("hand"): + # All three spellings land on the `hand` backend: `hand` is the role + # name, `minor`/`cheap` are the back-compat tier names it inherited when + # it replaced Qwen3.5-4B in that slot. + expected += [("hand", "hand"), ("minor", "hand"), ("cheap", "hand")] + # NOTE: `shape.hosts_role("minor")` is deliberately NOT checked here. The + # opt-in `minor` GEAR (the 4B, hosted by orin-small) is no longer what the + # `minor` TIER resolves to — that alias belongs to `hand` now. The 4B gear + # remains addressable by its explicit model id, exactly like the legacy 14B + # `middle` gear, so there is no alias of its own left to assert. assert expected, f"{shape_name} hosts no generate lane at all?" for alias, backend in expected: resp, calls = _post(table, cfg, alias) diff --git a/tests/test_shapes.py b/tests/test_shapes.py index 893b2bb..48d9327 100644 --- a/tests/test_shapes.py +++ b/tests/test_shapes.py @@ -100,6 +100,7 @@ def test_colleague_roles_is_profile_roles_plus_audio_roles() -> None: "senses", "muse", "worker", + "hand", "embedder", "reranker", "stt", @@ -118,7 +119,18 @@ def test_default_hosted_roles_is_colleague_roles_minus_opt_in_core() -> None: assert DEFAULT_HOSTED_ROLES == tuple( role for role in COLLEAGUE_ROLES if role not in OPT_IN_CORE_ROLES ) - assert DEFAULT_HOSTED_ROLES == ("cortex", "senses", "embedder", "reranker", "stt", "tts") + # NOTE `hand` IS here — it is the first DEFAULT-HOSTED cheap role. Before + # it, this set happened to coincide with "the expensive lobes plus the + # gears"; it no longer does. See shapes.py's own re-baseline note. + assert DEFAULT_HOSTED_ROLES == ( + "cortex", + "senses", + "hand", + "embedder", + "reranker", + "stt", + "tts", + ) def test_shape_roles_is_colleague_roles_plus_opt_in_roles() -> None: @@ -132,6 +144,7 @@ def test_shape_roles_is_colleague_roles_plus_opt_in_roles() -> None: "senses", "muse", "worker", + "hand", "embedder", "reranker", "stt", @@ -280,14 +293,14 @@ def test_machine_as_brain_carries_no_overrides() -> None: def test_spark_lobe_hosts_cortex_embedder_reranker_and_audio_no_senses() -> None: spark_lobe = load_builtin_shape("spark-lobe") assert spark_lobe is not None - assert set(spark_lobe.hosts) == {"cortex", "embedder", "reranker", "stt", "tts"} + assert set(spark_lobe.hosts) == {"cortex", "hand", "embedder", "reranker", "stt", "tts"} assert "senses" not in spark_lobe.hosts def test_thor_lobe_hosts_senses_embedder_reranker_and_audio_no_cortex() -> None: thor_lobe = load_builtin_shape("thor-lobe") assert thor_lobe is not None - assert set(thor_lobe.hosts) == {"senses", "embedder", "reranker", "stt", "tts"} + assert set(thor_lobe.hosts) == {"senses", "hand", "embedder", "reranker", "stt", "tts"} assert "cortex" not in thor_lobe.hosts @@ -297,7 +310,7 @@ def test_thor_muse_hosts_muse_pooling_and_audio_no_default_heavy_lobe() -> None: # senses to a peer box, e.g. an Orin — declared via MULTIMODAL_PEER_*). thor_muse = load_builtin_shape("thor-muse") assert thor_muse is not None - assert set(thor_muse.hosts) == {"muse", "embedder", "reranker", "stt", "tts"} + assert set(thor_muse.hosts) == {"muse", "hand", "embedder", "reranker", "stt", "tts"} assert "cortex" not in thor_muse.hosts assert "senses" not in thor_muse.hosts # The FULL muse declaration lives in the shape's overrides (the card @@ -316,7 +329,7 @@ def test_orin_small_hosts_minor_embedder_reranker_and_audio_no_heavy_lobe() -> N # are both absent -- and hosts the opt-in `minor` gear instead. orin_small = load_builtin_shape("orin-small") assert orin_small is not None - assert set(orin_small.hosts) == {"minor", "embedder", "reranker", "stt", "tts"} + assert set(orin_small.hosts) == {"minor", "hand", "embedder", "reranker", "stt", "tts"} assert "cortex" not in orin_small.hosts assert "senses" not in orin_small.hosts @@ -330,7 +343,7 @@ def test_orin_lobe_hosts_senses_and_pooling_but_no_cortex_and_no_audio() -> None # forwarded to a peer via the operator-declared AUDIO_URL instead. orin_lobe = load_builtin_shape("orin-lobe") assert orin_lobe is not None - assert set(orin_lobe.hosts) == {"senses", "embedder", "reranker"} + assert set(orin_lobe.hosts) == {"senses", "hand", "embedder", "reranker"} assert "cortex" not in orin_lobe.hosts for audio_role in AUDIO_ROLES: assert audio_role not in orin_lobe.hosts diff --git a/tests/test_smoke_duo.py b/tests/test_smoke_duo.py index 6d465f9..34a7c88 100644 --- a/tests/test_smoke_duo.py +++ b/tests/test_smoke_duo.py @@ -46,7 +46,7 @@ from lobes.assess import _trace_field from lobes.catalog import SUPPORTED_MODELS, resolve_tier from lobes.gateway._config import ( - _DEFAULT_MINOR, + _DEFAULT_HAND, _DEFAULT_MULTIMODAL, _DEFAULT_PRIMARY, build_config, @@ -66,7 +66,8 @@ # (coolthor/…); the coder fine-tune (sakamakismile/…) is kept but demoted to a # candidate — see tests/test_catalog.py for the dedicated coder coverage. _GEMMA_ID = "coolthor/gemma-4-12B-it-NVFP4A16" -_MINOR_ID = "Qwen/Qwen3.5-4B" +# The gear the cheap tier resolves to since the hand lobe replaced the 4B. +_HAND_ID = "LiquidAI/LFM2.5-1.2B-Instruct" _PRIMARY_ID = "unsloth/Qwen3.6-27B-NVFP4" # Tiny 1×1 RGB PNG (valid PNG, useful as a minimal image payload). Used only by @@ -129,7 +130,7 @@ def _build_tiny_wav_b64() -> str: # A three-tier fleet env (primary always wired; minor + multimodal explicit). _FULL_FLEET_ENV = { - "MINOR_BASE_URL": "http://vllm-minor:8000", + "HAND_BASE_URL": "http://vllm-hand:8000", "MULTIMODAL_BASE_URL": "http://vllm-multimodal:8000", } @@ -158,21 +159,22 @@ def test_multimodal_resolves_to_gemma_via_gateway(self) -> None: table, _ = build_config({"MULTIMODAL_BASE_URL": "http://vllm-multimodal:8000"}) assert resolve_model(table, "multimodal") == _DEFAULT_MULTIMODAL - def test_minor_resolves_to_4b_via_gateway(self) -> None: - # model=minor must route to the Qwen 4B served name when wired. - table, _ = build_config({"MINOR_BASE_URL": "http://vllm-minor:8000"}) - assert resolve_model(table, "minor") == _DEFAULT_MINOR + def test_minor_resolves_to_the_hand_gear_via_gateway(self) -> None: + # model=minor routes to the `hand` served name — the hand lobe replaced + # the Qwen 4B in this tier; `minor` is a back-compat spelling for it. + table, _ = build_config({"HAND_BASE_URL": "http://vllm-hand:8000"}) + assert resolve_model(table, "minor") == _DEFAULT_HAND def test_full_fleet_three_tier_aliases_all_resolve(self) -> None: # With both optional backends wired, all six tier aliases resolve correctly. table = _build_full_fleet() assert resolve_model(table, "main") == _DEFAULT_PRIMARY assert resolve_model(table, "multimodal") == _DEFAULT_MULTIMODAL - assert resolve_model(table, "minor") == _DEFAULT_MINOR + assert resolve_model(table, "minor") == _DEFAULT_HAND # Back-compat aliases agree. assert resolve_model(table, "hard") == _DEFAULT_PRIMARY assert resolve_model(table, "normal") == _DEFAULT_MULTIMODAL - assert resolve_model(table, "cheap") == _DEFAULT_MINOR + assert resolve_model(table, "cheap") == _DEFAULT_HAND def test_default_primary_constant_matches_catalog(self) -> None: # The gateway's _DEFAULT_PRIMARY constant must agree with the catalog @@ -184,9 +186,9 @@ def test_default_multimodal_constant_matches_catalog(self) -> None: gemma = resolve_tier("multimodal") assert gemma.id == _DEFAULT_MULTIMODAL - def test_default_minor_constant_matches_catalog(self) -> None: - minor = resolve_tier("minor") - assert minor.id == _DEFAULT_MINOR + def test_default_cheap_tier_constant_matches_catalog(self) -> None: + hand = resolve_tier("minor") + assert hand.id == _DEFAULT_HAND class TestCatalogTierResolution: @@ -204,11 +206,11 @@ def test_multimodal_resolve_tier_returns_gemma(self) -> None: assert m.task == "generate" assert m.id == _GEMMA_ID - def test_minor_resolve_tier_returns_4b(self) -> None: + def test_minor_resolve_tier_returns_the_hand_gear(self) -> None: m = resolve_tier("minor") - assert m.role_hint == "minor" + assert m.role_hint == "hand" assert m.task == "generate" - assert m.id == _MINOR_ID + assert m.id == _HAND_ID class TestLegacy14BProfileSelectability: diff --git a/tests/test_status_pressure.py b/tests/test_status_pressure.py index 3e4686b..c3ecdd0 100644 --- a/tests/test_status_pressure.py +++ b/tests/test_status_pressure.py @@ -33,7 +33,10 @@ # Tier model IDs — mirrors test_catalog_tiers.py constants so that any catalog # change that renames an ID also breaks *this* test (intentional coupling). -_MINOR_ID = "Qwen/Qwen3.5-4B" # minor tier (the servable floor under pressure) +# The cheap tier's gear — the servable floor under pressure. The `hand` lobe +# replaced Qwen/Qwen3.5-4B here; `minor`/`cheap` remain accepted request +# spellings but the tier RESOLVES to this model and REPORTS itself as "hand". +_HAND_ID = "LiquidAI/LFM2.5-1.2B-Instruct" _PRIMARY_ID = "unsloth/Qwen3.6-27B-NVFP4" # main tier (full) _KEYS = {"mode", "shed", "servable_tier", "model", "reason", "retry_after", "pressure"} @@ -67,8 +70,8 @@ def test_status_pressure_json_high_swap_busy(capsys, monkeypatch) -> None: assert set(payload.keys()) == _KEYS assert payload["mode"] == "busy" assert payload["shed"] is True - assert payload["servable_tier"] == "minor" - assert payload["model"] == _MINOR_ID + assert payload["servable_tier"] == "hand" + assert payload["model"] == _HAND_ID assert payload["reason"] == "pressure" assert payload["retry_after"] == BUSY_RETRY_AFTER_SECONDS assert payload["pressure"] == {"swap_used_percent": 80.0, "iowait_percent": 5.0} @@ -132,8 +135,8 @@ def test_status_pressure_json_high_iowait_busy(capsys, monkeypatch) -> None: payload = json.loads(capsys.readouterr().out) assert payload["mode"] == "busy" assert payload["shed"] is True - assert payload["servable_tier"] == "minor" - assert payload["model"] == _MINOR_ID + assert payload["servable_tier"] == "hand" + assert payload["model"] == _HAND_ID assert payload["reason"] == "pressure" assert payload["retry_after"] == BUSY_RETRY_AFTER_SECONDS @@ -171,7 +174,7 @@ def test_status_pressure_text_output_busy_shows_shed(capsys, monkeypatch) -> Non out = capsys.readouterr().out assert "busy" in out assert "429" in out # the shed line names the busy status - assert "minor" in out + assert "hand" in out assert "pressure" in out diff --git a/tests/test_tier_request.py b/tests/test_tier_request.py index 035dfba..bd5c346 100644 --- a/tests/test_tier_request.py +++ b/tests/test_tier_request.py @@ -40,8 +40,12 @@ def _full_fleet(): table, _ = build_config( { "PRIMARY_SERVED_NAME": "PRIMARY", - "MINOR_BASE_URL": "http://vllm-minor:8000", - "MINOR_SERVED_NAME": "MINOR", + # The cheap tier is the `hand` backend now — `minor`/`cheap` are + # back-compat spellings that resolve to it. Wiring only a `minor` + # backend would leave the tier UNWIRED and upward-fall-back to + # multimodal, which is what this fixture is here to rule out. + "HAND_BASE_URL": "http://vllm-hand:8000", + "HAND_SERVED_NAME": "HAND", "MULTIMODAL_BASE_URL": "http://vllm-multimodal:8000", "MULTIMODAL_SERVED_NAME": "MULTIMODAL", } @@ -54,7 +58,7 @@ def _primary_only(): return table -# Primary + multimodal wired, but MINOR is UNWIRED. +# Primary + multimodal wired, but the cheap-tier (HAND) backend is UNWIRED. # This is the DEFAULT fleet shape (no minor profile activated). # Regression fixture for issue #85: under pressure, a cortex/main request # must NOT silently fall back to the multimodal/gemma served name. @@ -249,10 +253,10 @@ def test_minor_request_under_high_pressure_is_served() -> None: out = resolve_tier_request("minor", _HIGH_SWAP, override=False, table=table) assert out == { "busy": False, - "served_name": "MINOR", - "served_tier": "minor", + "served_name": "HAND", + "served_tier": "hand", "reason": "default", - "requested_tier": "minor", + "requested_tier": "hand", } @@ -380,8 +384,8 @@ def test_minor_request_below_degraded_is_not_marked_pressure() -> None: pressure = {"swap_used_percent": 60.0, "iowait_percent": 0.0} out = resolve_tier_request("minor", pressure, override=False, table=table) assert out["busy"] is False - assert out["served_name"] == "MINOR" - assert out["served_tier"] == "minor" + assert out["served_name"] == "HAND" + assert out["served_tier"] == "hand" assert out["reason"] == "default" diff --git a/tests/test_tool_parser_plugin.py b/tests/test_tool_parser_plugin.py index da169c3..25eec49 100644 --- a/tests/test_tool_parser_plugin.py +++ b/tests/test_tool_parser_plugin.py @@ -20,8 +20,9 @@ This is scoped to vllm-primary ONLY — every other fleet-compose service (vllm-multimodal, vllm-multimodal-coder, vllm-embed, vllm-rerank, vllm-minor, -vllm-middle, gateway) must be byte-for-byte unchanged, proven below with a -sha256 hash of each service's sorted YAML subtree. These hashes were captured +vllm-hand, vllm-middle, vllm-muse, vllm-worker, gateway) must be +byte-for-byte unchanged, proven below with a sha256 hash of each service's +sorted YAML subtree. These hashes were captured from the SAME edit that added the vllm-primary changes above (t2's diff touched only vllm-primary, so the non-primary services' rendering is identical whether captured before or after) — if a FUTURE change to one of @@ -55,6 +56,25 @@ _PLUGIN_PARSER_NAME = "qwen3_coder_thinking" _EXPECTED_NON_PRIMARY_HASHES = { + # Recomputed 2026-08-10 for the `hand` lobe (hand-lobe plan t6). TWO + # services moved and both are deliberate: + # + # * `vllm-hand` is NEW — the ninth Colleague role's lane (LFM2.5-1.2B, the + # fine-tuning base). DEFAULT-ON, so it carries no `profiles:` gate, + # unlike vllm-minor/vllm-muse/vllm-worker: at ~2.4 GiB it co-resides on + # every card, which is the point of the role. Served bf16 (NO + # --quantization), text-only (NO --language-model-only — there is no ViT + # to drop), no --reasoning-parser (no thinking mode), and ARMED for LoRA + # with an EMPTY inventory. + # * `gateway` gained the HAND_* passthroughs (BASE_URL / SERVED_NAME / + # LORA_MODULES). Note the deliberate ABSENCE of HAND_PEER_ORIGIN / + # _PEER_PROXY / _PEER_API_KEY, which every other role's block carries: + # `hand` is never proxied (lobes.gateway._config.NEVER_PROXIED_BACKENDS) + # because it runs on every box, so there is no peer to refer it to. + # + # Every other service is byte-identical, which is this tripwire proving the + # blast radius. + # # Recomputed 2026-08-04 for the senses MTP off-switch (unsloth-QAT-senses plan, # t5): ONLY `vllm-multimodal` moved. Its `command:` changed shape from a YAML # list to a single shell-lexed STRING so the `--speculative-config` flag can be @@ -113,9 +133,10 @@ # pair + *_PEER_PROXY / *_PEER_API_KEY knobs.) Every other service is # byte-identical — this tripwire firing on exactly the intended services, and # NOTHING else, is itself the proof of each change's blast radius. - "gateway": "079f50f67e47cf09f31fd960b5fd6dd831eb6099f9eddb8978434626e0d23211", + "gateway": "88448c70bf43f4b757c3a6f4f86c428c040fbf4321ffa75f2e5fe35e0bb2f5b8", "vllm-embed": "63db52dc1121c1b861b5559c03d1b2c76699af86a575718908306f2440bd4b85", "vllm-embed-deep": "532b5b24c76c6cb90d06a4336ec42e6cc856a18ee112186aeff1141403f1143e", + "vllm-hand": "832a9aef512acfb2911f7925d483754f4d0bd012e1082b10aa91d9c236d5233e", "vllm-middle": "efef630842164793e43313fff2b588b92d7f57aad35fffc941a3617cddc1a129", "vllm-minor": "ddca0c0c64eb06514ba23d5327f61ce410bf8de40d3d7f519c399c6b8c60bc01", "vllm-multimodal": "64c129b764059c3e78ed248da60634cd2ecf4a0af26c61e3508c5676ddb11134", From f4dccfed6112c483f65e778e603dd92e6703ed81 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 06:10:39 +0300 Subject: [PATCH 04/17] docs: hand across the role contract, the counts, and the honesty discipline (t11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 1's documentation sweep for the ninth Colleague role. Role-count phrasing: every "eight roles" / "eight-role contract" occurrence across docs/, CLAUDE.md and README.md now reads nine. The sweep also caught counts that were ALREADY stale before this change and had never caught up with `worker` — lobes/explain/catalog.py said SEVEN throughout, and several capabilities/measure/learn strings said six or seven. docs/specs/ and docs/plans/ are deliberately untouched: they are dated historical artifacts. New content: - docs/colleague-stack.md gains a `hand` section (the worker-vs-hand table, the three consequences of being cheap, adapter addressing) and the ADDING A ROLE IS EFFECTIVELY IRREVERSIBLE callout the plan asked for — it enumerates the surfaces a role name lands on and says what to reach for instead when a new role is not actually the answer. - CLAUDE.md gains the `hand` paragraph and updated tier vocabulary (capability order hand < multimodal < worker < muse < main; the floor is now spelled `hand`). - README gains the per-model doc entry. - docs/qwen3.5-4b-minor.md is re-headed as the DEMOTED gear, with the "read this in the past tense" note: its LoRA promises were never realised on that checkpoint, which is why the role moved. Honesty discipline (#108): docs/machine-profiles.md now states that `hand` is DECLARED on every card and VALIDATED on none, that this holds PER CARD so one board's boot never promotes another, and that the Orin value specifically must come from a real boot rather than the Spark's number. Re-verified against the tree, as t11 requires: zero LoRA surface existed before this change, `unsloth` appears under lobes/ only in comments and model ids (never as a code dependency), and the before-state was eight roles. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- CLAUDE.md | 105 +++++++++++++---- README.md | 29 +++-- docs/colleague-stack.md | 111 +++++++++++++++--- docs/deployment-shapes.md | 10 +- docs/gateway-fleet.md | 7 +- docs/gemma-4-31b-nvfp4.md | 2 +- docs/machine-profiles.md | 26 +++- docs/openai-api.md | 12 +- docs/qwen3-embedding-4b.md | 2 +- docs/qwen3.5-4b-minor.md | 25 +++- docs/qwen3.6-35b-a3b-nvfp4.md | 6 +- lobes/cli/__init__.py | 2 +- lobes/cli/_commands/capabilities.py | 16 +-- lobes/cli/_commands/init.py | 2 +- lobes/cli/_commands/learn.py | 2 +- lobes/cli/_commands/measure.py | 2 +- lobes/cli/_commands/overview.py | 2 +- lobes/cli/_commands/up.py | 2 +- lobes/explain/catalog.py | 66 +++++++---- lobes/gateway/server.py | 6 +- .../builtin_shapes/machine-as-brain.toml | 2 +- lobes/profiles/builtin_shapes/orin-small.toml | 2 +- lobes/profiles/shapes.py | 4 +- lobes/roles.py | 16 +-- lobes/roles_measure.py | 2 +- 25 files changed, 330 insertions(+), 131 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8246f9b..cab69e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,9 +134,10 @@ not reproduce). ### Colleague roles: cortex / senses / muse / worker / embedder / reranker / stt / tts -Beyond `cortex`, the **fleet** exposes EIGHT first-class, Colleague-facing -**roles** (issue #81; `worker` joined as the eighth — thor-worker-lobe plan) -— the primary contract callers should address, not raw model ids: `cortex` +Beyond `cortex`, the **fleet** exposes NINE first-class, Colleague-facing +**roles** (issue #81; `worker` joined as the eighth — thor-worker-lobe plan — +and `hand` as the ninth — hand-lobe plan) — the primary contract callers +should address, not raw model ids: `cortex` (the 27B primary — reasoning/deciding/final authority), `senses` (the Gemma 4 12B multimodal gear — vision intake/perception; never decides or takes repo actions; the checkpoint declares audio support but it is **not currently @@ -145,7 +146,10 @@ practice, and the purpose-built `stt` role, below, is the supported path for speech), `muse` (the opt-in-hosted creative/ideation lobe — **currently DORMANT/unhosted mesh-wide**, see the paragraph below), `worker` (the opt-in-hosted fast ground-work DOER, and the first non-`cortex` role allowed -to act on the repo — see the paragraph below), `embedder` +to act on the repo — see the paragraph below), `hand` (the 1.2B LFM2.5 +fine-tuning base and trained specialist — default-hosted on EVERY card, the +`minor`/`cheap` tier, and the pressure-policy servable floor; see the +paragraph below), `embedder` (`Qwen/Qwen3-Embedding-0.6B` → `POST /v1/embeddings`), `reranker` (`Qwen/Qwen3-Reranker-0.6B` → `POST /v1/rerank` + `/v1/score`), and the opt-in audio overlay's `stt`/`tts`. Roles are routed by **task family** @@ -161,36 +165,43 @@ run at `*_GPU_MEM_UTIL=0.06` each — default budget `0.30 + 0.14 + 0.06 + 0.06 0.56` on the 128 GB GB10. These are the **machine-as-brain** (default) values — one box hosting every role it can serve; a mesh-brain **deployment shape** (below) drops one heavy lobe to a peer box and reclaims its budget -instead of merely co-residing it. The 4B `minor` (back-compat `cheap`, -`COMPOSE_PROFILES=minor`, util 0.10) and the legacy 14B Qwen +instead of merely co-residing it. `hand` is default-hosted too, at a per-card +util (0.06 on the 128 GB Spark/Thor, 0.10 on the 64 GB Orin). The legacy 4B +`vllm-minor` gear (`COMPOSE_PROFILES=minor`, util 0.10) and the legacy 14B Qwen (`COMPOSE_PROFILES=middle`, util 0.12) are **opt-in** gears and are not -first-class Colleague roles. Callers address the generate lane by -**capability-tier alias** — `model=main|minor|multimodal|worker|muse` -(back-compat: `hard|cheap|normal`; capability order `minor` < `multimodal` < -`worker` < `muse` < `main`), or the Colleague-role names `model=cortex|senses` -layered on top of `main`/`multimodal` (`muse`'s and `worker`'s role names ARE -their tier/backend names); `normal`/`multimodal` maps to the Gemma gear, not -the demoted 14B. A swap/iowait **pressure policy** SHEDS full-tier `cortex`, +first-class Colleague roles — and since `hand` took over the `minor`/`cheap` +tier, the 4B is addressable only by explicit model id, exactly like the 14B. +Callers address the generate lane by **capability-tier alias** — +`model=main|hand|multimodal|worker|muse` (back-compat: `hard|minor|cheap|normal`; +capability order `hand` < `multimodal` < `worker` < `muse` < `main`), or the +Colleague-role names `model=cortex|senses` layered on top of +`main`/`multimodal` (`hand`'s, `muse`'s and `worker`'s role names ARE their +tier/backend names); `minor`/`cheap` map to `hand`, and `normal`/`multimodal` +to the Gemma gear, not the demoted 14B. A `hand` LoRA adapter is addressed as +`model=hand:`. A swap/iowait **pressure policy** SHEDS full-tier `cortex`, `senses`, `worker`, and `muse` requests with **HTTP 429 + `Retry-After`** rather than substituting a different model (swap > 75 % or iowait > 50 % → busy — the former degrade-to-`minor` substitution path was removed outright, so there is no cheaper-rung fallback for any of the four); an explicit -`minor` request is the servable floor and is always served regardless of -pressure. `lobes status --pressure` shows the current busy/warm state. -Start/stop one role at a time with `lobes up ` (or the six-default-role -bundle, `lobes up colleague-stack` — `muse` and `worker` are deliberately -excluded from the bundle, both being opt-in-hosted; `lobes up muse` works on +`hand` request (or its `minor`/`cheap` spellings) is the servable floor and is +always served regardless of pressure. `lobes status --pressure` shows the current busy/warm state. +Start/stop one role at a time with `lobes up ` (or the +seven-default-role bundle, `lobes up colleague-stack` — `muse` and `worker` are +deliberately excluded from the bundle, both being opt-in-hosted, while `hand` +IS included, being default-hosted and un-gated; `lobes up muse` works on a muse-hosting deployment and errors helpfully when `COMPOSE_PROFILES` doesn't include `muse`, and `lobes up worker` is landing alongside the worker-hosting shape, below, to mirror that exact mechanic); measure -per-role runtime with `lobes measure` (muse and worker ride the llm family) +per-role runtime with `lobes measure` (hand, muse and worker ride the llm +family) and compare fleet profiles with `lobes benchmark --profile {cortex-only, cortex+senses,senses-direct,qwen-nvfp4-vs-bf16,all}`. LoRA adapter training targets the 4B bf16 `minor` only — the 14B NVFP4 is inference-only, and there is no `lobes train` verb. See `docs/qwen3-embedding-0.6b.md`, `docs/qwen3-reranker-0.6b.md`, `docs/gemma-4-12b-nvfp4.md`, `docs/gemma-4-31b-nvfp4.md`, `docs/qwen3.6-35b-a3b-nvfp4.md`, -`docs/gateway-fleet.md`, and `docs/colleague-stack.md` (the eight-role +`docs/lfm2.5-1.2b-hand.md`, +`docs/gateway-fleet.md`, and `docs/colleague-stack.md` (the nine-role contract). **`muse` — the seventh role, currently DORMANT/unhosted mesh-wide.** @@ -281,6 +292,60 @@ in 0.54.6 but missing from `server.py`'s `_PEER_SERVED_NAME_ENV` / `WORKER_PEER_PROXY=true` did nothing. See `docs/evidence/2026-07-31-accept-worker-proxy-spark.txt`. +**`hand` — the ninth role, the fleet's FINE-TUNING BASE (default-hosted +everywhere).** Checkpoint: `LiquidAI/LFM2.5-1.2B-Instruct` (a ~1.2B hybrid +short-conv + GQA `Lfm2ForCausalLM`; 32768 native; bf16 — the catalog's +`quantization="none"` sentinel, so the lane omits `--quantization` ENTIRELY; +**text-only** — no ViT, hence no `--language-model-only`, and no thinking mode, +hence no `--reasoning-parser`; LiquidAI ships `LFM2.5-1.2B-Thinking` and a +vision variant as SEPARATE checkpoints). Requires vLLM >= 0.23.0, so the lane +pins the nightly digest, never the NGC 26.04 tag `vllm-minor` rides — verified +against the pinned digest on a physical Thor 2026-08-10 (`VLLM_VERSION +0.23.1rc1.dev672+g93d8f834d`, `LFM2_REGISTERED True`). + +The metaphor is **muscle memory**: one cheap base, many LoRA adapters, each +mastering a domain. `worker` is an untrained generalist doer; `hand` is a +trained specialist. Responsibilities: `domain_mastery`, `learned_skill`, +`specialized_task`, `tool_use` (forbidden: `final_decision`, `repo_action`, +`security_decision` — v1 withholds `repo_action` deliberately, since ADDING a +responsibility later is contract-compatible while REMOVING one is a break; +granting it once adapters exist is issue #180). + +Three things follow from it being cheap (~2.4 GiB bf16): it is **hosted by +every built-in shape** including the mesh-lobe ones, it is **never proxied** +(deliberately absent from all three peer channels — `NEVER_PROXIED_BACKENDS` +names that absence so a symmetry-minded refactor must delete a constant to +break it), and it is the **pressure-policy servable floor**. It also +**replaced `Qwen/Qwen3.5-4B` as the `minor`/`cheap` tier**; the 4B stays in the +catalog as a plain candidate (cite-don't-delete), still selectable via `lobes +switch`, but no tier resolves to it. + +**Tool calling** uses LFM2's own `<|tool_call_start|>`/`<|tool_call_end|>` +delimiters, which are **special tokens** — the same trap that made `pythonic` +silently wrong for Gemma 4. vLLM ships a purpose-built **`lfm2`** parser whose +`__init__` RESOLVES both delimiters and **raises** when either is missing, so a +bad tokenizer revision fails loudly at startup instead of relaying a well-formed +call as prose. + +**LoRA serving** ships ARMED (`--enable-lora`) with the inventory **EMPTY** — +v1 has zero adapters. Adapters are declared once in `HAND_LORA_MODULES` +(`name=path`, comma-separated), read by BOTH the engine's `--lora-modules` and +the gateway's `hand:` alias derivation so the two cannot disagree, and +fixed at boot — there is no runtime hot-load. `model=hand` serves the base and +never 404s on an empty inventory; `model=hand:` serves that adapter; an +UNdeclared `hand:` is refused with `model_not_found`, never silently +downgraded to the base. An adapter vLLM did not actually load is absent from +both `/v1/models` and `/capabilities` — verified by probing the lane's OWN +`/v1/models` rather than the gateway's filesystem, since adapter paths are +mounted into `vllm-hand`, not the gateway. Adapter PRODUCTION is `unsloth-cli`, +out of tree and one-directional (nothing under `lobes/` imports it); +`agentculture/unsloth-cli#16` tracks LFM2.5 support there. + +Budgets are **DECLARED, not measured** (#108) on every card. See +`docs/lfm2.5-1.2b-hand.md` and `docs/colleague-stack.md`, whose `hand` section +also records why **adding a role is effectively irreversible** — read it before +proposing a tenth. + An opt-in **realtime audio overlay** (`lobes init --fleet --audio`) adds an OpenAI `/v1/audio/*` facade — a `realtime` bridge container (shipped in the wheel as `lobes.realtime`) that the gateway fans `/v1/audio/*` out to — backed by two diff --git a/README.md b/README.md index 82cdca3..a6f3a40 100644 --- a/README.md +++ b/README.md @@ -252,14 +252,18 @@ native MTP default-on — the coder fine-tune, `sakamakismile/gemma-4-12B-coder- is kept as an opt-in `multimodal-coder` gear; see [`docs/vllm-nightly-migration.md` §7](docs/vllm-nightly-migration.md)) — plus the tiny embedding + reranker gears (`0.06` each), for a default budget of -`0.30 + 0.14 + 0.06 + 0.06 = 0.56` on the 128 GB GB10. The 4B -`minor` companion and the legacy 14B Qwen are opt-in compose profiles -(`COMPOSE_PROFILES=minor` / `COMPOSE_PROFILES=middle`). Callers address the -generate lane by role/tier alias — `model=cortex|senses|muse` (or -`main|minor|multimodal|muse`; back-compat `hard|cheap|normal`; `muse` — the -opt-in-hosted 31B creative lobe, declared/unvalidated — is served only by a -muse-hosting shape, never the default fleet); see -[`docs/colleague-stack.md`](docs/colleague-stack.md) for the seven-role contract. `lobes switch` drives the single-model deployment (it can +`0.30 + 0.14 + 0.06 + 0.06 = 0.56` on the 128 GB GB10 — plus the 1.2B `hand` +lobe (`0.06` on a 128 GB card, `0.10` on a 64 GB Orin), the fleet's +fine-tuning base, which is default-hosted on every card. The legacy 4B +`vllm-minor` gear and the legacy 14B Qwen are opt-in compose profiles +(`COMPOSE_PROFILES=minor` / `COMPOSE_PROFILES=middle`) and are addressable only +by explicit model id — `hand` took over the `minor`/`cheap` tier. Callers +address the generate lane by role/tier alias — `model=cortex|senses|hand|muse|worker` +(or `main|multimodal|hand`; back-compat `hard|normal|minor|cheap`; a `hand` LoRA +adapter is `model=hand:`; `muse` — the opt-in-hosted 31B creative lobe, +declared/unvalidated — and `worker` — the opt-in-hosted 35B-A3B doer — are +served only by their own hosting shapes, never the default fleet); see +[`docs/colleague-stack.md`](docs/colleague-stack.md) for the nine-role contract. `lobes switch` drives the single-model deployment (it can also serve an embed/score gear solo — auto-detected from the catalog, or forced with `--task embed|score`); change the fleet primary by editing the fleet `.env` and re-running `lobes fleet up --apply`. See `lobes explain fleet` / `lobes @@ -269,7 +273,8 @@ explain gateway` for the routing semantics, gears, [`docs/gemma-4-12b-nvfp4.md`](docs/gemma-4-12b-nvfp4.md) for the multimodal gear, [`docs/gateway-fleet.md`](docs/gateway-fleet.md) for the full topology, and [`docs/colleague-stack.md`](docs/colleague-stack.md) for -the seven-role Colleague contract (`cortex`/`senses`/`muse`/`embedder`/`reranker`/`stt`/`tts`, +the nine-role Colleague contract +(`cortex`/`senses`/`muse`/`worker`/`hand`/`embedder`/`reranker`/`stt`/`tts`, `lobes capabilities`, `GET /capabilities`). ### Per-model notes @@ -311,6 +316,12 @@ results, and caveats: **MoE fallback** (`mmangkad/Qwen3.6-35B-A3B-NVFP4`), now a candidate. It does **not** load reliably on a GB10 shared with other services, and two ~30B models do not co-reside there — see [`docs/gateway-fleet.md`](docs/gateway-fleet.md). +- [`docs/lfm2.5-1.2b-hand.md`](docs/lfm2.5-1.2b-hand.md) — the **`hand` lobe** + (`LiquidAI/LFM2.5-1.2B-Instruct`), the fleet's designated **fine-tuning base** + and the ninth Colleague role. bf16, 32K native, text-only, no thinking mode; + served with `--enable-lora` armed and (in v1) an empty adapter inventory. + Default-hosted on every card, the `minor`/`cheap` tier, and the + pressure-policy servable floor. DECLARED, not yet validated live (#108). The numbers in each doc come from `lobes switch --apply` then `lobes assess` (correctness) and `lobes benchmark` (throughput). `lobes overview --list` diff --git a/docs/colleague-stack.md b/docs/colleague-stack.md index 6041eb0..7eb216b 100644 --- a/docs/colleague-stack.md +++ b/docs/colleague-stack.md @@ -1,12 +1,13 @@ -# The Colleague stack: eight roles, one contract +# The Colleague stack: nine roles, one contract -> The eight first-class, Colleague-facing roles lobes exposes over the fleet — -> `cortex` / `senses` / `muse` / `worker` / `embedder` / `reranker` / `stt` / -> `tts` — how a caller discovers them, drives them, measures them, and the -> before→after context migration that shipped alongside this contract (issue -> #81; `muse` joined as the seventh, opt-in-hosted role, and `worker` as the -> eighth, thor-worker-lobe plan). **`muse` is currently DORMANT/unhosted -> mesh-wide** — see the callout below the role table. +> The nine first-class, Colleague-facing roles lobes exposes over the fleet — +> `cortex` / `senses` / `muse` / `worker` / `hand` / `embedder` / `reranker` / +> `stt` / `tts` — how a caller discovers them, drives them, measures them, and +> the before→after context migration that shipped alongside this contract +> (issue #81; `muse` joined as the seventh, opt-in-hosted role, `worker` as the +> eighth, thor-worker-lobe plan, and `hand` as the ninth, hand-lobe plan). +> **`muse` is currently DORMANT/unhosted mesh-wide** — see the callout below +> the role table. This doc is the **role contract** reference. For the fleet's Docker topology, tuning knobs, and memory budget, see [`docs/gateway-fleet.md`](gateway-fleet.md); @@ -25,7 +26,7 @@ serves. Renaming or re-quantizing the underlying checkpoint is then an operator-side change with **zero client-code change** — see "Client flow" below. -## The eight roles +## The nine roles | Role | Backend / service | Endpoint path | What it's for | |---|---|---|---| @@ -33,6 +34,7 @@ below. | `senses` | `multimodal` (generate) | `POST /v1/chat/completions` | Intake/perception (text+image) and speaking back to the user. Does **not** decide or act. | | `muse` | `muse` (generate, **opt-in hosting, currently DORMANT/unhosted**) | `POST /v1/chat/completions` | Creative generation, long-form writing, ideation, a divergent second opinion. Proposes; never decides or acts. | | `worker` | `worker` (generate, **opt-in hosting**) | `POST /v1/chat/completions` | Fast ground-work execution — bulk transforms, drafting, image/video understanding — **and repo actions**, under `cortex`'s direction. Never the final decision or a security call. | +| `hand` | `hand` (generate, **default-hosted everywhere**) | `POST /v1/chat/completions` | The fine-tuning base and trained specialist — domain mastery via LoRA adapters. Also the `minor`/`cheap` tier and the pressure-policy **servable floor**. Never decides, acts on the repo, or makes a security call. | | `embedder` | `embed` (pooling) | `POST /v1/embeddings` | Dense text embeddings for memory/retrieval. | | `reranker` | `rerank` (pooling) | `POST /v1/rerank` (+ `/v1/score`) | Reordering/scoring retrieved candidates. | | `stt` | Parakeet (audio overlay, opt-in) | `POST /v1/audio/transcriptions` | Speech-to-text. | @@ -71,7 +73,7 @@ per the callout above, but it never decides). > either.** The `nvidia/Gemma-4-31B-IT-NVFP4` checkpoint behind `muse` and the > `unsloth/Qwen3.6-35B-A3B-NVFP4` checkpoint behind `worker` are both too > heavy to co-reside with the default `cortex`+`senses` duo on a 128 GB box, -> so the default shape's hosted set stays the SIX default roles +> so the default shape's hosted set stays the SEVEN default-hosted roles > (`DEFAULT_HOSTED_ROLES` in `lobes/profiles/shapes.py`) while the contract > set capabilities reports (`COLLEAGUE_ROLES`) is eight. Only an explicit > hosting shape serves either: `lobes init --shape thor-muse` for `muse` — @@ -165,6 +167,76 @@ authority stays `cortex`'s alone. `senses` has no `tool_use` at all: it is intake/perception, even though its Gemma lane *can* serve tool calls (see `tools`, below — a capability of the lane, not a licence for the role). +### `hand` — the ninth role, the trained specialist + +`hand` (LiquidAI `LFM2.5-1.2B-Instruct`) is the fleet's **designated +fine-tuning base**. The metaphor is **muscle memory**: one cheap base, many +LoRA adapters, each mastering a domain. + +The distinction from `worker` is the point of having both: + +| | `worker` | `hand` | +|---|---|---| +| what it is | an untrained **generalist doer** | a trained **specialist** | +| how it gets good | it is already big (35B-A3B) | someone taught it (a LoRA adapter) | +| breadth | anything, adequately | a few things, extremely well | +| may act on the repo | **yes**, under cortex's direction | no (v1 — see below) | +| hosting | opt-in, one box | **default, every box** | + +At ~1.2B parameters (~2.4 GiB bf16) it is cheap enough to co-reside on *every* +card, which is what makes it different in kind from the other generate lobes. +That has three consequences worth stating plainly: + +- It is **default-hosted by every built-in shape**, including the mesh-lobe + shapes that drop a heavy lobe. A caller always has a local generate lane. +- It is **never proxied**. `hand` is deliberately absent from the peer + origin/proxy/key channels (`NEVER_PROXIED_BACKENDS`): referral exists so a + box that *cannot* host a lobe can still reach it, and that situation does not + arise here. +- It is the **servable floor**. Under pressure `cortex`/`senses`/`worker`/`muse` + all shed with 429; `hand` is served regardless. + +It also **replaced `Qwen/Qwen3.5-4B` as the `minor`/`cheap` tier**. Those tier +spellings still work and now resolve to `hand`; the 4B stays in the catalog as +a plain candidate (cite-don't-delete), selectable via `lobes switch`, but no +tier resolves to it. + +**Addressing an adapter.** `model=hand` serves the base — it never 404s just +because the inventory is empty. `model=hand:` serves that adapter. An +*undeclared* `hand:` is refused with `model_not_found`; it is never +silently downgraded to the base, because a caller who asked for the legal +specialist and got the generalist has been lied to. Adapters are declared once +in `HAND_LORA_MODULES` (read by both the engine and the gateway, so they cannot +disagree), fixed at boot — there is no runtime hot-load. + +**v1 ships zero adapters**, with `--enable-lora` armed and the inventory empty. +The serving half of muscle memory is here; the training half is `unsloth-cli`, +out of tree (`agentculture/unsloth-cli#16`). lobes **serves** adapters and +never trains them — nothing under `lobes/` imports or shells out to unsloth. + +`repo_action` is **forbidden** for v1 even though `worker` has it. That is a +deliberate asymmetry, not an oversight: granting it later is +contract-compatible, revoking it is a break, so the conservative list ships +first and `agentculture/lobes-cli#180` tracks granting it once adapters exist. + +> ### Adding a role is effectively irreversible +> +> Every name in `lobes.roles.ROLES` becomes a public address: a key on +> `GET /capabilities` and `lobes capabilities`, a `model=` alias, a +> `lobes up ` target, a `_*` env vocabulary, an entry in six +> per-role tables, a row in every card profile and every deployment shape, and +> a line in 28 golden `.env` files. **Removing one later breaks every caller +> that learned to use it** — and by the honesty rule (#92) you cannot soften +> the break by half-serving it. +> +> `hand` was worth that cost because it is a *kind* of lobe the fleet did not +> have: cheap enough to be everywhere, and the only one meant to be taught. A +> tenth role should have to clear the same bar. If what you want is a different +> checkpoint, that is a catalog change; if it is a different budget, that is a +> profile or shape change; if it is a different behaviour on an existing lane, +> that is a responsibilities token. Reach for a new role only when none of +> those can express it. + ## cortex/senses ↔ primary/multimodal — one mapping, three vocabularies `cortex` and `senses` are **new names layered on the existing `primary` / @@ -195,7 +267,7 @@ base URL. Everything else — which model backs a role, whether it's loaded, what context it's served at — comes from the contract itself. ```bash -lobes capabilities # human-readable table, all eight roles +lobes capabilities # human-readable table, all nine roles lobes capabilities --json # the machine-readable contract lobes endpoint cortex # just the base URL for one role curl -s http://localhost:8000/capabilities # the same contract, over HTTP @@ -267,7 +339,7 @@ RESPONSIBILITY of the role. `senses` has `tools: true` and no `tool_use`: its Gemma lane can serve tool calls, but the division of labour doesn't ask it to. **Every role's `endpoint` is the one client-reachable gateway origin** — dial -it directly (issue #87). All eight roles (`cortex`/`senses`/`muse`/`worker`/ +it directly (issue #87). All nine roles (`cortex`/`senses`/`muse`/`worker`/`hand`/ `embedder`/`reranker` **and** `stt`/`tts`) report the same base URL because routing happens via the `model` field / the OpenAI `path`, not distinct per-role URLs; the internal upstream hosts (`vllm-primary:8000`, `realtime:8080`) are never leaked. When you @@ -313,7 +385,7 @@ Example (`cortex`, fully wired, default fleet): An unwired role (e.g. `stt`/`tts` without `--audio`, or `senses` before the multimodal gear is up) is **never omitted** — it's returned with `loaded: false` and the model it *would* serve named from the catalog, so a -client can always render all eight roles. (An unwired `muse` or `worker` +client can always render all nine roles. (An unwired `muse` or `worker` additionally defaults to `feasible: false` — the opt-in-hosting honesty rule above.) @@ -427,7 +499,7 @@ lobes up embedder --apply # docker compose up -d vllm-embed lobes up reranker --apply # docker compose up -d vllm-rerank lobes up stt --apply # requires the --audio overlay lobes up tts --apply # requires the --audio overlay -lobes up colleague-stack --apply # the SIX default roles at once (requires --audio scaffolded) +lobes up colleague-stack --apply # the SEVEN default roles at once (requires --audio scaffolded) ``` Dry-run by default (prints the exact `docker compose …` command); `--apply` @@ -437,8 +509,9 @@ tag, because tagging the already-default-on services with a profile would demote them out of the default fleet (a regression). If the audio overlay isn't scaffolded, `colleague-stack` (and `up stt`/`up tts`) fail with a remediation pointing at `lobes init --fleet --audio --apply`, rather than -silently starting only four of the six roles. **`colleague-stack` stays the -six default-hosted roles — `muse` and `worker` are deliberately excluded** +silently starting only some of them. **`colleague-stack` stays the +seven default-hosted roles — `muse` and `worker` are deliberately excluded, +while `hand` IS included (default-hosted, no compose-profile gate)** (their services are compose-profile-gated, so bundling either would break the target on every non-hosting deployment). `lobes up muse` works on a muse-hosting deployment and errors helpfully — naming the fix — when the @@ -453,8 +526,8 @@ claim (lobes measures serving performance; whether an *answer* was good is Colleague's call): ```bash -lobes measure # all eight roles, table -lobes measure --json # all eight roles, JSON +lobes measure # all nine roles, table +lobes measure --json # all nine roles, JSON lobes measure --role cortex --json ``` @@ -590,7 +663,7 @@ and live-validation history behind this rebalance. - [`docs/openai-api.md`](openai-api.md) — the raw OpenAI-compatible wire endpoints each role sits behind. - [`docs/deployment-shapes.md`](deployment-shapes.md) — the orthogonal - deployment-shape axis: which of these eight roles a given box hosts at all, + deployment-shape axis: which of these nine roles a given box hosts at all, the cross-box honest-referral surface for a role it doesn't, and the opt-in proxy-lobes extension (the awake/asleep/proxy table, the pairwise key contract, a worked example). diff --git a/docs/deployment-shapes.md b/docs/deployment-shapes.md index 365b877..ac97c28 100644 --- a/docs/deployment-shapes.md +++ b/docs/deployment-shapes.md @@ -2,14 +2,14 @@ A **deployment shape** answers a question the #108/#110 machine profile deliberately does not: not "how is each role *tuned* on this card?" but -"which of the eight Colleague roles does *this box* host **at all**?" A shape +"which of the nine Colleague roles does *this box* host **at all**?" A shape is composed as pure data **over** the machine profile at render time — the two axes are orthogonal: **shape × card**. This document is the deep reference; `lobes explain shapes` is the brief in-CLI version. ## What a deployment shape is -The fleet exposes eight first-class Colleague roles (issue #81): `cortex`, +The fleet exposes nine first-class Colleague roles (issue #81): `cortex`, `senses`, `muse`, `worker`, `embedder`, `reranker`, `stt`, `tts` — `muse` being the opt-in-hosted seventh and `worker` the opt-in-hosted eighth (thor-worker-lobe plan), both below. **`muse` is currently DORMANT/unhosted @@ -83,7 +83,7 @@ All shipped shapes are pure data over the `#108` `Profile` schema (`lobes/profiles/builtin_shapes/{machine-as-brain,spark-lobe,thor-lobe,orin-lobe,orin-small,thor-muse,thor-worker}.toml`) differ from each other only in their `hosts` role subset and their `overrides` budget re-derivation. `orin-small` adds one -new hostable role beyond the eight first-class Colleague roles: the opt-in +new hostable role beyond the nine first-class Colleague roles: the opt-in `minor` gear (`lobes/profiles/shapes.py`'s `OPT_IN_ROLES`), which carries no Profile knobs of its own — re-using the `cortex` role slot for a 4B model instead would mean the box advertises the 27B Colleague role while actually @@ -113,7 +113,7 @@ machine-as-brain. Concretely: - **The machine-as-brain identity set is `DEFAULT_HOSTED_ROLES`** (the six: `cortex`/`senses`/`embedder`/`reranker`/`stt`/`tts`); the Colleague - *contract* set capabilities reports (`COLLEAGUE_ROLES`) is eight. On every + *contract* set capabilities reports (`COLLEAGUE_ROLES`) is nine. On every non-hosting shape — machine-as-brain included — muse and worker each render *nothing*: the card's own declaration for each passes through verbatim, which is exactly what keeps machine-as-brain a byte-identical no-op over @@ -619,7 +619,7 @@ elsewhere: data-plane mechanics: marker headers, the loop guard, peer failure modes - `docs/machine-profiles.md` — the per-machine (card) tuning axis this composes with -- `docs/colleague-stack.md` — the eight-role Colleague contract, including the +- `docs/colleague-stack.md` — the nine-role Colleague contract, including the proxied role state - `lobes/profiles/shapes.py` — the `Shape` schema + built-in loader (`COLLEAGUE_ROLES` / `DEFAULT_HOSTED_ROLES` / `OPT_IN_CORE_ROLES` / diff --git a/docs/gateway-fleet.md b/docs/gateway-fleet.md index 447740f..7e17696 100644 --- a/docs/gateway-fleet.md +++ b/docs/gateway-fleet.md @@ -10,7 +10,7 @@ creative/ideation lobe, Gemma 4 31B NVFP4 — **currently DORMANT/unhosted mesh-wide**, see [`docs/colleague-stack.md`](colleague-stack.md) for the callout), and `worker` (the opt-in-hosted fast ground-work DOER, Qwen3.6 35B-A3B — thor-worker-lobe plan, its hosting shape forthcoming) — these are -the EIGHT first-class Colleague-facing roles (issue #81) — see +the NINE first-class Colleague-facing roles (issue #81) — see [`docs/colleague-stack.md`](colleague-stack.md) for the full role contract (`lobes capabilities`, `GET /capabilities`, `lobes up `, `lobes measure`). This doc covers the fleet's Docker topology, tuning, and memory @@ -167,11 +167,12 @@ same backends, same fallback contract, just the Colleague-facing role name `model=muse` and `model=worker` are role names that ARE their own tier and backend name — no extra alias layer, for either. `minor` has no role-name alias — it is not one of the -eight first-class +nine first-class Colleague roles; it is the servable floor under pressure (an explicit `minor` request is always served, while full tiers are shed — see "Pressure policy and busy backpressure" below). See [`docs/colleague-stack.md`](colleague-stack.md) -for the full eight-role contract (`cortex`/`senses`/`muse`/`worker`/`embedder`/`reranker`/`stt`/`tts`), +for the full nine-role contract +(`cortex`/`senses`/`muse`/`worker`/`hand`/`embedder`/`reranker`/`stt`/`tts`), their `responsibilities`/`forbidden_responsibilities`, and `GET /capabilities`. **Fallback contract:** when a tier's own backend is absent, the alias falls back diff --git a/docs/gemma-4-31b-nvfp4.md b/docs/gemma-4-31b-nvfp4.md index 73fda6d..b4e7d5b 100644 --- a/docs/gemma-4-31b-nvfp4.md +++ b/docs/gemma-4-31b-nvfp4.md @@ -273,7 +273,7 @@ the status callout at the top of this document. ## Related docs -- [`colleague-stack.md`](colleague-stack.md) — the eight-role contract, +- [`colleague-stack.md`](colleague-stack.md) — the nine-role contract, `muse`'s responsibilities/forbidden lists, and the dormant-role callout. - [`deployment-shapes.md`](deployment-shapes.md) — the `thor-muse` shape, the opt-in-core-role concept (now shared with `worker`), referral/proxy for the diff --git a/docs/machine-profiles.md b/docs/machine-profiles.md index 53ae8f3..a4d554c 100644 --- a/docs/machine-profiles.md +++ b/docs/machine-profiles.md @@ -95,7 +95,7 @@ Every knob is optional (`None` = "profile takes no position, template default applies"). Only knobs the profile diverges on appear in the TOML or env; the rest are inherited from the template. -The six roles and seven knobs map to env vars via `lobes/profiles/render.py`: +The seven roles and seven knobs map to env vars via `lobes/profiles/render.py`: | role | env prefix | feasible | model | gpu_mem_util | max_model_len | quantization | kv_cache_dtype | attention_backend | enforce_eager | max_num_seqs | |---|---|---|---|---|---|---|---|---|---|---| @@ -145,13 +145,15 @@ no `_FEASIBLE` key is emitted — "feasible" is the assumed default. **When set:** - `spark` profile (GB10): all four default roles `feasible=true` (all - load-tested here); silent on `muse` and `worker` (both shape-declared — + load-tested here); `hand=true` but **DECLARED, not load-tested here** (see + the note below); silent on `muse` and `worker` (both shape-declared — see above). - `thor` profile (Jetson AGX Thor): all four default roles `feasible=true` - (validated live 2026-07-13); silent on `muse` and `worker` (both - shape-declared — see above). + (validated live 2026-07-13); `hand=true` but **DECLARED, not load-tested + here**; silent on `muse` and `worker` (both shape-declared — see above). - `base` profile (unknown card): `cortex=true`, `senses=false`, `muse=false`, - `worker=false`, `embedder=true`, `reranker=true` — the multimodal gear, the + `worker=false`, `hand=true`, `embedder=true`, `reranker=true` — the + multimodal gear, the 31B muse lobe, and the 35B-A3B worker lobe are all disabled to save memory on unknown hardware. @@ -164,6 +166,18 @@ compose template passes the served name to vLLM's `--served-model-name` separately from the model id it downloads; the two must agree for the gateway to route correctly. +> **`hand` is DECLARED on every card and VALIDATED on none of them yet +> (#108).** The per-card `[roles.hand]` blocks in +> `lobes/profiles/builtin/*.toml` carry real budget numbers — 0.06 on the +> 128 GB Spark/Thor, 0.10 on the 64 GB Orin — but they are *hypotheses*, not +> measurements: no physical box has booted the lane. Per the #108 rule this +> holds **per card**, so a successful boot on one board never promotes another; +> each card is promoted only when its own acceptance transcript lands under +> `docs/evidence/`. The Orin value in particular must be validated by an actual +> boot at the served `max_model_len` rather than inferred from the Spark's, +> since 0.06 of 64 GB leaves markedly less KV headroom than 0.06 of 128 GB — +> which is exactly why the two cards declare different numbers. + **When set:** - `spark` cortex: `sakamakismile/Qwen3.6-27B-Text-NVFP4-MTP` (the 256K-native @@ -481,7 +495,7 @@ max_model_len = 8192 confusion from stale copies). - **Roles are optional.** A role omitted from the TOML means "take no position — use the template's defaults for this role." You don't have to restate all - six roles; a minimal profile might only touch `cortex`. (Staying silent on + seven roles; a minimal profile might only touch `cortex`. (Staying silent on `muse`/`worker` is the norm — the built-in card profiles do; a muse-/worker-hosting *shape* carries each role's own declaration.) - **Knobs within a role are optional.** A knob omitted means "no opinion" (the diff --git a/docs/openai-api.md b/docs/openai-api.md index fa87463..05f8f64 100644 --- a/docs/openai-api.md +++ b/docs/openai-api.md @@ -21,7 +21,7 @@ request's `model` field. Clients point at the same URL either way. | `/v1/realtime` | GET (WebSocket upgrade) | realtime bridge, tunneled through the gateway | server_vad session; base64 PCM16 mono LE JSON events both ways, 24000 Hz default / 16000 Hz accepted. Transcription-only by default; opt in with `response.create` for a spoken, interruptible reply on the same socket (issues #149, #151) — see below | | `/v1/models` | GET | gateway | OpenAI-standard list of loaded backends (what is hot now) | | `/v1/models/supported` | GET | gateway | full supported-model catalog (every gear you can switch to; each flagged `loaded`/`default`) | -| `/capabilities` | GET | gateway | the eight-role Colleague contract (`cortex`/`senses`/`muse`/`worker`/`embedder`/`reranker`/`stt`/`tts`) resolved to live endpoint + metadata — non-OpenAI, lobes-native | +| `/capabilities` | GET | gateway | the nine-role Colleague contract (`cortex`/`senses`/`muse`/`worker`/`embedder`/`reranker`/`stt`/`tts`) resolved to live endpoint + metadata — non-OpenAI, lobes-native | | `/health` | GET | gateway | liveness | Embeddings, rerank, score, and audio (including the `/v1/realtime` WebSocket @@ -525,9 +525,9 @@ the gateway defaults to). This is the HTTP equivalent of `lobes overview --list` curl -s http://localhost:8000/v1/models/supported ``` -### Capabilities (the eight-role Colleague contract) +### Capabilities (the nine-role Colleague contract) -`GET /capabilities` — the EIGHT first-class, Colleague-facing roles (`cortex`, +`GET /capabilities` — the NINE first-class, Colleague-facing roles (`cortex`, `senses`, `muse`, `worker`, `embedder`, `reranker`, `stt`, `tts` — issue #81; `worker` joined as the eighth, thor-worker-lobe plan), each resolved to @@ -574,7 +574,7 @@ curl -s http://localhost:8000/capabilities } ``` -**All eight roles** report this **one** client-reachable gateway `endpoint` — +**All nine roles** report this **one** client-reachable gateway `endpoint` — including `stt`/`tts` — because routing is by the `model` field / OpenAI `path`, not by distinct URLs (issue #87). The gateway advertises the origin you dialed (the request `Host` header; override with `GATEWAY_PUBLIC_URL` for a tunnel), so @@ -687,13 +687,13 @@ two-step provisioning flow (`cultureflare` + `lobes tunnel`). - `lobes explain gateway` — routing semantics (name / default / failover / SSE) - `lobes explain fleet` — the multi-container fleet topology -- `lobes explain roles` — the eight-role Colleague contract (`GET /capabilities`) +- `lobes explain roles` — the nine-role Colleague contract (`GET /capabilities`) - `lobes explain embeddings` — `/v1/embeddings` request/response detail - `lobes explain rerank` — `/v1/rerank` request/response detail - `lobes explain score` — `/v1/score` request/response detail - `lobes explain tunnel` — Cloudflare Tunnel bring-up - `lobes explain realtime` — the `/v1/realtime` session surface, in-CLI - [`docs/gateway-fleet.md`](gateway-fleet.md) — full fleet topology, memory guidance, live validation findings -- [`docs/colleague-stack.md`](colleague-stack.md) — the eight-role Colleague contract, `GET /capabilities` JSON shape, `lobes up`/`measure`/`benchmark --profile` +- [`docs/colleague-stack.md`](colleague-stack.md) — the nine-role Colleague contract, `GET /capabilities` JSON shape, `lobes up`/`measure`/`benchmark --profile` - [`docs/realtime-pipeline.md`](realtime-pipeline.md) — audio overlay bring-up (STT + TTS), the `/v1/realtime` session contract, health/readiness, runbooks - [`docs/chatterbox-tts.md`](chatterbox-tts.md) — Chatterbox TTS details, voice prompting diff --git a/docs/qwen3-embedding-4b.md b/docs/qwen3-embedding-4b.md index f31e112..3e06079 100644 --- a/docs/qwen3-embedding-4b.md +++ b/docs/qwen3-embedding-4b.md @@ -184,4 +184,4 @@ the 0.6B. The deep gear surfaces on `GET /v1/models` when wired. - [`qwen3-embedding-0.6b.md`](qwen3-embedding-0.6b.md) — the hot-path gear - [`gateway-fleet.md`](gateway-fleet.md) — backend wiring and alias resolution -- [`colleague-stack.md`](colleague-stack.md) — the eight-role contract +- [`colleague-stack.md`](colleague-stack.md) — the nine-role contract diff --git a/docs/qwen3.5-4b-minor.md b/docs/qwen3.5-4b-minor.md index eb56563..d750809 100644 --- a/docs/qwen3.5-4b-minor.md +++ b/docs/qwen3.5-4b-minor.md @@ -1,7 +1,24 @@ -# Qwen/Qwen3.5-4B — "minor" small-brain gear - -**Role:** `minor` — the fleet's 4B small-brain companion to the 27B primary. -**Status:** `configured` (not yet load-tested on the DGX Spark). +# Qwen/Qwen3.5-4B — the demoted small-brain gear + +**Role:** `candidate` — DEMOTED 2026-08-10, kept not deleted. +**Status:** `configured` (never load-tested on the DGX Spark). + +> **This is no longer the `minor`/`cheap` tier.** The **`hand` lobe** +> (`LiquidAI/LFM2.5-1.2B-Instruct`) replaced it in that slot and inherited both +> of the duties described below — the cheap-tier gear AND the LoRA fine-tune +> target — as a first-class Colleague role. See +> [`docs/lfm2.5-1.2b-hand.md`](lfm2.5-1.2b-hand.md). +> +> Nothing about *this* checkpoint changed; only which gear the tier resolves to. +> It stays in the catalog (cite-don't-delete) as a plain candidate, still +> selectable via `lobes switch` and still runnable as the opt-in `vllm-minor` +> compose service (`COMPOSE_PROFILES=minor`) — but it is now addressable **only +> by explicit model id**, exactly like the legacy 14B `middle` gear. No tier +> alias resolves to it. +> +> Read the rest of this document in the past tense: the "will be" statements +> about LoRA below were never realised on this checkpoint, and that is precisely +> why the role moved. ## What it is diff --git a/docs/qwen3.6-35b-a3b-nvfp4.md b/docs/qwen3.6-35b-a3b-nvfp4.md index 789df0c..aebab76 100644 --- a/docs/qwen3.6-35b-a3b-nvfp4.md +++ b/docs/qwen3.6-35b-a3b-nvfp4.md @@ -115,7 +115,7 @@ direction; it never makes the final call or a security decision on its own authority. This is a materially different contract from `senses` (perceives, never acts) and `muse` (proposes via tool calls, never acts) — see [`docs/colleague-stack.md`](colleague-stack.md) for the full division of -labour across all eight roles. +labour across all nine roles. ### Tool calling: the Qwen-family parser pair @@ -173,7 +173,7 @@ box). ### Related docs -- [`docs/colleague-stack.md`](colleague-stack.md) — the eight-role Colleague +- [`docs/colleague-stack.md`](colleague-stack.md) — the nine-role Colleague contract, `worker`'s `responsibilities`/`forbidden_responsibilities` exactly as declared, and the "first non-`cortex` actor" division of labour. - [`docs/deployment-shapes.md`](deployment-shapes.md) — the opt-in-core-role @@ -182,7 +182,7 @@ box). inverted feasibility default, peer channels, pressure policy. - [`docs/gemma-4-31b-nvfp4.md`](gemma-4-31b-nvfp4.md) — `muse`, the sibling opt-in core role, now DORMANT on the box `worker` is moving onto. -- `CLAUDE.md`'s "Colleague roles" section — the eight-role summary and the +- `CLAUDE.md`'s "Colleague roles" section — the nine-role summary and the muse-to-worker mesh migration in one place. --- diff --git a/lobes/cli/__init__.py b/lobes/cli/__init__.py index 0ff24e4..0547776 100644 --- a/lobes/cli/__init__.py +++ b/lobes/cli/__init__.py @@ -138,7 +138,7 @@ def _build_parser() -> argparse.ArgumentParser: _explain_cmd.register(sub) _overview_cmd.register(sub) _doctor_cmd.register(sub) - # The #81 Colleague contract: the seven first-class roles resolved to live + # The #81 Colleague contract: the nine first-class roles resolved to live # endpoint + metadata (read-only; no --apply — capabilities/endpoint never # touch docker/compose). _capabilities_cmd.register(sub) diff --git a/lobes/cli/_commands/capabilities.py b/lobes/cli/_commands/capabilities.py index 97a9fec..556ce66 100644 --- a/lobes/cli/_commands/capabilities.py +++ b/lobes/cli/_commands/capabilities.py @@ -2,7 +2,7 @@ (issue #81; the gateway-client rewrite is issue #96, plan "advertised implies reachable" task t7). -Both verbs used to REBUILD the six-role registry from the deployment's +Both verbs used to REBUILD the role registry from the deployment's ``.env`` and call that the truth — this module's docstring used to claim the CLI and the gateway share "exactly one source of truth" because both called the same pure builder, :func:`lobes.roles.role_registry_from_env` / @@ -50,13 +50,13 @@ of health — this generalises issue #96's fix past stt/tts to all six roles), and the mode is always discoverable — but NOT by adding a key to the JSON payload. A prior revision of this module added a top-level - ``source`` sibling next to the six role keys in ``--json`` output; a Qodo + ``source`` sibling next to the role keys in ``--json`` output; a Qodo action-required finding on PR #102 correctly flagged that as a second, new divergence from the gateway's own contract (``GET /capabilities`` returns *exactly* ``{cortex, senses, embedder, reranker, stt, tts}``, so a caller doing ``set(payload) == ROLES`` broke on the extra key — ironic, since t7's whole point was to make the CLI and gateway agree). The fix: - ``--json`` output is now, in EVERY mode, the bare six-role dict and + ``--json`` output is now, in EVERY mode, the bare role-keyed dict and nothing else — byte-for-byte what the gateway would return in gateway mode. The offline/gateway distinction is instead surfaced out-of-band: the human-readable table keeps its ``# source: ...`` header line (stdout, @@ -127,7 +127,7 @@ # The full RoleInfo field set — used to sanity-check a gateway response before # trusting it. A 200 from *something* listening on the resolved port whose -# body happens to be a dict keyed by all six role names but missing fields a +# body happens to be a dict keyed by all nine role names but missing fields a # real /capabilities response always carries is treated as malformed, not # authoritative (see _fetch_gateway_capabilities). _ROLE_INFO_FIELDS = {f.name for f in dataclasses.fields(RoleInfo)} @@ -146,7 +146,7 @@ # The core field set below is already conclusive for the check's ACTUAL job — # telling a real gateway from a stray daemon on a guessed port (see the # lobes.roles._gateway_base_url docstring for why that hazard is real on this -# rig). Nothing that answers with every core field, keyed by all seven role +# rig). Nothing that answers with every core field, keyed by all nine role # names, is a stray uvicorn. `_render_table` already `.get`s both keys with # safe defaults, so an older payload renders without fabricating either. _ADDITIVE_ROLE_FIELDS = frozenset({"tools", "feasible"}) @@ -248,7 +248,7 @@ def _role_payload(info: RoleInfo) -> dict[str, object]: def _capabilities_view(args: argparse.Namespace) -> tuple[dict[str, dict], str]: - """Resolve the six-role payload, preferring the live gateway. + """Resolve the role-keyed payload, preferring the live gateway. Returns ``(payload, source)``: ``payload`` is a dict keyed by all six :data:`ROLES` (each value the role's JSON metadata), and ``source`` is @@ -348,7 +348,7 @@ def cmd_capabilities(args: argparse.Namespace) -> int: with _runtime_ops.friendly_unauthorized_errors(deploy_dir): payload, source = _capabilities_view(args) if json_mode: - # The JSON payload is the bare six-role dict in EVERY mode — no + # The JSON payload is the bare role-keyed dict in EVERY mode — no # "source"/mode key is ever mixed into it. In gateway mode this is # the live GET /capabilities body rendered verbatim; in offline mode # it is the .env-derived fallback (every role's ready forced False), @@ -395,7 +395,7 @@ def _add_common_args(p: argparse.ArgumentParser) -> None: def register(sub: argparse._SubParsersAction) -> None: cap = sub.add_parser( "capabilities", - help="Read-only: the eight first-class roles (cortex/senses/muse/worker/embedder/" + help="Read-only: the nine first-class roles (cortex/senses/muse/worker/hand/embedder/" "reranker/stt/tts) resolved to live endpoint + metadata (issue #81).", ) _add_common_args(cap) diff --git a/lobes/cli/_commands/init.py b/lobes/cli/_commands/init.py index 6642d45..696c378 100644 --- a/lobes/cli/_commands/init.py +++ b/lobes/cli/_commands/init.py @@ -22,7 +22,7 @@ :class:`~lobes.profiles.schema.Profile` detection/``--profile`` resolves (the per-machine TUNING axis, issue #110). Fleet topology only (a fleet-scaffold axis — incompatible with ``--single``). The default, ``machine-as-brain``, -hosts every one of the six default-hosted Colleague roles this card can serve — +hosts every one of the seven default-hosted Colleague roles this card can serve — today's behaviour, unchanged — and t3's :func:`~lobes.profiles.shape_render.render_shape` composes it as a strict no-op over the profile, so a bare ``lobes init`` (no ``--shape`` at all) makes diff --git a/lobes/cli/_commands/learn.py b/lobes/cli/_commands/learn.py index 0a66b68..4c62f1a 100644 --- a/lobes/cli/_commands/learn.py +++ b/lobes/cli/_commands/learn.py @@ -126,7 +126,7 @@ lobes explain rerank (POST /v1/rerank + /v1/score — the reranker gear) lobes explain realtime (the /v1/audio/* overlay — Parakeet STT + Chatterbox TTS) lobes explain api (the full OpenAI-compatible endpoint surface) - lobes explain roles (the six-role Colleague contract: cortex/senses + services) + lobes explain roles (the nine-role Colleague contract: cortex/senses/hand + services) lobes explain gateway (the fleet front — routing, /status, auth limitation) lobes explain tunnel (expose the local API anywhere via Cloudflare Tunnel) diff --git a/lobes/cli/_commands/measure.py b/lobes/cli/_commands/measure.py index c8fb131..f27d6f0 100644 --- a/lobes/cli/_commands/measure.py +++ b/lobes/cli/_commands/measure.py @@ -1,6 +1,6 @@ """``lobes measure`` — read-only per-role RUNTIME measurement (issue #81, t8). -Probes each of the eight first-class roles (``cortex``/``senses``/``muse``/``worker``/ +Probes each of the nine first-class roles (``cortex``/``senses``/``muse``/``worker``/``hand``/ ``embedder``/``reranker``/``stt``/``tts``) on its own live endpoint and reports **runtime** metrics, organised BY ROLE: TTFT/decode-tps/prefill-tps/context(+mem, when cheaply available) for the LLM roles, requests-or-docs-per-sec/latency/batch/ diff --git a/lobes/cli/_commands/overview.py b/lobes/cli/_commands/overview.py index 80deff1..5c2ee63 100644 --- a/lobes/cli/_commands/overview.py +++ b/lobes/cli/_commands/overview.py @@ -41,7 +41,7 @@ "whoami — tool, machine, served model, container health", "explain — markdown docs for a topic", "doctor — diagnose docker / compose / .env / health", - "capabilities [--json] — read-only: the eight first-class roles (cortex/senses/muse/" + "capabilities [--json] — read-only: the nine first-class roles (cortex/senses/muse/" "worker/embedder/reranker/stt/tts) resolved to live endpoint + metadata (issue #81)", "endpoint — read-only: print one role's base URL", "measure [--role ] [--json] — read-only: per-role RUNTIME metrics " diff --git a/lobes/cli/_commands/up.py b/lobes/cli/_commands/up.py index 1008ce3..2503057 100644 --- a/lobes/cli/_commands/up.py +++ b/lobes/cli/_commands/up.py @@ -24,7 +24,7 @@ ``container_name:`` values — ``docker compose up -d `` addresses services. **r4 (issue #81) — colleague-stack bundles audio.** ``colleague-stack`` is a -first-class target that brings up the FULL six-role Colleague set = the default +first-class target that brings up the FULL seven-role default-hosted set = the default fleet roles (cortex/senses/embedder/reranker) PLUS the audio-overlay roles (stt/tts). It therefore REQUIRES the audio overlay compose file (``docker-compose.audio.yml``), scaffolded by ``lobes init --fleet --audio``; if diff --git a/lobes/explain/catalog.py b/lobes/explain/catalog.py index a13e6be..98c01ae 100644 --- a/lobes/explain/catalog.py +++ b/lobes/explain/catalog.py @@ -64,7 +64,7 @@ - `lobes explain tuning` (purpose + machine profiles) - `lobes explain fleet` - `lobes explain gateway` -- `lobes explain roles` (the seven-role Colleague contract: +- `lobes explain roles` (the nine-role Colleague contract: cortex/senses/muse/embedder/reranker/stt/tts) - `lobes explain tunnel` (expose the API from anywhere) - `lobes explain assess` @@ -747,13 +747,14 @@ ## Knobs per role -Each of the five core roles (cortex, senses, muse, embedder, reranker) carries -tunable +Each of the seven core roles (cortex, senses, muse, worker, hand, embedder, +reranker) carries tunable knobs (each optional — "no opinion" = template default applies): `feasible`, `model`, `gpu_mem_util`, `max_model_len`, `quantization`, `kv_cache_dtype`, `attention_backend`, `enforce_eager`, `max_num_seqs`. -Render to env vars via role→prefix (`cortex` → `PRIMARY_`, `senses` → +Render to env vars via role→prefix (`cortex` → `PRIMARY_`, `hand` → `HAND_`, +`senses` → `MULTIMODAL_`, `muse` → `MUSE_`, `embedder` → `EMBED_`, `reranker` → `RERANK_`), so `cortex.gpu_mem_util=0.30` → `PRIMARY_GPU_MEM_UTIL=0.30`. @@ -813,14 +814,14 @@ A **deployment shape** is the axis orthogonal to the machine profile: not "how is each role tuned on this card?" (that's the profile, `lobes explain -profiles`) but "which of the seven Colleague roles does this box host at +profiles`) but "which of the nine Colleague roles does this box host at all?" A shape composes as pure data over the resolved card profile at render time — `shape × card` — never a per-shape code fork. ## The built-in shapes - **`machine-as-brain`** (the default) — hosts every role the card can - serve (the six DEFAULT_HOSTED_ROLES — never the opt-in `muse`, below). + serve (the seven DEFAULT_HOSTED_ROLES — never the opt-in `muse`/`worker`). Carries zero overrides and renders byte-identically to the bare card profile: a non-hosted opt-in core role renders nothing at all. No `MUSE_FEASIBLE=false` marker is needed — the gateway already treats an @@ -937,7 +938,7 @@ co-residency tax numbers, the mesh-brain end-state decisions, the acceptance script, the dev lane) - `lobes explain profiles` — the per-machine tuning axis this composes with -- `lobes explain roles` — the seven-role Colleague contract +- `lobes explain roles` — the nine-role Colleague contract - `lobes/profiles/shapes.py` / `shape_render.py` — the schema + renderer - `lobes/profiles/builtin_shapes/*.toml` — the five shipped shapes - `scripts/accept-shape.sh` — the live acceptance script @@ -1152,7 +1153,7 @@ | `/v1/realtime` | GET (WS upgrade) | the realtime bridge, tunneled (audio overlay) | | `/v1/models` | GET | the backends loaded now (what's hot) | | `/v1/models/supported` | GET | the supported catalog (what you can switch to) | -| `/capabilities` | GET | the seven-role Colleague contract (`lobes explain roles`) | +| `/capabilities` | GET | the nine-role Colleague contract (`lobes explain roles`) | | `/health` | GET | gateway liveness | ## Routing @@ -1177,14 +1178,14 @@ See `lobes explain gateway` (routing), `lobes explain embeddings|rerank|score` (per-endpoint shapes), `lobes explain realtime` (audio), `lobes explain roles` -(the seven-role Colleague contract), and `docs/openai-api.md` for the full +(the nine-role Colleague contract), and `docs/openai-api.md` for the full reference with `curl` examples and auth/exposure. """ _ROLES = """\ -# lobes explain roles — the seven-role Colleague contract +# lobes explain roles — the nine-role Colleague contract -lobes exposes the fleet as SEVEN first-class, Colleague-facing **roles** +lobes exposes the fleet as NINE first-class, Colleague-facing **roles** (issue #81) — a caller addresses a *capability*, never a hardcoded model id: | Role | Backend | Endpoint path | @@ -1192,6 +1193,8 @@ | `cortex` | `primary` (27B MTP) | `/v1/chat/completions` | | `senses` | `multimodal` (Gemma 4 12B) | `/v1/chat/completions` | | `muse` | `muse` (Gemma 4 31B NVFP4, opt-in hosting) | `/v1/chat/completions` | +| `worker` | `worker` (Qwen3.6-35B-A3B NVFP4, opt-in hosting) | `/v1/chat/completions` | +| `hand` | `hand` (LFM2.5-1.2B, default-hosted everywhere) | `/v1/chat/completions` | | `embedder` | `embed` (Qwen3-Embedding-0.6B) | `/v1/embeddings` | | `reranker` | `rerank` (Qwen3-Reranker-0.6B) | `/v1/rerank` (+ `/v1/score`) | | `stt` | Parakeet (audio overlay, opt-in) | `/v1/audio/transcriptions` | @@ -1205,15 +1208,25 @@ prepare_context_packet, speak_back. Forbidden: final_decision, repo_action, security_decision. - `muse` — creative_generation, long_form_writing, ideation, - style_variation, divergent_second_opinion. Forbidden: final_decision, - repo_action, security_decision (muse proposes, cortex decides). + style_variation, divergent_second_opinion, tool_use. Forbidden: + final_decision, repo_action, security_decision (muse proposes, cortex + decides). +- `worker` — execution, ground_work, bulk_transform, drafting, + image_understanding, video_understanding, tool_use, repo_action. Forbidden: + final_decision, security_decision — the only non-cortex role that may ACT on + the repo, under cortex's direction. +- `hand` — domain_mastery, learned_skill, specialized_task, tool_use. + Forbidden: final_decision, repo_action, security_decision. The fleet's + fine-tuning base: one cheap base, many LoRA adapters, each mastering a + domain. `model=hand` serves the base, `model=hand:` an adapter. - `embedder` — vectorization, memory_retrieval_input. Forbidden: *(none)*. - `reranker` — retrieval_ordering, relevance_refinement. Forbidden: *(none)*. - `stt` — transcribe, audio_input_to_text. Forbidden: *(none)*. - `tts` — speech_output, synthesize. Forbidden: *(none)*. -`cortex`/`senses`/`muse`/`embedder`/`reranker` are always enumerated (present -with `loaded: false` if unwired); `stt`/`tts` need `lobes init --fleet --audio`. +`cortex`/`senses`/`muse`/`worker`/`hand`/`embedder`/`reranker` are always +enumerated (present with `loaded: false` if unwired); `stt`/`tts` need +`lobes init --fleet --audio`. **`muse` is opt-in for HOSTING** — machine-as-brain never hosts the 31B (it cannot co-reside with the cortex+senses duo on a 128 GB box); only a muse-hosting shape (`thor-muse`, DECLARED/UNVALIDATED — budget measured @@ -1229,14 +1242,16 @@ `senses` == the `multimodal` backend == tier alias `multimodal` (back-compat `normal`). All four names resolve to the SAME warm backend — no internal service/container/env var was renamed; this is additive vocabulary. `muse` -is the first role whose name IS its backend and tier name (`model=muse`, no -back-compat alias); the tier capability order is -minor < multimodal < muse < main. +was the first role whose name IS its backend and tier name (`model=muse`, no +back-compat alias); `worker` and `hand` are the same way. The tier capability +order is hand < multimodal < worker < muse < main, and `hand` also answers to +the back-compat `minor`/`cheap` spellings it inherited when it replaced +Qwen3.5-4B in that tier. ## Discovery: `lobes capabilities` / `lobes endpoint` / `GET /capabilities` ```bash -lobes capabilities # human table, all seven roles +lobes capabilities # human table, all nine roles lobes capabilities --json # the machine-readable contract lobes endpoint cortex # just the base URL for one role curl -s http://localhost:8000/capabilities # same contract, over HTTP @@ -1259,12 +1274,15 @@ ## Serving and measuring - `lobes up [--apply]` — start (or `--down`: stop) ONE role's gear; - `lobes up colleague-stack --apply` brings up the SIX default roles - (requires the audio overlay scaffolded; `muse` is deliberately excluded — - `lobes up muse` works on a muse-hosting deployment and errors helpfully - when COMPOSE_PROFILES doesn't include muse). Dry-run by default. + `lobes up colleague-stack --apply` brings up the SEVEN default-hosted roles + (requires the audio overlay scaffolded; `muse` and `worker` are deliberately + excluded, both being opt-in-hosted and compose-profile-gated — `lobes up + muse` works on a muse-hosting deployment and errors helpfully when + COMPOSE_PROFILES doesn't include muse. `hand` IS included). Dry-run by + default. - `lobes measure [--role ] [--json]` — read-only per-role RUNTIME - metrics (ttft/decode/prefill for cortex/senses/muse; reqs-per-sec/latency + metrics (ttft/decode/prefill for cortex/senses/muse/worker/hand; + reqs-per-sec/latency for embedder/reranker; RTF/latency for stt/tts). Never a correctness claim. - `lobes benchmark --profile {cortex-only,cortex+senses,senses-direct, qwen-nvfp4-vs-bf16,all}` — RUNTIME-ONLY side-by-side comparison across a diff --git a/lobes/gateway/server.py b/lobes/gateway/server.py index a3cabc3..95b73c5 100644 --- a/lobes/gateway/server.py +++ b/lobes/gateway/server.py @@ -601,7 +601,7 @@ def _role_infeasible_body( """4xx body for a request pinned to a HARDWARE-infeasible backend (t6). Distinct ``type``/``code`` from :func:`_model_not_found_body`: the - requested id/role IS part of the six-role contract (it may even be + requested id/role IS part of the role contract (it may even be wired — the primary is unconditionally wired regardless of feasibility) but this machine's per-machine profile declared its owning backend (``backend_name``) unable to serve it at all. Never a reason to @@ -1857,7 +1857,7 @@ def capabilities_payload( audio_ready: bool | None = None, backend_ready: Mapping[str, bool | None] | None = None, ) -> dict: - """The seven first-class roles (issue #81), resolved via the shared registry. + """The nine first-class roles (issue #81), resolved via the shared registry. ``env`` defaults to ``os.environ``. The fleet compose passes the served ``PRIMARY_MAX_MODEL_LEN`` / ``MULTIMODAL_MAX_MODEL_LEN`` / @@ -2219,7 +2219,7 @@ def _get_v1_models(self) -> None: ) def _get_capabilities(self) -> None: - # The #81 role→endpoint contract: SEVEN first-class roles resolved to + # The #81 role→endpoint contract: NINE first-class roles resolved to # live metadata via the shared lobes.roles registry. The endpoint is # the client-reachable origin this request actually dialed (#87), # stt/tts readiness is a live probe of the audio backend (#89), and the diff --git a/lobes/profiles/builtin_shapes/machine-as-brain.toml b/lobes/profiles/builtin_shapes/machine-as-brain.toml index 576f2ed..5c2ade6 100644 --- a/lobes/profiles/builtin_shapes/machine-as-brain.toml +++ b/lobes/profiles/builtin_shapes/machine-as-brain.toml @@ -4,7 +4,7 @@ # later task, t4, wires the flag; this file only needs to exist and round-trip # so t3's shape-x-card renderer has a first, whole-brain shape to compose). # -# Carries every one of the six first-class Colleague roles (issue #81) -- +# Carries every one of the seven DEFAULT-HOSTED Colleague roles (issue #81) -- # the four Profile-machinery core roles (cortex/senses/embedder/reranker, # lobes/profiles/schema.py) plus the audio-overlay pair (stt/tts, # lobes/templates/fleet/docker-compose.audio.yml). Whether a hosted role is diff --git a/lobes/profiles/builtin_shapes/orin-small.toml b/lobes/profiles/builtin_shapes/orin-small.toml index 5dccda1..a74f5f3 100644 --- a/lobes/profiles/builtin_shapes/orin-small.toml +++ b/lobes/profiles/builtin_shapes/orin-small.toml @@ -29,7 +29,7 @@ # hardware). # # WHY `minor`, NOT `cortex`, hosts the generate lane here: the Shape schema's -# `hosts` vocabulary (SHAPE_ROLES) is normally exactly the six first-class +# `hosts` vocabulary (SHAPE_ROLES) is normally exactly the nine first-class # Colleague roles (issue #81) -- cortex/senses/embedder/reranker/stt/tts. # `minor` is deliberately NOT one of those six (see CLAUDE.md's "Colleague # roles" section: "the 4B minor ... are opt-in gears and not first-class diff --git a/lobes/profiles/shapes.py b/lobes/profiles/shapes.py index 1ed21af..d2db52f 100644 --- a/lobes/profiles/shapes.py +++ b/lobes/profiles/shapes.py @@ -108,7 +108,7 @@ # The opt-in `minor` compose service (`vllm-minor`, gated in the fleet # template by the "minor" Docker Compose profile -- see env.example's # `COMPOSE_PROFILES=minor`) -- a light 4B bf16 generate gear that is -# DELIBERATELY NOT one of the six first-class Colleague roles (issue #81; +# DELIBERATELY NOT one of the nine first-class Colleague roles (issue #81; # see CLAUDE.md's "Colleague roles" section: "the 4B minor ... are opt-in # gears and not first-class Colleague roles"). Added to the Shape schema's # hostable vocabulary for the mesh-brain end-state's t2 (issue #112): a box @@ -129,7 +129,7 @@ # Every role a Shape may declare hosted: :data:`COLLEAGUE_ROLES` (the seven # first-class, Colleague-facing roles) plus the opt-in `minor` gear -# (:data:`OPT_IN_ROLES`) -- the one addition beyond that seven-role vocabulary. +# (:data:`OPT_IN_ROLES`) -- the one addition beyond that nine-role vocabulary. SHAPE_ROLES: tuple[str, ...] = COLLEAGUE_ROLES + OPT_IN_ROLES BUILTIN_SHAPES_PACKAGE = "lobes.profiles.builtin_shapes" diff --git a/lobes/roles.py b/lobes/roles.py index 6e0d2b4..fc51fce 100644 --- a/lobes/roles.py +++ b/lobes/roles.py @@ -95,12 +95,12 @@ ) # role → the internal gateway backend NAME that serves it — the key space the -# RoutingTable's feasibility/peer channels use. The six gateway-fronted roles +# RoutingTable's feasibility/peer channels use. The seven gateway-fronted roles # map to their vLLM backends; ``stt``/``tts`` (first-class since issue #129) # map to themselves — they are path-routed audio lanes, not model-routed # backends (still resolved from ``ServerConfig.audio_url`` below), but their # names now ride the SAME ``FEASIBLE_ENV`` / peer origin/proxy/key channels, -# so :func:`annotate_peer_referrals` covers all eight roles uniformly. +# so :func:`annotate_peer_referrals` covers all nine roles uniformly. # NOTE the name↔role_hint mismatch for the pooling lane: the *backend* is named # ``embed``/``rerank`` while the *catalog* role_hint is ``embedding``/``reranker``. # ``muse`` and ``worker`` each use their own name as their backend name. @@ -154,7 +154,7 @@ _STT_RUNTIME = "parakeet" _TTS_MODEL = "ResembleAI/chatterbox" # Chatterbox, Resemble AI 0.5B, Apache-2.0 _TTS_RUNTIME = "chatterbox" -_VLLM_RUNTIME = "vllm" # the six gateway-fronted roles all serve on vLLM +_VLLM_RUNTIME = "vllm" # the seven gateway-fronted roles all serve on vLLM # Canonical responsibilities per role (issue #81 worked examples — PROVISIONAL, # see the module docstring). A role's responsibilities are what it is EXPECTED to @@ -379,7 +379,7 @@ class RoleInfo: # build_role_registry: `backend_ready` (keyed by the ROLE_BACKEND name) # for the six gateway-fronted roles, `audio_ready` for stt/tts (issue # #89). Generalised from the stt/tts-only split (issue #89/#90) to all - # eight roles (issue #81 t5) — `ready` is no longer a bare alias of `loaded`. + # nine roles (issue #81 t5) — `ready` is no longer a bare alias of `loaded`. # # `backend_ready` is TRI-STATE PER BACKEND but resolves to `ready` under a # SUPPLIED-vs-OMITTED rule the builder self-enforces (issue #92 / honesty @@ -403,7 +403,7 @@ class RoleInfo: # `False` (task t6) can never report `ready=True`, no matter what signal a # caller passes in. This mirrors — and is enforced by the same code path # as — the stt/tts clamp on `audio_configured` (issue #89/#90 review - # finding), now applied to all eight roles by build_role_registry itself, + # finding), now applied to all nine roles by build_role_registry itself, # not left to caller discipline. The `feasible` clamp is what makes an # infeasible-but-HEALTHY role (a live `backend_ready=True` signal) still # report `ready=False` — a healthy PROCESS is not evidence this MACHINE @@ -661,7 +661,7 @@ def build_role_registry( backend_ready: Mapping[str, bool | None] | None = None, peer_ready: Mapping[str, bool | None] | None = None, ) -> dict[str, RoleInfo]: - """Resolve the eight first-class roles to live metadata — the #81 contract. + """Resolve the nine first-class roles to live metadata — the #81 contract. This is the ONE canonical builder both the CLI (t5) and gateway (t6) call. Its inputs are exactly what :func:`lobes.gateway._config.build_config` @@ -736,14 +736,14 @@ def build_role_registry( and every deployment with no proxied roles) leaves every role's ``ready`` exactly as before: a proxied role without a live peer signal is honestly not-ready, never hardcoded true. - :returns: an ordered ``dict`` keyed by role name with EXACTLY the eight roles. + :returns: an ordered ``dict`` keyed by role name with EXACTLY the nine roles. Every role is always present — an unconfigured/opt-in role (stt/tts with ``audio_url`` unset, or an unwired embed/rerank/multimodal backend) is returned with ``loaded=False``, never omitted and never raising. Readiness (``RoleInfo.ready``) is no longer a bare alias of ``loaded`` (issue #81 t5 — generalising the stt/tts split from issue #89/#90 to all - eight roles). When a caller supplies ``backend_ready``/``audio_ready`` it is + nine roles). When a caller supplies ``backend_ready``/``audio_ready`` it is AUTHORITATIVE (a present ``None``/``False`` or a missing key ⇒ not ready); only an OMITTED signal falls back to the coarse "configured/wired" ``loaded`` proxy. Either way it is CLAMPED, here, to ``False`` whenever a diff --git a/lobes/roles_measure.py b/lobes/roles_measure.py index d3edd22..0ae0647 100644 --- a/lobes/roles_measure.py +++ b/lobes/roles_measure.py @@ -1,6 +1,6 @@ """Per-role RUNTIME measurement — issue #81, task t8. -Probes each of the seven first-class roles (:data:`lobes.roles.ROLES`) on its own +Probes each of the nine first-class roles (:data:`lobes.roles.ROLES`) on its own live endpoint and reports **runtime/serving** metrics, organised BY ROLE and grouped by the metric family its ``runtime`` implies: From f3389baf25259ae3a64ba9e53183e602c04ccda3 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 06:27:59 +0300 Subject: [PATCH 05/17] fix: the hand lane was missing the cudagraph-estimate off-switch (t10, live) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEASURED on a physical Jetson AGX Orin, 2026-08-10. Booting the lane as committed produced: Available KV cache memory: -9.25 GiB at util 0.10 — NEGATIVE, so the engine could not allocate a KV cache at all and the boot died. vLLM's own log named the cause: its cudagraph memory ESTIMATE, which every other lane on this nightly image already disables (primary, embed, rerank, multimodal, multimodal-coder, muse, worker all set VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0). The hand lane was the only omission — a copy-paste gap that offline tests could not have caught, since nothing about the rendered compose is wrong until an engine profiles it. vLLM offered two remedies: raise util 0.10 -> 0.2762, or disable the estimate. We disable it, matching every sibling lane. Raising util instead would make `hand` cost 4.6x more memory on every card to buy exactly nothing. Also adds the HAND_CUDAGRAPH_ESTIMATE escape hatch (default 0), mirroring MULTIMODAL_/MUSE_/MULTIMODAL_CODER_CUDAGRAPH_ESTIMATE. Also bumps to 0.56.0 with the full changelog for the hand lobe. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- CHANGELOG.md | 24 ++++++++++++++++++++++++ lobes/templates/fleet/docker-compose.yml | 11 +++++++++++ lobes/templates/fleet/env.example | 7 +++++++ pyproject.toml | 2 +- tests/goldens/template-defaults.env | 1 + tests/test_tool_parser_plugin.py | 2 +- uv.lock | 2 +- 7 files changed, 46 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 374d390..2cda0cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.56.0] - 2026-08-10 + +### Added + +- **`hand` — the NINTH Colleague role** (`LiquidAI/LFM2.5-1.2B-Instruct`), the fleet's designated **fine-tuning base**. "Muscle memory": one cheap base, many LoRA adapters, each mastering a domain. Where `worker` is an untrained generalist doer, `hand` is a trained specialist. At ~1.2B (~2.4 GiB bf16) it is cheap enough to co-reside on **every card**, so unlike `muse`/`worker` it is **default-hosted by every built-in shape** and **never proxied to a peer** (`NEVER_PROXIED_BACKENDS` names that absence so a symmetry-minded refactor must delete a constant to break it). Responsibilities `domain_mastery`/`learned_skill`/`specialized_task`/`tool_use`; forbidden `final_decision`/`repo_action`/`security_decision`. See `docs/lfm2.5-1.2b-hand.md` +- **LoRA adapter serving** — the `vllm-hand` lane ships **armed** (`--enable-lora`, `--max-loras=4`, `--max-lora-rank=32`) with the inventory **empty**: v1 has zero adapters. Adapters are declared once in `HAND_LORA_MODULES` (`name=path`, comma-separated), read by BOTH the engine's `--lora-modules` and the gateway's alias derivation so the two cannot disagree, and fixed at boot — there is no runtime hot-load. `model=hand` serves the base and never 404s on an empty inventory; `model=hand:` serves that adapter; an UNdeclared `hand:` is refused with `model_not_found`, never silently downgraded to the base +- **Adapter honesty (#92 for adapters)** — a declared adapter is advertised on `GET /v1/models` and `/capabilities` only once the lane's OWN `/v1/models` confirms the engine loaded it (`_readiness.probe_backend_adapters`). Deliberately asks the ENGINE, not the filesystem: adapter paths are mounted into `vllm-hand`, not the gateway, so a path check there would false-negative every correct config while still missing the failures that matter (unreadable file, rank above `--max-lora-rank`, a checkpoint vLLM refused) +- **`lfm2` tool-call parser** — LFM2 emits `<|tool_call_start|>…<|tool_call_end|>`, whose delimiters are **special tokens**: the same trap that made `pythonic` silently wrong for Gemma 4. vLLM's purpose-built `lfm2` parser resolves both delimiters in `__init__` and **raises** when either is missing, so a bad tokenizer revision fails loudly at startup rather than relaying a well-formed call as prose. No `--reasoning-parser`: this checkpoint has no thinking mode (LiquidAI ships `LFM2.5-1.2B-Thinking` separately), so unlike the cortex and Gemma 4 lanes there is no second half to pair with +- `docs/lfm2.5-1.2b-hand.md` — the per-model reference, and an **"adding a role is effectively irreversible"** callout in `docs/colleague-stack.md` enumerating the surfaces a role name lands on + +### Changed + +- **`hand` replaced `Qwen/Qwen3.5-4B` as the `minor`/`cheap` tier.** Both spellings still work and now resolve to `hand`; the 4B stays in the catalog as a plain `candidate` (cite-don't-delete), still selectable via `lobes switch` and still runnable as the opt-in `vllm-minor` compose service, but no tier alias resolves to it any more — it is addressable only by explicit model id, exactly like the legacy 14B `middle` gear. Capability order is now `hand` < `multimodal` < `worker` < `muse` < `main` +- **`hand` is the pressure-policy servable floor.** Under swap > 75 % / iowait > 50 %, `cortex`/`senses`/`worker`/`muse` still shed with 429 + `Retry-After` and `hand` is always served — the floor's PROMISE is unchanged, only its name (a `minor`/`cheap` request normalizes to `hand`). `servable_tier` therefore reports `"hand"` where it previously reported `"minor"` +- `colleague-stack` is the **seven** default-hosted roles (`hand` joins; `muse`/`worker` stay excluded, both being opt-in-hosted and compose-profile-gated) +- `mg-logwrap` now drops an argument that is exactly `--flag=` with an empty value. A compose `command:` list cannot omit an argument conditionally, so an unset templated flag renders as a bare `--flag=` — and vLLM would parse `--lora-modules=` as a malformed `name=path` pair. The rule is narrow by construction: a flag with a value, a bare `--flag`, a lone `--`, a short `-x`, and every non-flag argument all pass through untouched +- Role-count prose swept to nine across `docs/`, `CLAUDE.md` and `README.md`. The sweep also corrected counts that were **already stale before this change** and had never caught up with `worker` — `lobes/explain/catalog.py` said SEVEN throughout, and several `capabilities`/`measure`/`learn` strings said six or seven +- `docs/qwen3.5-4b-minor.md` re-headed as the DEMOTED gear, with a note to read its (never-realised) LoRA promises in the past tense — that unfulfilled plan is precisely why the role moved + +### Fixed + +- `lobes measure` would have raised `KeyError` on any role missing from `roles_measure._FAMILY_BY_ROLE` — a crash, not a degraded reading. Both that map and `_MEASURE_FN` now cover every role, and a parametrised test iterates `ROLES` so the class cannot recur +- `build_role_registry` iterated a **hand-typed copy** of the gateway-fronted roles instead of deriving them from `ROLES`, so a new role could be registered in all six per-role tables yet be silently missing from the registry the CLI and `GET /capabilities` both read. Now derived (`GATEWAY_FRONTED_ROLES`), with a parametrised completeness test per table — the same half-landed-role failure mode that made `WORKER_PEER_PROXY=true` inert in 0.54.6 + ## [0.55.1] - 2026-08-10 **Three surfaces still named the checkpoint 0.54.9 demoted.** The multimodal diff --git a/lobes/templates/fleet/docker-compose.yml b/lobes/templates/fleet/docker-compose.yml index 4d38c7b..0f60271 100644 --- a/lobes/templates/fleet/docker-compose.yml +++ b/lobes/templates/fleet/docker-compose.yml @@ -511,6 +511,17 @@ services: environment: - HF_HOME=/root/.cache/huggingface - TOKENIZERS_PARALLELISM=false + # The nightly's cudagraph memory ESTIMATE, disabled here as it is on every + # other lane on this image (primary, embed, rerank, multimodal, muse, + # worker). MEASURED on a physical Jetson AGX Orin 2026-08-10: with the + # estimate ON, this lane profiles to **Available KV cache memory: + # -9.25 GiB** at util 0.10 — NEGATIVE, so the engine cannot allocate a KV + # cache at all and the boot dies. vLLM's own log names the cause and + # suggests either raising util to 0.2762 or disabling the estimate; we + # disable it, matching every sibling lane, so the util knob maps back to + # true usage instead of a padded guess. Raising util instead would make + # `hand` cost 4.6x more memory on every card to buy nothing. + - VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=${HAND_CUDAGRAPH_ESTIMATE:-0} - MG_LOG_DIR=/logs/model-gear # in-container mount point (explicit, no drift) - MG_LOG_NAME=hand # → /logs/model-gear/hand-.log volumes: diff --git a/lobes/templates/fleet/env.example b/lobes/templates/fleet/env.example index 7c7c5c0..62005dc 100644 --- a/lobes/templates/fleet/env.example +++ b/lobes/templates/fleet/env.example @@ -248,6 +248,13 @@ HAND_BASE_URL=http://vllm-hand:8000 # 0.06 of 64 GB leaves too little KV after the weights). HAND_MAX_MODEL_LEN=32768 HAND_GPU_MEM_UTIL=0.06 +# The nightly over-estimates cudagraph memory for this lane too. MEASURED on a +# physical Jetson AGX Orin 2026-08-10: with the estimate ON, hand profiles to +# **Available KV cache memory: -9.25 GiB** at util 0.10 — negative, so the boot +# dies with no KV cache at all. Disabling it maps the util knob back to true +# usage, exactly as the primary/multimodal/muse/worker lanes already do. Set to +# 1 to re-enable (and then expect to need ~4.6x the util for the same KV). +HAND_CUDAGRAPH_ESTIMATE=0 # bf16 — there is deliberately NO HAND_QUANTIZATION knob. The catalog's # quantization="none" sentinel means the lane omits --quantization ENTIRELY; # passing modelopt to a bf16 checkpoint corrupts the weights. diff --git a/pyproject.toml b/pyproject.toml index b02f26c..efa1352 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lobes-cli" -version = "0.55.1" +version = "0.56.0" description = "lobes — run, assess, and switch the local vLLM model." readme = "README.md" license = "Apache-2.0" diff --git a/tests/goldens/template-defaults.env b/tests/goldens/template-defaults.env index 00f01bc..5e5284d 100644 --- a/tests/goldens/template-defaults.env +++ b/tests/goldens/template-defaults.env @@ -25,6 +25,7 @@ GATEWAY_PUBLIC_URL= GATEWAY_READ_TIMEOUT=600 HAND_ADAPTER_DIR=./adapters HAND_BASE_URL=http://vllm-hand:8000 +HAND_CUDAGRAPH_ESTIMATE=0 HAND_GPU_MEM_UTIL=0.06 HAND_IMAGE=${VLLM_NIGHTLY_IMAGE:-vllm/vllm-openai@sha256:7c5a10e9a8b3c8642f4d0463a41215176c0dd834b4f0967287c7e3e517cf1be9} HAND_LORA_MODULES= diff --git a/tests/test_tool_parser_plugin.py b/tests/test_tool_parser_plugin.py index 25eec49..0a4b949 100644 --- a/tests/test_tool_parser_plugin.py +++ b/tests/test_tool_parser_plugin.py @@ -136,7 +136,7 @@ "gateway": "88448c70bf43f4b757c3a6f4f86c428c040fbf4321ffa75f2e5fe35e0bb2f5b8", "vllm-embed": "63db52dc1121c1b861b5559c03d1b2c76699af86a575718908306f2440bd4b85", "vllm-embed-deep": "532b5b24c76c6cb90d06a4336ec42e6cc856a18ee112186aeff1141403f1143e", - "vllm-hand": "832a9aef512acfb2911f7925d483754f4d0bd012e1082b10aa91d9c236d5233e", + "vllm-hand": "abc870186791cd8d443f165c94031abde451f309cded279a3221bcaa05f72636", "vllm-middle": "efef630842164793e43313fff2b588b92d7f57aad35fffc941a3617cddc1a129", "vllm-minor": "ddca0c0c64eb06514ba23d5327f61ce410bf8de40d3d7f519c399c6b8c60bc01", "vllm-multimodal": "64c129b764059c3e78ed248da60634cd2ecf4a0af26c61e3508c5676ddb11134", diff --git a/uv.lock b/uv.lock index 3050804..e1287dc 100644 --- a/uv.lock +++ b/uv.lock @@ -440,7 +440,7 @@ wheels = [ [[package]] name = "lobes-cli" -version = "0.55.1" +version = "0.56.0" source = { editable = "." } [package.optional-dependencies] From 7011e5442b510f3de8df909c0c04f98d14093bb8 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 06:36:18 +0300 Subject: [PATCH 06/17] feat: hand VALIDATED on the Jetson AGX Orin; budget re-derived (t10, t12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two evidence transcripts land, and the Orin's committed budget changes as a direct result — r1 (the blocking risk) materialised exactly as written. docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt (t10) Records THREE findings, two of them refutations: A. The lane was missing VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0 (fixed in the previous commit). With the estimate on, hand profiled to "Available KV cache memory: -9.25 GiB" — negative, so no boot at any util. B. Orin at the DECLARED 0.10: "Free memory on device cuda:0 (4.67/61.34 GiB) on startup is less than desired GPU memory utilization (0.1, 6.13 GiB)". No headroom beside the orin-lobe shape's committed lanes. C. Thor, with LoRA armed on a loaded box (108/122 GiB, load avg 4.30), died in LoRA embedding-slot allocation (vocal_parallel_embedding.py:49, "CUDA error: device not ready"). Deliberately left UNATTRIBUTED — three candidate causes are listed rather than guessed between. docs/evidence/2026-08-10-accept-hand-orin.txt (t12) — Orin VALIDATED gpu_mem_util 0.06 at the full 32768 window: available KV 2.7 GiB, pool 235,721 tokens, 7.19x concurrency, co-resident with senses + the pooling gears. /health 200. /v1/models lists exactly one id (no phantom adapter). Known-answer completion correct ("Paris"), reasoning: null. The check this lane exists to pass: a tool call returns a STRUCTURED tool_calls array — {"name":"get_weather","arguments":"{\"city\": \"Paris\"}"}, finish_reason "tool_calls", content null — the exact opposite of the gemma4/`pythonic` failure mode on every field. The tokenizer's <|tool_call_start|>/<|tool_call_end|> were read from the actually-pulled revision (ids 10 and 11). An unknown model id 404s rather than being served under the base weights. BUDGET CHANGE: the Orin card profile moves 0.10 -> 0.06 (measured). The reasoning behind 0.10 — "0.06 of 64 GB leaves too little KV after the weights" — was plausible and WRONG: 0.06 yields 7.19x concurrency there. Note the per-card mechanism is retained but now carries no divergence; every card declares 0.06, and only the Orin's is measured. Per #108 the docs state VALIDATED for the Orin ONLY. Thor, Spark and base stay DECLARED. Both probe containers were removed; every production container on both boxes was verified still running. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- docs/evidence/2026-08-10-accept-hand-orin.txt | 138 ++++++++++++++++++ ...2026-08-10-hand-lobe-budget-derivation.txt | 134 +++++++++++++++++ docs/lfm2.5-1.2b-hand.md | 34 ++++- docs/machine-profiles.md | 33 +++-- lobes/profiles/builtin/orin.toml | 32 ++-- tests/goldens/orin.env | 2 +- tests/goldens/shapes/orin-lobe__orin.env | 2 +- tests/goldens/shapes/orin-small__orin.env | 2 +- tests/goldens/shapes/spark-lobe__orin.env | 2 +- tests/goldens/shapes/thor-lobe__orin.env | 2 +- tests/goldens/shapes/thor-muse__orin.env | 2 +- tests/goldens/shapes/thor-worker__orin.env | 2 +- 12 files changed, 356 insertions(+), 29 deletions(-) create mode 100644 docs/evidence/2026-08-10-accept-hand-orin.txt create mode 100644 docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt diff --git a/docs/evidence/2026-08-10-accept-hand-orin.txt b/docs/evidence/2026-08-10-accept-hand-orin.txt new file mode 100644 index 0000000..b59c1b3 --- /dev/null +++ b/docs/evidence/2026-08-10-accept-hand-orin.txt @@ -0,0 +1,138 @@ +============================================================================= +hand lobe (LiquidAI/LFM2.5-1.2B-Instruct) — ACCEPTANCE, Jetson AGX Orin +============================================================================= +Date: 2026-08-10 +Box: Jetson AGX Orin 64GB (sm_87), 61.34 GiB unified, orin-lobe shape + (senses 0.45 + embed 0.06 + rerank 0.06 already resident) +Image: vllm/vllm-openai@sha256:7c5a10e9a8b3c8642f4d0463a41215176c0dd834b4f0967287c7e3e517cf1be9 +Engine: vLLM 0.23.1rc1.dev672+g93d8f834d +Plan: docs/plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md, task t12 +Companion: docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt (t10 — + how this budget was arrived at, including the two refuted attempts) + +SCOPE OF THIS TRANSCRIPT: the Jetson AGX Orin ONLY. Per #108 it promotes no +other card. Thor and the DGX Spark remain UNVALIDATED for `hand`; the base +fallback profile is untested by construction. + +Lane under test (the committed vllm-hand lane's rendered flags): + --model LiquidAI/LFM2.5-1.2B-Instruct + --served-model-name LiquidAI/LFM2.5-1.2B-Instruct + --max-model-len 32768 + --gpu-memory-utilization 0.06 + --enable-lora --max-loras 4 --max-lora-rank 32 + --enable-auto-tool-choice --tool-call-parser lfm2 + env VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0 + env VLLM_ATTENTION_BACKEND=TRITON_ATTN (the orin card divergence) + NO --quantization (bf16; the catalog's "none" sentinel) + NO --language-model-only (text-only checkpoint; there is no ViT to drop) + NO --reasoning-parser (no thinking mode on this checkpoint) + +----------------------------------------------------------------------------- +1. BUDGET — MEASURED +----------------------------------------------------------------------------- + INFO [gpu_worker.py:538] Available KV cache memory: 2.7 GiB + INFO [kv_cache_utils.py:2146] GPU KV cache size: 235,721 tokens + INFO [kv_cache_utils.py:2147] Maximum concurrency for 32,768 tokens + per request: 7.19x + + Healthy at the FULL served 32768 window, co-resident with the orin-lobe + shape's existing lanes. 0.06 is therefore the Orin's committed value — + measured, replacing the DECLARED 0.10 the box refused (see the companion + transcript). + + Read 7.19x as a KV-pool CEILING, not measured throughput — the standing + caveat in CLAUDE.md. Usable concurrency was not measured here. + +----------------------------------------------------------------------------- +2. ARCHITECTURE + FLAG PLUMBING — CONFIRMED +----------------------------------------------------------------------------- + Resolved architecture: Lfm2ForCausalLM + dtype=torch.bfloat16, quantization=None, speculative_config=None, + reasoning_parser='', max_seq_len=32768 + Loading weights took 1.84 seconds + Using PunicaWrapperGPU (the LoRA path, armed) + + quantization=None is the bf16 sentinel proving out end to end: the lane + omitted --quantization entirely and vLLM served native precision. + +----------------------------------------------------------------------------- +3. SERVING — 200, correct answer +----------------------------------------------------------------------------- + GET /health -> 200 + + GET /v1/models -> + {"object":"list","data":[{"id":"LiquidAI/LFM2.5-1.2B-Instruct",..., + "max_model_len":32768,...}]} + EXACTLY one id. No phantom adapter is advertised — the inventory is empty in + v1 and nothing invented one. + + POST /v1/chat/completions "What is the capital of France? Answer with one + word." (max_tokens=16, temperature=0) -> + content : "Paris" + finish_reason : "stop" + reasoning : null <- no thinking mode, as designed + usage : 21 prompt / 2 completion + +----------------------------------------------------------------------------- +4. TOOL CALLING THROUGH THE lfm2 PARSER — THE CRITICAL CHECK +----------------------------------------------------------------------------- + Tokenizer, read from the ACTUALLY-PULLED revision inside the container: + <|tool_call_start|>: id=10 + <|tool_call_end|>: id=11 + name_or_path: LiquidAI/LFM2.5-1.2B-Instruct + + Both delimiters present as special tokens. This is what Lfm2ToolParser's + __init__ resolves via self.vocab.get() and RAISES on when missing — so the + parser loading at all is itself evidence, and the ids make it explicit. + + POST /v1/chat/completions with one `get_weather(city)` tool, tool_choice=auto: + finish_reason : "tool_calls" + content : null + tool_calls : [{"id":"chatcmpl-tool-9b51ce3641de023d","type":"function", + "function":{"name":"get_weather", + "arguments":"{\"city\": \"Paris\"}"}}] + + A STRUCTURED tool_calls array — not prose. This is the exact failure mode the + parser choice was made to avoid: with a parser that cannot see special-token + delimiters (the `pythonic`-on-Gemma-4 mistake), vLLM relays a well-formed + call as assistant CONTENT with tool_calls=null and finish_reason="stop". The + response above is the opposite of that on every field. + +----------------------------------------------------------------------------- +5. UNKNOWN MODEL ID — REFUSED, not silently substituted +----------------------------------------------------------------------------- + POST /v1/chat/completions {"model":"nosuch-adapter"} -> + 404 {"error":{"message":"The model `nosuch-adapter` does not exist.", + "type":"NotFoundError","param":"model","code":404}} + + The engine refuses an unknown id rather than serving it under the base + weights — the upstream half of the gateway's own `hand:` contract + (an undeclared adapter must never be silently downgraded to the base). + +----------------------------------------------------------------------------- +6. DECODE — indicative only +----------------------------------------------------------------------------- + 200-token cap, "count from 1 to 60": 179 completion tokens in 5.22 s wall + (~34 tok/s single-stream, INCLUDING request overhead). + + NOT a benchmark: one untimed-prefill request, on a box concurrently serving + senses + the pooling gears + unrelated production containers. Recorded as an + order-of-magnitude sanity check ("this lane is not pathologically slow"), + nothing more. + +----------------------------------------------------------------------------- +7. WHAT THIS TRANSCRIPT DOES NOT ESTABLISH +----------------------------------------------------------------------------- + - Any card other than this Orin. Thor and the Spark stay UNVALIDATED (#108). + - Adapter SERVING end to end. v1 ships zero adapters by decision, so + `hand:` resolution, the adapter-honesty filter, and the + rank-ceiling failure path are all covered by offline tests only. They + become live-testable when unsloth-cli can produce an LFM2.5 adapter + (agentculture/unsloth-cli#16). + - Anything through the lobes GATEWAY. These calls went straight to the + engine; the gateway's alias/tier/pressure layer is offline-tested. + - Throughput, concurrency, or any correctness claim beyond the two + known-answer probes above. + +Probe container removed afterwards; every production container on the box was +verified still running. diff --git a/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt b/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt new file mode 100644 index 0000000..22349af --- /dev/null +++ b/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt @@ -0,0 +1,134 @@ +============================================================================= +hand lobe (LiquidAI/LFM2.5-1.2B-Instruct) — BUDGET DERIVATION, t10 +============================================================================= +Date: 2026-08-10 +Plan: docs/plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md, task t10 +Risk: r1 (unknown_blocking) — "every mesh-lobe reclaim was measured with + NO hand co-resident; t10 may find that no headroom exists on one or + more cards and force a re-apportionment" +Boxes: Jetson AGX Thor (sm_110, 122 GiB unified) — thor-worker shape + Jetson AGX Orin (sm_87, 61 GiB unified) — orin-lobe shape +Image: vllm/vllm-openai@sha256:7c5a10e9a8b3c8642f4d0463a41215176c0dd834b4f0967287c7e3e517cf1be9 + (the pinned fleet nightly; already present on both boxes — no pull) +Engine: vLLM 0.23.1rc1.dev672+g93d8f834d + +VERDICT: r1 MATERIALISED. The declared budgets did not hold, for two distinct +reasons — one a bug in the lane, one a genuine capacity limit on a shared box. +Both are recorded below with the raw engine output that produced them. + +----------------------------------------------------------------------------- +0. What the engine confirms about the checkpoint (both boxes) +----------------------------------------------------------------------------- + Resolved architecture: Lfm2ForCausalLM + version 0.23.1rc1.dev672+g93d8f834d + model LiquidAI/LFM2.5-1.2B-Instruct + dtype=torch.bfloat16, quantization=None, speculative_config=None, + reasoning_parser='', trust_remote_code=False, max_seq_len=32768 + + - quantization=None confirms the catalog's `quantization="none"` bf16 + sentinel works end to end: the lane omits --quantization ENTIRELY and vLLM + serves native precision. (Passing it empty would have been a parse error; + passing modelopt would corrupt the weights.) + - reasoning_parser='' confirms the deliberate absence of a --reasoning-parser + on this lane: LFM2.5-1.2B-Instruct has no thinking mode. + - The `lfm2` tool parser was ACCEPTED at startup on both boxes. That is a + real signal, not a null one: Lfm2ToolParser.__init__ resolves + <|tool_call_start|> / <|tool_call_end|> through self.vocab.get() and RAISES + when either is missing. The actually-pulled tokenizer revision carries both. + - LoRA initialised: "Using PunicaWrapperGPU" (Thor). --enable-lora with an + empty inventory is a valid configuration; the engine arms the LoRA path. + - Loading weights took 0.57 s (Thor) / 1.84 s (Orin) — the ~2.4 GiB bf16 + footprint the role's whole premise rests on. + +----------------------------------------------------------------------------- +1. FINDING A — the lane was missing the cudagraph-estimate off-switch (FIXED) +----------------------------------------------------------------------------- +Orin, util 0.10, lane exactly as first committed: + + INFO [gpu_worker.py:538] Available KV cache memory: -9.25 GiB + INFO [gpu_worker.py:553] CUDA graph memory profiling is enabled (default + since v0.21.0). The current --gpu-memory-utilization=0.1000 is equivalent + to --gpu-memory-utilization=-0.0762 without CUDA graph memory profiling. + To maintain the same effective KV cache size as before, increase + --gpu-memory-utilization to 0.2762. To disable, set + VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0. + +NEGATIVE available KV. The engine cannot allocate a cache at all, so the boot +dies — at ANY util the estimate dominates. + +Root cause: every other lane on this nightly image sets +VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0 (primary, embed, rerank, +multimodal, multimodal-coder, muse, worker). The hand lane was the only +omission. No offline test could have caught it: the rendered compose is +perfectly valid right up until an engine profiles memory with it. + +Resolution: disable the estimate, matching every sibling lane, plus a +HAND_CUDAGRAPH_ESTIMATE escape hatch (default 0). vLLM's alternative remedy — +raising util to 0.2762 — was refused: it would cost 4.6x the memory on every +card to buy nothing. + +----------------------------------------------------------------------------- +2. FINDING B — the Orin has no headroom for hand at the declared 0.10 +----------------------------------------------------------------------------- +Orin, util 0.10, WITH the estimate disabled: + + ValueError: Free memory on device cuda:0 (4.67/61.34 GiB) on startup is + less than desired GPU memory utilization (0.1, 6.13 GiB). Decrease GPU + memory utilization or reduce GPU memory used by other processes. + +This is r1 exactly as written. The 0.10 figure was a DECLARED hypothesis, +reasoned from "0.06 of 64 GB leaves too little KV after the weights"; the box +disagrees. The orin-lobe shape's committed lanes (senses 0.45 + embed 0.06 + +rerank 0.06 = 0.57 = ~35 GiB) plus the box's non-lobes workload leave 4.67 GiB +free — less than 0.10 asks for. + +Note what this measurement is and is not. "Free memory at startup" is a +property of a SHARED box at an instant, not of the card; the honest reading is +that hand cannot be added on top of the Orin's current committed total, not +that an Orin categorically cannot host it. + +----------------------------------------------------------------------------- +3. FINDING C — Thor: LoRA embedding-slot allocation failed under load +----------------------------------------------------------------------------- +Thor, util 0.06, with --enable-lora, on a box at 108/122 GiB used and load avg +4.30: + + File ".../vllm/lora/layers/vocal_parallel_embedding.py", line 49, + in create_lora_weights + self.lora_a_stacked = torch.zeros( + torch.AcceleratorError: CUDA error: device not ready + +The failure is in LoRA slot allocation, not in loading the model — weights had +already loaded (0.57 s) and PunicaWrapperGPU had initialised. This box also +ran the FIRST of the two runs above, i.e. before Finding A was understood, so +it carried the same inflated cudagraph reservation. + +NOT YET ATTRIBUTED. Candidate causes, none confirmed: + (a) the Finding-A over-reservation leaving nothing for the LoRA slots; + (b) memory pressure / a concurrent-allocation race on a loaded unified-memory + Jetson — the same class as the boot-ordering caveat already recorded for + Thor in docs/machine-profiles.md; + (c) an sm_110-specific problem in the LoRA embedding path. +Distinguishing them needs a re-run on a quiet Thor with the fix in place. It is +recorded here rather than guessed at. + +----------------------------------------------------------------------------- +4. What is NOT claimed +----------------------------------------------------------------------------- +Per #108, and stated plainly because a partial run invites over-reading: + + - NO card is VALIDATED for `hand`. Not Thor, not Orin, not the Spark (which + was not exercised at all), not the base fallback. + - The per-card gpu_mem_util values in lobes/profiles/builtin/*.toml remain + DECLARED. This run REFUTED the Orin's 0.10; it did not establish a + replacement, which requires a boot that reaches "Application startup + complete" and reports a positive KV pool. + - No throughput, concurrency, or acceptance number is claimed. None was + measured. + - The tool-call ROUND TRIP (a request returning a structured tool_calls + array) was not exercised. What is established is narrower and worth + separating: the parser LOADS, which proves the tokenizer carries the two + special-token delimiters it requires. + +Both probe containers were removed afterwards; every production container on +both boxes was verified still running. diff --git a/docs/lfm2.5-1.2b-hand.md b/docs/lfm2.5-1.2b-hand.md index d159952..ba36f57 100644 --- a/docs/lfm2.5-1.2b-hand.md +++ b/docs/lfm2.5-1.2b-hand.md @@ -172,8 +172,38 @@ yet be exercised end to end. Verifying/adding LFM2.5 support there is tracked as Per the #108 rule, `hand` is **DECLARED** on every card and becomes **VALIDATED** only on the cards whose acceptance transcript has landed under `docs/evidence/`. -One box's successful boot never promotes another card. See -`docs/machine-profiles.md` and `docs/deployment-shapes.md` for the per-card +One box's successful boot never promotes another card. + +| card | status | evidence | +|---|---|---| +| **Jetson AGX Orin** (sm_87, 64 GB) | **VALIDATED 2026-08-10** | `docs/evidence/2026-08-10-accept-hand-orin.txt` | +| Jetson AGX Thor (sm_110, 128 GB) | DECLARED | — | +| DGX Spark GB10 (128 GB) | DECLARED | — | +| `base` (unrecognised card) | DECLARED | untestable by construction | + +What the Orin run established: `gpu_mem_util = 0.06` at the full 32768 window +(available KV 2.7 GiB, pool 235,721 tokens, 7.19x concurrency), the bf16 +sentinel and the text-only / no-reasoning-parser flags all plumbed correctly, a +correct known-answer completion, and — the check this lane exists to pass — a +**tool call returning a structured `tool_calls` array**, with the tokenizer's +`<|tool_call_start|>` / `<|tool_call_end|>` confirmed present as special tokens +(ids 10 and 11). + +Two things it deliberately does **not** establish: adapter serving end to end +(v1 ships zero adapters, so `hand:` resolution and the adapter-honesty +filter are covered by offline tests only), and anything reached through the +lobes gateway rather than the engine directly. + +**A budget was refuted here.** The Orin was declared at `0.10` and the box +refused it twice — once because the lane was missing the cudagraph-estimate +off-switch (available KV profiled to **-9.25 GiB**, negative), and once because +`0.10` simply does not fit beside the orin-lobe shape's committed lanes. Both +runs are recorded in +`docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt`. The reasoning +behind `0.10` was plausible and wrong, which is the argument for measuring +rather than computing a budget on this class of board. + +See `docs/machine-profiles.md` and `docs/deployment-shapes.md` for the per-card picture. ## See also diff --git a/docs/machine-profiles.md b/docs/machine-profiles.md index a4d554c..e71e3d2 100644 --- a/docs/machine-profiles.md +++ b/docs/machine-profiles.md @@ -166,17 +166,28 @@ compose template passes the served name to vLLM's `--served-model-name` separately from the model id it downloads; the two must agree for the gateway to route correctly. -> **`hand` is DECLARED on every card and VALIDATED on none of them yet -> (#108).** The per-card `[roles.hand]` blocks in -> `lobes/profiles/builtin/*.toml` carry real budget numbers — 0.06 on the -> 128 GB Spark/Thor, 0.10 on the 64 GB Orin — but they are *hypotheses*, not -> measurements: no physical box has booted the lane. Per the #108 rule this -> holds **per card**, so a successful boot on one board never promotes another; -> each card is promoted only when its own acceptance transcript lands under -> `docs/evidence/`. The Orin value in particular must be validated by an actual -> boot at the served `max_model_len` rather than inferred from the Spark's, -> since 0.06 of 64 GB leaves markedly less KV headroom than 0.06 of 128 GB — -> which is exactly why the two cards declare different numbers. +> **`hand` is VALIDATED on the Jetson AGX Orin and DECLARED everywhere else +> (#108).** +> +> - **Orin — VALIDATED, 2026-08-10** +> (`docs/evidence/2026-08-10-accept-hand-orin.txt`): `gpu_mem_util = 0.06` +> at the full served `max_model_len = 32768`, MEASURED — available KV 2.7 GiB, +> KV pool 235,721 tokens, 7.19x concurrency, co-resident with the orin-lobe +> shape's existing lanes. Tool calling through the `lfm2` parser returns a +> structured `tool_calls` array. +> - **Spark / Thor / base — DECLARED.** `0.06` on the two 128 GB cards is a +> hypothesis, not a measurement; no boot has confirmed it there. Per the #108 +> rule this holds **per card**: the Orin's successful boot promotes nothing +> else, and each card is promoted only when its own acceptance transcript +> lands under `docs/evidence/`. +> +> The Orin number is worth reading closely, because it was **declared at 0.10 +> and the box refused it twice** — see +> `docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt`. The reasoning +> behind 0.10 ("0.06 of 64 GB leaves too little KV after the weights") was +> plausible and wrong: 0.06 yields 7.19x concurrency there. That is the whole +> argument for measuring rather than computing a budget on this class of +> unified-memory board. **When set:** diff --git a/lobes/profiles/builtin/orin.toml b/lobes/profiles/builtin/orin.toml index 607e607..f65c0b8 100644 --- a/lobes/profiles/builtin/orin.toml +++ b/lobes/profiles/builtin/orin.toml @@ -175,17 +175,31 @@ attention_backend = "TRITON_ATTN" # --quantization ENTIRELY. Declaring it here — even as an empty string — would # render a flag that corrupts bf16 weights. # -# BUDGET: DECLARED, NOT MEASURED (#108), and deliberately HIGHER than the -# 128 GB cards' 0.06 — this is why the schema declares hand's util PER CARD -# rather than once globally. On a 64 GB Orin 0.06 is only ~3.84 GiB, which -# leaves ~1.4 GiB of KV after the ~2.4 GiB of weights: servable, but thin enough -# that a couple of concurrent 32K requests could exhaust it. 0.10 (~6.4 GiB) -# restores a comparable KV margin. Total 0.57 -> 0.67. The Orin value must be -# validated by an ACTUAL BOOT at the served max_model_len (t10/t12), never -# inferred from the Spark's. +# BUDGET: MEASURED on a physical Jetson AGX Orin, 2026-08-10 — see +# docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt. This value was +# DECLARED at 0.10 and the box refuted it TWICE, which is exactly why t10 +# exists: +# +# * at 0.10 the boot died before profiling — "Free memory on device cuda:0 +# (4.67/61.34 GiB) on startup is less than desired GPU memory utilization +# (0.1, 6.13 GiB)". The orin-lobe shape's committed lanes plus the box's +# other workload simply do not leave 6.13 GiB. +# * at 0.06 it boots and profiles HEALTHY: Available KV cache memory 2.7 GiB, +# GPU KV cache size 235,721 tokens, maximum concurrency 7.19x at the served +# 32768 window. +# +# So 0.10 was both too big to fit AND unnecessary. The reasoning behind it — +# "0.06 of 64 GB leaves too little KV after the weights" — was plausible and +# WRONG: 0.06 yields 7.19x concurrency here, which is more headroom than the +# 27B cortex has ever had on any card. Recorded because the arithmetic looked +# convincing right up until the box disagreed. +# +# NOTE this is the ONE card where hand's util was actually measured, and it +# landed on the same 0.06 the 128 GB cards declare — so the per-card mechanism +# is retained (see schema.py) but currently carries no divergence. feasible = true model = "LiquidAI/LFM2.5-1.2B-Instruct" -gpu_mem_util = 0.10 +gpu_mem_util = 0.06 max_model_len = 32768 # TRITON_ATTN: the conservative sm_87 carry-over every role that booted on this # box uses. FlashInfer is unvalidated here (docs/orin-profiles.md). diff --git a/tests/goldens/orin.env b/tests/goldens/orin.env index a555ede..0ca0c1d 100644 --- a/tests/goldens/orin.env +++ b/tests/goldens/orin.env @@ -4,7 +4,7 @@ EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B HAND_ATTENTION_BACKEND=TRITON_ATTN -HAND_GPU_MEM_UTIL=0.1 +HAND_GPU_MEM_UTIL=0.06 HAND_MAX_MODEL_LEN=32768 HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct diff --git a/tests/goldens/shapes/orin-lobe__orin.env b/tests/goldens/shapes/orin-lobe__orin.env index a555ede..0ca0c1d 100644 --- a/tests/goldens/shapes/orin-lobe__orin.env +++ b/tests/goldens/shapes/orin-lobe__orin.env @@ -4,7 +4,7 @@ EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B HAND_ATTENTION_BACKEND=TRITON_ATTN -HAND_GPU_MEM_UTIL=0.1 +HAND_GPU_MEM_UTIL=0.06 HAND_MAX_MODEL_LEN=32768 HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct diff --git a/tests/goldens/shapes/orin-small__orin.env b/tests/goldens/shapes/orin-small__orin.env index f62777e..c2ab455 100644 --- a/tests/goldens/shapes/orin-small__orin.env +++ b/tests/goldens/shapes/orin-small__orin.env @@ -5,7 +5,7 @@ EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B HAND_ATTENTION_BACKEND=TRITON_ATTN -HAND_GPU_MEM_UTIL=0.1 +HAND_GPU_MEM_UTIL=0.06 HAND_MAX_MODEL_LEN=32768 HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct diff --git a/tests/goldens/shapes/spark-lobe__orin.env b/tests/goldens/shapes/spark-lobe__orin.env index d5ec28d..505a872 100644 --- a/tests/goldens/shapes/spark-lobe__orin.env +++ b/tests/goldens/shapes/spark-lobe__orin.env @@ -4,7 +4,7 @@ EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B HAND_ATTENTION_BACKEND=TRITON_ATTN -HAND_GPU_MEM_UTIL=0.1 +HAND_GPU_MEM_UTIL=0.06 HAND_MAX_MODEL_LEN=32768 HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct diff --git a/tests/goldens/shapes/thor-lobe__orin.env b/tests/goldens/shapes/thor-lobe__orin.env index e997b90..1aa9026 100644 --- a/tests/goldens/shapes/thor-lobe__orin.env +++ b/tests/goldens/shapes/thor-lobe__orin.env @@ -4,7 +4,7 @@ EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B HAND_ATTENTION_BACKEND=TRITON_ATTN -HAND_GPU_MEM_UTIL=0.1 +HAND_GPU_MEM_UTIL=0.06 HAND_MAX_MODEL_LEN=32768 HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct diff --git a/tests/goldens/shapes/thor-muse__orin.env b/tests/goldens/shapes/thor-muse__orin.env index d5ec28d..505a872 100644 --- a/tests/goldens/shapes/thor-muse__orin.env +++ b/tests/goldens/shapes/thor-muse__orin.env @@ -4,7 +4,7 @@ EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B HAND_ATTENTION_BACKEND=TRITON_ATTN -HAND_GPU_MEM_UTIL=0.1 +HAND_GPU_MEM_UTIL=0.06 HAND_MAX_MODEL_LEN=32768 HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct diff --git a/tests/goldens/shapes/thor-worker__orin.env b/tests/goldens/shapes/thor-worker__orin.env index d5ec28d..505a872 100644 --- a/tests/goldens/shapes/thor-worker__orin.env +++ b/tests/goldens/shapes/thor-worker__orin.env @@ -4,7 +4,7 @@ EMBED_MAX_MODEL_LEN=8192 EMBED_MODEL=Qwen/Qwen3-Embedding-0.6B EMBED_SERVED_NAME=Qwen/Qwen3-Embedding-0.6B HAND_ATTENTION_BACKEND=TRITON_ATTN -HAND_GPU_MEM_UTIL=0.1 +HAND_GPU_MEM_UTIL=0.06 HAND_MAX_MODEL_LEN=32768 HAND_MODEL=LiquidAI/LFM2.5-1.2B-Instruct HAND_SERVED_NAME=LiquidAI/LFM2.5-1.2B-Instruct From 6437056385fb1039faf081e7c71a4ccc163822f1 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 06:36:58 +0300 Subject: [PATCH 07/17] plan: resolve r1/r2 against live evidence; record r5/r6 from the Orin+Thor runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r1 (blocking, budget headroom) and r2 (per-card util) both resolved with MEASURED outcomes rather than promises — r2 was REFUTED outright. Two new risks recorded from what the live runs actually found: the missing cudagraph-estimate knob (r5, follow_up — an offline compose env-parity check would have caught it and does not exist) and Thor's unattributed LoRA embedding-slot failure (r6, unknown_nonblocking). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- .devague/plans/hand-lobe-lfm2-5-1-2b.json | 22 ++++++++++++++++--- .../plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md | 2 ++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.devague/plans/hand-lobe-lfm2-5-1-2b.json b/.devague/plans/hand-lobe-lfm2-5-1-2b.json index d124192..67c41f9 100644 --- a/.devague/plans/hand-lobe-lfm2-5-1-2b.json +++ b/.devague/plans/hand-lobe-lfm2-5-1-2b.json @@ -5,7 +5,7 @@ "schema_version": 4, "status": "exported", "created": "2026-08-10T00:10:52Z", - "updated": "2026-08-10T02:14:18Z", + "updated": "2026-08-10T03:36:45Z", "targets": [ { "id": "c1", @@ -845,8 +845,8 @@ "text": "hand's per-card gpu_mem_util is unmeasured \u2014 0.06 is 7.7 GiB on a 128 GB Spark but 3.84 GiB on a 64 GB Orin against ~2.4 GiB of weights, leaving little KV at 32K", "kind": "unknown_nonblocking", "task_id": null, - "resolved": false, - "resolution": "" + "resolved": true, + "resolution": "MEASURED and REFUTED on a physical Jetson AGX Orin 2026-08-10 (docs/evidence/2026-08-10-accept-hand-orin.txt). The concern was that 0.06 on a 64 GB card leaves too little KV after the ~2.4 GiB of weights; the box says otherwise \u2014 0.06 yields available KV 2.7 GiB, a 235,721-token pool and 7.19x concurrency at the full 32768 window. The DECLARED 0.10 that this risk motivated was in fact BOTH too big to fit beside the orin-lobe shape's committed lanes AND unnecessary, so the Orin card profile moved 0.10 -> 0.06. The per-card util mechanism is retained (schema.py) but currently carries no divergence: every card declares 0.06, and only the Orin's is measured." }, { "id": "r3", @@ -863,6 +863,22 @@ "task_id": null, "resolved": false, "resolution": "" + }, + { + "id": "r5", + "text": "The vllm-hand lane shipped without VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0, which every other lane on the same nightly image sets. Live boot profiled available KV to -9.25 GiB (negative). Fixed; a compose-lane env-parity check would have caught it offline and does not exist", + "kind": "follow_up", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r6", + "text": "Thor's LoRA embedding-slot allocation failed with 'CUDA error: device not ready' on a loaded box (108/122 GiB, load avg 4.30). Unattributed between the pre-fix cudagraph over-reservation, a unified-memory allocation race, and an sm_110 LoRA-embedding problem. Needs a re-run on a quiet Thor with the fix in place", + "kind": "unknown_nonblocking", + "task_id": null, + "resolved": false, + "resolution": "" } ] } diff --git a/docs/plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md b/docs/plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md index cb778e1..ccafed6 100644 --- a/docs/plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md +++ b/docs/plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md @@ -129,3 +129,5 @@ slug: `hand-lobe-lfm2-5-1-2b` · status: `exported` · from frame: `hand-lobe-lf - [unknown_nonblocking] hand's per-card `gpu_mem_util` is unmeasured — 0.06 is 7.7 GiB on a 128 GB Spark but 3.84 GiB on a 64 GB Orin against ~2.4 GiB of weights, leaving little KV at 32K - [unknown_nonblocking] Downstream mesh consumers (culture, daria, reachy-mini-cli, colleague) may parse model ids in a way the new 'hand:' shape breaks; every existing id is org/name and lobes itself is clean - [follow_up] Adapter production is cross-repo (agentculture/unsloth-cli#16) and unsloth-cli 0.5.0 ships only scaffold verbs, so the muscle-memory loop cannot be exercised end-to-end from this plan alone +- [follow_up] The vllm-hand lane shipped without `VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS`=0, which every other lane on the same nightly image sets. Live boot profiled available KV to -9.25 GiB (negative). Fixed; a compose-lane env-parity check would have caught it offline and does not exist +- [unknown_nonblocking] Thor's LoRA embedding-slot allocation failed with 'CUDA error: device not ready' on a loaded box (108/122 GiB, load avg 4.30). Unattributed between the pre-fix cudagraph over-reservation, a unified-memory allocation race, and an `sm_110` LoRA-embedding problem. Needs a re-run on a quiet Thor with the fix in place From ec044dbf8bf2538dd4711a448835b701eb546c14 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 06:41:22 +0300 Subject: [PATCH 08/17] chore: cross-link #181/#182/#183 into the lane, evidence and per-model doc Every gap the live runs exposed now points at its tracking issue from the place a reader hits it: the missing cudagraph parity test (#182) from the lane comment and the derivation transcript, Thor's unattributed LoRA failure (#181) from the derivation transcript and the validation table, and the remaining Thor/Spark/adapter validation (#183) from the acceptance transcript and the per-model doc. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- .../deliveries/hand-lobe-lfm2-5-1-2b.json | 97 +++++++++++++++++++ docs/evidence/2026-08-10-accept-hand-orin.txt | 6 +- ...2026-08-10-hand-lobe-budget-derivation.txt | 5 +- docs/lfm2.5-1.2b-hand.md | 7 +- lobes/templates/fleet/docker-compose.yml | 1 + 5 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 .devague/deliveries/hand-lobe-lfm2-5-1-2b.json diff --git a/.devague/deliveries/hand-lobe-lfm2-5-1-2b.json b/.devague/deliveries/hand-lobe-lfm2-5-1-2b.json new file mode 100644 index 0000000..65b877c --- /dev/null +++ b/.devague/deliveries/hand-lobe-lfm2-5-1-2b.json @@ -0,0 +1,97 @@ +{ + "plan_slug": "hand-lobe-lfm2-5-1-2b", + "schema_version": 1, + "created": "2026-08-10T03:38:10Z", + "updated": "2026-08-10T03:39:00Z", + "deviations": [ + { + "id": "d1", + "what": "t1 also repointed lobes/cli/_commands/route.py and run.py, both outside t1's declared FILES scope", + "task_ref": "t1", + "reason": "Both modules resolved the cheap-tier gear by role_hint=='minor', which no catalog entry carries after the repoint \u2014 they would have raised 'no model with role_hint=minor found in the catalog' at runtime. Leaving them stale to respect the FILES line would have shipped a live regression. route.py's _KNOWN_GEARS and classifier prompt needed the same treatment.", + "affects": [ + "t7" + ], + "origin": "user", + "status": "approved", + "classification": "acceptable" + }, + { + "id": "d2", + "what": "t4 also added a probe surface to lobes/gateway/_readiness.py (probe_backend_adapters, AdapterProbe, ReadinessCache adapter store + current_adapters), outside t4's declared FILES", + "task_ref": "t4", + "reason": "t4's acceptance requires a declared-but-unloaded adapter to be absent from /v1/models AND capabilities. The obvious implementation \u2014 stat the adapter path \u2014 is WRONG here: adapter paths are mounted into the vllm-hand container, not the gateway's, so a filesystem check would false-negative every correctly-configured adapter while still missing the failures that matter (unreadable file, rank above --max-lora-rank, a checkpoint vLLM refused). The honest evidence is the lane's OWN /v1/models, which is a readiness-layer concern and could not live in the four declared gateway files.", + "affects": [ + "c19", + "h14" + ], + "origin": "user", + "status": "approved", + "classification": "acceptable" + }, + { + "id": "d3", + "what": "t6 also added an empty-flag drop rule to lobes/templates/mg-logwrap.sh, outside t6's declared FILES (templates/fleet/{docker-compose.yml,env.example})", + "task_ref": "t6", + "reason": "v1 ships --enable-lora armed with an EMPTY inventory, so --lora-modules=${HAND_LORA_MODULES:-} renders as a bare '--lora-modules=' and vLLM would parse the empty string as a malformed name=path pair, killing the default boot. A compose command list cannot omit an argument conditionally, so the only place to express this is the shared entrypoint. Rule is narrow by construction (matches --flag= exactly) and tested against bare flags, lone --, short flags and non-flag args.", + "affects": [], + "origin": "user", + "status": "approved", + "classification": "acceptable" + }, + { + "id": "d4", + "what": "t9 also touched lobes/runtime/_compose.py (GPU_SERVICES) and t3 also touched lobes/profiles/shape_render.py (ROLE_SERVICE), both outside their declared FILES", + "task_ref": "t9", + "reason": "Two role->compose-service maps exist outside the declared file sets and both raise KeyError on an unmapped role: shape_render.ROLE_SERVICE (crashed the goldens regenerator) and _compose.GPU_SERVICES (a shipped-template mirror asserted by test_init_gpu_access). Neither could be deferred \u2014 the goldens do not regenerate without the first, and the second fails CI.", + "affects": [ + "t3" + ], + "origin": "user", + "status": "approved", + "classification": "acceptable" + }, + { + "id": "d5", + "what": "t6's committed lane changed AFTER t6 was complete: VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0 was added when t10's live boot found it missing", + "task_ref": "t6", + "reason": "MEASURED on a physical Orin 2026-08-10: without the knob the lane profiles to 'Available KV cache memory: -9.25 GiB' \u2014 negative, so no boot at any util. Every other lane on the same nightly image already sets it; the hand lane was the sole omission. Nothing about the rendered compose is detectably wrong until an engine profiles memory with it, so no offline test could have caught this at t6 time. Also re-baselined the vllm-hand service hash and the template-defaults golden.", + "affects": [ + "t10", + "t8" + ], + "origin": "user", + "status": "approved", + "classification": "needs-follow-up" + }, + { + "id": "d6", + "what": "t8's committed Orin budget changed AFTER t8 was complete: gpu_mem_util 0.10 -> 0.06, regenerating its goldens a second time", + "task_ref": "t8", + "reason": "This is r1 firing as designed and t10 exercising its authorisation to re-apportion, but it does mean t8's output was not final when t8 closed. The Orin refused 0.10 twice live ('Free memory on device cuda:0 (4.67/61.34 GiB) ... less than desired (0.1, 6.13 GiB)') and boots healthy at 0.06 (KV pool 235,721 tokens, 7.19x concurrency). The reasoning behind 0.10 was plausible and wrong. Net effect: the per-card util mechanism is retained but now carries no divergence \u2014 every card declares 0.06 and only the Orin's is measured.", + "affects": [ + "t10", + "c11", + "h8" + ], + "origin": "user", + "status": "approved", + "classification": "acceptable" + }, + { + "id": "d7", + "what": "t12 delivered ORIN ONLY. Its acceptance says 'Live validation on Thor AND Orin'; Thor is not validated and the plan's scope is therefore not fully met", + "task_ref": "t12", + "reason": "Thor's boot died in LoRA embedding-slot allocation (vllm/lora/layers/vocal_parallel_embedding.py:49, torch.AcceleratorError: CUDA error: device not ready) on a box at 108/122 GiB used with load avg 4.30, and that run predated the cudagraph fix. Three candidate causes remain unseparated: the pre-fix over-reservation, a unified-memory allocation race (the same class as Thor's documented boot-ordering caveat), or an sm_110-specific LoRA-embedding problem. Distinguishing them needs a quiet Thor with the fix in place, which was not available without displacing production containers. Recorded as a scope shortfall rather than presented as a pass: Orin is VALIDATED, Thor and Spark stay DECLARED per #108.", + "affects": [ + "c1", + "h1", + "c33", + "h18" + ], + "origin": "user", + "status": "approved", + "classification": "needs-follow-up" + } + ] +} diff --git a/docs/evidence/2026-08-10-accept-hand-orin.txt b/docs/evidence/2026-08-10-accept-hand-orin.txt index b59c1b3..5acb1a6 100644 --- a/docs/evidence/2026-08-10-accept-hand-orin.txt +++ b/docs/evidence/2026-08-10-accept-hand-orin.txt @@ -123,12 +123,14 @@ Lane under test (the committed vllm-hand lane's rendered flags): ----------------------------------------------------------------------------- 7. WHAT THIS TRANSCRIPT DOES NOT ESTABLISH ----------------------------------------------------------------------------- - - Any card other than this Orin. Thor and the Spark stay UNVALIDATED (#108). + - Any card other than this Orin. Thor and the Spark stay UNVALIDATED (#108); + the remaining validation work is issue #183, and Thor's own boot failure + is issue #181. - Adapter SERVING end to end. v1 ships zero adapters by decision, so `hand:` resolution, the adapter-honesty filter, and the rank-ceiling failure path are all covered by offline tests only. They become live-testable when unsloth-cli can produce an LFM2.5 adapter - (agentculture/unsloth-cli#16). + (agentculture/unsloth-cli#16); tracked here as issue #183. - Anything through the lobes GATEWAY. These calls went straight to the engine; the gateway's alias/tier/pressure layer is offline-tested. - Throughput, concurrency, or any correctness claim beyond the two diff --git a/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt b/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt index 22349af..55789c3 100644 --- a/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt +++ b/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt @@ -60,7 +60,8 @@ Root cause: every other lane on this nightly image sets VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0 (primary, embed, rerank, multimodal, multimodal-coder, muse, worker). The hand lane was the only omission. No offline test could have caught it: the rendered compose is -perfectly valid right up until an engine profiles memory with it. +perfectly valid right up until an engine profiles memory with it. The missing +cross-lane parity test is tracked as issue #182. Resolution: disable the estimate, matching every sibling lane, plus a HAND_CUDAGRAPH_ESTIMATE escape hatch (default 0). vLLM's alternative remedy — @@ -110,7 +111,7 @@ NOT YET ATTRIBUTED. Candidate causes, none confirmed: Thor in docs/machine-profiles.md; (c) an sm_110-specific problem in the LoRA embedding path. Distinguishing them needs a re-run on a quiet Thor with the fix in place. It is -recorded here rather than guessed at. +recorded here rather than guessed at, and tracked as issue #181. ----------------------------------------------------------------------------- 4. What is NOT claimed diff --git a/docs/lfm2.5-1.2b-hand.md b/docs/lfm2.5-1.2b-hand.md index ba36f57..1f97e29 100644 --- a/docs/lfm2.5-1.2b-hand.md +++ b/docs/lfm2.5-1.2b-hand.md @@ -177,8 +177,8 @@ One box's successful boot never promotes another card. | card | status | evidence | |---|---|---| | **Jetson AGX Orin** (sm_87, 64 GB) | **VALIDATED 2026-08-10** | `docs/evidence/2026-08-10-accept-hand-orin.txt` | -| Jetson AGX Thor (sm_110, 128 GB) | DECLARED | — | -| DGX Spark GB10 (128 GB) | DECLARED | — | +| Jetson AGX Thor (sm_110, 128 GB) | DECLARED | boot failed — [#181](https://github.com/agentculture/lobes-cli/issues/181) | +| DGX Spark GB10 (128 GB) | DECLARED | not yet exercised — [#183](https://github.com/agentculture/lobes-cli/issues/183) | | `base` (unrecognised card) | DECLARED | untestable by construction | What the Orin run established: `gpu_mem_util = 0.06` at the full 32768 window @@ -192,7 +192,8 @@ correct known-answer completion, and — the check this lane exists to pass — Two things it deliberately does **not** establish: adapter serving end to end (v1 ships zero adapters, so `hand:` resolution and the adapter-honesty filter are covered by offline tests only), and anything reached through the -lobes gateway rather than the engine directly. +lobes gateway rather than the engine directly. Both are tracked in +[#183](https://github.com/agentculture/lobes-cli/issues/183). **A budget was refuted here.** The Orin was declared at `0.10` and the box refused it twice — once because the lane was missing the cudagraph-estimate diff --git a/lobes/templates/fleet/docker-compose.yml b/lobes/templates/fleet/docker-compose.yml index 0f60271..bfd954d 100644 --- a/lobes/templates/fleet/docker-compose.yml +++ b/lobes/templates/fleet/docker-compose.yml @@ -521,6 +521,7 @@ services: # disable it, matching every sibling lane, so the util knob maps back to # true usage instead of a padded guess. Raising util instead would make # `hand` cost 4.6x more memory on every card to buy nothing. + # No test asserts this parity across lanes — that gap is issue #182. - VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=${HAND_CUDAGRAPH_ESTIMATE:-0} - MG_LOG_DIR=/logs/model-gear # in-container mount point (explicit, no drift) - MG_LOG_NAME=hand # → /logs/model-gear/hand-.log From d74f5298614d389961a41c14d4d87dec113e0b9b Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 06:42:08 +0300 Subject: [PATCH 09/17] docs: correct two stale claims in my own derivation transcript Its 'What is NOT claimed' section was written before the corrected Orin boot succeeded, so it still said NO card is validated and the tool-call round trip was not exercised. Both became false minutes later. A transcript that understates is less harmful than one that overstates, but it directly contradicted the acceptance transcript sitting beside it. Scoped rather than rewritten: the file now says up front that it covers the DERIVATION runs only, and each item in the list distinguishes what THESE runs did not establish from what the later run settled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- ...2026-08-10-hand-lobe-budget-derivation.txt | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt b/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt index 55789c3..704e584 100644 --- a/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt +++ b/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt @@ -16,6 +16,10 @@ VERDICT: r1 MATERIALISED. The declared budgets did not hold, for two distinct reasons — one a bug in the lane, one a genuine capacity limit on a shared box. Both are recorded below with the raw engine output that produced them. +This file covers the DERIVATION runs only — the three findings below are all +refutations. The subsequent successful Orin boot at the corrected budget is a +separate transcript: docs/evidence/2026-08-10-accept-hand-orin.txt. + ----------------------------------------------------------------------------- 0. What the engine confirms about the checkpoint (both boxes) ----------------------------------------------------------------------------- @@ -116,20 +120,29 @@ recorded here rather than guessed at, and tracked as issue #181. ----------------------------------------------------------------------------- 4. What is NOT claimed ----------------------------------------------------------------------------- -Per #108, and stated plainly because a partial run invites over-reading: - - - NO card is VALIDATED for `hand`. Not Thor, not Orin, not the Spark (which - was not exercised at all), not the base fallback. - - The per-card gpu_mem_util values in lobes/profiles/builtin/*.toml remain - DECLARED. This run REFUTED the Orin's 0.10; it did not establish a - replacement, which requires a boot that reaches "Application startup - complete" and reports a positive KV pool. - - No throughput, concurrency, or acceptance number is claimed. None was - measured. - - The tool-call ROUND TRIP (a request returning a structured tool_calls - array) was not exercised. What is established is narrower and worth - separating: the parser LOADS, which proves the tokenizer carries the two - special-token delimiters it requires. +Per #108, and stated plainly because a partial run invites over-reading. + +SCOPE NOTE: this transcript covers the DERIVATION runs only — the three +findings above, all of which are refutations. A SUBSEQUENT run on the Orin at +the corrected 0.06 succeeded and is recorded separately in +docs/evidence/2026-08-10-accept-hand-orin.txt. The list below is what THESE +runs did not establish; several items were settled by that later run and are +marked accordingly, so this file is not read as denying it. + + - NO card is VALIDATED by these runs. The Orin was subsequently VALIDATED by + the acceptance transcript above; Thor (issue #181) and the DGX Spark (never + exercised) and the base fallback remain DECLARED. + - The per-card gpu_mem_util values remain DECLARED except the Orin's. These + runs REFUTED the Orin's 0.10 without establishing a replacement; the later + run measured 0.06 (available KV 2.7 GiB, pool 235,721 tokens, 7.19x), and + lobes/profiles/builtin/orin.toml now carries that measured value. + - No throughput, concurrency, or acceptance number is claimed HERE. None was + measured in these runs. + - The tool-call ROUND TRIP was not exercised HERE. What these runs establish + is narrower and worth separating: the parser LOADS, which proves the + tokenizer carries the two special-token delimiters it requires. The round + trip itself — a structured tool_calls array — was confirmed in the later + Orin run. Both probe containers were removed afterwards; every production container on both boxes was verified still running. From 68654ea4c6c11fb8c9294b2f0df91fe7da565f16 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 06:51:25 +0300 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20HAND=5FATTENTION=5FBACKEND=20was?= =?UTF-8?q?=20a=20DEAD=20KNOB=20=E2=80=94=20rendered=20into=20.env,=20read?= =?UTF-8?q?=20by=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second live finding of the same class as the cudagraph one, caught by booting the ACTUAL committed compose lane rather than a hand-rolled docker run. builtin/orin.toml declares attention_backend = "TRITON_ATTN" for hand, and `lobes init --profile orin` duly renders HAND_ATTENTION_BACKEND=TRITON_ATTN into the deployment's .env. The vllm-hand lane never substituted it. The operator reads a configured attention backend; the engine never sees one. That is precisely the half-honest posture #92 exists to forbid, and it is why my earlier manual probe and the compose lane disagreed: the probe set the backend by hand, the lane could not. Fixed via --attention-config (NOT VLLM_ATTENTION_BACKEND, which is gone on this nightly — the vllm-embed lane already documents that), defaulting to "auto", which vLLM treats as its automatic-selection sentinel and is byte-equivalent to omitting the flag on cards that declare nothing. Adds a test for the whole CLASS, not just this instance: test_every_rendered_profile_knob_is_substituted_by_the_fleet_template asserts that every KEY any card profile renders appears as ${KEY} in the fleet template. Verified it FAILS with the fix reverted, so it genuinely catches the bug it was written for rather than merely passing alongside it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- .../2026-08-10-hand-lobe-lfm2-5-1-2b.md | 135 ++++++++++++++++++ lobes/templates/fleet/docker-compose.yml | 13 ++ lobes/templates/fleet/env.example | 4 + tests/goldens/template-defaults.env | 1 + tests/test_shape_goldens.py | 35 +++++ tests/test_tool_parser_plugin.py | 2 +- 6 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md diff --git a/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md b/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md new file mode 100644 index 0000000..1ae3809 --- /dev/null +++ b/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md @@ -0,0 +1,135 @@ +# Delivery Summary — hand lobe LFM2.5-1.2B + +plan: `hand-lobe-lfm2-5-1-2b` · run: `complete` · date: `2026-08-10` +baseline: `devague summary skeleton` + +## Intent + +> lobes serves 'hand' — a 1.2B LFM2.5 lobe cheap enough to run on every machine +> in the mesh, and the fleet's designated fine-tuning base + +After: every machine in the mesh — Spark, Thor, Orin, and any unrecognised card +— hosts a `hand`: a 1.2B LFM2.5 lobe served bf16 with `--enable-lora` armed, +addressable as `model=hand` for the base and `model=hand:` for a learned +adapter. `hand` is the fleet's always-servable floor (never shed under pressure) +and has replaced the 4B `minor` as the cheap-tier target and the LoRA base. Its +adapter inventory is operator-declared in `.env` and honestly advertised: +`/v1/models` and `GET /capabilities` list exactly the adapters actually loaded, +and none that are not. + +## Planned Work + +- `t1` — Catalog gear + lfm2 tool-parser rule + per-model doc + minor/cheap tier repoint +- `t2` — Role registry: hand as the ninth role in lobes/roles.py +- `t3` — Profile schema, render and shape vocabulary learn hand +- `t4` — Gateway wiring: backend, dynamic adapter aliases, adapter honesty, never-proxied +- `t5` — Pressure policy: hand becomes the servable floor +- `t6` — vllm-hand compose lane on the nightly digest with --enable-lora +- `t7` — Reconcile lobes/minor governance and 'lobes eval minor' with the repoint +- `t8` — Declare hand across all 4 card profiles and all 7 shape TOMLs, regenerate goldens +- `t9` — CLI surfaces: `roles_measure` family map, colleague-stack bundle, capabilities, overview +- `t10` — Re-derive every hosting shape's GPU budget with hand co-resident +- `t11` — Documentation sweep: role contract, UNVALIDATED discipline, irreversibility, unsloth boundary +- `t12` — Live validation on Thor and Orin; commit acceptance transcripts + +Executed **in-house, not fanned out** — the session's standing constraint +forbids spawning subagents unrequested, so `/assign-to-workforce` was not used. +The plan's wave order was still followed as the build order. + +## Actual Delivery + +| Plan task | Status | What actually landed | +|---|---|---| +| `t1` | delivered | Catalog entry (`role_hint="hand"`, 32768, bf16 `"none"`, `tool_parser="lfm2"`), the `lfm2` rule in `runtime/_parser.py`, `docs/lfm2.5-1.2b-hand.md`, `TIER_ROLE` repointed (`minor`/`cheap`/`hand` → `hand`), 4B demoted to `candidate`. **Plus `route.py`/`run.py`** — see `d1`. | +| `t2` | delivered | `ROLES` has nine; six per-role tables extended; parametrised completeness tests per table. `build_role_registry`'s hand-typed role tuple replaced by derived `GATEWAY_FRONTED_ROLES`. | +| `t3` | delivered | `schema.ROLES`, `render.ROLE_ENV_PREFIX` (`hand`→`HAND`). `OPT_IN_CORE_ROLES` unchanged; `DEFAULT_HOSTED_ROLES` picks `hand` up by derivation. **Plus `shape_render.ROLE_SERVICE`** — see `d4`. | +| `t4` | delivered | `hand` backend, `Backend.adapters`, `hand:` aliases, `NEVER_PROXIED_BACKENDS`. Adapter honesty via a live engine probe. **Plus `_readiness.py`** — see `d2`. | +| `t5` | delivered | `_FLOOR_TIER = "hand"`; shed set unchanged; `minor`/`cheap` normalize to `hand` so the floor's promise is unchanged, only its name. | +| `t6` | delivered (amended post-completion) | `vllm-hand` lane, default-on, `--enable-lora` armed + empty inventory. **Plus `mg-logwrap.sh`** (`d3`) and **plus the cudagraph knob added after t6 closed** (`d5`). | +| `t7` | delivered | `minor/governance.py` `ROLE = "hand"`; duty lists re-derived for an adapter-dependent specialist and deliberately unchanged. Module still names no model id — its own invariant, asserted by its tests. | +| `t8` | delivered (amended post-completion) | `hand` in 4 card profiles + 7 shapes; 28 goldens regenerated, diff `HAND_*`-only, zero deletions. **Orin budget later changed 0.10 → 0.06** — see `d6`. | +| `t9` | delivered | `_FAMILY_BY_ROLE` + `_MEASURE_FN` + `_LLM_ROLES`; colleague-stack bundle is seven. **Plus `_compose.GPU_SERVICES`** — see `d4`. | +| `t10` | delivered | Budgets re-derived on real hardware. Produced two refutations and one fix. `docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt`. | +| `t11` | delivered | Role counts swept to nine across docs/CLAUDE.md/README; irreversibility callout; per-card UNVALIDATED discipline; 4B doc re-headed as demoted. Also corrected counts that were **already stale before this change** (`explain/catalog.py` said SEVEN throughout). | +| `t12` | **partial** | **Orin VALIDATED** (`docs/evidence/2026-08-10-accept-hand-orin.txt`). **Thor NOT validated** — boot failed, cause unattributed. See `d7`, issue #181. | + +## Mid-work Decisions + +Seven approved deviations, recorded in the ledger at the moment each occurred +(`devague deviate --list`). Four are file-scope spills, two are +post-completion amendments, one is a scope shortfall. + +- `d1` *(acceptable)* — **t1 spilled into `route.py`/`run.py`.** Both resolved + the cheap gear by `role_hint == "minor"`, which no entry carries after the + repoint; leaving them would have shipped a runtime regression. +- `d2` *(acceptable)* — **t4 spilled into `_readiness.py`.** The obvious + implementation of adapter honesty — stat the adapter path — is *wrong* here: + paths are mounted into `vllm-hand`, not the gateway, so a filesystem check + would false-negative every correct config while missing the failures that + matter. The honest evidence is the lane's own `/v1/models`, which is a + readiness concern. +- `d3` *(acceptable)* — **t6 spilled into `mg-logwrap.sh`.** A compose command + list cannot omit an argument conditionally, so `--lora-modules=` renders empty + and vLLM would parse it as a malformed pair. **Validated live** — see the + delivery claims below. +- `d4` *(acceptable)* — **t9/t3 spilled into `_compose.GPU_SERVICES` and + `shape_render.ROLE_SERVICE`.** Two role→service maps outside the declared file + sets, both raising `KeyError` on an unmapped role; neither deferrable (one + crashes the goldens regenerator, the other fails CI). +- `d5` *(needs-follow-up)* — **t6's lane changed after t6 closed**: the missing + `VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0`. Tracked as #182. +- `d6` *(acceptable)* — **t8's Orin budget changed after t8 closed**: 0.10 → + 0.06. This is `r1` firing and t10 exercising its authorisation, but it does + mean t8's output was not final when t8 closed. +- `d7` *(needs-follow-up)* — **t12 delivered Orin only.** Tracked as #181/#183. + +## Drift From Plan + +| Plan item | Drift | Classification | +|---|---|---| +| `t12` | Acceptance says "Live validation on Thor **and** Orin". Thor is not validated; its boot died in LoRA embedding-slot allocation on a loaded box, cause unattributed between three candidates. Delivered as Orin-only. | `needs-follow-up` (`d7`, #181) | +| `t6` | Shipped without a knob every sibling lane sets, making the lane unbootable. Found and fixed by t10's live run, not by t6. | `needs-follow-up` (`d5`, #182) | +| `t8` | Committed a budget the hardware then refused; regenerated its goldens twice. | `acceptable` (`d6`) | +| `t1`, `t3`, `t4`, `t6`, `t9` | Each touched files outside its declared `FILES:` line. All were forced by cross-cutting maps or runtime correctness, none by convenience. | `acceptable` (`d1`–`d4`) | +| — | **The plan's `FILES:` discipline was for merge-disjointness under parallel fan-out.** Since the run was in-house and serial, the spills cost nothing operationally — but they mean the plan's file partition was *wrong*, not merely exceeded. A future fan-out of this same plan would have collided. | `acceptable`, noted | + +## Delivery Claims + +| Claim | Confidence | Evidence | +|---|---|---| +| `hand` is a first-class ninth role across catalog, roles, profiles, shapes, gateway, pressure, CLI and docs | **high** | 2889 tests pass; parametrised per-table completeness tests; 28 regenerated goldens | +| The `lfm2` parser is the correct choice and returns structured tool calls | **high** | Live: `tool_calls:[{"name":"get_weather","arguments":"{\"city\": \"Paris\"}"}]`, `finish_reason:"tool_calls"`, `content:null`; tokenizer delimiters confirmed as special tokens (ids 10/11) | +| Orin serves `hand` at `gpu_mem_util=0.06`, full 32768 window | **high** | `docs/evidence/2026-08-10-accept-hand-orin.txt` — available KV 2.7 GiB, pool 235,721 tokens, 7.19× | +| The bf16 `"none"` sentinel plumbs end to end (no `--quantization`) | **high** | Live engine config: `quantization=None` | +| `mg-logwrap` strips the empty `--lora-modules=` in the real committed lane | **high** | Live compose boot; the wrapper's own durable-log argv line shows the flag absent | +| Thor can serve `hand` | **unverified** | Boot failed; three candidate causes unseparated (#181) | +| Spark can serve `hand` | **unverified** | Never exercised (#183) | +| `hand:` adapter serving, honesty filter, rank ceiling | **unverified** | Offline tests only — v1 ships zero adapters by decision (#183, `agentculture/unsloth-cli#16`) | +| `hand` through the **gateway** (aliases, tier, pressure floor) | **unverified** | Offline tests only; all live probes dialled the engine directly | + +## Remaining Work + +1. **#181** — Thor's LoRA embedding-slot failure. Re-run on a quiet Thor with + the cudagraph fix; the outcome distinguishes the three candidate causes. +2. **#183** — Validate Thor and Spark; exercise adapter serving end to end once + `agentculture/unsloth-cli#16` can produce an LFM2.5 adapter. +3. **#182** — No test asserts compose-lane env parity. This run's most + instructive defect: the lane was valid, rendered clean, and passed the whole + suite while being unbootable. +4. **#180** — Grant `hand` `repo_action` once adapters exist. +5. **Plan risks left open**: `r3` (downstream consumers parsing the new + `hand:` id shape — unverified against culture/daria/reachy-mini-cli/ + colleague), `r4`/`r5`/`r6`. + +## What I would flag to a reviewer + +The single most useful thing this run produced is **not** the feature — it is +the demonstration that a lane can be fully green offline and still not boot. +`t6` passed every test it had, rendered valid compose, and was unbootable on +real hardware. Only `t10`'s live run caught it. If one item from Remaining Work +gets picked up, #182 is the one that prevents a recurrence. + +Second: `t12` is **partial** and the artifact says so in every place a reader +might skim. `hand` is validated on exactly one card. The temptation to write +"validated on Jetson" — which would be true of the Orin and read as true of +Thor — was specifically avoided. diff --git a/lobes/templates/fleet/docker-compose.yml b/lobes/templates/fleet/docker-compose.yml index bfd954d..8ff4c98 100644 --- a/lobes/templates/fleet/docker-compose.yml +++ b/lobes/templates/fleet/docker-compose.yml @@ -550,6 +550,19 @@ services: # bf16 — NO --quantization flag (quantization="none" in catalog). - --max-model-len=${HAND_MAX_MODEL_LEN:-32768} - --gpu-memory-utilization=${HAND_GPU_MEM_UTIL:-0.06} + # Attention backend, via AttentionConfig — NOT the VLLM_ATTENTION_BACKEND + # env var, which is GONE on this nightly (see the vllm-embed lane's own + # note). "auto" is byte-equivalent to omitting the flag: vLLM treats the + # string as its automatic-selection sentinel. + # + # This line exists because the orin card profile DECLARES + # attention_backend = "TRITON_ATTN" for hand and the lane originally had + # nowhere to receive it — `lobes init --profile orin` rendered + # HAND_ATTENTION_BACKEND into .env and the lane silently ignored it. A + # profile knob that reaches no flag is exactly the kind of dead + # declaration #92 exists to prevent: it reads as configured and does + # nothing. + - '--attention-config={"backend": "${HAND_ATTENTION_BACKEND:-auto}"}' # LoRA serving: ARMED by default with an EMPTY inventory (v1 ships zero # adapters). --enable-lora costs a small fixed amount of memory and makes # the lane able to serve an adapter the moment one is declared; arming it diff --git a/lobes/templates/fleet/env.example b/lobes/templates/fleet/env.example index 62005dc..1deae64 100644 --- a/lobes/templates/fleet/env.example +++ b/lobes/templates/fleet/env.example @@ -255,6 +255,10 @@ HAND_GPU_MEM_UTIL=0.06 # usage, exactly as the primary/multimodal/muse/worker lanes already do. Set to # 1 to re-enable (and then expect to need ~4.6x the util for the same KV). HAND_CUDAGRAPH_ESTIMATE=0 +# Attention backend, consumed via --attention-config (VLLM_ATTENTION_BACKEND is +# gone on this nightly). "auto" is byte-equivalent to omitting the flag. The +# orin card profile declares TRITON_ATTN here; the 128 GB cards stay on auto. +HAND_ATTENTION_BACKEND=auto # bf16 — there is deliberately NO HAND_QUANTIZATION knob. The catalog's # quantization="none" sentinel means the lane omits --quantization ENTIRELY; # passing modelopt to a bf16 checkpoint corrupts the weights. diff --git a/tests/goldens/template-defaults.env b/tests/goldens/template-defaults.env index 5e5284d..1aff84b 100644 --- a/tests/goldens/template-defaults.env +++ b/tests/goldens/template-defaults.env @@ -24,6 +24,7 @@ GATEWAY_PIP_EXTRA_INDEX_URL= GATEWAY_PUBLIC_URL= GATEWAY_READ_TIMEOUT=600 HAND_ADAPTER_DIR=./adapters +HAND_ATTENTION_BACKEND=auto HAND_BASE_URL=http://vllm-hand:8000 HAND_CUDAGRAPH_ESTIMATE=0 HAND_GPU_MEM_UTIL=0.06 diff --git a/tests/test_shape_goldens.py b/tests/test_shape_goldens.py index 4553bff..fe7bb8a 100644 --- a/tests/test_shape_goldens.py +++ b/tests/test_shape_goldens.py @@ -486,3 +486,38 @@ def _boom(*args: object, **kwargs: object) -> None: rendered = render_shape(shape, resolve_profile(card)) assert rendered.env, f"{shape_name}/{card} rendered no env" assert rendered.services + + +# --------------------------------------------------------------------------- +# Rendered-knob reachability (hand-lobe plan, live finding 2026-08-10) +# --------------------------------------------------------------------------- +# A card profile that DECLARES a knob renders `_=value` into the +# deployment's .env. If the compose template never substitutes that variable, +# the declaration is DEAD: `lobes init` writes it, the operator reads it as +# configured, and the lane ignores it. +# +# This is not hypothetical. The `hand` lane shipped consuming HAND_MODEL, +# HAND_SERVED_NAME, HAND_MAX_MODEL_LEN and HAND_GPU_MEM_UTIL but NOT +# HAND_ATTENTION_BACKEND — which builtin/orin.toml declares as TRITON_ATTN. The +# Orin therefore rendered an attention-backend choice the engine never saw. +# Caught by a live compose boot, not by any test; this is that test. + + +@pytest.mark.parametrize("card_name", sorted(builtin_names())) +def test_every_rendered_profile_knob_is_substituted_by_the_fleet_template(card_name): + """Every KEY a card profile renders must appear as ${KEY...} in the template. + + Guards the dead-knob class: a profile declaration that reaches no compose + flag is silently inert, which reads as configured and is not. + """ + template = FLEET_COMPOSE.read_text(encoding="utf-8") + rendered = profile_env(resolve_profile(card_name)) + dead = [ + key + for key in rendered + if f"${{{key}" not in template and f"${{{key}}}" not in template + ] + assert not dead, ( + f"{card_name}: these profile-rendered keys are never substituted by " + f"templates/fleet/docker-compose.yml, so the declaration is dead: {dead}" + ) diff --git a/tests/test_tool_parser_plugin.py b/tests/test_tool_parser_plugin.py index 0a4b949..e908d43 100644 --- a/tests/test_tool_parser_plugin.py +++ b/tests/test_tool_parser_plugin.py @@ -136,7 +136,7 @@ "gateway": "88448c70bf43f4b757c3a6f4f86c428c040fbf4321ffa75f2e5fe35e0bb2f5b8", "vllm-embed": "63db52dc1121c1b861b5559c03d1b2c76699af86a575718908306f2440bd4b85", "vllm-embed-deep": "532b5b24c76c6cb90d06a4336ec42e6cc856a18ee112186aeff1141403f1143e", - "vllm-hand": "abc870186791cd8d443f165c94031abde451f309cded279a3221bcaa05f72636", + "vllm-hand": "cc07d34320aacf2bce52e6cded64b4ad7240931ad8f966545142e6f17311e7ec", "vllm-middle": "efef630842164793e43313fff2b588b92d7f57aad35fffc941a3617cddc1a129", "vllm-minor": "ddca0c0c64eb06514ba23d5327f61ce410bf8de40d3d7f519c399c6b8c60bc01", "vllm-multimodal": "64c129b764059c3e78ed248da60634cd2ecf4a0af26c61e3508c5676ddb11134", From c869147a3dcdbcbb1a969227a524508ae4ddb81f Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 06:58:28 +0300 Subject: [PATCH 11/17] =?UTF-8?q?fix:=20RETRACT=20the=20Orin=20VALIDATED?= =?UTF-8?q?=20claim=20=E2=80=94=20the=20budget=20does=20not=20reproduce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I wrote an acceptance transcript after ONE successful boot. Two later boots of the same configuration on the same box refused to start. That is not a validated budget and the claim is withdrawn before this branch becomes a PR. Three boots at identical gpu_mem_util=0.06 / max_model_len=32768: run 1 (docker run) -> Available KV 2.7 GiB -> served run 2 (compose) -> Available KV 0.14 GiB -> ValueError run 3 (compose, clean) -> Available KV 0.09 GiB -> ValueError, "estimated maximum model length 7984" vLLM clamps its budget against actual free memory at startup. The Orin is shared — senses at 0.45, the pooling gears, unrelated production containers — and its free memory moved ~2.7 GiB across these runs. A util leaving ~1 GiB of margin on a 61 GiB card sits inside that noise. WHAT SURVIVES, because it was observed on a real serving engine and does not depend on the budget: the lfm2 parser, the structured tool_calls array, the tokenizer delimiters (ids 10/11), the bf16 sentinel, the unknown-id 404. WHAT DOES NOT: "Orin VALIDATED", and 0.06/235,721 tokens/7.19x as a committed measurement. NO card is validated for hand. Changes: the transcript is renamed ...-accept-... -> ...-partial-... and carries the retraction at its head; orin.toml relabels 0.06 as one data point with operator guidance for a box that refuses it; the per-model doc, machine-profiles.md, the delivery summary and CHANGELOG all read DECLARED. Recorded as deviation d9. The Orin joins #183. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- .../deliveries/hand-lobe-lfm2-5-1-2b.json | 28 ++++++++- CHANGELOG.md | 2 + .../2026-08-10-hand-lobe-lfm2-5-1-2b.md | 48 +++++++++++++--- ...2026-08-10-hand-lobe-budget-derivation.txt | 4 +- ...n.txt => 2026-08-10-partial-hand-orin.txt} | 57 ++++++++++++++++--- docs/lfm2.5-1.2b-hand.md | 38 +++++++++---- docs/machine-profiles.md | 40 ++++++------- lobes/profiles/builtin/orin.toml | 29 ++++++---- 8 files changed, 184 insertions(+), 62 deletions(-) rename docs/evidence/{2026-08-10-accept-hand-orin.txt => 2026-08-10-partial-hand-orin.txt} (69%) diff --git a/.devague/deliveries/hand-lobe-lfm2-5-1-2b.json b/.devague/deliveries/hand-lobe-lfm2-5-1-2b.json index 65b877c..3ebdc2c 100644 --- a/.devague/deliveries/hand-lobe-lfm2-5-1-2b.json +++ b/.devague/deliveries/hand-lobe-lfm2-5-1-2b.json @@ -2,7 +2,7 @@ "plan_slug": "hand-lobe-lfm2-5-1-2b", "schema_version": 1, "created": "2026-08-10T03:38:10Z", - "updated": "2026-08-10T03:39:00Z", + "updated": "2026-08-10T03:56:55Z", "deviations": [ { "id": "d1", @@ -92,6 +92,32 @@ "origin": "user", "status": "approved", "classification": "needs-follow-up" + }, + { + "id": "d8", + "what": "A second dead-knob defect shipped and was caught only by booting the real compose lane: HAND_ATTENTION_BACKEND was rendered into .env by the orin card profile and substituted by nothing", + "task_ref": "t6", + "reason": "builtin/orin.toml declares attention_backend='TRITON_ATTN' for hand, so lobes init renders HAND_ATTENTION_BACKEND=TRITON_ATTN, but the vllm-hand lane never referenced the variable \u2014 the operator reads a configured backend the engine never receives. Same class as d5 (the cudagraph knob) and same detection story: valid compose, green suite, wrong behaviour only visible when an engine runs it. It also explains why my earlier hand-rolled docker-run probe and the committed lane disagreed \u2014 the probe set the backend manually, the lane could not. Fixed via --attention-config (VLLM_ATTENTION_BACKEND is gone on this nightly) plus a test asserting EVERY profile-rendered key is substituted by the template, verified to fail with the fix reverted.", + "affects": [ + "t8", + "t12" + ], + "origin": "user", + "status": "approved", + "classification": "needs-follow-up" + }, + { + "id": "d9", + "what": "RETRACTED the Orin VALIDATED claim before the branch became a PR \u2014 the budget does not reproduce", + "task_ref": "t12", + "reason": "The acceptance transcript was written after ONE successful boot at gpu_mem_util=0.06 (available KV 2.7 GiB, pool 235,721 tokens, 7.19x). Two later boots of the same configuration on the same box profiled 0.14 GiB and 0.09 GiB and refused to start \u2014 vLLM clamps its budget against actual free memory at startup, and this is a SHARED box whose free memory moved ~2.7 GiB across the runs. One success against two refusals is an unstable budget, not a validated one. The functional results survive (they were observed on a real serving engine and do not depend on the budget): the lfm2 parser, the structured tool_calls array, the bf16 sentinel, the unknown-id 404. The budget claim does not. Transcript renamed to ...-partial-... with the retraction at its head; orin.toml, the per-model doc and machine-profiles.md all now read DECLARED. NO card is validated for hand.", + "affects": [ + "t8", + "t10" + ], + "origin": "user", + "status": "approved", + "classification": "needs-follow-up" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cda0cd..ae6edf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **Adapter honesty (#92 for adapters)** — a declared adapter is advertised on `GET /v1/models` and `/capabilities` only once the lane's OWN `/v1/models` confirms the engine loaded it (`_readiness.probe_backend_adapters`). Deliberately asks the ENGINE, not the filesystem: adapter paths are mounted into `vllm-hand`, not the gateway, so a path check there would false-negative every correct config while still missing the failures that matter (unreadable file, rank above `--max-lora-rank`, a checkpoint vLLM refused) - **`lfm2` tool-call parser** — LFM2 emits `<|tool_call_start|>…<|tool_call_end|>`, whose delimiters are **special tokens**: the same trap that made `pythonic` silently wrong for Gemma 4. vLLM's purpose-built `lfm2` parser resolves both delimiters in `__init__` and **raises** when either is missing, so a bad tokenizer revision fails loudly at startup rather than relaying a well-formed call as prose. No `--reasoning-parser`: this checkpoint has no thinking mode (LiquidAI ships `LFM2.5-1.2B-Thinking` separately), so unlike the cortex and Gemma 4 lanes there is no second half to pair with - `docs/lfm2.5-1.2b-hand.md` — the per-model reference, and an **"adding a role is effectively irreversible"** callout in `docs/colleague-stack.md` enumerating the surfaces a role name lands on +- **VALIDATION STATUS: `hand` is DECLARED on every card and VALIDATED on NONE (#108).** Stated here rather than left unsaid, because two live findings in this release were *only* visible on hardware. The Orin **served** and passed every functional check — a structured `tool_calls` array through the `lfm2` parser, the tokenizer's `<|tool_call_start|>`/`<|tool_call_end|>` confirmed as special tokens (ids 10/11), the bf16 sentinel, a correct known-answer completion, an unknown id refused 404 — but its **budget did not reproduce**: three boots at the identical `gpu_mem_util=0.06` / `max_model_len=32768` profiled 2.7 GiB, 0.14 GiB and 0.09 GiB of available KV (one success, two refusals), because vLLM clamps against actual free memory on a box that also hosts `senses` and unrelated workloads. The Thor boot failed outright in LoRA embedding-slot allocation, cause unattributed. `docs/evidence/2026-08-10-partial-hand-orin.txt` (a PARTIAL record carrying its own retraction), `docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt`, and issues #181/#182/#183 ### Changed @@ -25,6 +26,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **The `vllm-hand` lane shipped unbootable AND silently misconfigured — two defects no offline test could see.** (1) It omitted `VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0`, which every other lane on the same nightly image sets; live, the lane profiled to **`Available KV cache memory: -9.25 GiB`** — negative, so no boot at any util. (2) It never substituted `HAND_ATTENTION_BACKEND`, a **dead knob**: `builtin/orin.toml` declares `attention_backend = "TRITON_ATTN"` and `lobes init` renders it into `.env`, where nothing read it — the operator sees a configured backend the engine never receives. Both were caught by booting the real committed compose lane; both rendered valid compose and passed the full suite. The second now has a class-level guard (`test_every_rendered_profile_knob_is_substituted_by_the_fleet_template`, verified to fail with the fix reverted); the first's equivalent is #182 and does not exist yet - `lobes measure` would have raised `KeyError` on any role missing from `roles_measure._FAMILY_BY_ROLE` — a crash, not a degraded reading. Both that map and `_MEASURE_FN` now cover every role, and a parametrised test iterates `ROLES` so the class cannot recur - `build_role_registry` iterated a **hand-typed copy** of the gateway-fronted roles instead of deriving them from `ROLES`, so a new role could be registered in all six per-role tables yet be silently missing from the registry the CLI and `GET /capabilities` both read. Now derived (`GATEWAY_FRONTED_ROLES`), with a parametrised completeness test per table — the same half-landed-role failure mode that made `WORKER_PEER_PROXY=true` inert in 0.54.6 diff --git a/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md b/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md index 1ae3809..97f901c 100644 --- a/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md +++ b/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md @@ -51,13 +51,14 @@ The plan's wave order was still followed as the build order. | `t9` | delivered | `_FAMILY_BY_ROLE` + `_MEASURE_FN` + `_LLM_ROLES`; colleague-stack bundle is seven. **Plus `_compose.GPU_SERVICES`** — see `d4`. | | `t10` | delivered | Budgets re-derived on real hardware. Produced two refutations and one fix. `docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt`. | | `t11` | delivered | Role counts swept to nine across docs/CLAUDE.md/README; irreversibility callout; per-card UNVALIDATED discipline; 4B doc re-headed as demoted. Also corrected counts that were **already stale before this change** (`explain/catalog.py` said SEVEN throughout). | -| `t12` | **partial** | **Orin VALIDATED** (`docs/evidence/2026-08-10-accept-hand-orin.txt`). **Thor NOT validated** — boot failed, cause unattributed. See `d7`, issue #181. | +| `t12` | **partial** | **NO card validated.** Orin *served* and passed every functional check, but its budget did not reproduce (2.7 / 0.14 / 0.09 GiB KV across three identical boots) — see `d9`. Thor's boot failed outright. `docs/evidence/2026-08-10-partial-hand-orin.txt`, `d7`, #181, #183. | ## Mid-work Decisions -Seven approved deviations, recorded in the ledger at the moment each occurred -(`devague deviate --list`). Four are file-scope spills, two are -post-completion amendments, one is a scope shortfall. +Nine approved deviations, recorded in the ledger at the moment each occurred +(`devague deviate --list`). Four are file-scope spills, three are +post-completion amendments, one is a scope shortfall, one is a retraction of a +claim I had already written down. - `d1` *(acceptable)* — **t1 spilled into `route.py`/`run.py`.** Both resolved the cheap gear by `role_hint == "minor"`, which no entry carries after the @@ -82,6 +83,18 @@ post-completion amendments, one is a scope shortfall. 0.06. This is `r1` firing and t10 exercising its authorisation, but it does mean t8's output was not final when t8 closed. - `d7` *(needs-follow-up)* — **t12 delivered Orin only.** Tracked as #181/#183. +- `d9` *(needs-follow-up)* — **the Orin budget does not reproduce**, so the + VALIDATED claim was RETRACTED before this branch became a PR. Three boots at + identical settings gave 2.7 / 0.14 / 0.09 GiB of available KV. The acceptance + transcript was renamed `…-partial-…` and carries the retraction at its head; + the card profile, per-model doc and machine-profiles all now say DECLARED. +- `d8` *(needs-follow-up)* — **a second dead knob**: `HAND_ATTENTION_BACKEND` + was rendered into `.env` by the Orin card profile and substituted by nothing. + The operator reads a configured attention backend; the engine never receives + one. Found only by booting the **real committed compose lane** — my earlier + probes were hand-rolled `docker run`s that set the backend manually, which is + exactly why they disagreed with the lane. Fixed, plus a test asserting every + profile-rendered key is substituted by the template. ## Drift From Plan @@ -89,6 +102,7 @@ post-completion amendments, one is a scope shortfall. |---|---|---| | `t12` | Acceptance says "Live validation on Thor **and** Orin". Thor is not validated; its boot died in LoRA embedding-slot allocation on a loaded box, cause unattributed between three candidates. Delivered as Orin-only. | `needs-follow-up` (`d7`, #181) | | `t6` | Shipped without a knob every sibling lane sets, making the lane unbootable. Found and fixed by t10's live run, not by t6. | `needs-follow-up` (`d5`, #182) | +| `t6` | Shipped a **dead knob**: the Orin's declared `attention_backend` reached no compose flag. Found only by booting the real lane. | `needs-follow-up` (`d8`) | | `t8` | Committed a budget the hardware then refused; regenerated its goldens twice. | `acceptable` (`d6`) | | `t1`, `t3`, `t4`, `t6`, `t9` | Each touched files outside its declared `FILES:` line. All were forced by cross-cutting maps or runtime correctness, none by convenience. | `acceptable` (`d1`–`d4`) | | — | **The plan's `FILES:` discipline was for merge-disjointness under parallel fan-out.** Since the run was in-house and serial, the spills cost nothing operationally — but they mean the plan's file partition was *wrong*, not merely exceeded. A future fan-out of this same plan would have collided. | `acceptable`, noted | @@ -99,10 +113,12 @@ post-completion amendments, one is a scope shortfall. |---|---|---| | `hand` is a first-class ninth role across catalog, roles, profiles, shapes, gateway, pressure, CLI and docs | **high** | 2889 tests pass; parametrised per-table completeness tests; 28 regenerated goldens | | The `lfm2` parser is the correct choice and returns structured tool calls | **high** | Live: `tool_calls:[{"name":"get_weather","arguments":"{\"city\": \"Paris\"}"}]`, `finish_reason:"tool_calls"`, `content:null`; tokenizer delimiters confirmed as special tokens (ids 10/11) | -| Orin serves `hand` at `gpu_mem_util=0.06`, full 32768 window | **high** | `docs/evidence/2026-08-10-accept-hand-orin.txt` — available KV 2.7 GiB, pool 235,721 tokens, 7.19× | +| Orin serves `hand` at `gpu_mem_util=0.06`, full 32768 window | **low** | RETRACTED as a budget claim — one success (2.7 GiB KV) against two refusals (0.14, 0.09 GiB) at the identical util. The lane *did* serve on run 1; the budget is not reproducible. | | The bf16 `"none"` sentinel plumbs end to end (no `--quantization`) | **high** | Live engine config: `quantization=None` | | `mg-logwrap` strips the empty `--lora-modules=` in the real committed lane | **high** | Live compose boot; the wrapper's own durable-log argv line shows the flag absent | +| Every knob any card profile renders is now consumed by the fleet template | **high** | `test_every_rendered_profile_knob_is_substituted_by_the_fleet_template`, verified to FAIL with the `d8` fix reverted | | Thor can serve `hand` | **unverified** | Boot failed; three candidate causes unseparated (#181) | +| Any card has a reproducible `hand` budget | **unverified** | No card validated. The Orin's 0.06 is one observation (#183) | | Spark can serve `hand` | **unverified** | Never exercised (#183) | | `hand:` adapter serving, honesty filter, rank ceiling | **unverified** | Offline tests only — v1 ships zero adapters by decision (#183, `agentculture/unsloth-cli#16`) | | `hand` through the **gateway** (aliases, tier, pressure floor) | **unverified** | Offline tests only; all live probes dialled the engine directly | @@ -124,10 +140,24 @@ post-completion amendments, one is a scope shortfall. ## What I would flag to a reviewer The single most useful thing this run produced is **not** the feature — it is -the demonstration that a lane can be fully green offline and still not boot. -`t6` passed every test it had, rendered valid compose, and was unbootable on -real hardware. Only `t10`'s live run caught it. If one item from Remaining Work -gets picked up, #182 is the one that prevents a recurrence. +the demonstration that a lane can be fully green offline and still be wrong on +hardware, **twice**, in two different ways: + +- `d5` — a missing env knob made the lane **unbootable** (negative KV). +- `d8` — a dead env knob made the lane **silently misconfigured** (the Orin's + declared attention backend reached nothing). + +Both passed the full suite. Both rendered valid compose. `d8` is the more +uncomfortable of the two, because it was invisible even to my own live probes +— those were hand-rolled `docker run`s that set the backend by hand, so they +disagreed with the committed lane and I initially read the difference as box +noise. It only surfaced when I stopped approximating the lane and booted it. + +The lesson generalises past this PR: **a live test that does not run the +artifact you are shipping is not a live test of it.** `d8` now has a +class-level guard (every profile-rendered key must be substituted by the +template); `d5`'s equivalent is #182 and does not exist yet. If one item from +Remaining Work gets picked up, that is the one. Second: `t12` is **partial** and the artifact says so in every place a reader might skim. `hand` is validated on exactly one card. The temptation to write diff --git a/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt b/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt index 704e584..fb0e0e1 100644 --- a/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt +++ b/docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt @@ -18,7 +18,7 @@ Both are recorded below with the raw engine output that produced them. This file covers the DERIVATION runs only — the three findings below are all refutations. The subsequent successful Orin boot at the corrected budget is a -separate transcript: docs/evidence/2026-08-10-accept-hand-orin.txt. +separate transcript: docs/evidence/2026-08-10-partial-hand-orin.txt. ----------------------------------------------------------------------------- 0. What the engine confirms about the checkpoint (both boxes) @@ -125,7 +125,7 @@ Per #108, and stated plainly because a partial run invites over-reading. SCOPE NOTE: this transcript covers the DERIVATION runs only — the three findings above, all of which are refutations. A SUBSEQUENT run on the Orin at the corrected 0.06 succeeded and is recorded separately in -docs/evidence/2026-08-10-accept-hand-orin.txt. The list below is what THESE +docs/evidence/2026-08-10-partial-hand-orin.txt. The list below is what THESE runs did not establish; several items were settled by that later run and are marked accordingly, so this file is not read as denying it. diff --git a/docs/evidence/2026-08-10-accept-hand-orin.txt b/docs/evidence/2026-08-10-partial-hand-orin.txt similarity index 69% rename from docs/evidence/2026-08-10-accept-hand-orin.txt rename to docs/evidence/2026-08-10-partial-hand-orin.txt index 5acb1a6..d86d23c 100644 --- a/docs/evidence/2026-08-10-accept-hand-orin.txt +++ b/docs/evidence/2026-08-10-partial-hand-orin.txt @@ -1,6 +1,45 @@ ============================================================================= -hand lobe (LiquidAI/LFM2.5-1.2B-Instruct) — ACCEPTANCE, Jetson AGX Orin +hand lobe (LiquidAI/LFM2.5-1.2B-Instruct) — PARTIAL, Jetson AGX Orin + *** NOT AN ACCEPTANCE — SEE THE RETRACTION BELOW *** ============================================================================= + +RETRACTION (appended 2026-08-10, same session, before this branch was opened +as a PR). This file was first written as an ACCEPTANCE transcript claiming the +Orin VALIDATED at gpu_mem_util=0.06. That claim is WITHDRAWN. Two later boots +of the SAME configuration on the SAME box failed at the memory gate: + + run 1 (docker run, util 0.06): Available KV cache memory: 2.7 GiB + -> served; every functional result + in sections 2-6 below came from it + run 2 (compose, util 0.06): Available KV cache memory: 0.14 GiB + -> ValueError, needs 0.38 GiB for 32768 + run 3 (compose, clean, util 0.06): Available KV cache memory: 0.09 GiB + -> ValueError, "estimated maximum model + length is 7984" + +One success and two failures at an identical util is not a validated budget — +it is an unstable one. The Orin is a SHARED box (senses at 0.45, the pooling +gears, and unrelated production containers), and vLLM clamps its budget against +actual free memory at startup, which moved from ~9 GiB to ~6.3 GiB across these +runs. A util that leaves ~1 GiB of margin on a 61 GiB card is inside that noise. + +WHAT SURVIVES the retraction, because it was observed on a real serving engine +(run 1) and does not depend on the budget: + - the architecture/flag plumbing in section 2 + - the correct known-answer completion in section 3 + - the STRUCTURED tool_calls array and the tokenizer delimiters in section 4 + - the unknown-model 404 in section 5 +These are behaviours of the lane, not of its memory budget. + +WHAT DOES NOT SURVIVE: + - "Orin VALIDATED". The card is DECLARED, like every other. + - the 0.06 / 235,721-token / 7.19x figures in section 1 as a COMMITTED budget. + They are one observation, not a reproducible measurement. + +Consequence: NO card is validated for `hand`. See issue #183, which now also +carries the Orin. The committed Orin util stays 0.06 as the best available +single data point, explicitly DECLARED — see lobes/profiles/builtin/orin.toml. +============================================================================== Date: 2026-08-10 Box: Jetson AGX Orin 64GB (sm_87), 61.34 GiB unified, orin-lobe shape (senses 0.45 + embed 0.06 + rerank 0.06 already resident) @@ -10,9 +49,10 @@ Plan: docs/plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md, task t12 Companion: docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt (t10 — how this budget was arrived at, including the two refuted attempts) -SCOPE OF THIS TRANSCRIPT: the Jetson AGX Orin ONLY. Per #108 it promotes no -other card. Thor and the DGX Spark remain UNVALIDATED for `hand`; the base -fallback profile is untested by construction. +SCOPE OF THIS TRANSCRIPT: the Jetson AGX Orin ONLY, and per the retraction +above it is a PARTIAL record, not an acceptance. Per #108 it promotes no card +at all — not even this one. Thor, the DGX Spark and the base fallback are +likewise UNVALIDATED. Lane under test (the committed vllm-hand lane's rendered flags): --model LiquidAI/LFM2.5-1.2B-Instruct @@ -35,10 +75,11 @@ Lane under test (the committed vllm-hand lane's rendered flags): INFO [kv_cache_utils.py:2147] Maximum concurrency for 32,768 tokens per request: 7.19x - Healthy at the FULL served 32768 window, co-resident with the orin-lobe - shape's existing lanes. 0.06 is therefore the Orin's committed value — - measured, replacing the DECLARED 0.10 the box refused (see the companion - transcript). + *** SEE THE RETRACTION AT THE TOP OF THIS FILE. *** This reading is ONE + observation out of three at the same util; the other two profiled 0.14 and + 0.09 GiB and refused to boot. It is recorded because it is real and because + it is the best single data point available, NOT as a committed measurement. + 0.06 remains the Orin's declared value for want of a better one. Read 7.19x as a KV-pool CEILING, not measured throughput — the standing caveat in CLAUDE.md. Usable concurrency was not measured here. diff --git a/docs/lfm2.5-1.2b-hand.md b/docs/lfm2.5-1.2b-hand.md index 1f97e29..6b5cafc 100644 --- a/docs/lfm2.5-1.2b-hand.md +++ b/docs/lfm2.5-1.2b-hand.md @@ -174,20 +174,38 @@ Per the #108 rule, `hand` is **DECLARED** on every card and becomes **VALIDATED* only on the cards whose acceptance transcript has landed under `docs/evidence/`. One box's successful boot never promotes another card. +**No card is validated.** `hand` is DECLARED everywhere. + | card | status | evidence | |---|---|---| -| **Jetson AGX Orin** (sm_87, 64 GB) | **VALIDATED 2026-08-10** | `docs/evidence/2026-08-10-accept-hand-orin.txt` | -| Jetson AGX Thor (sm_110, 128 GB) | DECLARED | boot failed — [#181](https://github.com/agentculture/lobes-cli/issues/181) | -| DGX Spark GB10 (128 GB) | DECLARED | not yet exercised — [#183](https://github.com/agentculture/lobes-cli/issues/183) | +| Jetson AGX Orin (sm_87, 64 GB) | DECLARED — *served once, budget not reproducible* | `docs/evidence/2026-08-10-partial-hand-orin.txt` | +| Jetson AGX Thor (sm_110, 128 GB) | DECLARED — boot failed | [#181](https://github.com/agentculture/lobes-cli/issues/181) | +| DGX Spark GB10 (128 GB) | DECLARED — not yet exercised | [#183](https://github.com/agentculture/lobes-cli/issues/183) | | `base` (unrecognised card) | DECLARED | untestable by construction | -What the Orin run established: `gpu_mem_util = 0.06` at the full 32768 window -(available KV 2.7 GiB, pool 235,721 tokens, 7.19x concurrency), the bf16 -sentinel and the text-only / no-reasoning-parser flags all plumbed correctly, a -correct known-answer completion, and — the check this lane exists to pass — a -**tool call returning a structured `tool_calls` array**, with the tokenizer's -`<|tool_call_start|>` / `<|tool_call_end|>` confirmed present as special tokens -(ids 10 and 11). +**What the Orin runs established — and what they did not.** The lane *served* +once, and everything observed on that live engine holds: the bf16 sentinel and +the text-only / no-reasoning-parser flags plumbed correctly, a correct +known-answer completion, an unknown model id refused with 404, and — the check +this lane exists to pass — a **tool call returning a structured `tool_calls` +array**, with the tokenizer's `<|tool_call_start|>` / `<|tool_call_end|>` +confirmed present as special tokens (ids 10 and 11). + +The **budget** did not hold. Three boots at the identical +`gpu_mem_util = 0.06` / `max_model_len = 32768` on the same box profiled +2.7 GiB, 0.14 GiB and 0.09 GiB of available KV — one success, two refusals. +The Orin is shared (senses at 0.45, the pooling gears, unrelated production +containers) and vLLM clamps its budget against actual free memory at startup, +which moved by ~2.7 GiB across the runs. A util leaving ~1 GiB of margin on a +61 GiB card sits inside that noise. **0.06 is one data point, not a +measurement**, and the card is DECLARED accordingly. + +The practical reading for an operator: `hand` at the full 32 K window wants +more headroom than this Orin has while it also hosts `senses`. Either raise +`HAND_GPU_MEM_UTIL` until the profile reports comfortable KV on *your* box, or +trim `HAND_MAX_MODEL_LEN` (vLLM names the length that would fit — 7984 in the +tightest run). Tracked in +[#183](https://github.com/agentculture/lobes-cli/issues/183). Two things it deliberately does **not** establish: adapter serving end to end (v1 ships zero adapters, so `hand:` resolution and the adapter-honesty diff --git a/docs/machine-profiles.md b/docs/machine-profiles.md index e71e3d2..0493c3a 100644 --- a/docs/machine-profiles.md +++ b/docs/machine-profiles.md @@ -166,28 +166,28 @@ compose template passes the served name to vLLM's `--served-model-name` separately from the model id it downloads; the two must agree for the gateway to route correctly. -> **`hand` is VALIDATED on the Jetson AGX Orin and DECLARED everywhere else -> (#108).** +> **`hand` is DECLARED on every card and VALIDATED on none (#108).** > -> - **Orin — VALIDATED, 2026-08-10** -> (`docs/evidence/2026-08-10-accept-hand-orin.txt`): `gpu_mem_util = 0.06` -> at the full served `max_model_len = 32768`, MEASURED — available KV 2.7 GiB, -> KV pool 235,721 tokens, 7.19x concurrency, co-resident with the orin-lobe -> shape's existing lanes. Tool calling through the `lfm2` parser returns a -> structured `tool_calls` array. -> - **Spark / Thor / base — DECLARED.** `0.06` on the two 128 GB cards is a -> hypothesis, not a measurement; no boot has confirmed it there. Per the #108 -> rule this holds **per card**: the Orin's successful boot promotes nothing -> else, and each card is promoted only when its own acceptance transcript -> lands under `docs/evidence/`. +> - **Orin** — the lane *served* once and every functional check passed +> (`docs/evidence/2026-08-10-partial-hand-orin.txt`), but the BUDGET does not +> reproduce: three boots at the identical `gpu_mem_util = 0.06` / +> `max_model_len = 32768` profiled 2.7 GiB, 0.14 GiB and 0.09 GiB of +> available KV — one success, two refusals. The committed 0.06 is therefore +> one data point, not a measurement. +> - **Thor** — boot failed in LoRA embedding-slot allocation, cause +> unattributed ([#181](https://github.com/agentculture/lobes-cli/issues/181)). +> - **Spark / base** — never exercised. > -> The Orin number is worth reading closely, because it was **declared at 0.10 -> and the box refused it twice** — see -> `docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt`. The reasoning -> behind 0.10 ("0.06 of 64 GB leaves too little KV after the weights") was -> plausible and wrong: 0.06 yields 7.19x concurrency there. That is the whole -> argument for measuring rather than computing a budget on this class of -> unified-memory board. +> Two lessons are worth carrying, both learned the expensive way here: +> +> 1. **A computed budget is a hypothesis.** The Orin was declared at 0.10 on +> the reasoning that "0.06 of 64 GB leaves too little KV after the weights". +> The box refused 0.10 outright — see +> `docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt`. +> 2. **A single measurement on a SHARED box is also a hypothesis.** vLLM clamps +> its budget against actual free memory at startup, so a util leaving only +> ~1 GiB of margin on a 61 GiB card sits inside the noise of whatever else +> the box is doing. A budget is only measured once it reproduces. **When set:** diff --git a/lobes/profiles/builtin/orin.toml b/lobes/profiles/builtin/orin.toml index f65c0b8..4300076 100644 --- a/lobes/profiles/builtin/orin.toml +++ b/lobes/profiles/builtin/orin.toml @@ -175,7 +175,7 @@ attention_backend = "TRITON_ATTN" # --quantization ENTIRELY. Declaring it here — even as an empty string — would # render a flag that corrupts bf16 weights. # -# BUDGET: MEASURED on a physical Jetson AGX Orin, 2026-08-10 — see +# BUDGET: DECLARED. One live observation, NOT a reproducible measurement — see # docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt. This value was # DECLARED at 0.10 and the box refuted it TWICE, which is exactly why t10 # exists: @@ -184,19 +184,24 @@ attention_backend = "TRITON_ATTN" # (4.67/61.34 GiB) on startup is less than desired GPU memory utilization # (0.1, 6.13 GiB)". The orin-lobe shape's committed lanes plus the box's # other workload simply do not leave 6.13 GiB. -# * at 0.06 it boots and profiles HEALTHY: Available KV cache memory 2.7 GiB, -# GPU KV cache size 235,721 tokens, maximum concurrency 7.19x at the served -# 32768 window. +# * at 0.06 it booted ONCE and profiled healthy (available KV 2.7 GiB, pool +# 235,721 tokens, 7.19x at the served 32768 window) — and then FAILED TWICE +# at the identical util, profiling 0.14 GiB and 0.09 GiB. One success, two +# refusals. # -# So 0.10 was both too big to fit AND unnecessary. The reasoning behind it — -# "0.06 of 64 GB leaves too little KV after the weights" — was plausible and -# WRONG: 0.06 yields 7.19x concurrency here, which is more headroom than the -# 27B cortex has ever had on any card. Recorded because the arithmetic looked -# convincing right up until the box disagreed. +# So 0.10 was too big to fit, and 0.06 is not reliably big enough. This box is +# SHARED (senses at 0.45, the pooling gears, unrelated production containers) +# and vLLM clamps its budget against actual free memory at startup, which moved +# by ~2.7 GiB across the three runs. A util leaving ~1 GiB of margin on a 61 GiB +# card sits inside that noise. # -# NOTE this is the ONE card where hand's util was actually measured, and it -# landed on the same 0.06 the 128 GB cards declare — so the per-card mechanism -# is retained (see schema.py) but currently carries no divergence. +# 0.06 is kept as the best available single data point, explicitly DECLARED. +# An operator whose Orin refuses it should raise this value until the boot log +# reports comfortable KV, or trim max_model_len (vLLM names the length that +# would fit — 7984 in the tightest run). Tracked as issue #183. +# +# The per-card util mechanism (see schema.py) is retained and currently carries +# no divergence: every card declares 0.06. feasible = true model = "LiquidAI/LFM2.5-1.2B-Instruct" gpu_mem_util = 0.06 From ee8d6625950f2fea7e89e374482ae174eb678899 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 09:41:07 +0300 Subject: [PATCH 12/17] =?UTF-8?q?feat:=20hand=20serves=20on=20the=20DGX=20?= =?UTF-8?q?Spark=20=E2=80=94=20and=20three=20boots=20name=20why=20no=20bud?= =?UTF-8?q?get=20reproduces=20(0.56.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Spark was the one card `hand` had never been exercised on. It serves, and every functional probe passes on this third card: `Lfm2ForCausalLM` resolved, `quantization=None` (the bf16 sentinel), `reasoning_parser=''`, `PunicaWrapperGPU` (LoRA armed on an empty inventory), a correct known-answer, a structured `tool_calls` array with `finish_reason: "tool_calls"` — the gemma4 prose-relay trap not happening — an undeclared `hand:` refused 404, and `GET /v1/models` carrying exactly one entry, the base. Two results are new here: the first live confirmation that the d8 `--attention-config` fix actually resolves (rather than merely not crashing), and the committed lane reaching compose `healthy`. The budget still does not reproduce, and that is the finding worth keeping. Three boots minutes apart at the identical `gpu_mem_util = 0.06` profiled 6.21 / 3.34 / 3.54 GiB of available KV. Runs 2 and 3 agree within 6% and were taken at the same free-memory level; run 1 followed 31.7 GiB being freed. So the pool tracks memory free AT THAT INSTANT, not the util: On a unified-memory card with co-resident tenants, gpu_mem_util does not name a stable budget. A single boot's KV number measures the box's state, not the card's capacity for the role. That retro-explains the Orin retraction as a property rather than a fluke, and it is not a tight-margin artifact — the Orin showed it with ~1 GiB of margin on a 61 GiB card, the Spark with several GiB on a 121 GiB one. All four cards stay DECLARED (#183); no pool or concurrency figure is promoted anywhere. Also recorded: the four earlier Spark boot failures were memory exhaustion on a shared workstation (swap 100% full, 16 GiB available, a browser holding 31.7 GiB), not a defect in the lane. With that freed, the identical `docker compose up -d` returned in 10s instead of 10+ minutes and the engine served in 71.75s. And `--lora-modules=` is absent from the post-strip argv on a third machine, so deviation d3 is three-for-three across Thor/Orin/Spark. One drive-by: tests/test_shape_goldens.py was committed in a state black would reformat, which CI's lint job would have failed on. Formatted. Recorded as deviation d10 against t12. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- .../deliveries/hand-lobe-lfm2-5-1-2b.json | 14 +- CHANGELOG.md | 10 + .../2026-08-10-partial-hand-spark.txt | 189 ++++++++++++++++++ docs/lfm2.5-1.2b-hand.md | 70 +++++-- docs/machine-profiles.md | 20 +- lobes/profiles/builtin/spark.toml | 10 +- pyproject.toml | 2 +- tests/test_shape_goldens.py | 4 +- uv.lock | 2 +- 9 files changed, 292 insertions(+), 29 deletions(-) create mode 100644 docs/evidence/2026-08-10-partial-hand-spark.txt diff --git a/.devague/deliveries/hand-lobe-lfm2-5-1-2b.json b/.devague/deliveries/hand-lobe-lfm2-5-1-2b.json index 3ebdc2c..baf6ed1 100644 --- a/.devague/deliveries/hand-lobe-lfm2-5-1-2b.json +++ b/.devague/deliveries/hand-lobe-lfm2-5-1-2b.json @@ -2,7 +2,7 @@ "plan_slug": "hand-lobe-lfm2-5-1-2b", "schema_version": 1, "created": "2026-08-10T03:38:10Z", - "updated": "2026-08-10T03:56:55Z", + "updated": "2026-08-10T06:26:20Z", "deviations": [ { "id": "d1", @@ -118,6 +118,18 @@ "origin": "user", "status": "approved", "classification": "needs-follow-up" + }, + { + "id": "d10", + "what": "t12 live validation extends to the DGX Spark: functional PASS on a third card, budget again NOT reproducible (6.21/3.34/3.54 GiB at identical 0.06) \u2014 the three-run spread identifies the MECHANISM (vLLM profiles against free-memory-at-that-instant on unified memory), which retro-explains the Orin retraction as a property rather than a fluke; all four cards stay DECLARED", + "task_ref": "t12", + "reason": "the user asked for a Spark answer; the box's earlier boot failures turned out to be memory exhaustion (swap 100% full, browser holding 31.7 GiB), not a lane defect \u2014 with memory freed the committed lane booted in 71.75s and served correctly", + "affects": [ + "t10" + ], + "origin": "user", + "status": "approved", + "classification": "needs-follow-up" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index ae6edf2..b2cd336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.56.1] - 2026-08-10 + +### Added + +- docs/evidence/2026-08-10-partial-hand-spark.txt — hand on the DGX Spark GB10: functional PASS on a third card (Lfm2ForCausalLM, bf16 sentinel, no reasoning parser, LoRA armed, known-answer, structured tool_calls, empty-inventory /v1/models, undeclared adapter 404), plus the first live confirmation that the d8 --attention-config fix resolves. + +### Changed + +- hand budget guidance now names the MECHANISM behind every non-reproducing budget: on a unified-memory card with co-resident tenants vLLM profiles against memory free AT THAT INSTANT, so the same gpu_mem_util yields a different KV pool run to run. Three Spark boots at the identical 0.06 gave 6.21 / 3.34 / 3.54 GiB. This retro-explains the Orin retraction as a property, not a fluke — and it is not a tight-margin artifact, since the Spark reproduced the 2x spread with several GiB of headroom. All four cards stay DECLARED (#183). + ## [0.56.0] - 2026-08-10 ### Added diff --git a/docs/evidence/2026-08-10-partial-hand-spark.txt b/docs/evidence/2026-08-10-partial-hand-spark.txt new file mode 100644 index 0000000..5170e7a --- /dev/null +++ b/docs/evidence/2026-08-10-partial-hand-spark.txt @@ -0,0 +1,189 @@ +============================================================================= +hand lobe (LiquidAI/LFM2.5-1.2B-Instruct) — DGX Spark GB10, PARTIAL +============================================================================= +Date: 2026-08-10 +Plan: docs/plans/2026-08-10-hand-lobe-lfm2-5-1-2b.md, task t12 +Issue: #183 (no card has a reproducible budget) +Box: DGX Spark GB10 (Grace Blackwell, 121 GiB unified) — the production + spark-lobe box, full fleet co-resident (cortex 0.30 + embed x2 + + rerank + stt + tts + gateway, all healthy throughout) +Image: vllm/vllm-openai@sha256:7c5a10e9a8b3c8642f4d0463a41215176c0dd834b4f0967287c7e3e517cf1be9 +Engine: vLLM 0.23.1rc1.dev672+g93d8f834d +Lane: the COMMITTED fleet lane (lobes/templates/fleet/docker-compose.yml, + service vllm-hand) extracted verbatim; the only additions are + HF_HUB_OFFLINE=1, a probe-only container_name, and a probe log dir. + Deliberately NOT a hand-rolled `docker run` — an earlier hand-rolled + probe is exactly what hid the dead-knob defect d8 (it set the + attention backend manually and so disagreed with the real lane). + +VERDICT — split, and the split is the point: + + FUNCTIONAL: PASS. Every functional probe passes, across two independent + boots. The lane serves correctly on the GB10. + BUDGET: NOT REPRODUCIBLE. Three boots at identical settings produced + three different KV pools. spark.toml's hand budget therefore + stays DECLARED, per #108 — this transcript does not promote it. + +The budget half is not a new disappointment; it EXPLAINS the Orin retraction +recorded in docs/evidence/2026-08-10-partial-hand-orin.txt. See section 3. + +----------------------------------------------------------------------------- +0. What the engine confirms about the checkpoint +----------------------------------------------------------------------------- + Resolved architecture: Lfm2ForCausalLM + version 0.23.1rc1.dev672+g93d8f834d + dtype=torch.bfloat16, quantization=None, reasoning_parser='', + trust_remote_code=False, max_seq_len=32768 + Using FLASH_ATTN attention backend out of potential backends: + ['FLASH_ATTN', 'FLASHINFER', 'TRITON_ATTN', 'FLEX_ATTENTION'] + punica_selector.py:20] Using PunicaWrapperGPU. + Model loading took 2.41 GiB memory and 11.17 seconds + + - quantization=None — the catalog's `quantization="none"` bf16 sentinel + works on a third card. The lane omits --quantization entirely. + - reasoning_parser='' — the deliberate absence of --reasoning-parser holds; + LFM2.5-1.2B-Instruct has no thinking mode. + - PunicaWrapperGPU — --enable-lora with an EMPTY inventory is a valid + configuration and the engine arms the LoRA path. + - HAND_ATTENTION_BACKEND=auto resolved through the '--attention-config' + flag that fix d8 introduced. This is the first confirmation that the + replacement knob is live on the Spark, not merely non-fatal. + - 2.41 GiB — the cheap-base premise the whole role rests on. + +----------------------------------------------------------------------------- +1. The empty-flag strip (deviation d3) — confirmed on a THIRD machine +----------------------------------------------------------------------------- +The lane templates `--lora-modules=${HAND_LORA_MODULES:-}`, and a compose +command list cannot omit an argument conditionally. mg-logwrap drops any +argument matching `--*=`, so vLLM never sees an empty --lora-modules. + +The wrapper's own boot header records the post-strip argv: + + === lobes hand :: boot 20260810T054840Z :: vllm serve + LiquidAI/LFM2.5-1.2B-Instruct + --served-model-name=LiquidAI/LFM2.5-1.2B-Instruct + --host=0.0.0.0 --port=8000 --max-model-len=32768 + --gpu-memory-utilization=0.06 + --attention-config={"backend": "auto"} + --enable-lora --max-loras=4 --max-lora-rank=32 + --enable-auto-tool-choice --tool-call-parser=lfm2 === + +No --lora-modules. Thor, Orin, Spark — three for three. + +----------------------------------------------------------------------------- +2. Functional probes — ALL PASS +----------------------------------------------------------------------------- +2a. Known-answer correctness + prompt: "What is the capital of France? Answer with one word." + -> 'Paris' + +2b. Adapter honesty with an EMPTY inventory — GET /v1/models + {"object":"list","data":[{"id":"LiquidAI/LFM2.5-1.2B-Instruct", + "max_model_len":32768,...}]} + Exactly one entry: the base. No phantom adapter is advertised. This is + the #92 honesty rule holding on the surface that matters — the probe asks + the ENGINE's own /v1/models, not the gateway's filesystem, because + adapter paths are mounted into vllm-hand and not into the gateway. + +2c. Undeclared adapter is REFUSED, never downgraded + model="hand:nonexistent-domain" -> HTTP 404 + An adapter nobody declared does not silently fall back to the base. + +2d. Tool-call ROUND TRIP — a structured call, not prose + finish_reason: tool_calls + content: 'Checking the current weather in Paris.' + tool_calls: [ + {"id":"chatcmpl-tool-99b9c81accac0f91","type":"function", + "function":{"name":"get_weather","arguments":"{\"city\": \"Paris\"}"}} + ] + + This is the probe that earns its place. The gemma4 precedent + (docs/gemma-4-31b-nvfp4.md) is a family whose delimiters are SPECIAL + tokens, where a guessed generic parser matched nothing and vLLM relayed + a well-formed call as ordinary assistant content with tool_calls: null. + LFM2's <|tool_call_start|>/<|tool_call_end|> are the same trap. A + structured tool_calls array with finish_reason "tool_calls" is the + positive result that trap would have denied. + +2e. The delimiters the lfm2 parser requires are present + <|tool_call_start|> = 10 + <|tool_call_end|> = 11 + Identical to the Orin. Lfm2ToolParser.__init__ RAISES when either is + missing, so a bad tokenizer revision fails loudly at startup rather than + degrading to prose — strictly better than the gemma4 failure mode. + +----------------------------------------------------------------------------- +3. The budget does NOT reproduce — and WHY (this supersedes the Orin puzzle) +----------------------------------------------------------------------------- +Three boots, identical lane, identical HAND_GPU_MEM_UTIL=0.06, same box, same +co-resident fleet, minutes apart: + + run | free RAM before | Available KV | KV tokens | pool/32768 + ----|-----------------|--------------|-----------|----------- + 1 | ~28 GiB | 6.21 GiB | 541,886 | 16.54x + 2 | ~18 GiB | 3.34 GiB | 291,970 | 8.91x + 3 | ~18 GiB | 3.54 GiB | 308,754 | 9.42x + +Runs 2 and 3 agree with each other to within 6% and were both taken at ~18 GiB +free. Run 1 was taken minutes after 31.7 GiB was freed on the box. The KV pool +tracks FREE MEMORY AT PROFILING TIME, not a fixed fraction of the card. + +That is the mechanism, and it retro-explains the Orin. The Orin's three runs +(2.7 GiB, then 0.14 GiB, then 0.09 GiB — recorded in the retraction header of +docs/evidence/2026-08-10-partial-hand-orin.txt) were read at the time as an +unexplained non-reproduction. They are the same effect with a smaller card and +a tighter margin: the first run caught a transient window of free memory, and +the later ones did not. + +The generalisable finding, stated plainly because it outlives this role: + + On a unified-memory card with co-resident tenants, gpu_mem_util does not + name a stable budget. The engine profiles against memory that is free at + that instant, so the SAME util yields a different KV pool depending on + what else is resident. A single boot's KV number is a measurement of the + box's state, not of the card's capacity for the role. + +Note this does not make the declared 0.06 wrong — hand booted and served at +0.06 on all three runs, and the arithmetic 0.06 x 121 GiB = 7.3 GiB brackets +run 1 sensibly. What it makes unsupportable is any claim of a MEASURED pool +size or concurrency figure for this role on this card. + +Also worth recording, because it cost the previous session four failed +attempts: this box was in its own pressure-policy `busy` state throughout +those attempts (swap 15/15 GiB, 100% full; 16 GiB available; 273 Chrome +processes holding 31.7 GiB). Under that pressure a `docker compose up -d` +took over ten minutes and the engine never finished profiling. After Chrome +was closed the identical command returned in ten seconds and the engine +reached "Application startup complete" in 71.75 s. The earlier inability to +boot hand on the Spark was memory exhaustion on a shared workstation, not a +defect in the lane. + +----------------------------------------------------------------------------- +4. What is NOT claimed +----------------------------------------------------------------------------- +Per #108, and stated plainly because a split verdict invites over-reading. + + - The Spark's hand budget is NOT validated. lobes/profiles/builtin/spark.toml + keeps gpu_mem_util = 0.06 as DECLARED. No pool size and no concurrency + figure from section 3 may be quoted as this card's capacity. + - NO card is validated for budget. Orin retracted, Thor never booted + (issue #181), Spark split here, base fallback never exercised. #183 stays + open and now names all three cards. + - No throughput (tok/s) was measured. None was attempted. + - Adapter SERVING is still unexercised everywhere. v1 ships zero adapters, + so 2b/2c prove the empty-inventory and refusal contracts only. Nothing + here shows a real LoRA adapter loading and answering — that remains the + open half of #183. + - Nothing here exercises the gateway. Every probe in section 2 addressed + the vllm-hand engine directly. The gateway's hand: alias + derivation and its /capabilities surface are covered by the offline + suite, not by this transcript. + - The pressure-floor behaviour (hand always served under swap/iowait + pressure) was not exercised, despite the box having been genuinely in + that state. Sections 3's pressure notes are box observations, not a + test of the policy. + +Fleet integrity: all twelve production containers (model-gear-gateway, +vllm-primary, vllm-embed, vllm-embed-deep, vllm-rerank, realtime, stt, +chatterbox, and the non-lobes tenants) were verified Up and healthy before, +during and after the probe. The probe container was removed afterwards. diff --git a/docs/lfm2.5-1.2b-hand.md b/docs/lfm2.5-1.2b-hand.md index 6b5cafc..6e4f8ba 100644 --- a/docs/lfm2.5-1.2b-hand.md +++ b/docs/lfm2.5-1.2b-hand.md @@ -180,9 +180,13 @@ One box's successful boot never promotes another card. |---|---|---| | Jetson AGX Orin (sm_87, 64 GB) | DECLARED — *served once, budget not reproducible* | `docs/evidence/2026-08-10-partial-hand-orin.txt` | | Jetson AGX Thor (sm_110, 128 GB) | DECLARED — boot failed | [#181](https://github.com/agentculture/lobes-cli/issues/181) | -| DGX Spark GB10 (128 GB) | DECLARED — not yet exercised | [#183](https://github.com/agentculture/lobes-cli/issues/183) | +| DGX Spark GB10 (128 GB) | DECLARED — *functionally sound, budget not reproducible* | `docs/evidence/2026-08-10-partial-hand-spark.txt` | | `base` (unrecognised card) | DECLARED | untestable by construction | +The Orin and the Spark both land in the same split state, and for the same +reason — see **"Why no budget reproduces"** below, which is the finding that +matters more than either card's numbers. + **What the Orin runs established — and what they did not.** The lane *served* once, and everything observed on that live engine holds: the bf16 sentinel and the text-only / no-reasoning-parser flags plumbed correctly, a correct @@ -191,23 +195,55 @@ this lane exists to pass — a **tool call returning a structured `tool_calls` array**, with the tokenizer's `<|tool_call_start|>` / `<|tool_call_end|>` confirmed present as special tokens (ids 10 and 11). -The **budget** did not hold. Three boots at the identical -`gpu_mem_util = 0.06` / `max_model_len = 32768` on the same box profiled -2.7 GiB, 0.14 GiB and 0.09 GiB of available KV — one success, two refusals. -The Orin is shared (senses at 0.45, the pooling gears, unrelated production -containers) and vLLM clamps its budget against actual free memory at startup, -which moved by ~2.7 GiB across the runs. A util leaving ~1 GiB of margin on a -61 GiB card sits inside that noise. **0.06 is one data point, not a -measurement**, and the card is DECLARED accordingly. - -The practical reading for an operator: `hand` at the full 32 K window wants -more headroom than this Orin has while it also hosts `senses`. Either raise -`HAND_GPU_MEM_UTIL` until the profile reports comfortable KV on *your* box, or +**The Spark reproduced every one of those functional results** on a third card +and added two: `HAND_ATTENTION_BACKEND=auto` resolving through the +`--attention-config` flag (the first live confirmation that the `d8` dead-knob +fix actually works, rather than merely not crashing), and `GET /v1/models` +returning exactly one entry — the base — so an empty adapter inventory +advertises no phantom adapter. The container also reached compose `healthy`. +Full transcript: `docs/evidence/2026-08-10-partial-hand-spark.txt`. + +### Why no budget reproduces + +Both cards that served `hand` produced a **different KV pool on every boot** at +identical settings. On the Spark, three boots minutes apart: + +| run | free RAM before | available KV | KV tokens | +|---|---|---|---| +| 1 | ~28 GiB | 6.21 GiB | 541,886 | +| 2 | ~18 GiB | 3.34 GiB | 291,970 | +| 3 | ~18 GiB | 3.54 GiB | 308,754 | + +Runs 2 and 3 agree within 6% and were taken at the same free-memory level; run +1 was taken minutes after 31.7 GiB was freed on the box. The Orin's spread +(2.7 GiB, then 0.14 GiB, then 0.09 GiB) is the same effect on a smaller card +with a tighter margin. The mechanism: + +> On a unified-memory card with co-resident tenants, `gpu_mem_util` does not +> name a stable budget. vLLM profiles against memory that is **free at that +> instant**, so the same util yields a different KV pool depending on what else +> is resident. A single boot's KV number measures the box's state, not the +> card's capacity for the role. + +This does not make `0.06` wrong — `hand` booted and served at `0.06` on every +run of both cards. It makes any **measured** pool or concurrency figure for +this role unsupportable, which is why all four cards stay DECLARED. + +The practical reading for an operator: raise `HAND_GPU_MEM_UTIL` until the +profile reports comfortable KV on *your* box under *your* steady-state load, or trim `HAND_MAX_MODEL_LEN` (vLLM names the length that would fit — 7984 in the -tightest run). Tracked in -[#183](https://github.com/agentculture/lobes-cli/issues/183). - -Two things it deliberately does **not** establish: adapter serving end to end +Orin's tightest run). Do not treat a number from one boot as a budget. Tracked +in [#183](https://github.com/agentculture/lobes-cli/issues/183). + +One operational note the Spark runs paid for: four earlier attempts on that box +failed to boot at all, and the cause was **memory exhaustion on a shared +workstation**, not the lane. The box was in its own pressure-policy `busy` +state (swap 100% full, 16 GiB available, a browser holding 31.7 GiB); a +`docker compose up -d` took over ten minutes and the engine never finished +profiling. With the browser closed, the identical command returned in ten +seconds and the engine was serving in 71.75 s. + +Two things these runs deliberately do **not** establish: adapter serving end to end (v1 ships zero adapters, so `hand:` resolution and the adapter-honesty filter are covered by offline tests only), and anything reached through the lobes gateway rather than the engine directly. Both are tracked in diff --git a/docs/machine-profiles.md b/docs/machine-profiles.md index 0493c3a..a75a915 100644 --- a/docs/machine-profiles.md +++ b/docs/machine-profiles.md @@ -176,7 +176,13 @@ to route correctly. > one data point, not a measurement. > - **Thor** — boot failed in LoRA embedding-slot allocation, cause > unattributed ([#181](https://github.com/agentculture/lobes-cli/issues/181)). -> - **Spark / base** — never exercised. +> - **Spark** — same split as the Orin, on a bigger card +> (`docs/evidence/2026-08-10-partial-hand-spark.txt`): the lane served three +> times with every functional check passing, and the budget still did not +> reproduce — 6.21 / 3.34 / 3.54 GiB of available KV at the identical 0.06. +> Two of the three agree within 6% and were taken at the same free-memory +> level, which is what identifies the mechanism rather than just the noise. +> - **base** — never exercised, and untestable by construction. > > Two lessons are worth carrying, both learned the expensive way here: > @@ -184,10 +190,14 @@ to route correctly. > the reasoning that "0.06 of 64 GB leaves too little KV after the weights". > The box refused 0.10 outright — see > `docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt`. -> 2. **A single measurement on a SHARED box is also a hypothesis.** vLLM clamps -> its budget against actual free memory at startup, so a util leaving only -> ~1 GiB of margin on a 61 GiB card sits inside the noise of whatever else -> the box is doing. A budget is only measured once it reproduces. +> 2. **A single measurement on a SHARED box is also a hypothesis.** vLLM +> profiles against memory that is free *at that instant*, so on a +> unified-memory card with co-resident tenants the same util yields a +> different KV pool run to run. This is not a tight-margin artifact: the +> Orin showed it with ~1 GiB of margin on a 61 GiB card, and the Spark +> showed the same 2x spread with several GiB of margin on a 121 GiB one. +> A single boot's KV number measures the box's state, not the card's +> capacity for the role. **A budget is only measured once it reproduces.** **When set:** diff --git a/lobes/profiles/builtin/spark.toml b/lobes/profiles/builtin/spark.toml index 37cb5f1..6890faa 100644 --- a/lobes/profiles/builtin/spark.toml +++ b/lobes/profiles/builtin/spark.toml @@ -65,7 +65,15 @@ attention_backend = "TRITON_ATTN" # ~2.4 GiB of weights leaves ~5 GiB of KV at the 32K window, comfortable. The # arithmetic is not the evidence, though: every co-residency number on this card # was MEASURED because vLLM refused the computed one at least once. This card's -# total goes 0.56 -> 0.62 with hand added; t10 re-derives and t12 validates. +# total goes 0.56 -> 0.62 with hand added. +# +# The lane DID serve here at 0.06, three times, with every functional probe +# passing (docs/evidence/2026-08-10-partial-hand-spark.txt). The budget still +# stays DECLARED, because those three boots profiled three DIFFERENT KV pools — +# 6.21 / 3.34 / 3.54 GiB — tracking free memory at that instant rather than the +# util. On a unified-memory card with co-resident tenants a single boot's KV +# number measures the box's state, not the card's capacity; promoting one of +# them to MEASURED is exactly the error retracted on the Orin. See #183. feasible = true model = "LiquidAI/LFM2.5-1.2B-Instruct" gpu_mem_util = 0.06 diff --git a/pyproject.toml b/pyproject.toml index efa1352..dc5451a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lobes-cli" -version = "0.56.0" +version = "0.56.1" description = "lobes — run, assess, and switch the local vLLM model." readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_shape_goldens.py b/tests/test_shape_goldens.py index fe7bb8a..9096d62 100644 --- a/tests/test_shape_goldens.py +++ b/tests/test_shape_goldens.py @@ -513,9 +513,7 @@ def test_every_rendered_profile_knob_is_substituted_by_the_fleet_template(card_n template = FLEET_COMPOSE.read_text(encoding="utf-8") rendered = profile_env(resolve_profile(card_name)) dead = [ - key - for key in rendered - if f"${{{key}" not in template and f"${{{key}}}" not in template + key for key in rendered if f"${{{key}" not in template and f"${{{key}}}" not in template ] assert not dead, ( f"{card_name}: these profile-rendered keys are never substituted by " diff --git a/uv.lock b/uv.lock index e1287dc..122314c 100644 --- a/uv.lock +++ b/uv.lock @@ -440,7 +440,7 @@ wheels = [ [[package]] name = "lobes-cli" -version = "0.56.0" +version = "0.56.1" source = { editable = "." } [package.optional-dependencies] From fa7dfc59719842f27f6761ebf0054a7f7e3a7c30 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 09:45:20 +0300 Subject: [PATCH 13/17] docs: fold the Spark result and d10 into the delivery summary Four sections move: t12's row (Orin + Spark, neither budget reproducing), the mid-work decisions (d10), the claims table (Spark functional now high, "any card has a reproducible budget" demoted from unverified to REFUTED with the mechanism named), and remaining work (the Spark half of #183 is functionally done; a per-card budget may not be the right thing to chase at all). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- .../2026-08-10-hand-lobe-lfm2-5-1-2b.md | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md b/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md index 97f901c..059976a 100644 --- a/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md +++ b/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md @@ -51,7 +51,7 @@ The plan's wave order was still followed as the build order. | `t9` | delivered | `_FAMILY_BY_ROLE` + `_MEASURE_FN` + `_LLM_ROLES`; colleague-stack bundle is seven. **Plus `_compose.GPU_SERVICES`** — see `d4`. | | `t10` | delivered | Budgets re-derived on real hardware. Produced two refutations and one fix. `docs/evidence/2026-08-10-hand-lobe-budget-derivation.txt`. | | `t11` | delivered | Role counts swept to nine across docs/CLAUDE.md/README; irreversibility callout; per-card UNVALIDATED discipline; 4B doc re-headed as demoted. Also corrected counts that were **already stale before this change** (`explain/catalog.py` said SEVEN throughout). | -| `t12` | **partial** | **NO card validated.** Orin *served* and passed every functional check, but its budget did not reproduce (2.7 / 0.14 / 0.09 GiB KV across three identical boots) — see `d9`. Thor's boot failed outright. `docs/evidence/2026-08-10-partial-hand-orin.txt`, `d7`, #181, #183. | +| `t12` | **partial** | **NO card validated.** Orin and Spark both *served* and passed every functional check; neither budget reproduced (Orin 2.7 / 0.14 / 0.09 GiB, Spark 6.21 / 3.34 / 3.54 GiB across three identical boots each) — see `d9`, `d10`. Thor's boot failed outright. `docs/evidence/2026-08-10-partial-hand-orin.txt`, `…-partial-hand-spark.txt`, `d7`, #181, #183. | ## Mid-work Decisions @@ -95,12 +95,22 @@ claim I had already written down. probes were hand-rolled `docker run`s that set the backend manually, which is exactly why they disagreed with the lane. Fixed, plus a test asserting every profile-rendered key is substituted by the template. +- `d10` *(needs-follow-up)* — **t12 extended to the Spark, and the + non-reproducing budget became an explained property.** The Spark passed every + functional probe on a third card, and its three boots at the identical `0.06` + profiled 6.21 / 3.34 / 3.54 GiB. Runs 2 and 3 agree within 6% at the same + free-memory level; run 1 followed 31.7 GiB being freed. So the pool tracks + memory free *at that instant*, which retro-explains `d9` as a property rather + than a fluke — and rules out "tight margin" as the cause, since the Spark + reproduced the spread with several GiB of headroom. All four cards stay + DECLARED. Separately: the four earlier Spark boot failures were **memory + exhaustion on a shared workstation**, not a lane defect. ## Drift From Plan | Plan item | Drift | Classification | |---|---|---| -| `t12` | Acceptance says "Live validation on Thor **and** Orin". Thor is not validated; its boot died in LoRA embedding-slot allocation on a loaded box, cause unattributed between three candidates. Delivered as Orin-only. | `needs-follow-up` (`d7`, #181) | +| `t12` | Acceptance says "Live validation on Thor **and** Orin". Thor is not validated; its boot died in LoRA embedding-slot allocation on a loaded box, cause unattributed between three candidates. Delivered as Orin + Spark (the Spark was not in the acceptance criterion at all — it was added because the operator asked, and it is what identified the budget mechanism). | `needs-follow-up` (`d7`, `d10`, #181) | | `t6` | Shipped without a knob every sibling lane sets, making the lane unbootable. Found and fixed by t10's live run, not by t6. | `needs-follow-up` (`d5`, #182) | | `t6` | Shipped a **dead knob**: the Orin's declared `attention_backend` reached no compose flag. Found only by booting the real lane. | `needs-follow-up` (`d8`) | | `t8` | Committed a budget the hardware then refused; regenerated its goldens twice. | `acceptable` (`d6`) | @@ -118,8 +128,10 @@ claim I had already written down. | `mg-logwrap` strips the empty `--lora-modules=` in the real committed lane | **high** | Live compose boot; the wrapper's own durable-log argv line shows the flag absent | | Every knob any card profile renders is now consumed by the fleet template | **high** | `test_every_rendered_profile_knob_is_substituted_by_the_fleet_template`, verified to FAIL with the `d8` fix reverted | | Thor can serve `hand` | **unverified** | Boot failed; three candidate causes unseparated (#181) | -| Any card has a reproducible `hand` budget | **unverified** | No card validated. The Orin's 0.06 is one observation (#183) | -| Spark can serve `hand` | **unverified** | Never exercised (#183) | +| Any card has a reproducible `hand` budget | **refuted** | Two cards, six boots, no reproduction. The cause is now identified: on unified memory vLLM profiles against free-memory-at-that-instant, so the same util yields a different pool run to run. All four cards DECLARED (#183) | +| Spark serves `hand` and passes every functional probe | **high** | Three live boots; `Lfm2ForCausalLM`, `quantization=None`, `reasoning_parser=''`, `PunicaWrapperGPU`, known-answer, structured `tool_calls`, undeclared adapter 404, `/v1/models` = base only, compose `healthy`. `docs/evidence/2026-08-10-partial-hand-spark.txt` | +| Spark's `hand` **budget** at `0.06` | **low** | Not reproducible — 6.21 / 3.34 / 3.54 GiB across three identical boots. Serves at 0.06 every time; no pool figure is promoted | +| The `d8` `--attention-config` fix actually resolves live | **high** | Spark boot log: `HAND_ATTENTION_BACKEND=auto` resolved through the flag, `FLASH_ATTN` selected — first confirmation beyond "does not crash" | | `hand:` adapter serving, honesty filter, rank ceiling | **unverified** | Offline tests only — v1 ships zero adapters by decision (#183, `agentculture/unsloth-cli#16`) | | `hand` through the **gateway** (aliases, tier, pressure floor) | **unverified** | Offline tests only; all live probes dialled the engine directly | @@ -127,8 +139,12 @@ claim I had already written down. 1. **#181** — Thor's LoRA embedding-slot failure. Re-run on a quiet Thor with the cudagraph fix; the outcome distinguishes the three candidate causes. -2. **#183** — Validate Thor and Spark; exercise adapter serving end to end once - `agentculture/unsloth-cli#16` can produce an LFM2.5 adapter. +2. **#183** — Validate Thor; exercise adapter serving end to end once + `agentculture/unsloth-cli#16` can produce an LFM2.5 adapter. The Spark half + is now done *functionally*, but no card has a reproducible budget and — given + the mechanism `d10` identified — a per-card budget may not be the right thing + to chase. What an operator actually needs is guidance for profiling on + *their* box under *their* steady-state load, which the docs now give. 3. **#182** — No test asserts compose-lane env parity. This run's most instructive defect: the lane was valid, rendered clean, and passed the whole suite while being unbootable. From 7f916f94069cd763281bd559deaaa27571e02ccb Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 10:11:23 +0300 Subject: [PATCH 14/17] =?UTF-8?q?test:=20cover=20the=20hand=20adapter-hone?= =?UTF-8?q?sty=20surface=20=E2=80=94=20new-code=20coverage=2051.2%=20->=20?= =?UTF-8?q?100%=20(0.56.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarCloud's new-code coverage gate failed at 51.2% (needs 80%), and it was pointing at something real rather than at a metric. The uncovered lines were almost entirely the LoRA-adapter machinery — the declaration parser, the engine probe, the cache refresh, the alias derivation and the /v1/models filter. That is the honesty surface this release is largely *about*, and it had no direct tests at all. My delivery summary's claim that it was "covered by offline tests" was overstated; that row is corrected in the same commit. 29 tests across four collaborating pieces, weighted toward the silent failure mode — an adapter vLLM refused is simply absent from the engine's model list, so every layer must let that absence propagate rather than paper over it with the declaration: - _hand_adapter_names: partition-not-split (a path keeps an "="), malformed segments dropped rather than yielding a nameless alias, dedupe, whitespace - probe_backend_adapters: the intersection; ids lobes never declared cannot be injected; an empty declaration opens no socket; correct path; NO api key (co-resident lane, not a cross-box peer); and fail-closed on non-200, unreachable, and malformed/wrong-shape bodies - ReadinessCache: empty seed with no probe at construction, snapshot copy isolation, and one raising probe degrading to empty WITHOUT aborting the pass - the hand: aliases derived from the same declaration the engine reads, an undeclared adapter getting no alias (which is what produces the 404 rather than a silent fall-back to the base), and list_models_payload listing only CONFIRMED adapters — declared-but-unconfirmed is invisible, and an adapter cannot outlive an unready lane - _resolve_model picking the hand role, asserting the cheap tier is no longer the demoted 4B 2893 -> 2922 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- .agex/data/pr/events.jsonl | 5 + CHANGELOG.md | 10 + .../2026-08-10-hand-lobe-lfm2-5-1-2b.md | 2 +- pyproject.toml | 2 +- tests/test_cli_route.py | 26 ++ tests/test_gateway_hand_adapters.py | 406 ++++++++++++++++++ uv.lock | 2 +- 7 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 tests/test_gateway_hand_adapters.py diff --git a/.agex/data/pr/events.jsonl b/.agex/data/pr/events.jsonl index 1057ebb..dac3c21 100644 --- a/.agex/data/pr/events.jsonl +++ b/.agex/data/pr/events.jsonl @@ -96,3 +96,8 @@ {"ts":"2026-07-25T04:45:47.866069+00:00","type":"pr_webhook_posted","pr":157,"event":"pr_replied"} {"ts":"2026-07-25T04:45:57.235287+00:00","type":"readiness_arrived","pr":157,"waited_secs":0} {"ts":"2026-07-25T04:46:01.607351+00:00","type":"pr_read","pr":157,"comment_count":6,"threads_unresolved":0,"ci_state":"ok"} +{"ts":"2026-08-10T06:46:40.854177+00:00","type":"pr_opened","pr":184,"title":"feat: hand \u2014 the ninth Colleague role and the fleet's fine-tuning base, on LiquidAI LFM2.5-1.2B (0.56.1)"} +{"ts":"2026-08-10T06:46:41.881031+00:00","type":"pr_review_triggered","pr":184,"command":"/agentic_review"} +{"ts":"2026-08-10T06:50:45.373614+00:00","type":"pr_webhook_posted","pr":184,"event":"pr_opened"} +{"ts":"2026-08-10T06:50:46.870979+00:00","type":"readiness_arrived","pr":184,"waited_secs":0} +{"ts":"2026-08-10T06:50:52.682664+00:00","type":"pr_read","pr":184,"comment_count":3,"threads_unresolved":0,"ci_state":"failure"} diff --git a/CHANGELOG.md b/CHANGELOG.md index b2cd336..e04bf38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.56.2] - 2026-08-10 + +### Added + +- 29 tests over the hand LoRA-adapter honesty surface — the declaration parser (HAND_LORA_MODULES: partition-not-split so a path keeps an =, malformed segments dropped, dedupe, whitespace), the engine probe (intersection, undeclared ids ignored, empty declaration opens no socket, correct path, no API key, and every fail-closed mode: non-200, unreachable, malformed body), the ReadinessCache background refresh (empty seed, copy isolation, a raising probe degrading to empty without aborting the pass), the hand: alias derivation, and the /v1/models filter (declared-but-unconfirmed is invisible; an adapter cannot outlive its lane). + +### Changed + +- SonarCloud new-code coverage 51.2% -> 100%. The gate exposed a real gap rather than a metric one: the adapter surface — the honesty machinery this release is largely about — had NO direct tests. The delivery summary claim that it was covered offline was overstated and is corrected. + ## [0.56.1] - 2026-08-10 ### Added diff --git a/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md b/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md index 059976a..8369971 100644 --- a/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md +++ b/docs/deliveries/2026-08-10-hand-lobe-lfm2-5-1-2b.md @@ -132,7 +132,7 @@ claim I had already written down. | Spark serves `hand` and passes every functional probe | **high** | Three live boots; `Lfm2ForCausalLM`, `quantization=None`, `reasoning_parser=''`, `PunicaWrapperGPU`, known-answer, structured `tool_calls`, undeclared adapter 404, `/v1/models` = base only, compose `healthy`. `docs/evidence/2026-08-10-partial-hand-spark.txt` | | Spark's `hand` **budget** at `0.06` | **low** | Not reproducible — 6.21 / 3.34 / 3.54 GiB across three identical boots. Serves at 0.06 every time; no pool figure is promoted | | The `d8` `--attention-config` fix actually resolves live | **high** | Spark boot log: `HAND_ATTENTION_BACKEND=auto` resolved through the flag, `FLASH_ATTN` selected — first confirmation beyond "does not crash" | -| `hand:` adapter serving, honesty filter, rank ceiling | **unverified** | Offline tests only — v1 ships zero adapters by decision (#183, `agentculture/unsloth-cli#16`) | +| `hand:` adapter serving, honesty filter, rank ceiling | **unverified live**, offline covered | Offline: 29 tests added in review over the declaration parser, the engine probe (intersection + every fail-closed mode), the cache refresh, the `hand:` alias derivation and the `/v1/models` filter. Live: still unexercised — v1 ships zero adapters by decision (#183, `agentculture/unsloth-cli#16`). **This row previously read "offline tests only", which overstated it — the adapter surface had no direct tests until SonarCloud's new-code coverage gate exposed the gap at 51.2%.** | | `hand` through the **gateway** (aliases, tier, pressure floor) | **unverified** | Offline tests only; all live probes dialled the engine directly | ## Remaining Work diff --git a/pyproject.toml b/pyproject.toml index dc5451a..ed3d0db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lobes-cli" -version = "0.56.1" +version = "0.56.2" description = "lobes — run, assess, and switch the local vLLM model." readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_cli_route.py b/tests/test_cli_route.py index 46bc395..85c681a 100644 --- a/tests/test_cli_route.py +++ b/tests/test_cli_route.py @@ -520,3 +520,29 @@ def test_unknown_gear_clamped_to_primary(capsys: pytest.CaptureFixture[str]) -> with patch("lobes.cli._commands.route.chat_completion", return_value=canned): route.cmd_route(args) assert json.loads(capsys.readouterr().out)["chosen_gear"] == "primary" + + +# --- _resolve_model: the cheap tier is `hand`, not the demoted 4B ----------- + + +def test_resolve_model_prefers_an_explicit_flag() -> None: + """``--model`` wins outright — no catalog lookup happens.""" + args = argparse.Namespace(model="some/explicit-id") + assert route._resolve_model(args) == "some/explicit-id" + + +def test_resolve_model_falls_back_to_the_hand_role() -> None: + """With no ``--model``, the cheap-tier lobe is resolved by ``role_hint``. + + This is the tier hand-over made concrete: `hand` replaced `Qwen/Qwen3.5-4B` + as the cheap tier, and the 4B stays in the catalog as a plain candidate + (cite-don't-delete) that no role_hint resolves to any more. + """ + from lobes.catalog import supported_models + + args = argparse.Namespace(model=None) + resolved = route._resolve_model(args) + hand_ids = [m.id for m in supported_models() if m.role_hint == "hand"] + assert resolved in hand_ids + assert resolved == "LiquidAI/LFM2.5-1.2B-Instruct" + assert resolved != "Qwen/Qwen3.5-4B" diff --git a/tests/test_gateway_hand_adapters.py b/tests/test_gateway_hand_adapters.py new file mode 100644 index 0000000..577cfeb --- /dev/null +++ b/tests/test_gateway_hand_adapters.py @@ -0,0 +1,406 @@ +"""Tests for the ``hand`` lobe's LoRA-adapter honesty surface. + +The rule these all serve is #92 — *advertised implies reachable* — applied to +adapters rather than roles. An adapter is only ever advertised once the ENGINE +has confirmed it serves it, and an adapter nobody declared is refused rather +than quietly answered by the base weights. + +Four collaborating pieces, one per section below: + +* :func:`~lobes.gateway._config._hand_adapter_names` parses the operator's + ``HAND_LORA_MODULES`` declaration — the single source both the engine's + ``--lora-modules`` and the gateway's ``hand:`` aliases read, so the + two cannot disagree. +* :func:`~lobes.gateway._readiness.probe_backend_adapters` asks the backend's + own ``/v1/models`` which of those declared adapters actually loaded. +* :class:`~lobes.gateway._readiness.ReadinessCache` folds that probe into the + background refresh and hands it back through ``current_adapters()``. +* :func:`~lobes.gateway._routing.list_models_payload` lists only the confirmed + ones. + +The failure mode worth guarding is a *silent* one: an adapter that vLLM refused +(unreadable file, rank above ``--max-lora-rank``, a checkpoint it rejected) is +absent from the engine's model list, and every one of these pieces must let +that absence propagate rather than paper over it with the declaration. + +Stdlib only, mirroring the gateway's dependency-free discipline. +""" + +from __future__ import annotations + +import http.client +import json +import threading +import time + +from lobes.gateway import _config as C +from lobes.gateway import _readiness as R +from lobes.gateway._routing import Backend, RoutingTable, list_models_payload + +# --- _hand_adapter_names: parsing the operator's declaration ---------------- + + +def test_hand_adapter_names_parses_a_comma_separated_declaration() -> None: + env = {C.HAND_LORA_MODULES_ENV: "legal=/adapters/legal,sql=/adapters/sql"} + assert C._hand_adapter_names(env) == ("legal", "sql") + + +def test_hand_adapter_names_is_empty_when_undeclared_or_blank() -> None: + # v1 ships zero adapters, so the EMPTY inventory is the default path, not an + # edge case. It must be an empty tuple, never a one-element tuple of "". + assert C._hand_adapter_names({}) == () + assert C._hand_adapter_names({C.HAND_LORA_MODULES_ENV: ""}) == () + assert C._hand_adapter_names({C.HAND_LORA_MODULES_ENV: " "}) == () + + +def test_hand_adapter_names_keeps_a_path_containing_equals() -> None: + # partition("=") not split("="): a path with a query string or a padded + # base64 segment keeps its full value, and the NAME is still just "legal". + env = {C.HAND_LORA_MODULES_ENV: "legal=/adapters/legal?rev=v2"} + assert C._hand_adapter_names(env) == ("legal",) + + +def test_hand_adapter_names_skips_malformed_segments() -> None: + # A segment with no "=", no name, or no path is dropped rather than + # producing a nameless alias that could never resolve. + env = { + C.HAND_LORA_MODULES_ENV: "good=/a,noequals,=/orphanpath,emptypath=, ,ok=/b", + } + assert C._hand_adapter_names(env) == ("good", "ok") + + +def test_hand_adapter_names_dedupes_preserving_first_position() -> None: + env = {C.HAND_LORA_MODULES_ENV: "legal=/a,sql=/b,legal=/c"} + assert C._hand_adapter_names(env) == ("legal", "sql") + + +def test_hand_adapter_names_tolerates_surrounding_whitespace() -> None: + env = {C.HAND_LORA_MODULES_ENV: " legal = /adapters/legal , sql=/adapters/sql "} + assert C._hand_adapter_names(env) == ("legal", "sql") + + +# --- probe_backend_adapters: ask the ENGINE, and fail closed ---------------- + + +def _models_opener(status: int, ids: list[str]): + """A ``PeerOpener`` returning a vLLM-shaped ``/v1/models`` body.""" + body = json.dumps({"object": "list", "data": [{"id": i} for i in ids]}) + + def opener(_url: str, _timeout: float, _api_key: str | None): + return status, body + + return opener + + +def test_probe_backend_adapters_returns_the_intersection() -> None: + # Declared three, engine loaded two -> only the two that loaded. + got = R.probe_backend_adapters( + "http://vllm-hand:8000", + ("legal", "sql", "refused"), + opener=_models_opener(200, ["LiquidAI/LFM2.5-1.2B-Instruct", "legal", "sql"]), + ) + assert got == frozenset({"legal", "sql"}) + + +def test_probe_backend_adapters_ignores_ids_lobes_never_declared() -> None: + # An engine listing an id we never declared cannot inject it into this + # box's advertised surface — including the base checkpoint itself. + got = R.probe_backend_adapters( + "http://vllm-hand:8000", + ("legal",), + opener=_models_opener(200, ["legal", "smuggled", "LiquidAI/LFM2.5-1.2B-Instruct"]), + ) + assert got == frozenset({"legal"}) + + +def test_probe_backend_adapters_empty_declaration_opens_no_socket() -> None: + calls: list[str] = [] + + def opener(url: str, _t: float, _k: str | None): + calls.append(url) + return 200, "{}" + + assert R.probe_backend_adapters("http://vllm-hand:8000", (), opener=opener) == frozenset() + assert calls == [], "an empty declaration must short-circuit before dialling" + + +def test_probe_backend_adapters_hits_the_models_path() -> None: + seen: list[str] = [] + + def opener(url: str, _t: float, _k: str | None): + seen.append(url) + return 200, json.dumps({"data": [{"id": "legal"}]}) + + R.probe_backend_adapters("http://vllm-hand:8000/", ("legal",), opener=opener) + assert seen == ["http://vllm-hand:8000" + R._MODELS_PATH] + + +def test_probe_backend_adapters_sends_no_api_key() -> None: + # A co-resident fleet backend on the internal compose network, not a + # cross-box peer: no credential should be presented. + seen: list[object] = [] + + def opener(_url: str, _t: float, api_key: str | None): + seen.append(api_key) + return 200, json.dumps({"data": [{"id": "legal"}]}) + + R.probe_backend_adapters("http://vllm-hand:8000", ("legal",), opener=opener) + assert seen == [None] + + +def test_probe_backend_adapters_fails_closed_on_non_200() -> None: + got = R.probe_backend_adapters( + "http://vllm-hand:8000", ("legal",), opener=_models_opener(503, ["legal"]) + ) + assert got == frozenset(), "a warming engine must advertise nothing, not its declaration" + + +def test_probe_backend_adapters_fails_closed_when_unreachable() -> None: + for exc in (OSError("refused"), http.client.HTTPException("bad"), ValueError("port")): + + def boom(_u, _t, _k, _exc=exc): + raise _exc + + assert R.probe_backend_adapters("http://vllm-hand:8000", ("legal",), opener=boom) == ( + frozenset() + ) + + +def test_probe_backend_adapters_fails_closed_on_malformed_body() -> None: + # Not JSON, JSON of the wrong shape, and entries that are not dicts — none + # may raise into the caller that folds this into /v1/models. + for body in ("not json at all", '{"data": "not-a-list"}', "null", '{"data": [1, 2, 3]}'): + + def opener(_u, _t, _k, _b=body): + return 200, _b + + assert R.probe_backend_adapters("http://vllm-hand:8000", ("legal",), opener=opener) == ( + frozenset() + ) + + +# --- ReadinessCache: the background adapter refresh ------------------------- + + +def test_cache_seeds_adapters_empty_and_construction_opens_no_socket() -> None: + calls: list[str] = [] + + def probe(base_url: str, _declared: tuple[str, ...]) -> frozenset[str]: + calls.append(base_url) + return frozenset({"legal"}) + + cache = R.ReadinessCache( + {}, + adapter_targets={"hand": ("http://vllm-hand:8000", ("legal",))}, + adapter_probe=probe, + start=False, + ) + assert cache.current_adapters() == {"hand": frozenset()} + assert calls == [], "construction must not probe" + + +def test_current_adapters_returns_a_copy_isolated_from_caller_mutation() -> None: + cache = R.ReadinessCache( + {}, + adapter_targets={"hand": ("http://vllm-hand:8000", ("legal",))}, + adapter_probe=lambda _u, _d: frozenset({"legal"}), + start=False, + ) + snapshot = cache.current_adapters() + snapshot["hand"] = frozenset({"tampered"}) + snapshot["injected"] = frozenset() + assert cache.current_adapters() == {"hand": frozenset()} + + +def test_background_refresh_populates_confirmed_adapters() -> None: + seen = threading.Event() + + def probe(_base_url: str, declared: tuple[str, ...]) -> frozenset[str]: + seen.set() + return frozenset(set(declared) & {"legal"}) + + cache = R.ReadinessCache( + {}, + adapter_targets={"hand": ("http://vllm-hand:8000", ("legal", "refused"))}, + adapter_probe=probe, + interval=0.01, + start=True, + ) + try: + assert seen.wait(timeout=5.0) + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if cache.current_adapters().get("hand") == frozenset({"legal"}): + break + time.sleep(0.01) + assert cache.current_adapters() == {"hand": frozenset({"legal"})} + finally: + cache.stop() + + +def test_one_adapter_probe_raising_degrades_to_empty_and_spares_the_others() -> None: + # Per-backend try: a misbehaving probe advertises nothing rather than + # aborting the pass, so a second adapter-bearing lane still refreshes. + done = threading.Event() + + def probe(base_url: str, _declared: tuple[str, ...]) -> frozenset[str]: + if "broken" in base_url: + raise RuntimeError("probe exploded") + done.set() + return frozenset({"ok"}) + + cache = R.ReadinessCache( + {}, + adapter_targets={ + "hand": ("http://broken:8000", ("legal",)), + "other": ("http://good:8000", ("ok",)), + }, + adapter_probe=probe, + interval=0.01, + start=True, + ) + try: + assert done.wait(timeout=5.0) + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if cache.current_adapters().get("other") == frozenset({"ok"}): + break + time.sleep(0.01) + snapshot = cache.current_adapters() + assert snapshot["hand"] == frozenset(), "a raising probe must fail closed" + assert snapshot["other"] == frozenset({"ok"}), "and must not abort the pass" + finally: + cache.stop() + + +def test_no_adapter_targets_leaves_current_adapters_empty() -> None: + # The overwhelmingly common deployment: no adapters declared anywhere. The + # adapter refresh must be skipped entirely, not run against nothing. + cache = R.ReadinessCache({}, interval=0.01, start=True) + try: + time.sleep(0.05) + assert cache.current_adapters() == {} + finally: + cache.stop() + + +# --- list_models_payload: only CONFIRMED adapters are listed ---------------- + + +def _hand_table() -> RoutingTable: + return RoutingTable( + backends=( + Backend( + "hand", + "http://vllm-hand:8000", + "LiquidAI/LFM2.5-1.2B-Instruct", + adapters=("legal", "sql"), + ), + ), + default_model="LiquidAI/LFM2.5-1.2B-Instruct", + aliases={}, + ) + + +def _ids(payload: dict) -> list[str]: + return [entry["id"] for entry in payload["data"]] + + +def test_list_models_omits_adapters_when_none_are_confirmed() -> None: + # Declared but unconfirmed is INVISIBLE. This is the empty-inventory shape + # v1 actually ships, and the live Spark/Orin probes match it: exactly one + # entry, the base. + assert _ids(list_models_payload(_hand_table())) == ["LiquidAI/LFM2.5-1.2B-Instruct"] + assert _ids(list_models_payload(_hand_table(), loaded_adapters={})) == [ + "LiquidAI/LFM2.5-1.2B-Instruct" + ] + assert _ids(list_models_payload(_hand_table(), loaded_adapters={"hand": frozenset()})) == [ + "LiquidAI/LFM2.5-1.2B-Instruct" + ] + + +def test_list_models_lists_only_the_confirmed_adapters() -> None: + # "sql" was declared and did NOT load — it must not appear. + payload = list_models_payload(_hand_table(), loaded_adapters={"hand": frozenset({"legal"})}) + assert _ids(payload) == ["LiquidAI/LFM2.5-1.2B-Instruct", "legal"] + + +def test_list_models_ignores_a_confirmation_for_an_undeclared_adapter() -> None: + # Confirmation is an intersection, not a source of truth: an adapter the + # routing table never declared cannot be listed even if a probe reports it. + payload = list_models_payload( + _hand_table(), loaded_adapters={"hand": frozenset({"legal", "smuggled"})} + ) + assert _ids(payload) == ["LiquidAI/LFM2.5-1.2B-Instruct", "legal"] + + +def test_hand_adapter_aliases_are_derived_from_the_same_declaration() -> None: + # The point of deriving both from HAND_LORA_MODULES: the gateway's + # ``hand:`` aliases and the engine's ``--lora-modules`` cannot drift + # apart, because there is only one declaration to read. + table, _ = C.build_config( + { + "HAND_BASE_URL": "http://vllm-hand:8000", + C.HAND_LORA_MODULES_ENV: "legal=/adapters/legal,sql=/adapters/sql", + } + ) + hand = next(b for b in table.backends if b.name == "hand") + assert hand.adapters == ("legal", "sql") + assert table.aliases[f"hand{C.HAND_ADAPTER_SEP}legal"] == "legal" + assert table.aliases[f"hand{C.HAND_ADAPTER_SEP}sql"] == "sql" + + +def test_an_undeclared_hand_adapter_gets_no_alias() -> None: + # No alias is what produces the ``model_not_found`` 404 rather than a + # silent fall-back to the base weights — a caller who asked for the legal + # specialist must never be handed a generalist answer and told it worked. + table, _ = C.build_config( + { + "HAND_BASE_URL": "http://vllm-hand:8000", + C.HAND_LORA_MODULES_ENV: "legal=/adapters/legal", + } + ) + assert f"hand{C.HAND_ADAPTER_SEP}nonexistent" not in table.aliases + + +def test_empty_inventory_yields_no_adapter_aliases_but_still_serves_the_base() -> None: + table, _ = C.build_config({"HAND_BASE_URL": "http://vllm-hand:8000"}) + hand = next(b for b in table.backends if b.name == "hand") + assert hand.adapters == () + assert not [a for a in table.aliases if a.startswith(f"hand{C.HAND_ADAPTER_SEP}")] + assert table.aliases.get("hand") is not None, "model=hand must still resolve to the base" + + +def test_default_adapter_probe_uses_the_local_timeout() -> None: + # The default probe binds the LOCAL timeout, never the peer thread's + # cross-box budget: these are co-resident lanes on the compose network. + seen: dict[str, object] = {} + + def fake_probe(base_url, declared, *, timeout, opener=None): + seen.update(base_url=base_url, declared=tuple(declared), timeout=timeout) + return frozenset({"legal"}) + + cache = R.ReadinessCache({}, timeout=1.25, start=False) + original = R.probe_backend_adapters + R.probe_backend_adapters = fake_probe # type: ignore[assignment] + try: + got = cache._default_adapter_probe("http://vllm-hand:8000", ("legal",)) + finally: + R.probe_backend_adapters = original # type: ignore[assignment] + assert got == frozenset({"legal"}) + assert seen == { + "base_url": "http://vllm-hand:8000", + "declared": ("legal",), + "timeout": 1.25, + } + + +def test_list_models_drops_adapters_of_an_unready_backend() -> None: + # A backend filtered out by readiness takes its adapters with it — an + # adapter cannot outlive the lane that serves it. + payload = list_models_payload( + _hand_table(), + ready={"hand": False}, + loaded_adapters={"hand": frozenset({"legal"})}, + ) + assert _ids(payload) == [] diff --git a/uv.lock b/uv.lock index 122314c..011edf3 100644 --- a/uv.lock +++ b/uv.lock @@ -440,7 +440,7 @@ wheels = [ [[package]] name = "lobes-cli" -version = "0.56.1" +version = "0.56.2" source = { editable = "." } [package.optional-dependencies] From b44fe32ac0a6d8ae66f64a6598ad072e178380ac Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 10:27:00 +0300 Subject: [PATCH 15/17] fix: adapter id collisions, a misleading comment, and build_config complexity (0.56.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both Qodo findings on #184 and the SonarCloud S3776 issue. **Qodo 1 — adapter id collision (valid, fixed).** `_backend_for` matches `requested == backend.served_name or requested in backend.adapters`, so an adapter name is an ownership claim exactly like a served name is. But `_warn_on_served_name_collisions` only indexed `served_name`, so an adapter colliding with another backend's served name resolved by backend order with no warning at all. It now indexes every id a backend claims (`_claimed_model_ids`), and the message names the remedy that actually applies — telling an operator to change `*_SERVED_NAME` is useless advice when the duplicate came from `HAND_LORA_MODULES`. Qodo also suggested warning on adapter-vs-alias collisions, which is a distinct and arguably worse case: `resolve_model` checks aliases FIRST, so an adapter named `cortex` is not order-dependent, it is totally unreachable. That gets its own warning and its own message. **Qodo 2 — misleading comment (valid, fixed).** My comment said the hand backend is wired when `HAND_BASE_URL` **or** `HAND_SERVED_NAME` is present. `_optional_backend`'s own docstring says the opposite in as many words: the name key alone is not enough, because a served name with no URL describes a model rather than a reachable backend, and wiring one invents a phantom backend advertised on /v1/models that no request can reach. The comment contradicted the function it was documenting. **Sonar S3776.** `build_config` was at cognitive complexity 18 (limit 15); my `hand` alias loop is what pushed it over. Rather than trim just my own contribution to land exactly on the limit, three alias derivations are now named helpers — `_hand_adapter_aliases`, `_add_self_named_opt_in_aliases`, `_add_pooling_role_aliases` — taking it well clear. Behaviour-preserving: the existing alias tests pass unchanged, and the comments explaining *why* each alias exists (the WRONG VECTOR SPACE hazard for embed-deep, the checkpoint-swap survival argument for the pooling role names) moved into the helpers' docstrings rather than being dropped. 6 new tests: adapter-vs-served-name collision, the served-name path not regressing into adapter advice, no false positive when ids are distinct, alias shadowing, the `hand:` alias not reporting itself, and the extracted helper in isolation. 2922 -> 2928. New-code coverage stays 100%. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- .agex/data/pr/events.jsonl | 2 + CHANGELOG.md | 15 +++ lobes/gateway/_config.py | 179 ++++++++++++++++++++++------ pyproject.toml | 2 +- tests/test_gateway_hand_adapters.py | 79 ++++++++++++ 5 files changed, 238 insertions(+), 39 deletions(-) diff --git a/.agex/data/pr/events.jsonl b/.agex/data/pr/events.jsonl index dac3c21..6681a2b 100644 --- a/.agex/data/pr/events.jsonl +++ b/.agex/data/pr/events.jsonl @@ -101,3 +101,5 @@ {"ts":"2026-08-10T06:50:45.373614+00:00","type":"pr_webhook_posted","pr":184,"event":"pr_opened"} {"ts":"2026-08-10T06:50:46.870979+00:00","type":"readiness_arrived","pr":184,"waited_secs":0} {"ts":"2026-08-10T06:50:52.682664+00:00","type":"pr_read","pr":184,"comment_count":3,"threads_unresolved":0,"ci_state":"failure"} +{"ts":"2026-08-10T07:16:51.792749+00:00","type":"readiness_arrived","pr":184,"waited_secs":0} +{"ts":"2026-08-10T07:16:57.343496+00:00","type":"pr_read","pr":184,"comment_count":6,"threads_unresolved":2,"ci_state":"ok"} diff --git a/CHANGELOG.md b/CHANGELOG.md index e04bf38..e2a2a24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.56.3] - 2026-08-10 + +### Added + +- Collision detection now covers LoRA adapter names, not just served names (Qodo #184-1): _backend_for matches `requested in backend.adapters`, so an adapter name is an ownership claim exactly like a served name — a collision between the two resolved silently by backend order. The warning also names the right remedy (HAND_LORA_MODULES vs *_SERVED_NAME) for whichever kind of duplicate it found. +- A new warning for an adapter name shadowed by an alias: resolve_model checks aliases FIRST, so an adapter named after a tier, role or operator alias is unreachable by its own name — a total shadow rather than an order-dependent race. + +### Changed + +- build_config cognitive complexity 18 -> well under the 15 limit (Sonar S3776) by extracting three alias derivations into named helpers: _hand_adapter_aliases, _add_self_named_opt_in_aliases and _add_pooling_role_aliases. Behaviour-preserving — the existing alias tests pass unchanged. + +### Fixed + +- The hand backend comment claimed HAND_SERVED_NAME alone wires the lane (Qodo #184-2). It does not — _optional_backend requires *_BASE_URL, by its own documented contract, since a served name with no URL describes a model rather than a reachable backend. + ## [0.56.2] - 2026-08-10 ### Added diff --git a/lobes/gateway/_config.py b/lobes/gateway/_config.py index eac7ac7..b3b5df7 100644 --- a/lobes/gateway/_config.py +++ b/lobes/gateway/_config.py @@ -569,11 +569,30 @@ def _optional_backend( ) +def _claimed_model_ids(backend: Backend) -> tuple[str, ...]: + """Every model id ``backend`` answers to: its served name AND its adapters. + + Both are routable. :func:`~lobes.gateway._routing._backend_for` matches + ``served_name == requested or requested in backend.adapters``, so an adapter + name is an ownership claim exactly like a served name is, and a collision + between the two kinds is as order-dependent as a collision between two + served names. + """ + return (backend.served_name, *backend.adapters) + + def _warn_on_served_name_collisions(backends: list[Backend]) -> None: - """Emit a stderr warning for any served name claimed by more than one backend.""" + """Emit a stderr warning for any model id claimed by more than one backend. + + Covers served names *and* LoRA adapter names — see + :func:`_claimed_model_ids` for why the two are the same kind of claim. A + backend claiming the same id twice (an adapter named after its own base) is + also reported, since that too resolves by first match. + """ by_name: dict[str, list[Backend]] = {} for backend in backends: - by_name.setdefault(backend.served_name, []).append(backend) + for claimed in _claimed_model_ids(backend): + by_name.setdefault(claimed, []).append(backend) for served, owners in sorted(by_name.items()): if len(owners) < 2: continue @@ -585,14 +604,110 @@ def _warn_on_served_name_collisions(backends: list[Backend]) -> None: if tasks == {"embed"} else "" ) + # A LoRA adapter name is an ownership claim too, so a collision can be + # adapter-vs-served-name or even a single backend claiming an id twice. + # Name the remedy for whichever kind this is: *_SERVED_NAME is useless + # advice when the duplicate came from HAND_LORA_MODULES. + remedy = ( + "Give each backend a distinct *_SERVED_NAME." + if all(served == b.served_name for b in owners) + else "Rename the colliding LoRA adapter in HAND_LORA_MODULES, or give " + "the backend a distinct *_SERVED_NAME." + ) sys.stderr.write( - f"[gateway] WARNING: served name {served!r} is claimed by {len(owners)} " - f"backends ({names}); routing resolves it to the first match, so " - f"ownership is order-dependent.{detail} Give each backend a distinct " - f"*_SERVED_NAME.\n" + f"[gateway] WARNING: model id {served!r} is claimed by {len(owners)} " + f"backend claim(s) ({names}); routing resolves it to the first match, " + f"so ownership is order-dependent.{detail} {remedy}\n" ) +def _hand_adapter_aliases(backends: list[Backend]) -> dict[str, str]: + """``{"hand:": ""}`` for a WIRED hand backend's adapters. + + Extracted from :func:`build_config` so the alias derivation is testable on + its own and does not add branching to an already-large builder. + + Note what is NOT here: ``hand`` itself. That comes from ``tier_aliases`` as + a capability tier, so the bare role name resolves to the BASE checkpoint and + never 404s just because the adapter inventory is empty — an armed-but-empty + lane is a working lane. An UNdeclared ``hand:`` gets no alias, is + not any backend's served name or adapter, and therefore takes the + :func:`~lobes.gateway._routing.is_unknown_model` 404 ``model_not_found`` — + never a silent fall-back to the base weights or to another lane, which + would hand a caller who asked for the legal specialist a generalist answer + and call it success. + """ + hand = next((b for b in backends if b.name == "hand"), None) + if hand is None: + return {} + return {f"hand{HAND_ADAPTER_SEP}{adapter}": adapter for adapter in hand.adapters} + + +def _warn_on_adapter_alias_shadowing(backends: list[Backend], aliases: Mapping[str, str]) -> None: + """Warn when an adapter name is also an alias key — the alias WINS. + + :func:`~lobes.gateway._routing.resolve_model` checks ``table.aliases`` + first, so an adapter named after a tier or role (``cortex``, ``main``, + ``senses``, an operator's ``GATEWAY_ALIASES`` key) is unreachable by its own + name: the request silently routes to the alias target instead. Unlike the + served-name collision above this is not order-dependent — it is a total + shadow — which makes it worth its own message. + """ + for backend in backends: + for adapter in backend.adapters: + if adapter in aliases and aliases[adapter] != adapter: + sys.stderr.write( + f"[gateway] WARNING: LoRA adapter {adapter!r} on backend " + f"{backend.name!r} is ALSO an alias for " + f"{aliases[adapter]!r}; the alias wins in resolve_model, so " + f"this adapter is unreachable by name. Rename it in " + f"HAND_LORA_MODULES.\n" + ) + + +def _add_self_named_opt_in_aliases(aliases: dict[str, str], backends: list[Backend]) -> None: + """Alias each wired opt-in backend to its own name. + + ``multimodal-coder`` and ``embed-deep`` are addressed by their backend name + rather than a capability tier. ``embed-deep`` in particular gets **no** + upward fallback: it serves ``task="embed"`` and ``tier_aliases`` is + generate-only, so an absent deep gear means the alias is simply absent — + never a silent downgrade to the 0.6B, which would answer in the WRONG + VECTOR SPACE. + + Only a WIRED backend earns an alias; an alias must never point at a served + name nothing actually serves. Extracted from :func:`build_config` to keep + that builder's branching down. + """ + for name in ("multimodal-coder", "embed-deep"): + backend = next((b for b in backends if b.name == name), None) + if backend is not None: + aliases[name] = backend.served_name + + +def _add_pooling_role_aliases(aliases: dict[str, str], backends: list[Backend]) -> None: + """Give the embed/rerank lanes the stable role names the generate lane has. + + A caller that names a role survives a checkpoint swap; a caller that + hardcodes a served id does not. Before this, ``embedder``/``reranker`` were + not addressable at all — the only working address was the raw served id, + because ``tier_aliases`` is generate-only. Both the Colleague-facing ROLE + name and the internal BACKEND name are accepted, exactly as the generate + lane takes ``senses``/``multimodal``. + + ``setdefault``, so an operator alias already in ``aliases`` still wins. An + unwired lane gets nothing — absent (404) rather than substituted, since an + embedding from a different model answers in the WRONG VECTOR SPACE and a + rerank from the wrong head returns meaningless orderings. Extracted from + :func:`build_config` to keep that builder's branching down. + """ + for role_name, backend_name in (("embedder", "embed"), ("reranker", "rerank")): + backend = next((b for b in backends if b.name == backend_name), None) + if backend is not None: + aliases.setdefault(role_name, backend.served_name) + aliases.setdefault(backend_name, backend.served_name) + + def build_config(env: Mapping[str, str] | None = None) -> tuple[RoutingTable, ServerConfig]: """Construct the routing table and server config from environment variables.""" env = os.environ if env is None else env @@ -619,14 +734,17 @@ def build_config(env: Mapping[str, str] | None = None) -> tuple[RoutingTable, Se # gear the minor/cheap capability tier resolves to since it replaced # Qwen3.5-4B in that slot (lobes.catalog.TIER_ROLE). # - # Wired when HAND_BASE_URL or HAND_SERVED_NAME is present. It is - # default-HOSTED (every rendered card profile declares it), so on a - # freshly-inited deployment these are always set; it stays an - # _optional_backend anyway so a pre-hand `.env` — which has neither — - # simply renders no hand backend rather than pointing at a container - # that isn't running. `hand` is NOT in OPT_IN_BACKENDS, so that unwired - # state reads feasible:true / ready:false (the sleeping lobe), not - # role_infeasible. + # Wired when HAND_BASE_URL is present — that env var ALONE, per + # _optional_backend's contract: HAND_SERVED_NAME only names the + # backend once a URL has wired it, and never activates it on its own + # (a served name with no URL describes a model, not a reachable + # backend). It is default-HOSTED (every rendered card profile declares + # it), so on a freshly-inited deployment the URL is always set; it + # stays an _optional_backend anyway so a pre-hand `.env` — which has + # neither — simply renders no hand backend rather than pointing at a + # container that isn't running. `hand` is NOT in OPT_IN_BACKENDS, so + # that unwired state reads feasible:true / ready:false (the sleeping + # lobe), not role_infeasible. _optional_backend( env, name="hand", @@ -788,10 +906,7 @@ def build_config(env: Mapping[str, str] | None = None) -> tuple[RoutingTable, Se # serves task="embed", and tier_aliases is generate-only), so it gets no # upward fallback — an absent deep gear means the alias is absent, never a # silent downgrade to the 0.6B, which would answer in the WRONG VECTOR SPACE. - for _opt_in in ("multimodal-coder", "embed-deep"): - _opt_in_backend = next((b for b in backends if b.name == _opt_in), None) - if _opt_in_backend is not None: - aliases[_opt_in] = _opt_in_backend.served_name + _add_self_named_opt_in_aliases(aliases, backends) # HAND ADAPTER aliases (hand-lobe plan t4): the caller-facing # ``hand:`` spelling for each declared LoRA adapter, resolving to # the bare name vLLM itself serves the adapter under (its @@ -803,21 +918,10 @@ def build_config(env: Mapping[str, str] | None = None) -> tuple[RoutingTable, Se # Added only for a WIRED hand backend, mirroring the opt-in-alias contract # directly above: an alias must never point at a served name nothing # actually serves. Declared BEFORE the GATEWAY_ALIASES merge so an explicit - # operator override still wins. - # - # Note what is NOT here: ``hand`` itself. That comes from ``tier_aliases`` - # as a capability tier, so the bare role name resolves to the BASE - # checkpoint and never 404s just because the adapter inventory is empty — - # an armed-but-empty lane is a working lane. An UNdeclared - # ``hand:`` gets no alias, is not any backend's served name or - # adapter, and therefore takes the ``is_unknown_model`` 404 - # ``model_not_found`` — never a silent fall-back to the base weights or to - # another lane, which would hand a caller who asked for the legal - # specialist a generalist answer and call it success. - _hand_backend = next((b for b in backends if b.name == "hand"), None) - if _hand_backend is not None: - for _adapter in _hand_backend.adapters: - aliases[f"hand{HAND_ADAPTER_SEP}{_adapter}"] = _adapter + # operator override still wins. See _hand_adapter_aliases for what is + # deliberately NOT derived here (the bare ``hand``, and any UNdeclared + # ``hand:``). + aliases.update(_hand_adapter_aliases(backends)) # POOLING ROLE IDENTITY aliases — the stable address for the embed/rerank # lanes, mirroring what `cortex`/`senses` already give the generate lane. # @@ -838,11 +942,7 @@ def build_config(env: Mapping[str, str] | None = None) -> tuple[RoutingTable, Se # SPACE, and a rerank from the wrong head returns meaningless orderings. # Both the Colleague-facing ROLE name and the internal BACKEND name are # accepted, exactly as the generate lane takes `senses` and `multimodal`. - for _role_name, _backend_name in (("embedder", "embed"), ("reranker", "rerank")): - _pool_backend = next((b for b in backends if b.name == _backend_name), None) - if _pool_backend is not None: - aliases.setdefault(_role_name, _pool_backend.served_name) - aliases.setdefault(_backend_name, _pool_backend.served_name) + _add_pooling_role_aliases(aliases, backends) aliases.update(_expand_tier_alias_synonyms(_parse_aliases(env.get("GATEWAY_ALIASES")))) # Hardware feasibility (task t6): computed over the FIVE canonical backend # names FEASIBLE_ENV knows about — independent of whether each is actually @@ -862,6 +962,9 @@ def build_config(env: Mapping[str, str] | None = None) -> tuple[RoutingTable, Se # do not refuse to start — taking the fleet down over a name clash is worse # than serving it — but it must never be SILENT. _warn_on_served_name_collisions(backends) + # After the GATEWAY_ALIASES merge above, so an operator alias that shadows + # an adapter is caught too — not just the built-in tier/role ones. + _warn_on_adapter_alias_shadowing(backends, aliases) wired_names = frozenset(b.name for b in backends) infeasible = frozenset( name for name in FEASIBLE_ENV if not _is_feasible(env, name, wired=name in wired_names) diff --git a/pyproject.toml b/pyproject.toml index ed3d0db..94589e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lobes-cli" -version = "0.56.2" +version = "0.56.3" description = "lobes — run, assess, and switch the local vLLM model." readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_gateway_hand_adapters.py b/tests/test_gateway_hand_adapters.py index 577cfeb..e544034 100644 --- a/tests/test_gateway_hand_adapters.py +++ b/tests/test_gateway_hand_adapters.py @@ -371,6 +371,85 @@ def test_empty_inventory_yields_no_adapter_aliases_but_still_serves_the_base() - assert table.aliases.get("hand") is not None, "model=hand must still resolve to the base" +# --- collision + shadowing warnings (Qodo review, PR #184) ------------------ + + +def test_an_adapter_colliding_with_another_backends_served_name_warns(capsys) -> None: + # _backend_for matches `requested in backend.adapters` as well as + # served_name, so an adapter name is an ownership claim exactly like a + # served name — and the collision resolves by backend order, silently. + C._warn_on_served_name_collisions( + [ + Backend("primary", "http://p:8000", "some/model"), + Backend( + "hand", "http://h:8000", "LiquidAI/LFM2.5-1.2B-Instruct", adapters=("some/model",) + ), + ] + ) + err = capsys.readouterr().err + assert "'some/model'" in err + assert "order-dependent" in err + assert "HAND_LORA_MODULES" in err, "the remedy must name the knob the duplicate came from" + + +def test_a_served_name_collision_still_recommends_served_name() -> None: + # The pre-existing message must not regress into adapter advice when no + # adapter is involved. + import contextlib + import io + + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + C._warn_on_served_name_collisions( + [ + Backend("embed", "http://e:8000", "same/id", task="embed"), + Backend("embed_deep", "http://d:8000", "same/id", task="embed"), + ] + ) + err = buf.getvalue() + assert "*_SERVED_NAME" in err + assert "HAND_LORA_MODULES" not in err + assert "WRONG " in err and "VECTOR SPACE" in err, "the embed-specific detail must survive" + + +def test_no_warning_when_every_claimed_id_is_distinct(capsys) -> None: + C._warn_on_served_name_collisions( + [ + Backend("primary", "http://p:8000", "some/model"), + Backend("hand", "http://h:8000", "base/id", adapters=("legal", "sql")), + ] + ) + assert capsys.readouterr().err == "" + + +def test_an_adapter_shadowed_by_an_alias_warns(capsys) -> None: + # resolve_model checks aliases FIRST, so this adapter is unreachable by its + # own name — a total shadow, not an order-dependent race. + C._warn_on_adapter_alias_shadowing( + [Backend("hand", "http://h:8000", "base/id", adapters=("cortex",))], + {"cortex": "unsloth/Qwen3.6-27B-NVFP4"}, + ) + err = capsys.readouterr().err + assert "'cortex'" in err + assert "unreachable by name" in err + + +def test_the_hand_adapter_alias_itself_is_not_reported_as_shadowing(capsys) -> None: + # `hand:legal -> legal` is the alias we deliberately mint; it must not warn + # about the adapter it exists to reach. + backends = [Backend("hand", "http://h:8000", "base/id", adapters=("legal",))] + C._warn_on_adapter_alias_shadowing(backends, C._hand_adapter_aliases(backends)) + assert capsys.readouterr().err == "" + + +def test_hand_adapter_aliases_helper_is_empty_without_a_hand_backend() -> None: + assert C._hand_adapter_aliases([]) == {} + assert C._hand_adapter_aliases([Backend("primary", "http://p:8000", "m")]) == {} + assert C._hand_adapter_aliases( + [Backend("hand", "http://h:8000", "base/id", adapters=("legal",))] + ) == {f"hand{C.HAND_ADAPTER_SEP}legal": "legal"} + + def test_default_adapter_probe_uses_the_local_timeout() -> None: # The default probe binds the LOCAL timeout, never the peer thread's # cross-box budget: these are co-resident lanes on the compose network. From 3cca67e2b4761112bcd54dadab0e895400ddbeb6 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 10:38:40 +0300 Subject: [PATCH 16/17] fix: CI lint (markdown in my own changelog) and Sonar S8997 monkeypatch (0.56.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two markdownlint errors, both self-inflicted in CHANGELOG prose: - MD033: `hand:` unbackticked parses as inline HTML `` - MD037: bare `_backend_for`, `_hand_adapter_aliases`, `*_SERVED_NAME` etc. parse as emphasis markers Worth naming the process error rather than just the fix: my local markdownlint run passed because I ran it BEFORE `bump.py` appended the entry that contained the violations. Lint the changelog after the bump, not before. Sonar S8997 (x2): the adapter-probe test rebound a module global by hand with a try/finally restore. It now uses the `monkeypatch` fixture, which is both the convention and safer — restoration survives an exception in the assertions, not just in the call under test. A sibling test also drops its inline `contextlib.redirect_stderr` for the `capsys` fixture the rest of the module already uses. Sonar's remaining S3776 report is stale: it points at `_config.py:596`, which was `build_config`'s line before the previous commit extracted three helpers out of it. That function now starts at 668 and scores well under the limit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- .agex/data/pr/events.jsonl | 6 ++++++ CHANGELOG.md | 17 ++++++++++----- pyproject.toml | 2 +- tests/test_gateway_hand_adapters.py | 33 ++++++++++++----------------- uv.lock | 2 +- 5 files changed, 33 insertions(+), 27 deletions(-) diff --git a/.agex/data/pr/events.jsonl b/.agex/data/pr/events.jsonl index 6681a2b..a05ff7e 100644 --- a/.agex/data/pr/events.jsonl +++ b/.agex/data/pr/events.jsonl @@ -103,3 +103,9 @@ {"ts":"2026-08-10T06:50:52.682664+00:00","type":"pr_read","pr":184,"comment_count":3,"threads_unresolved":0,"ci_state":"failure"} {"ts":"2026-08-10T07:16:51.792749+00:00","type":"readiness_arrived","pr":184,"waited_secs":0} {"ts":"2026-08-10T07:16:57.343496+00:00","type":"pr_read","pr":184,"comment_count":6,"threads_unresolved":2,"ci_state":"ok"} +{"ts":"2026-08-10T07:27:31.383799+00:00","type":"pr_reply","pr":184,"thread_id":null,"in_reply_to":null} +{"ts":"2026-08-10T07:27:32.459631+00:00","type":"pr_reply","pr":184,"thread_id":null,"in_reply_to":null} +{"ts":"2026-08-10T07:27:32.463044+00:00","type":"pr_batch_replied","pr":184,"count":2,"resolved":0} +{"ts":"2026-08-10T07:27:33.257550+00:00","type":"pr_webhook_posted","pr":184,"event":"pr_replied"} +{"ts":"2026-08-10T07:28:07.155009+00:00","type":"readiness_arrived","pr":184,"waited_secs":0} +{"ts":"2026-08-10T07:28:12.425961+00:00","type":"pr_read","pr":184,"comment_count":8,"threads_unresolved":0,"ci_state":"failure"} diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a2a24..37089f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,26 +4,33 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.56.4] - 2026-08-10 + +### Fixed + +- CI lint: two markdownlint errors in my own CHANGELOG entries — `hand:` parsed as inline HTML (MD033) and bare snake_case identifiers parsed as emphasis markers (MD037). Both came from writing changelog prose with unbackticked code. My local markdownlint run had passed because I ran it BEFORE the version bump appended the entry. +- Sonar S8997 x2: the adapter-probe test swapped a module global by hand; it now uses the monkeypatch fixture, and a sibling test drops its inline contextlib/io redirect for the capsys fixture the rest of the module already uses. + ## [0.56.3] - 2026-08-10 ### Added -- Collision detection now covers LoRA adapter names, not just served names (Qodo #184-1): _backend_for matches `requested in backend.adapters`, so an adapter name is an ownership claim exactly like a served name — a collision between the two resolved silently by backend order. The warning also names the right remedy (HAND_LORA_MODULES vs *_SERVED_NAME) for whichever kind of duplicate it found. -- A new warning for an adapter name shadowed by an alias: resolve_model checks aliases FIRST, so an adapter named after a tier, role or operator alias is unreachable by its own name — a total shadow rather than an order-dependent race. +- Collision detection now covers LoRA adapter names, not just served names (Qodo #184-1): `_backend_for` matches `requested in backend.adapters`, so an adapter name is an ownership claim exactly like a served name — a collision between the two resolved silently by backend order. The warning also names the right remedy (`HAND_LORA_MODULES` vs `*_SERVED_NAME`) for whichever kind of duplicate it found. +- A new warning for an adapter name shadowed by an alias: `resolve_model` checks aliases FIRST, so an adapter named after a tier, role or operator alias is unreachable by its own name — a total shadow rather than an order-dependent race. ### Changed -- build_config cognitive complexity 18 -> well under the 15 limit (Sonar S3776) by extracting three alias derivations into named helpers: _hand_adapter_aliases, _add_self_named_opt_in_aliases and _add_pooling_role_aliases. Behaviour-preserving — the existing alias tests pass unchanged. +- `build_config` cognitive complexity 18 -> well under the 15 limit (Sonar S3776) by extracting three alias derivations into named helpers: `_hand_adapter_aliases`, `_add_self_named_opt_in_aliases` and `_add_pooling_role_aliases`. Behaviour-preserving — the existing alias tests pass unchanged. ### Fixed -- The hand backend comment claimed HAND_SERVED_NAME alone wires the lane (Qodo #184-2). It does not — _optional_backend requires *_BASE_URL, by its own documented contract, since a served name with no URL describes a model rather than a reachable backend. +- The hand backend comment claimed `HAND_SERVED_NAME` alone wires the lane (Qodo #184-2). It does not — `_optional_backend` requires `*_BASE_URL`, by its own documented contract, since a served name with no URL describes a model rather than a reachable backend. ## [0.56.2] - 2026-08-10 ### Added -- 29 tests over the hand LoRA-adapter honesty surface — the declaration parser (HAND_LORA_MODULES: partition-not-split so a path keeps an =, malformed segments dropped, dedupe, whitespace), the engine probe (intersection, undeclared ids ignored, empty declaration opens no socket, correct path, no API key, and every fail-closed mode: non-200, unreachable, malformed body), the ReadinessCache background refresh (empty seed, copy isolation, a raising probe degrading to empty without aborting the pass), the hand: alias derivation, and the /v1/models filter (declared-but-unconfirmed is invisible; an adapter cannot outlive its lane). +- 29 tests over the hand LoRA-adapter honesty surface — the declaration parser (HAND_LORA_MODULES: partition-not-split so a path keeps an =, malformed segments dropped, dedupe, whitespace), the engine probe (intersection, undeclared ids ignored, empty declaration opens no socket, correct path, no API key, and every fail-closed mode: non-200, unreachable, malformed body), the ReadinessCache background refresh (empty seed, copy isolation, a raising probe degrading to empty without aborting the pass), the `hand:` alias derivation, and the `/v1/models` filter (declared-but-unconfirmed is invisible; an adapter cannot outlive its lane). ### Changed diff --git a/pyproject.toml b/pyproject.toml index 94589e9..e1cf7a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lobes-cli" -version = "0.56.3" +version = "0.56.4" description = "lobes — run, assess, and switch the local vLLM model." readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_gateway_hand_adapters.py b/tests/test_gateway_hand_adapters.py index e544034..7c2fd32 100644 --- a/tests/test_gateway_hand_adapters.py +++ b/tests/test_gateway_hand_adapters.py @@ -33,6 +33,8 @@ import threading import time +import pytest + from lobes.gateway import _config as C from lobes.gateway import _readiness as R from lobes.gateway._routing import Backend, RoutingTable, list_models_payload @@ -392,21 +394,16 @@ def test_an_adapter_colliding_with_another_backends_served_name_warns(capsys) -> assert "HAND_LORA_MODULES" in err, "the remedy must name the knob the duplicate came from" -def test_a_served_name_collision_still_recommends_served_name() -> None: +def test_a_served_name_collision_still_recommends_served_name(capsys) -> None: # The pre-existing message must not regress into adapter advice when no # adapter is involved. - import contextlib - import io - - buf = io.StringIO() - with contextlib.redirect_stderr(buf): - C._warn_on_served_name_collisions( - [ - Backend("embed", "http://e:8000", "same/id", task="embed"), - Backend("embed_deep", "http://d:8000", "same/id", task="embed"), - ] - ) - err = buf.getvalue() + C._warn_on_served_name_collisions( + [ + Backend("embed", "http://e:8000", "same/id", task="embed"), + Backend("embed_deep", "http://d:8000", "same/id", task="embed"), + ] + ) + err = capsys.readouterr().err assert "*_SERVED_NAME" in err assert "HAND_LORA_MODULES" not in err assert "WRONG " in err and "VECTOR SPACE" in err, "the embed-specific detail must survive" @@ -450,7 +447,7 @@ def test_hand_adapter_aliases_helper_is_empty_without_a_hand_backend() -> None: ) == {f"hand{C.HAND_ADAPTER_SEP}legal": "legal"} -def test_default_adapter_probe_uses_the_local_timeout() -> None: +def test_default_adapter_probe_uses_the_local_timeout(monkeypatch: pytest.MonkeyPatch) -> None: # The default probe binds the LOCAL timeout, never the peer thread's # cross-box budget: these are co-resident lanes on the compose network. seen: dict[str, object] = {} @@ -460,12 +457,8 @@ def fake_probe(base_url, declared, *, timeout, opener=None): return frozenset({"legal"}) cache = R.ReadinessCache({}, timeout=1.25, start=False) - original = R.probe_backend_adapters - R.probe_backend_adapters = fake_probe # type: ignore[assignment] - try: - got = cache._default_adapter_probe("http://vllm-hand:8000", ("legal",)) - finally: - R.probe_backend_adapters = original # type: ignore[assignment] + monkeypatch.setattr(R, "probe_backend_adapters", fake_probe) + got = cache._default_adapter_probe("http://vllm-hand:8000", ("legal",)) assert got == frozenset({"legal"}) assert seen == { "base_url": "http://vllm-hand:8000", diff --git a/uv.lock b/uv.lock index 011edf3..f98022b 100644 --- a/uv.lock +++ b/uv.lock @@ -440,7 +440,7 @@ wheels = [ [[package]] name = "lobes-cli" -version = "0.56.2" +version = "0.56.4" source = { editable = "." } [package.optional-dependencies] From d52b0dc5c9a28e8fc5188625b9b1eeda135eba68 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Mon, 10 Aug 2026 10:57:58 +0300 Subject: [PATCH 17/17] =?UTF-8?q?fix:=20Sonar=20S9073=20=E2=80=94=20split?= =?UTF-8?q?=20composite=20assertion=20(0.56.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assert "WRONG " in err and "VECTOR SPACE" in err` was only two clauses because the expected message wraps across two source lines; the emitted text is one contiguous "WRONG VECTOR SPACE", so a single substring check is both simpler and a strictly stronger assertion — the composite form would have passed on output where the two fragments appeared far apart. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PcC3i2xBuGcVPPH4tTr5pv --- .agex/data/pr/events.jsonl | 2 ++ CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- tests/test_gateway_hand_adapters.py | 2 +- uv.lock | 2 +- 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.agex/data/pr/events.jsonl b/.agex/data/pr/events.jsonl index a05ff7e..2fab3e2 100644 --- a/.agex/data/pr/events.jsonl +++ b/.agex/data/pr/events.jsonl @@ -109,3 +109,5 @@ {"ts":"2026-08-10T07:27:33.257550+00:00","type":"pr_webhook_posted","pr":184,"event":"pr_replied"} {"ts":"2026-08-10T07:28:07.155009+00:00","type":"readiness_arrived","pr":184,"waited_secs":0} {"ts":"2026-08-10T07:28:12.425961+00:00","type":"pr_read","pr":184,"comment_count":8,"threads_unresolved":0,"ci_state":"failure"} +{"ts":"2026-08-10T07:38:52.888182+00:00","type":"readiness_arrived","pr":184,"waited_secs":0} +{"ts":"2026-08-10T07:38:58.375296+00:00","type":"pr_read","pr":184,"comment_count":8,"threads_unresolved":0,"ci_state":"ok"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 37089f3..a82f4d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.56.5] - 2026-08-10 + +### Fixed + +- Sonar S9073: a composite assertion in the collision-message test is now a single substring check — the two halves were only separate because the expected text wraps across two source lines. + ## [0.56.4] - 2026-08-10 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index e1cf7a2..88a19ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lobes-cli" -version = "0.56.4" +version = "0.56.5" description = "lobes — run, assess, and switch the local vLLM model." readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_gateway_hand_adapters.py b/tests/test_gateway_hand_adapters.py index 7c2fd32..c07868c 100644 --- a/tests/test_gateway_hand_adapters.py +++ b/tests/test_gateway_hand_adapters.py @@ -406,7 +406,7 @@ def test_a_served_name_collision_still_recommends_served_name(capsys) -> None: err = capsys.readouterr().err assert "*_SERVED_NAME" in err assert "HAND_LORA_MODULES" not in err - assert "WRONG " in err and "VECTOR SPACE" in err, "the embed-specific detail must survive" + assert "WRONG VECTOR SPACE" in err, "the embed-specific detail must survive" def test_no_warning_when_every_claimed_id_is_distinct(capsys) -> None: diff --git a/uv.lock b/uv.lock index f98022b..43edb6e 100644 --- a/uv.lock +++ b/uv.lock @@ -440,7 +440,7 @@ wheels = [ [[package]] name = "lobes-cli" -version = "0.56.4" +version = "0.56.5" source = { editable = "." } [package.optional-dependencies]