Cargo workspace with three crates:
| Crate | Path | Purpose |
|---|---|---|
swarmllm |
/ (root) |
Main binary — daemon, networking, inference, API, all subsystems |
swarmllm-types |
crates/swarmllm-types/ |
Shared data types (78 types: NodeId, ModelManifest, SwarmMessage, etc.) |
swarmllm-frontend |
crates/swarmllm-frontend/ |
Frontend asset serving (embedded in release, disk-based in dev mode) |
Extension traits (ModelManifestExt, NicknameRecordExt, BlindedPoolInvitationExt) provide methods for types in swarmllm-types that depend on main crate functionality (filesystem, crypto, blake3).
Single Rust binary, three simultaneous functions:
┌──────────────────────────────────────────────────────────┐
│ swarmllm binary │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ P2P │ │ HTTP API │ │ Admin UI │ │
│ │ Node │ │ Server │ │ (embedded) │ │
│ │(TCP+QUIC)│ │ (Axum) │ │ │ │
│ └────┬─────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌────┴───────────────┴─────────────────┴─────────────┐ │
│ │ Shared State (Arc) │ │
│ │ │ │
│ │ config — boot-time snapshot (startup only) │ │
│ │ live_config — current config, read via cfg() │ │
│ │ │ │
│ │ ┌─ EventBus (state.events) ──────────────────────┐ │ │
│ │ │ broadcast::Sender<ActivityEvent> (cap 256) │ │ │
│ │ │ broadcast::Sender<DashboardSignal> (cap 32) │ │ │
│ │ │ activity_history, update_state │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ ┌─ CreditPool (state.credits) ──────────────────┐ │ │
│ │ │ credit_balance, pool_state, pool_registry │ │ │
│ │ │ trust_manager, escrow_manager, anti_gaming │ │ │
│ │ │ foreign_pool_catalog (R134) │ │ │
│ │ │ allow_cross_pool_inference (R137) │ │ │
│ │ │ share_model_catalog (R137) │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ ┌─ ModelMgmt (state.models) ────────────────────┐ │ │
│ │ │ acquisition_progress, hf_sources │ │ │
│ │ │ auto_manage_*, model_trust, locked_shards │ │ │
│ │ │ removed_by_user (deleted-shard tombstones) │ │ │
│ │ │ shards_needing_repair (corrupt → refetch) │ │ │
│ │ │ prune_history, download_cancel_flags │ │ │
│ │ │ wishlist (R111), hf_trending_cache (R112) │ │ │
│ │ │ foreign_wishlist (R130) │ │ │
│ │ │ quant_recommendations (R133) │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ ┌─ MetricsProviders (state.metrics) ────────────┐ │ │
│ │ │ node_stats, inference_requests_total │ │ │
│ │ │ channel_metrics, inference_latency_samples │ │ │
│ │ │ providers_config, provider_model_map │ │ │
│ │ │ swarm_capacity (R110) │ │ │
│ │ │ hedge_tracker (R136 L2) │ │ │
│ │ │ prefetch_orchestrator (R136 L3) │ │ │
│ │ │ ngram_hits / ngram_misses (R137 L1 telemetry) │ │ │
│ │ │ inference_latency_samples (R137: (Instant,f64))│ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Root: peer_registry, model_registry, executor, │ │
│ │ identity, db, active_pipelines, config, │ │
│ │ pending_layer_results, pending_stream_result_ │ │
│ │ routes, pending_prefix_kv_fetches, │ │
│ │ pending_activation_chunks (R139 Tier 4K), │ │
│ │ standalone_tokenizers (R136 L1/L3 follow-on), │ │
│ │ listen_multiaddrs (R140 pool invite v2), │ │
│ │ publicly_reachable + hole_punch_successes / │ │
│ │ hole_punch_failures (v0.3.21 NAT diagnostics), │ │
│ │ recent_failures (v0.3.22 diagnostics ring, 20) │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
┌──────────────┐
│ daemon/ │
│ (bootstrap) │
└──────┬───────┘
│ spawns tokio tasks
┌───────┬───────┬───────┬───────┬──┴────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌──────┐┌─────┐┌─────┐┌──────┐┌──────┐┌──────┐┌────────┐┌────────┐┌──────┐┌────────┐┌────────┐┌────────┐
│Netwrk││Infer││Crdit││Health││ API ││Rebal-││Acquisi-││Message ││ Pool ││AutoShrd││HfWatchr││Update │
│Mangr ││Routr││Ledgr││Mon. ││Servr ││ancer ││tion Mgr││Dispatc ││Mangr ││Manager ││(R112) ││Checker│
└──┬───┘└──┬──┘└──┬──┘└──┬───┘└──┬───┘└──┬───┘└───┬────┘└───┬────┘└──┬───┘└───┬────┘└───┬────┘└───┬────┘
│ │ │ │ │ │ │ │ │ │ │ │
└───────┴──────┴──────┴───────┴───────┴─────────┴─────────┴────────┴────────┴─────────┴─────────┘
mpsc channels between tasks
| From | To | Channel | Message Types |
|---|---|---|---|
| NetworkManager | MessageDispatcher | network_out_tx |
AuthenticatedMessage (transport-verified sender + SwarmMessage) |
| MessageDispatcher | InferenceRouter | router_cmd_tx |
InferenceRequest, LayerForward, LayerResult, PipelineAssignment, InferenceError |
| InferenceRouter | NetworkManager | network_tx |
SwarmMessage (outgoing P2P) |
| HealthMonitor | NetworkManager | network_tx |
HealthPing |
| HealthMonitor | ShardRebalancer | rebalance_tx |
RebalanceEvent |
| ApiServer | InferenceRouter | router_cmd_tx |
RouterCommand (from HTTP) |
| ApiServer | AcquisitionManager | acquisition_tx |
AcquisitionCommand (model download) |
| ApiServer | PoolManager | pool_cmd_tx |
PoolCommand (pool CRUD) |
| AcquisitionManager | NetworkManager | network_tx |
ShardAnnounce (shard requests) |
| CreditLedger | NetworkManager | network_tx |
CreditGossip, CreditTransaction |
| PoolManager | NetworkManager | network_tx |
PoolInvitation, PoolState gossip |
| AutoShardManager | AcquisitionManager | acquisition_tx |
AcquisitionCommand (auto downloads) |
The MessageDispatcher is a dedicated task in daemon/dispatch/mod.rs that routes inbound network messages to the appropriate subsystem. Inference messages go to InferenceRouter, CreditGossip updates peer balance distributions, and pool messages go to PoolManager.
1. Parse CLI args (clap) — including optional --model and --gpu-layers
2. Initialize tracing subscriber (verbosity: info → debug → debug+libp2p → trace)
3. Load or create config (TOML + env + defaults + CLI overrides)
4. Ensure data directory exists
5. Load or generate Ed25519 identity
6. Open redb database
7. Build Daemon { config, identity, db }
8. Initialize ModelExecutor (load GGUF model if --model provided)
9. Build Arc<SharedState> (includes ModelRegistry loaded from DB)
10. Scan local shards → register in model_registry (with disk existence verification).
Claims manifest publisher as our node_id + recomputes BLAKE3 hash (allows gossiping copied shards).
11. Create mpsc channels (network, router, rebalance, acquisition, pool)
12. Spawn all tasks (12 tasks: NetworkManager, InferenceRouter, MessageDispatcher,
HealthMonitor, ShardRebalancer, CreditLedger, AcquisitionManager, ApiServer,
PoolManager, AutoShardManager, HfWatcher (R112), UpdateChecker)
13. Open browser if ui.open_browser_on_start is true (setup wizard or admin)
14. tokio::select! on Ctrl+C signal or any task exit
15. Signal graceful shutdown via watch channel, save peer cache, flush redb database
SwarmLLM uses a 5-layer zero-config discovery stack. Each layer is independent — losing any layer doesn't break the others.
┌─────────────────────────────────────────────────────────────┐
│ Discovery Stack │
│ │
│ Layer 1: mDNS (LAN) │
│ Toggle-wrapped libp2p mdns — discovers peers on same │
│ network in seconds. Config: enable_mdns = true (default) │
│ │
│ Layer 2: Persistent Peer Cache (redb) │
│ Saves up to 200 peer multiaddrs every 5 min + shutdown │
│ Loads on startup → fastest reconnect path │
│ File: src/network/peer_cache.rs │
│ Storing and dialling are separate questions (R148): │
│ filter_storable — drops only always-junk (loopback, │
│ circuits through our own id). Keeps private addrs │
│ wherever we are, so a roaming laptop keeps its LAN │
│ peers. │
│ filter_dialable — adds context: a node with no │
│ private address of its own cannot reach anyone │
│ else's 192.168/10/172.16/CGNAT, so those are │
│ dropped on read. Empty listen_multiaddrs means │
│ "not bound yet", NOT "public" — unknown context │
│ keeps everything. │
│ │
│ Layer 3: Encrypted Network + Pool Invite Codes │
│ Network-only (single multiaddr): │
│ Format: swarm://<base64url(key‖nonce‖encrypted_addr)> │
│ API: GET /api/admin/network-code │
│ POST /api/admin/join-network │
│ Pool join (R140 — bundles discovery + join token): │
│ Format: swarmpool://<base64url(key‖nonce‖encrypted_json)>│
│ Payload: { version, pool_id, pool_name, multiaddrs[], │
│ code, expires_at_unix } │
│ API: POST /api/pool/generate-code │
│ POST /api/pool/join (accepts both v2 + legacy 8) │
│ Module: src/pool/invite.rs │
│ Encryption: ChaCha20-Poly1305 (key embedded in code — │
│ anti-IP-harvesting only; the code itself is the auth │
│ token). │
│ │
│ Layer 4: Peer Exchange (PEX) + RTT Measurement │
│ On each ConnectionEstablished, exchange up to 20 known │
│ peer addresses. Uses request_response channel. │
│ RTT measured on PEX request/response round-trip. │
│ RTT < 5ms → auto-detect as LAN peer (enables TP). │
│ │
│ Layer 5: Kademlia DHT + Bootstrap │
│ Existing: --bootstrap flag, Kademlia re-bootstrap 60s │
│ │
│ Anti-Gaming: Subnet Clustering Detection │
│ Tracks /24 IPv4 prefixes. >5 nodes per /24 → 25% │
│ spot-check rate (up from 5%). SubnetClustering trust │
│ event penalty (-0.03). │
│ │
│ Gossip Network ID: "swarmllm-mainnet-v1" (fixed) │
│ Configurable via gossip_network_id for private networks │
└─────────────────────────────────────────────────────────────┘
1. Listen on TCP (port+10, Noise+Yamux) and QUIC (port)
2. Subscribe to GossipSub topics
3. mDNS starts immediately (discovers LAN peers in seconds)
4. Dial cached peers from last session (fastest reconnect)
5. Dial user-configured --bootstrap peers and invite codes (if any)
6. Trigger Kademlia bootstrap
7. PEX fires on each ConnectionEstablished (exchanges peer lists)
8. Periodic: discovery tick every 5min (Kademlia bootstrap + re-dial cached
peers not currently connected), peer cache save every 5min. A bootstrap
RETRY loop polls every 5s but only acts while zero peers are connected,
on a backoff schedule.
9. On shutdown: save peer cache
10. mDNS race recovery: if simultaneous-dial kills both connections, the
pending_redial queue schedules a re-dial with hash-based jitter (2-5s).
`max_connections_per_peer` is 3, not 1 — DCUtR needs a relayed and a direct
connection open at once to hole-punch (see `network/behaviour.rs`).
11. Dialling is ONE attempt per peer carrying all its known addresses, through
`dial_checked` (foreign-peer gate, self-dial guard) with
`DisconnectedAndNotDialing`. Never one dial per address — see
`.claude/rules/architecture.md` § "One dial per PEER".
peer_registry capped at 200 entries. On overflow, evicts highest-latency non-LAN non-pipeline peer. Memory bounded at O(1) instead of O(N). LAN peers and pipeline-active peers are never evicted.
Two-tier shard holder discovery for 50K+ node scaling:
-
Tier 1 — Bounded in-memory cache:
ModelRegistry.shard_holdersusesHashMap<NodeId, Instant>(notHashSet<NodeId>) with max 50 holders per shard. LRU eviction when at capacity. Local node never evicted. Populated by GossipSubShardAnnounce+ DHT query results. Syncshard_holders()API unchanged — scheduler hot path stays fast. The DHT merge can only ADD a holder (merge_dht_providersloopsrecord_shard_holder), and a kad provider record outlives the fact it asserts by up to 24h — so a holder's explicit retraction is recorded inModelRegistry.retracted_claimsand outranks the record for 26h; only the holder's own announcement clears it. Without that, a withdrawn claim was reinstated within seconds and every request was scheduled onto a node that no longer had the weights (gotcha #364, fixed v0.3.113). -
Tier 2 — Kademlia provider records: Each node calls
kademlia.start_providing()for its shards (key:/swarm/provide/<model_id>/<shard_index>). Provider records TTL 1 hour, republished every 20 minutes.get_providers()results are resolved from PeerId → NodeId (same Ed25519 key, bidirectional conversion intransport.rs) and merged into the bounded cache. -
Pre-warm: Router fires
dht_query_tx.try_send(model_id)before pipeline assembly. NetworkManager issuesget_providers()for all model shards, merging results into registry asynchronously. First request may miss cache; subsequent requests benefit. -
Lifecycle wiring:
NetworkCommand::StartProvidingon shard acquisition (startup scan, rescan, download complete).NetworkCommand::StopProvidingon shard deletion (prune, admin API). -
Disconnect eviction:
handle_connection_closedcallsmodel_registry.remove_peer_from_all_shards(node_id)synchronously alongsidepeer_registry.remove. Prevents the scheduler from picking a just-disconnected peer (was a 90s window before the health-monitor stale-peer sweep ran). DHT can still re-inject the peer asynchronously, so the scheduler'sconnected_node_idsfilter is the load-bearing guard.
Memory: O(shards × 50) bounded regardless of network size (was O(shards × nodes) unbounded).
libp2p Swarm
├── Kademlia (DHT)
│ ├── /swarm/node/{node_id} → NodeCapability
│ ├── /swarm/shards/{model_id}/{node_id_hex} → Vec<ShardIndex> (per-node; avoids last-writer-wins)
│ ├── /swarm/shard/{model}/{index} → Vec<NodeId> (batched per model)
│ └── /swarm/model/{model_id} → ModelManifest
│ └── Records expire after 1 hour, re-published periodically
│
├── GossipSub (pub/sub, mesh_n/mesh_n_low/mesh_n_high/mesh_outbound_min auto-scale with known_peers: 2/1/4/1 at <10 peers up to 8/6/16/4 at 10k+)
│ ├── swarm/models/{model_id} → ShardAnnounce, capacity
│ ├── swarm/credits → CreditGossip
│ ├── swarm/health → trust summaries
│ ├── swarm/identity → NicknameRecord (signed, timestamp-checked)
│ └── swarm/pools → PoolState, PoolInvitation
│ └── Messages >5 min old are rejected (replay protection)
│ └── Failed publishes buffered and replayed on mesh formation
│
├── request_response (unified protocol, /swarmllm/1.0.0, 600s timeout — slow CPU inference)
│ ├── JSON control messages — SwarmMessage, ShardRequest/ShardResponse (type-tag 0x00=WIRE_TAG_JSON)
│ ├── Binary tensor payloads — LayerForward, LayerResult (type-tag 0x01=WIRE_TAG_TENSOR, or 0x02=WIRE_TAG_TENSOR_COMPRESSED for zstd, flag-gated; inner ChaCha20-Poly1305 encryption marked by TENSOR_TAG_ENCRYPTED=0x10)
│ ├── Binary shard data — ShardResponse payload (type-tag 0x03=WIRE_TAG_SHARD, 32MB chunks as raw bytes, bypasses 4MB JSON limit)
│ ├── Cross-node prefix-KV snapshots — (type-tag 0x04=WIRE_TAG_PREFIX_KV, Item 8 Phase 2 fetched path)
│ └── ACK-timeout fast-fail: streaming-tracked sends (`SendDirectMessage` with `delivery_request_id = Some(uuid)`) are mapped to a Uuid via `pending_rr_observability`. The 10s `RR_ACK_TIMEOUT_SECS` sweep closes `streaming_token_txs[uuid]` if no Response/OutboundFailure event fires (libp2p rr can silently drop sends under load); caller sees Err in ~10–20s instead of 120s
│
├── TCP transport (Noise + Yamux, nodelay=true, port+10)
├── QUIC transport (port, fallback for NAT traversal)
├── mDNS (optional, LAN peer discovery — conditional dial, not added to Kademlia)
├── connection_limits (max 1/peer, 500 total)
├── allow_block_list (blocked_peers — nodes Identify showed do not speak SwarmLLM;
│ refuses both directions at the swarm level. Declining to REGISTER them left
│ something inside libp2p re-dialling them a few times a minute each, and every
│ one of our own dial sites already refusing: gotcha #404, v0.3.131)
├── Identify (protocol identification + peer_to_node reverse map;
│ `peer_speaks_swarmllm` gates registration BEFORE the Kademlia insert)
├── AutoNAT v2 client+server (per-address reachability test → ExternalAddrConfirmed / relay activation; replaced v1 in R143 to fix false-"Public")
├── DCUtR (hole punching)
├── UPnP (IGD gateway port-mapping → auto-confirms public external address; default on, off on WSL2)
└── relay::client (circuit relay)
Internet reachability (R143). A node's advertised address set
(state.listen_multiaddrs, consumed by v2 invite codes) is the UNION of
swarm.listeners() (bound sockets — private LAN on a NAT'd node) and
swarm.external_addresses() (public addresses confirmed by UPnP, AutoNAT,
relay circuits, or the manual network.external_address override). This closes
the gap where a NAT'd node minted invite codes carrying only its LAN address.
UPnP (default on) auto-opens the gateway port for the common home-router case;
network.external_addresses lets a port-forwarded box / VPS / dyndns anchor
declare its reachable address(es) explicitly (list form covers TCP + QUIC).
NAT detection + relay (R143). AutoNAT v2 (client + server, replacing v1)
tests each candidate address for real reachability; a confirmed address emits
ExternalAddrConfirmed, an AddressNotReachable result triggers relay
activation via NetworkManager::try_activate_relay (reserve a /p2p-circuit on
a bootstrap_peers relay). A belt-and-suspenders fallback in the run loop
reserves a relay if the node still has no internet-reachable address
RELAY_FALLBACK_DELAY_SECS after startup — so reachability for a CGNAT node
doesn't depend on AutoNAT producing a conclusive answer. The relay path
(reservation → circuit dial → DCUtR upgrade) is wired but still needs live
multi-NAT validation. See docs/NETWORKING.md for the operator guide.
Application-level relay (post-R150, docs/NETWORKING_PLAN.md). The libp2p
circuit relay above establishes a connection between two NAT'd peers, but a
/p2p-circuit cannot reliably round-trip a request_response substream under
load — so two NAT'd nodes can be is_connected == true yet unable to run
inference. A second, application-owned relay closes that gap the way Tailscale's
DERP does: a mutually-reachable third node forwards already-sealed payloads
without ever being able to read them.
- Two message classes.
SwarmMessage::RelayedEnvelopecarries a sealed control message (JSON, ≤256 KB);SwarmRequest::RelayedTensorcarries a sealed activation forward or its result (binary, ≤32 MB, wire tag0x06). Both are opaque to the relay — it matchesrelay_toagainst its connected peers and re-sends; it holds no key. - Ephemeral-seal, not session-seal. Session-sealed tensors are decrypted by
the transport sender's key, so a relay-forwarded session tensor would fail to
open at the target. Relayed payloads are instead sealed to the target's
static X25519 key (derived from its Ed25519 identity via
ed25519_pubkey_to_x25519) using a fresh ephemeral keypair per message (crypto/relay_seal.rs). AAD bindsorigin ‖ relay_to ‖ request_id, so a relay cannot redirect, replay across requests, or swap a payload between transfers without Poly1305 rejection. - Separate-request return path. A relayed forward and its result are two
independent relayed requests, never an RR response substream (which the relay
cannot proxy). The coordinator's
pending_layer_resultsoneshot resolves when the relayed-backLayerResultarrives; the relay-unwrap path stampssender_peer_bytes = originso the result routes home rather than being dropped as unattributed. - Feature-gated + prefer-direct.
NodeCapabilityadvertisesprotocol_version: u16and afeatures: u64bitset (features::{RELAY, TENSOR_RELAY}); a node only attempts a relayed send when the recipient advertises the matching bit, so the protocol evolves additively with no flag-day. The relay is chosen only when there is no usable direct connection (has_direct_connectionfalse — the circuit-only case); a real direct/QUIC path always wins. Learned relay routes live instate.relay_routes(daemon/state/relay.rs, 5-min TTL, swept on the HealthMonitor tick); selection + forwarding live innetwork/manager/relay.rs.
The split inference engine (src/inference/split/) enables true distributed inference
using candle for direct tensor computation with quantized GGUF weights. Each node loads
only the transformer layers it owns, forwarding hidden-state activations between nodes.
The module is split into focused subfiles: model.rs (SplitModel struct + accessors),
loader.rs (GGUF/shard load), executor.rs (forward pass + tensor-parallel),
kv_cache.rs (per-request KV-cache store; LayerKv holds each layer's f32 BHSD
cache plus an optional f16 BSHD mirror for the CUDA flash kernel — GQA only, worth
1.41x on long-context decode, see .claude/rules/architecture.md),
entry.rs (model entry + LRU eviction),
gguf_meta.rs (GGUF header parsing), shard_reader.rs (multi-shard virtual reader),
rope.rs (RoPE precomputation), prefix_cache.rs (cross-request prefix-KV reuse).
Client → API Server → InferenceRouter → Pipeline Assembly
│
┌───────────────────────┘
▼
┌──────────────────────┐
│ Pipeline Segment │ Token IDs (prefill) or
│ Node A: Layers 0-15 │ single token ID (decode)
│ (embedding + layers) │──── LayerForward ───▶
└──────────────────────┘ │
┌───────────────┘
▼
┌──────────────────────┐
│ Pipeline Segment │ Hidden states
│ Node B: Layers 16-27 │ [1, seq, 3584]
│ (layers + norm + LM) │── sample token ──▶
└──────────────────────┘ │
┌────────────────┘
▼
LayerResult
(token IDs)
│
▼
Client
The SplitModel loader detects the model architecture from GGUF metadata
(general.architecture) and applies architecture-specific behavior:
| Feature | Llama | Llama 4 | Qwen2 | Qwen 3.5 | Gemma/Gemma2 | Phi-3 | Mistral | Starcoder2 | DeepSeek-V2/V3 | GLM-4 |
|---|---|---|---|---|---|---|---|---|---|---|
| RoPE variant | Interleaved (rope_i) |
Contiguous (iRoPE) | Contiguous (rope) |
Partial (25% head_dim) | Interleaved | Su/YaRN | Interleaved | Contiguous | Contiguous (MLA split) | Contiguous (partial) |
| QKV biases | None | None | Yes | Yes | None | Yes | None | Yes | None (MLA projections) | Yes |
| Attention | Standard MHA/GQA | Standard GQA | Standard MHA | Standard + output gate | Standard MHA | Standard MHA | Standard GQA | Standard MHA | MLA (low-rank Q/KV) | Extreme GQA (16:1) |
| FFN | Dense | Dense + MoE (mixed) | Dense | Dense | Dense | Dense | Dense | Dense | MoE (top-k) + shared | Dense |
| Context length | 4096 (default) | 131072 | 32768 | 131072 | 8192 | 4096 | 32768 | 16384 | 163840 | 131072 |
| Special | — | NoPE every 4th layer | — | Hybrid SSM+attention | Embedding scaling (sqrt(d)), Gemma RmsNorm (+1), attn + final logit softcap, EOS 107, Gemma chat template | Fused QKV/FFN | — | — | Per-layer dense/MLA | Partial RoPE (50%) |
| E2E verified | ✅ | — | ✅ | — | ✅ (Gemma2) | ✅ | — | — | — | — |
Phi-3 fused tensors: Phi-3 GGUF models store
attn_qkv.weight(Q+K+V concatenated) andffn_up.weight(gate+up concatenated, noffn_gate.weight). The loader dequantizes on CPU, splits by head dimensions, and re-quantizes to Q4_0 on the target device.
DeepSeek models use two specialized mechanisms that differ from standard transformers:
Multi-head Latent Attention (MLA) — compressed KV via low-rank projections:
- Q path:
x → q_a (compress) → RMSNorm → q_b (decompress) → split (q_nope, q_rope) → RoPE on q_rope - KV path:
x → kv_a (compress) → split (c_kv, k_rope) → RoPE on k_rope → RMSNorm(c_kv) → kv_b → split (k_nope, v) - Full K/V stored in KV cache (decompressed, not latent)
- Uses
standard_attention()due to asymmetric key/value dimensions
Mixture-of-Experts (MoE) — router-selected sparse FFN:
- Router:
x.matmul(gate.T) → softmax → top-k selection (CPU argsort) - Expert loop: per-token top-k experts selected from stacked
[n_experts, dim, dim]tensors viaindex_select - Shared experts (always active) added to routed expert output
- Expert tensors dequantized at load time for
index_selectcompatibility
Per-layer type detection — early DeepSeek layers (~1-3) use standard dense attention + dense FFN; subsequent layers use MLA + MoE. The LayerVariant enum handles both:
LayerVariant::Dense(LayerWeights)— standard transformer layer (can useFfnVariant::DenseorFfnVariant::MoEfor mixed models like Llama 4)LayerVariant::DeepSeek { attention: MlaWeights, ffn: FfnVariant, ... }— MLA + MoE/dense FFN
Llama 4 introduces two novel mechanisms within the standard dense LayerVariant:
- iRoPE (interleaved RoPE) — every 4th layer uses NoPE (no positional encoding), the rest use standard RoPE. This is handled by a per-layer flag in
LayerWeights - Mixed Dense+MoE FFN —
FfnVariantenum (Dense(Mlp)|MoE(MoeFfn)) allows individual layers to use either dense or MoE FFN within the same model. Top-k expert routing reuses the sameMoeFfnstruct as DeepSeek
Qwen 3.5 introduces a hybrid architecture combining SSM (Gated Delta Networks) with standard attention:
- Layer pattern: 3 SSM (DeltaNet) layers + 1 full attention layer per 4-layer group
- GGUF arch strings:
"qwen35"(dense),"qwen35moe"(MoE variant) - SSM forward: conv1d → delta_net_scan (recurrent) → gated_norm → output projection
- Attention layers: Standard attention with sigmoid output gate + partial RoPE (25% of head_dim)
- State management:
SsmState(conv_state + recurrent_state) alongside KV-cache for attention layers - Per-layer detection: SSM vs attention determined by presence of
ssm_alpha.weighttensor in GGUF - Per-step alpha/beta gating:
ssm_alpha.weightandssm_beta.weighttensors are read from the GGUF and applied per timestep via the Gated DeltaNet formula: decayg_t = exp(-softplus(α + dt)), prediction errorerror = β_v·v - g·S@(β_k·k), state updateS_t = g·S + error ⊗ (β_k·k)^T.
When multiple LAN nodes hold the same shards, tensor parallelism splits computation within each layer across nodes:
Node A (rank 0, coordinator) Node B (rank 1)
┌─────────────────────┐ ┌─────────────────────┐
│ Load full weights │ │ Load full weights │
│ Slice heads 0..N/2 │ │ Slice heads N/2..N │
│ forward_attn_tp() │ │ forward_attn_tp() │
│ forward_tp() FFN │ │ forward_tp() FFN │
│ partial output │ │ partial output │
└────────┬────────────┘ └────────┬────────────┘
│ │
└──────────┐ ┌────────────────────┘
▼ ▼
AllReduce (star topology)
Coordinator sums partials
Broadcasts reduced tensor
│
┌─────┴─────┐
▼ ▼
Node A Node B
(continue to next layer)
- Topology: Star AllReduce — rank 0 collects partials, element-wise sums, broadcasts result
- LAN detection: Auto-detected via PEX RTT measurement (< 5ms →
is_lan_peer = true) - TP group formation: Requires
is_lan_peerOR measuredlatency_ms ≤ 10 - Weight splitting: Dynamic slicing at inference time (
forward_attn_tpslices attention heads,forward_tpslices FFN intermediate dimension) - Wire format: Partials zstd-compressed, sent via
SendAllReduceRequest/SendAllReduceResponseNetworkCommand variants - Registry cleanup:
AllReduceRegistry::cleanup_stale()runs on each HealthMonitor tick (30s), removing entries where the receiver was dropped (timed out) - Files:
src/inference/allreduce.rs(coordinator + registry),src/inference/scheduler/mod.rs(TP group detection)
SwarmLLM supports multimodal inference via src/inference/vision.rs:
- LLaVA — CLIP vision encoder + LLM backbone, image patches projected into token space
- Qwen2-VL — Native vision-language architecture with dynamic resolution
- Images are pre-processed, encoded into vision tokens, and inserted at the
<image>token position in the prompt (matching llama.cpp's approach: prompt split at<image>, before/after tokenized separately, vision embeddings inserted at exact position) - mmproj GGUF loading —
load_from_mmproj_gguf()loads CLIP ViT weights directly from llama.cpp-compatible mmproj GGUF files (verified with LLaVA-v1.5-7B mmproj: 577 vision tokens × 4096 LLM dim) - Status: Full E2E verified — LLaVA-v1.5-7B: base64 image → CLIP vision encoder (577 tokens × 4096 dim) → position-aware embedding insertion at
<image>→ 7B text model → correct output. CPU-only (~4min prefill, ~1.8s/token)
The mmproj (vision encoder, ~600MB) is modeled as a sentinel shard (index = u32::MAX) within the existing shard infrastructure. Vision encoding is a pre-processing step decoupled from the text pipeline — no single node needs both the vision encoder and a text shard.
API Request (with image)
│
▼
Router: does any node have mmproj?
│
├── Local node has mmproj → encode locally
├── Remote node has mmproj → VisionEncodeRequest → get embeddings back
└── Nobody has mmproj → HTTP 503 (VisionEncoderUnavailable)
│
▼
Pre-computed embeddings (577 × 4096 = ~9.4MB, zstd+FP16 compressed)
│
▼
Text Pipeline (unchanged): embeddings travel with LayerForward
Key design decisions:
- Sentinel shard index (
u32::MAX) reuses all ShardId infrastructure (registry, announcements, auto-manage, pruning) VisionEncodeRequest/VisionEncodeResponsenetwork messages for remote encoding (JPEG-compressed images on wire)LayerForward.vision_embeddings: Option<Vec<u8>>carries zstd-compressed FP16 embeddings on first forward- Vision node selection: prefer local → first-segment node → any mmproj holder
precompute_vision_embeddings()runs once before the token generation loop- Auto-manage: 5x priority bonus for mmproj download, higher pruning floor (min 3 replicas), only prunes under extreme pressure (>0.95)
A full byte-level BPE tokenizer is built from GGUF metadata at model load time:
- Vocabulary from
tokenizer.ggml.tokens - Merge rules from
tokenizer.ggml.merges - Pre-tokenization patterns selected by
tokenizer.ggml.pre— see below - GPT-2 byte encoding/decoding for proper UTF-8 handling
Pre-tokenization is where a byte-level BPE tokenizer is easiest to get
silently wrong, so pre_tokenizer_patterns in inference/tokenizer.rs
mirrors llama.cpp's regex_exprs table (src/llama-vocab.cpp) and its
tokenizer.ggml.pre string → enum mapping, including every alias:
- Llama-3 (
llama-bpe,llama3,llama-v3,falcon3,pixtral,dbrx,smaug-bpe,glm4, …) — the pattern most GGUFs in circulation want. - Qwen2 (
qwen2,deepseek-r1-qwen,stablelm2, …) — as Llama-3 but digits split one at a time (\p{N}, not\p{N}{1,3}). - Qwen3.5, GPT-4o/Llama-4, GPT-2, and the sequential-list types
(
default,falcon,starcoder,deepseek-coder) whose patterns are applied in order, each pass re-splitting the previous pass's fragments.
Two properties are load-bearing and pinned by tests:
- An unrecognised name warns and falls back to the GPT-2 splitter, never a
whitespace split.
llama-bpewas absent from the table for a long time and hit a whitespace fallback, which strands every leading space as its own token instead of attaching it to the following word. That is what a byte-level BPE model is trained on, so the effect was ~2x the tokens AND input in a shape the model had never seen — with no error anywhere (gotcha #247). - Text a pattern does not match is kept, not dropped. Several patterns cover only part of their input by design (the GPT-2 one does not match interior whitespace runs), so discarding the gaps deletes characters from the prompt outright.
Correctness is judged against a reference tokenizers BPE built from the SAME
vocab and merges — self-consistency (encode→decode round-trips) cannot detect
this class and passed throughout.
inference/chat_template/ renders the Jinja template a GGUF carries in
tokenizer.chat_template, producing the exact text handed to the model.
parser.rs tokenizes, eval.rs evaluates, fallbacks.rs supplies a
family-appropriate format for a model whose template we cannot run.
It implements the subset real templates use, not Jinja. What it does NOT
implement must FAIL — apply_chat_template returns None and the caller falls
back by model name — rather than render approximately, because a prompt that is
nearly right is simply a wrong prompt with no error attached:
{% set x = messages[1:] %}binds a slice, and the offset is honoured. Templates slice precisely to drop a message they have already placed by hand; ignoring the offset renders that message TWICE. Every Llama-3 system prompt was duplicated for exactly this reason (gotcha #248). A FILTER we do not implement (| reverse) is applied as identity, which is a harmless superset — the distinction between ignoring a refinement and ignoring a removal is the whole point.messages[0]['content']indexes one message and must not be mistaken for a binding to the list, or the expression is aliased instead of evaluated.- Comments obey trim markers.
{#- … #}drops its surrounding whitespace; skipping only the body leaves a blank line in the model's input. strftime_nowis provided. Llama-3.x templates guard on it and fall back to a date hardcoded when the model shipped, so reporting it undefined told every Llama-3 model it was 26 July 2024.- Output is capped (
MAX_TEMPLATE_OUTPUT) and recursion bounded (MAX_TEMPLATE_DEPTH): the template is peer-supplied GGUF metadata.
The integration guard is
the_official_llama3_template_renders_exactly_as_jinja2_does, which renders the
real shipped template against the exact text jinja2 produces for it. Expected
strings are taken FROM jinja2 rather than derived from this evaluator.
Hidden states are serialized for network transmission:
[4B ndim][4B×ndim shape][4B dtype_tag][f32 data]
For a 7B model (hidden_dim=3584):
- Prefill (14 tokens): 1×14×3584×4 = ~200KB
- Decode (1 token): 1×1×3584×4 = ~14KB
Tensor Compression — optional zstd compression for wire tensors (configurable):
tensor_compression = true— enable zstd compression on hidden-state payloadstensor_compress_level = 3— zstd compression level (1-22, default 3)tensor_compress_threshold = 4096— minimum payload bytes to trigger compression- Reduces bandwidth for prefill payloads by 30-60% with minimal latency overhead
LayerForward optional trailers — the wire envelope appends optional trailer blocks after the activation bytes. Each trailer is a single tag byte + fixed payload. Decoders scan in tag order and ignore unknown tags (forward-compatible).
| Tag | Field | Layout | Purpose |
|---|---|---|---|
| 0x01 | layer_range + model_id | start(4)+end(4)+len(2)+model_id |
Required — receiver loads correct segment weights |
| 0x02 | tp_meta | rank(1)+size(1)+layer(4)+phase(1)+pre_embedded(1) |
Tensor-parallel AllReduce routing |
| 0x03 | speculative | flags(1)+n_drafts(2)+drafts(n×4) |
Draft tokens + spec_logits_requested flag |
| 0x04 | kv_truncate | target_len(4) |
Spec-decode KV-cache fixup after partial acceptance |
| 0x05 | chunk_meta (R139) | chunk_idx(4)+total_chunks(4) |
Tier 4K daemon-side STREAM-chunked transport |
All trailers are bound into the encryption AAD via
build_layer_forward_aad (single source of truth in
network/protocol/encrypted.rs). An attacker who flips a trailer
byte on the wire fails Poly1305 on the receiver's decrypt.
Tier 4K daemon-side chunked send (R139) — gated by
inference.streaming_chunked_send (default false). When on AND
the activation exceeds streaming_min_activation_bytes (default
64 KiB), the coordinator splits the activation at byte-offset
boundaries into K = ceil(size / streaming_chunk_size_bytes) chunks
(default chunk size 256 KiB — matches age STREAM construction +
TokenWeave MLSys 2026 K=2-4 sweet spot). Each chunk carries the
same request_id and a distinct (chunk_idx, total_chunks) in the
0x05 trailer. All chunks ride the same libp2p stream (QUIC
preserves byte order within a stream → no receiver-side reorder
state machine). Receiver assembles in
SharedState.pending_activation_chunks: DashMap<Uuid, ChunkAssemblyState> via try_assemble_chunked_forward, then
dispatches a single reassembled LayerForward to the worker. The
0x05 trailer is AAD-bound so reorder / wrong-total /
cross-transfer-substitution attempts fail Poly1305 before reaching
assembly.
- Fetch model manifest → determine layer ranges
- Query model_registry.shard_holders for hosting nodes
- Filter holders against
connected_node_ids— drops peers whose libp2p connection is gone (DHT can re-inject stale providers;peer_registryis preserved across mid-pipeline disconnects for reconnect attempts so it's not the right liveness oracle) - Fetch node load/latency from peer_registry
- Sort candidates by (latency ASC, load ASC, trust DESC)
- Greedy assignment: widest contiguous layer range per node
- Merge contiguous segments assigned to the same node
- Identify standby nodes per segment
- Send PipelineAssignment → all nodes ACK → begin forwarding
CUDA builds compile kernels for compute capability 8.0 (Ampere: RTX 30-series
and newer). This is FlashAttention's own requirement — every candle-flash-attn
kernel source is _sm80 and uses Ampere async-copy — and flash-attn is part of
both the cuda and windows-gpu features.
The floor is stated in four places that must agree, enforced by
compute_cap_matches_release_workflow in tests/repo_consistency.rs:
daemon::gpu_support::MIN_COMPUTE_CAP (runtime) and CUDA_COMPUTE_CAP in
release.yml, cache-warm.yml, ci.yml (build). A second test,
flash_attn_and_the_compute_cap_floor_agree, ties the floor to the feature in
both directions — the floor is only worth paying for because of flash-attn.
Detection, not failure. Device::cuda_if_available succeeds on a pre-Ampere
card; only module load fails, and it fails per-request with
CUDA_ERROR_NO_BINARY_FOR_GPU. Without a probe such a node starts cleanly,
logs "GPU detected", advertises itself to the swarm as a GPU node, and then
fails everything. So:
daemon::gpu_support::local_gpu_is_supported()probes the capability once at startup vianvidia-smi --query-gpu=compute_capand caches it. An unreadable capability means unknown, never unsupported — a working card must never be demoted because a subprocess misbehaved.ModelProcessPool::effective_gpu_layersreturns 0 for an unsupported card, so workers spawn on the CPU. This is the same choke point that handles thegpu_layersconfig and OOM CPU-pinning.worker_ipc::permanent_gpu_failureclassifies an architecture-mismatch error as permanently GPU-fatal (distinct from OOM, which gets different user-facing copy), so a card that slips past the probe falls back on first failure rather than crash-looping. The two causes use different ActivityEvent kinds —model_cpu_fallbackandmodel_cpu_fallback_gpu_too_old— because the frontend translates by kind, and one message must not be shown for the other.
vendor/candle-flash-attn carries two patches: static cudart_static (upstream
links it dynamically, which would put a hard libcudart dependency on a binary
that today needs only the display driver), and the 18 bf16 kernels removed
(run_attention casts to f16 before every call).
inference::layers::run_attention picks per device AND per shape. The rule is
measured:
prefill (q_len > 1) |
decode (q_len == 1) |
|
|---|---|---|
| CPU | standard | standard (GQA takes the grouped no-copy path inside it) |
| CUDA | flash | flash if GQA, standard if MHA |
CPU decode is standard for every shape (since c4cc3b16, 2026-08-16).
standard_attention used to materialize the repeat_kv expansion every token —
free when n_head == n_kv_head, growing with context otherwise — and that cost
is precisely why GQA decode was routed to the fused kernel. It no longer pays
it: for q_len == 1 it regroups the query heads as extra matmul rows against
the unexpanded cache (grouped_gqa_decode_attention — identical arithmetic,
zero copies, pinned byte-equivalent by test). Measured per attention call the
grouped path beats both the old expanded path (3-9x) and the fused kernel
(2-20x, kv 1024-8192); end to end it is 1.41x decode on llama-3.2-3b
(4.71 → 6.63 tok/s), validated by a 4-hour soak.
CUDA keeps flash for GQA decode. Its measurement predates the grouped path
and rested on the same repeat_kv premise, so the routing is a re-measure
candidate (docs/FUTURE_WORK.md) — but GPUs already route GQA decode to a fused
kernel, and this box cannot resolve small GPU deltas (gotcha #267). The MHA side
is not in question: flash unconditionally would still cost up to 25x per
attention call on MHA decode — candle-flash-attn has no split-KV kernel, so a
single query row cannot fill the card.
There is no context-length crossover, and re-introducing one needs a
forward-pass measurement. A k_len >= 1024 threshold shipped on 2026-08-07,
taken from timing the attention call in isolation; measured end to end the next
day, flash won at every length (1.13x at kv~272, 1.42x at ~528, 1.61x at ~912).
Third occurrence of the same error — see gotcha #266. The rule lives in
cuda_decode_prefers_standard, pinned by unit tests needing no GPU. Full tables
in docs/FUTURE_WORK.md; SWARMLLM_FORCE_STANDARD_ATTN=1 (docs/DIAGNOSTICS.md)
is the A/B switch.
Stop sequence handling: User-provided stop sequences (stop in OpenAI, stop_sequences in Anthropic) are enforced in all three inference execution paths:
pipeline/distributed.rsexecute_distributed— accumulated text scanned after each token decodemodel_worker.rshandle_generate— accumulated text checked after each token in the subprocess decode loopexecutor.rsgenerate_stream_llama— accumulated text checked after each token in the llama.cpp loop
Empty stop sequences are rejected at the API validation layer (must be 1–256 chars).
EOS token handling: The distributed pipeline checks for EOS tokens in result.token_ids explicitly (not just via result.finish_reason), preventing runaway generation if the worker subprocess returns EOS as a token ID without setting the finish reason.
Top-k sampling: Uses select_nth_unstable_by(k - 1, desc_cmp) to partition the k largest logits into [..k]. The k-1 pivot ensures exactly k elements are retained (not k-1).
RoPE position tracking: the prompt's length in tokens is a POSITION, not a
statistic — index_pos for the first generated token is set from it, so it must
equal the number of KV positions the prefill actually wrote.
inference::pipeline::prompt::prompt_positions is the single answer, and it
counts with the model's own tokenizer (standalone_tokenizer, lazily built from
gguf_header.bin).
max(chars / 4, 1) survives ONLY where a node genuinely has no tokenizer for the
model, and warns when it does. It used to be the answer in every case, including
when a tokenizer was available three lines below: on a 24 KB tool-calling prompt
it came out 6053 against a true 5529, so the first generated token was computed
524 positions past the end of the cache and the model answered with end-of-turn
or with filler repeated to max_tokens (gotcha #400, fixed v0.3.129). The
minimum of 1 remains, because position 0 is where a prompt starts rather than
where it ends.
- Per-request KV-cache isolation via
DashMap<String, KvCacheEntry>(key:"model_key\0request_id") - Each concurrent request gets its own cache — no corruption under concurrency
- Multi-turn reuse: session_id tracks conversations, prefix matching skips redundant prefill
- KV-cache is cleared when
sequence_num == 0(start of new request) index_postravels through the wire protocol so all nodes apply correct RoPE positioning- Position tracking:
index_pos = prompt_token_countafter prefill, increments by 1 per decode step - KvCacheManager tracks sessions and wired to inference router for cache reuse
- Causal masks cached with LRU eviction (max 16 entries) to prevent GPU memory leak
- Abandoned cache entries cleaned up after 10 minutes
- Sessions persisted across node restarts via redb, stamped with the build
that wrote them and discarded on a mismatch.
cached_tokensis a token COUNT used directly as the position to resume from, and nothing can check it against the saved prompt text without re-running the tokenizer that produced it — so a release that changes tokenization or prompt construction makes every persisted count wrong, and an auto-update restart inside the session TTL is exactly when they get read back. Re-reading a prompt costs a moment; resuming at the wrong position corrupts the answer.
Long prompts are split into chunks for overlapped prefill and decode:
- Prevents head-of-line blocking from long-context requests
- Decode steps for other requests can interleave between prefill chunks
- Chunk size auto-tuned based on available VRAM
Each worker stores a local prefix-cache keyed by BLAKE3 chained hashes over
fixed-size token blocks (prefix_cache_block_tokens, default 64). Blocks are
announced to peers via SwarmMessage::PrefixCacheAnnounce on the
swarm/models gossipsub topic, indexed by each recipient in
state.models.cross_node_prefix_index. When a local worker sees a prompt
whose prefix it hasn't prefilled, it emits WorkerMsg::PrefixFetchProbe; the
daemon walks the index (longest-match first), trust-gates candidate peers by
cross_node_prefix_trust_min (default 0.5), and issues a SendPrefixKvFetch
request-response to the best holder. The serving daemon re-issues
DaemonMsg::ExportPrefixSnapshot to its worker, which narrows a stored
KvSnapshot to the requested block boundary and returns the serialized bytes
in the IPC binary-payload slot. Back on the requesting side, the bytes are
BLAKE3-reverified against the requested hash and NaN/Inf-scanned before
hydrating a new KvCacheEntry for the in-flight request, which then only has
to prefill the suffix beyond the cached block boundary.
The fetch path uses three chained timeouts so a stuck peer or worker always
degrades to a clean miss rather than blocking the request: worker-side probe
(PREFIX_FETCH_TIMEOUT_MS, 3000 ms outer bound), daemon-side network
dispatch (2500 ms), and serving-side worker IPC
(fetch_local_snapshot, 2000 ms). Sized for 7B-class snapshots
(~70–150 MB f32) — a clean miss is no worse than not having the feature.
See docs/plans/benchmarks/round6.md for the two-daemon loopback bench
recipe and measured TTFT numbers: TinyLlama on GPU is too small to win on
localhost (28 MB fetch ≈ 460 ms prefill), but Qwen-7B on CPU crosses over
decisively at 12.9× iter-1 TTFT speedup (151.7 s full local prefill
→ 11.8 s with fetch).
Three paths, and they differ in where the draft comes from:
Draft-model (opt-in). A small model proposes K tokens per step (default 4);
the target verifies all K in one forward; KV is trimmed and reseeded on
rejection. Config: speculative_decoding, speculative_gamma,
draft_model_path. Greedy only by construction — a draft model has a real
distribution, so doing this properly needs min(1, p/q) and a residual built
from both sides. Falls back to standard decoding with no draft model.
Draft-free n-gram, LOCAL (v0.3.116, on by default). A whole model on one
machine drafts from an n-gram match against the prompt and its own output, and
verifies the batch in one forward — no second model, nothing downloaded. Lives
in model_worker::ngram_spec_round, i.e. in the worker's decode loop, which is
the choke point every local surface funnels through (streaming and not, OpenAI
and Anthropic). ~2x CPU / ~3x GPU on replies that copy from context.
Draft-free n-gram, DISTRIBUTED. pipeline::ngram_only_spec for pipelines
with a remote segment, verifying through forward_verify_through_segments.
Three invariants the two draft-free paths share, all learned the hard way:
- Any temperature. With a point-mass draft (
q = δ_x) the rejection rule is "accept w.p.p(x), else draw frompminusxrenormalised", and "drawt ~ p, keep the draft ifft == x" has exactly those branches. Both paths were once gated ontemperature == 0, which made them inert — the OpenAI default is 0.7 and Anthropic's 1.0. - NOT bit-identical (gotcha #370). A verify forward reassociates, so a near-tie can land the other way. Each run is deterministic; that is how you tell reassociation from a race.
- A miss is not free (#371), and diverting a request out of the batched path
is not free either (#373).
SpecBackoffhandles the first,spec_payoff_justifies_divertingthe second.
Each loaded model runs in its own swarmllm model-worker subprocess. This guarantees that unloading a model immediately reclaims all GPU memory — the OS and CUDA driver free all allocations when the process exits, bypassing the CUDA allocator cache that prevents memory release within a single process.
Main daemon (control + P2P + API) Worker subprocess per model
──────────────────────────────── ──────────────────────────
InferenceRouter model-worker --socket /tmp/...
↓ connects to daemon socket
ModelProcessPool.generate() ──socket──▶ loads shards from disk
ModelProcessPool.forward() ──socket──▶ runs forward passes / decode loop
◀──socket── streams tokens / LayerResult back
exits on unload → VRAM freed
Communication: Unix domain socket with binary framing ([4B json_len][json][4B payload_len][raw bytes]). JSON carries message metadata; the payload carries raw activation tensor bytes to avoid base64 overhead.
Message types (src/inference/worker_ipc.rs):
DaemonMsg::Forward(IpcForward)— single-step LayerForward for distributed inferenceDaemonMsg::Generate(IpcGenerate)— full prompt→tokens decode loop for API inferenceDaemonMsg::Unload— drop a layer range within the worker (partial memory reclaim)DaemonMsg::Shutdown— graceful exitWorkerMsg::Token— streaming token during GenerateWorkerMsg::LayerResult— activation result for distributed pipeline forwarding
ModelProcessPool (src/inference/process_pool.rs):
DashMap<ModelId, Arc<WorkerHandle>>— one worker per active modelget_or_spawn()— lazily spawns a worker on the first request for a modelforward()— routes aLayerForwardto the subprocess, awaitsLayerResultgenerate()— sends a full generate request, streamsWorkerMsg::Tokenbackunload_model()— kills the subprocess → OS/CUDA reclaims all GPU memory- Crash recovery: if a worker subprocess crashes (OOM, CUDA fault, panic), the IO error from
send_daemon/recv_workerevicts the deadWorkerHandlefrom the pool. The next inference request for that model automatically respawns a fresh worker viaget_or_spawn(). - Socket cleanup: a RAII guard (
SocketCleanup) ensures the Unix socket file in/tmp/is removed ifspawn_workerfails at any step after binding. On success, the guard is defused viamem::forgetandWorkerHandle::drophandles cleanup.
SplitModelEntry is now metadata-only (no Arc<Mutex<SplitModel>> in the main process):
- Caches
eos_tokens,vocab,chat_template,bos_token,eos_token_strfrom the GGUF header estimated_vram_mbfrom shard file sizes on disk- The actual model weights live exclusively in the worker subprocess
Granularity: one process per ModelId (not per shard). A single worker handles all layer ranges for one model, owns its own KvCacheStore, and processes requests sequentially — matching the prior Mutex<SplitModel> serialization. Individual shard load/unload is handled within the worker via DaemonMsg::Unload; the process only exits when all shards are released or Shutdown is received.
Dashboard responsiveness: since inference never runs on the main Tokio runtime, API and WebSocket handlers always get a fast response even under heavy inference load.
ModelProcessPool admits, charges and reclaims graphics memory. Nothing else
may take it from a loaded model.
- Admission:
admit_to_gpuweighsestimate_worker_vram_mb(weights + KV atADMISSION_KV_CONTEXT) againstvram_budget_mb, chargingvram_reserved_mb. - On-demand reclaim:
free_vram_for_admission→plan_vram_reclaim— plans the whole eviction first and abandons it if it still would not fit, never takes a busy worker, and never one used within the swap floor. - Timed reclaim:
try_idle_vram_unload, outside theauto_manage.enabledgate. - A refusal places the model on the processor for that spawn and takes no
standing pin;
cpu_pinned_modelsmeans only that the card FAILED for a model (classify_worker_error). - A processor-resident model returns to the card on its next request once there
is room, including room the pool is willing to make
(
should_return_to_gpu+reclaimable_vram_mb).
SharedState.split_models is a metadata cache, not a memory manager.
SplitModelEntry caches eos_tokens, vocab, chat_template, bos_token,
eos_token_str and estimated_vram_mb read from the GGUF header; the weights
live in the worker. It is bounded by ENTRY COUNT
(trim_split_model_cache, MAX_SPLIT_MODEL_ENTRIES), LRU, protecting models
with an active pipeline — and cannot unload a worker. Trimming an entry that is
still wanted costs a header re-read, not a killed worker.
A separate registration budget decides whether to advertise another segment
as locally servable (split_model_budget_with + split_models_committed_mb +
MemoryScope: the graphics budget, or inference.max_split_model_memory_mb on
a node with no card). It may refuse; it may never take.
Until v0.3.130 this cache carried its own VRAM budget that evicted entries and
killed their workers — a second accountant with a smaller estimate, a weaker
in-flight oracle and no idle floor. See gotcha #402 and
.claude/rules/architecture.md.
How long a model keeps the card (VRAM_MAKE_ROOM_MIN_IDLE_SECS_DEFAULT, 5 s
since v0.3.131) protects a model in active use, not against thrash: at the
previous 60 s, two models alternating in conversation took 299 s against 82 s
with no floor, because the model left on the processor was slower at every turn
than the reload it was spared. examples/swap_patience.sh is the harness;
SWARMLLM_VRAM_SWAP_MIN_IDLE_SECS pins the value for A/B.
LoRA (Low-Rank Adaptation) adapters are supported via src/model/lora.rs:
- Per-request adapter loading from safetensors files
- Low-rank weight updates applied at inference time without modifying base model weights
- Multiple adapters can be loaded simultaneously and selected per request
- Adapter files stored alongside model shards in the model directory
- Verified with Qwen2.5-Coder-7B + rank-16 LoRA adapter; output distribution changes confirmed
DORMANT as of 2026-08-17 — credits gate nothing. Everything below still runs and is still recorded, but
MIN_BALANCE_FOR_INFERENCE = 0andcalculate_tierreturns a constant, so no balance affects who gets served, how fast, or what the dashboard shows; the leaderboard neither ranks by credits nor publishes them. The reason is that credit has never moved between nodes as payment for work — each node mints its own figure, so the books do not reconcile and acting on them meant rationing the product by a number nobody can stand behind. Readdocs/CREDITS_DESIGN.mdfor the full account, the bilateral-settlement design that would fix it, and the exit criteria that must hold before any of this is switched back on.
Earning (default rates, configurable per pool):
+10 credits per token served (balanced with consume side)
+1 credit per GB per hour hosting shards
+5 credits per GB seeding shard data
+2 credits per connection hour relay service
Spending (default rates, configurable per pool):
-10 credits per token consumed (balanced with earn side)
-50 credits per distributed inference failure (automatic penalty)
Minimum balance enforcement:
Nodes below -1000 credits have remote requests rejected.
Local API requests (localhost) are always allowed.
Earn credits by: hosting shards, serving inference, seeding data.
Tiers (enforced per-request in InferenceRouter):
Platinum (≥90th percentile, balance>0) → 2× concurrent slots
Gold (≥70th percentile, balance>0) → base concurrent slots
Silver (positive balance) → ½ concurrent slots
Bronze (zero/negative) → ¼ concurrent slots (min 1)
Balanced rates: Both earn and spend use rate × tokens (no layer multiplier). This prevents
credit inflation — a 22-layer model serving 100 tokens earns exactly as much as it costs to
consume. Previously the earn side multiplied by layers, causing 22× inflation per request.
Tier enforcement flow: On each handle_submit(), the router computes the network percentile
from peer_credit_balances (populated via credit gossip, deduplicated by NodeId to prevent
Sybil percentile stuffing), calls calculate_tier(), and sets the request priority. Balance must
be positive for Gold/Platinum tiers. In drain_queue(), max_concurrent_for_tier() limits
concurrent execution slots per tier.
Queue draining: drain_queue only fires on Submit/StreamSubmit commands or queue_notify.
Every path that calls active_count.fetch_sub(1) on completion MUST also call
queue_notify.notify_one() — otherwise queued requests beyond the per-tier cap sit indefinitely
until a new Submit arrives. Four call sites enforce this: ActivePipelineGuard::drop (panic
path), normal-completion in dispatch_single, execute_distributed_batch (spawn body + join-loop
panic arm), and BatchCleanup (complete_one + Drop) in local_exec.
Transient-failure retry: dispatch_single wraps execute_request with a single retry on
is_transient_remote_failure errors (silent rr drop, OutboundFailure, remote-generate timeout).
Retry passes preferred_pipeline = None so the scheduler re-runs and the dead/dropped peer is
filtered out via connected_node_ids. Bounded to one retry per request — failure of the second
attempt propagates to the user with a "try again" hint.
Minimum balance enforcement: Remote peers with balance below MIN_BALANCE_FOR_INFERENCE
(-1000) have their inference requests rejected with a descriptive error message telling them to
contribute. Local API requests (requester == NodeId([0;32])) bypass this check.
Atomic credit accumulation: Forward participation credits (earned during distributed inference
hot path) are accumulated in an AtomicI64 (pending_credit_earn) to avoid lock contention.
The CreditLedger periodic persist (every 60s) flushes the accumulator to the balance + DB.
No credits are lost under high concurrency.
Anti-Sybil balance gossip: Peer balance reports are deduplicated by NodeId via a DashMap. Each peer gets exactly one entry in the percentile calculation, preventing a single peer from dominating the distribution by re-gossiping frequently.
Relay credits: NetworkManager tracks active relay circuits via active_relay_circuits DashMap.
On CircuitReqAccepted, records start time. On CircuitClosed, computes duration and adds to
relay_seconds_served atomic counter. CreditLedger drains this counter periodically.
Failure penalties: When distributed inference fails, the router applies penalty_serve_failure
credits (default -50) and broadcasts InferenceError to all pipeline participants.
Credit earn/spend rates are configurable per pool via the pool configuration API.
Pool credit forwarding: When a pool member earns credits, earn_inference attempts to forward them to the pool owner before crediting locally. If forwarding succeeds, the member retains nothing (return 0). If forwarding fails or the node is not in a pool, credits are applied locally. This prevents credit inflation when pool members accumulate credits that should belong to the owner.
Serving credit earn: recorded in ONE place, SharedState::record_peer_serve, reached only from the two inbound paths that do work for a peer — dispatch::layer_forward (one pipeline segment) and dispatch::remote_generate (the whole decode, the single-segment fast path). Both count the work and bill for it together; the token count is clamped to MAX_CREDITABLE_TOKENS because the requester chooses it on the wire. Credits accumulate in pending_credit_earn and are flushed by the ledger with the inference_serve_earning tag.
A node does not earn for work it does for itself. The router's own completion hook and its local segment inside a pipeline it coordinates are excluded — counting them credited a user for their own chat and told them they had served the swarm. Before v0.3.88 the accounting lived in track_forward_participation, which only the multi-segment path called, so a node serving through the fast path (how a machine holding a whole model answers a peer — the common case) recorded nothing and was paid nothing while the requester was still debited.
Note that release_escrow reconciles the requester's balance only; it records to_node but transfers nothing, and credit::transaction::create_transaction has no production callers. The accumulator above is currently the only way a serving node is paid.
Users with multiple machines can link them into a device pool. All credits earned by member devices are forwarded to the owner (main) device, giving a combined credit balance.
Main Device (owner) Linked Device (member)
┌──────────────────┐ ┌──────────────────┐
│ Combined balance │◀──────────────│ Earns credits │
│ Pool management │ CreditForward│ Forwards to owner│
│ Invite codes │ (dual-signed)│ Keeps split % │
└──────────────────┘ └──────────────────┘
Setup flow (R140 — bootstrap-before-decentralization):
- Main device:
swarmllm pool create --name "My Devices" - Main device:
swarmllm pool invite-code→ generates aswarmpool://...blob that bundles the 8-char join token with the device's reachable listen multiaddrs (LAN + Tailscale CGN + public — everything except loopback / link-local). - Linked device:
swarmllm pool join "swarmpool://...". The joiner decodes the blob, dials each multiaddr (Tailscale, LAN, public IP — whatever reaches the owner), then broadcasts the existingPoolMessage::JoinRequest { code_hash, ... }over GossipSub. - Owner's code_hash matches → invitation auto-created → member auto-accepted.
The legacy 8-char form (A3F7K2M9) is still accepted by pool/join for
nodes already on a shared swarm (LAN mDNS, DHT-bootstrapped). v2 codes are
strictly an additive wrapper; the JoinRequest wire protocol is unchanged.
Invite code security:
- 8-char uppercase alphanumeric inner token (32^8 ≈ 1.1 trillion combos, no 0/O/1/I) — single source of truth for join authorization.
- One-time use, consumed immediately on claim.
- 24h expiry (configurable
invitation_ttl_hours); v2 blob carries the same expiry so decoders can fail fast before dialing. - Max 5 active codes at once per owner.
- Code hash (BLAKE3) on the wire — plaintext code never transmitted.
- v2 blob: ChaCha20-Poly1305 encrypted with embedded key — protects against casual IP harvesting from a code pasted into chat, not a cryptographic boundary (the code IS the auth token).
- Join requests signed with Ed25519.
- API input cap: 4096 chars (typical v2 blob is ~300-500 chars).
Pool features:
- Device nicknames (owner sets per device for easy identification)
- Online/offline status with last-seen timestamps
- Per-device stats (VRAM, shards hosted, forwards served, uptime)
- Combined VRAM display across all pool devices
- Credit split configuration (0-50% kept by member, rest to owner)
- Max 10 devices per pool (configurable), 10 operations/hour rate limit
Credit forwarding: When a pool member earns credits, forward_credits_to_owner() in
pool/forward.rs deducts the forward amount (respecting member_credit_split_pct) and sends a
PoolCreditForward message. The owner's PoolManager co-signs it and applies apply_credit_direct()
to the owner's balance. Both signatures (member + owner) are required — preventing forgery.
Terminology: "My Devices" / "Linked Devices" in the UI. Clearly distinguished from "Swarm Peers" (other users' nodes on the P2P network). The dashboard, setup wizard, and share popover all use distinct language to prevent confusion.
Models enter the system through a verified pipeline — arbitrary files on disk are never absorbed into the network.
Network Registry (GossipSub/DHT)
│
▼
┌─────────────────┐ BLAKE3 hash
│ Manifest Check │────────────────▶ Reject if tampered
└────────┬────────┘
│ verified manifest
▼
┌─────────────────┐ Rarest-first
│ Shard Selection │────────────────▶ BitTorrent-style
└────────┬────────┘
│ request from peers (3 retries, exponential backoff)
▼
┌─────────────────┐ Atomic write to .tmp
│ Download Loop │────────────────▶ Rename to .bin on completion
└────────┬────────┘
│ complete shard
▼
┌─────────────────┐ BLAKE3 vs manifest (strict: no zero-hash bypass)
│ Shard Verify │────────────────▶ Quarantine + penalize on mismatch
└────────┬────────┘
│ all shards verified
▼
Model Ready
Key invariants:
- Manifests MUST come from the network registry, not from disk
- Manifest integrity is verified (BLAKE3 self-hash) before trusting shard hashes
- DB-restored manifests are also hash-verified on startup
- Each downloaded shard is verified against the manifest hash (zero-hash bypass only for local HF downloads)
- Failed shards are renamed
.bin.quarantineand the serving peer's trust is penalized - Downloads are retried (3 attempts, 5s/30s/120s backoff) with alternate peer selection
- Atomic writes: shards written to
.tmpthen renamed, preventing corrupt partial files - P2P shard wire format: Shard chunks use
WIRE_TAG_SHARD(0x03) binary framing — raw bytes sent directly without JSON serialization. This is essential: the 4MB JSON body limit would silently fail all P2P shard transfers (shards are typically 256MB–1GB) - On startup,
load_all_local()rejects model directories without a valid manifest - On startup, every existing shard is re-verified against its manifest hash
- Stale
.tmpfiles cleaned up on startup
AcquisitionManager (src/model/acquisition.rs) orchestrates this flow as a
long-running Tokio task, receiving commands via mpsc from the API server.
~/.local/share/swarmllm/
├── config.toml # User configuration
├── identity.key # Ed25519 keypair (optionally encrypted)
├── db.redb # redb database (migrated from sled db/ directory)
└── models/
├── llama3-70b-q4km/
│ ├── manifest.json
│ ├── tokenizer.json
│ ├── shard_000.bin
│ └── ...
└── mistral-7b-q5km/
└── ...
Storage backend is redb (pure-Rust, ACID, single-file).
| Table | Key | Value |
|---|---|---|
| config | "config" | Config |
| config | "api_key" | String (32-byte hex Bearer token) |
| identity | "keypair" | Encrypted Ed25519 key |
| credits | "balance" | CreditBalance |
| credit_txns | {uuid} | CreditTransaction |
| peer_trust | {node_id_hex} | TrustScore |
| peer_cache | {multiaddr_string} | () (presence key) |
| shard_meta | {model_id}/{shard_index} | ShardInfo + path |
| model_meta | {model_id} | ModelManifest |
| sessions | {session_id} | KV-cache metadata (persisted across restarts) |
| nicknames | {node_id_hex} | NicknameRecord |
| identity_prefs | "nickname" | Local nickname preference |
| pool_state | "pool" | PoolState |
| pool_forwards | {uuid} | PoolCreditForward |
| trust_scores | {node_id_hex} | f64 trust score |
| escrow | {escrow_id} | EscrowEntry |
| hf_sources | {model_id} | HfSource metadata |
| locked_shards | {shard_id_json} | bool (presence = locked) |
| removed_shards | {shard_id_json} | bool (presence = the user deleted this shard; auto-manage will not re-acquire it until it is explicitly requested again) |
| resource_schedule | "current" | ResourceSchedule JSON |
The AutoShardManager (src/model/auto_manage/) is a background subsystem that
automatically acquires missing shards based on a VRAM-aware scoring algorithm.
The module is split into: manager.rs (struct + run loop + housekeeping),
scoring.rs (candidate ranking), download.rs (download orchestration),
prune.rs (shard pruning logic), scan.rs (local shard scanning + model loading),
vram.rs (VRAM budget utilities).
score = model_popularity × rarity_bonus × configured_bonus × vram_fitness
| Factor | Value | Description |
|---|---|---|
model_popularity |
1.0+ | Number of peers hosting any shard of the model |
rarity_bonus |
1.0–10.0 | Fewer existing holders → higher priority |
configured_bonus |
1.0 or 100.0 | 100x for shards within --shards range |
vram_fitness |
0.1–1.0 | Model VRAM needs vs. global pool VRAM capacity |
- Configured range focus: When any shards in the
--shardsrange are missing,candidates.retain()filters to ONLY those shards (ignores others) - Disk verification: Registration checks that shard files actually exist on disk
(both at startup and in
generate_and_register_local_manifest) - VRAM estimation:
model_size × 1.15(quantized weights + ~15% KV-cache overhead) - nvidia-smi fallback: If
gpu_infois None, falls back tonvidia-smifor local VRAM - Budget limits: max_storage_mb, max_shards_per_cycle (2), skips in-progress acquisitions
- mmproj support: Vision encoder (mmproj.gguf) treated as download candidate with 5x priority bonus; full-file HF download (not byte-range); higher pruning floor (min 3 replicas), only pruned under extreme pressure (>0.95)
- Download priority: HuggingFace CDN first (fast, doesn't burden peers). If no HF source available but peers hold the shard, falls back to P2P
ShardRequestto a random holder. P2P is single-source per shard (future: multi-source parallel download) - Upload bandwidth cap:
max_bandwidth_mbpsconfig enforced on shard serving via proportional delay after chunk reads. Tensor forwards exempt (latency-critical). Default 0 = unlimited - Config:
[auto_manage]section —enabled,max_storage_mb,interval_minutes,max_shards,prune_enabled,min_replicas,prune_cooldown_secs,max_holder_load_for_prune,auto_switch_quants(R134.6, default true since R141),hf_watcher_enabled,wishlist_gossip_publish - R141 — P2P stall timeout = 180s (was 600s). A silently-dropped libp2p send now fails over to the HF fallback path in ~3 minutes instead of 10. The original ceiling was sized for worst-case slow peers + pessimistic retries; the new value still covers an honest 32 MiB chunk over a slow link (~150 KiB/s sustained). Constant:
P2P_PERMIT_STALL_SECSinmodel/auto_manage/manager.rs.
When auto-manage is enabled and prune_enabled = true, the AutoShardManager also removes
over-replicated shards to free VRAM and disk on smaller nodes.
Dynamic Target Replicas — popularity-scaled based on per-model request counts (rolling 10-min window):
- 0 requests → base target (min_replicas, default 2)
- 1-10 requests → 1.5x base
- 11-50 requests → 2.0x base
- 51+ requests → 3.0x base
Prune Scoring (highest score pruned first):
+ redundancy_ratio (holder_count / effective_target)
+ 1.0 if not loaded in VRAM (cold shard)
+ 0.5 × resource_pressure
+ 1.0 if contribution_auto && holder_count ≥ 2 × target (R121, severe saturation)
- 0.5 if first/last shard (pipeline completeness)
- 0.3 if rarest shard for the model
- 0.2 if recently acquired (< 30 min)
R121 — contribution_auto scale-back. When config.node.contribution_auto
is true (the default), a shard with holder_count ≥ 1.5 × target bypasses
the RELAXED-state +1 nudge in pressure_adjusted_target and is eligible
to prune even at zero local pressure. This lets an idle node shed slack
once the swarm has plenty of copies, instead of waiting for VRAM/disk
pressure to build. Manual mode (contribution_auto = false) keeps the
pre-R121 behaviour — pressure-driven only. The toggle is hot-reloadable: like every
user-settable value it is read from the live config via
SharedState::cfg(), which PUT /api/admin/config replaces. Pure helper:
effective_prune_target(target, pressure, holder_count, contribution_auto, min_replicas) in model/auto_manage/prune.rs.
Safety Checks — pruning is blocked if:
- Shard is actively being downloaded by this node (prevents download/prune race)
- Shard is locked/pinned by user
- Shard is in configured
--shardsrange holder_count <= adjusted_target_replicas- Would eliminate last holder in this node's region
- Average remaining holder load >
max_holder_load_for_prune - Model actively loaded and used in last 5 minutes
- No re-acquisition path available (no HF source or reachable peers)
- Cooldown not expired (5 min per model)
Resource Pressure — max(disk_pressure, vram_pressure):
- VRAM pressure uses live
nvidia-smiquery (every 5 min tick) for actual GPU memory usage, with fallback to internal loaded-model tracking when nvidia-smi is unavailable - < 0.5: relaxed (+1 to target, keep extras)
- 0.5–0.8: normal
- 0.8–0.95: eager (-1 from target)
- > 0.95: urgent (-2, prune up to 2 shards/model/cycle)
Resource Schedule — configurable via API and UI, adds pressure bonus during reduced hours:
- "aggressive" → +0.3 pressure during reduced hours
- "normal" → +0.15 pressure
- "conservative" → no extra pressure
Per-Model Control — PUT /api/admin/models/:id/auto-manage with prune_enabled: false disables pruning per-model while keeping downloads active.
Per-Shard Lock — PUT /api/admin/models/:id/shards/:index/lock pins individual shards, preventing auto-pruning regardless of model-level settings.
Notifications — prune events flow through the unified activity_event WebSocket message (kind: shard_pruned, with toast_level: "info" and structured prune data fields). Prune history accessible via GET /api/admin/prune-history.
Prevents trash models from polluting the network when auto-manage is enabled.
Trust Levels (progressive, ordered):
Discovered → Pinned → DemandVerified → NetworkPopular
- Discovered: Seen via gossip, no local data. Auto-manage ignores these.
- Pinned: User explicitly downloaded/approved. Auto-manage propagates.
- DemandVerified: Model has received ≥3 real inference requests. Auto-manage propagates.
- NetworkPopular: ≥3 unique holder nodes across the network. Highest priority.
Auto-manage gate: gather_candidates() skips models below DemandVerified unless pinned_by_user = true or this node already hosts at least one shard. This means a node will never auto-download shards for a model nobody has actually used.
Trust transitions:
- Gossip-discovered models start as
Discovered(auto-created on first manifest registration) - User downloads via HF browser →
Pinned(persisted immediately to redb) - 3rd inference request →
DemandVerified(persisted on promotion) - 3+ unique holder nodes →
NetworkPopular(checked periodically by AutoShardManager) - HfWatcher trending feed promotes
Discovered→DemandVerifiedwhen the matching HF repo crosses the per-publisher download floor + 24h age gate (anti-gaming). R141 tiered the floor:- Trusted curators (
TRUSTED_HF_PUBLISHERSallowlist inhuggingface/watcher.rs— meta-llama, mistralai, Qwen, google, microsoft, deepseek-ai, HuggingFaceH4, stabilityai, tiiuae, 01-ai, NousResearch, allenai, ibm-granite, CohereForAI, bartowski, TheBloke, unsloth, lmstudio-community, MaziyarPanahi, QuantFactory, second-state) promote at 10k downloads (MIN_DOWNLOADS_FOR_TRUST_TRUSTED). - Unknown publishers keep the original 100k downloads floor (
MIN_DOWNLOADS_FOR_TRUST). - The 24h age gate is unchanged for both tiers — a fresh repo can still be a download-pump even from a trusted account if it's compromised.
- Helper
is_trusted_publisheris the canonical check, re-exported viacrate::model::huggingface. Used by both the watcher's promotion path AND the wishlist scorer for thewishlist.why.trusted_publisherwhy-tag + score bonus.
- Trusted curators (
- 7 days without requests → decay (
NetworkPopular→DemandVerified,DemandVerified→Discovered) - Auto-promoted models that decay back to
Discoveredwith zero real swarm requests bumpfailed_promotions(anti-gaming cooldown for re-promotion;FAILED_PROMOTION_COOLDOWN_BASE_DAYS = 7, cap 4 strikes — beyond that only a user pin lifts the level) - Pinned models never decay
Persistence: model_trust tree in redb, keyed by model_id, values are JSON ModelTrustInfo.
API: Trust level exposed as trust_level field in GET /api/admin/models response.
Scaling: Trust decisions are local per-node (no consensus needed). Each node independently decides what to download based on its own observed demand. This scales to thousands of nodes without coordination overhead.
The wishlist (src/model/auto_manage/wishlist.rs) is the user-visible
face of auto-manage. Instead of the daemon downloading models in silence,
the dashboard renders a ranked list with status badges + human-readable
"why" tags so non-technical users understand why the swarm cares about
each entry.
Generation cadence: rebuilt on every auto-manage tick AND every WS
stats_update build. Cheap (single registry pass), so the dashboard
sees fresh data the moment it connects. Stored as
ArcSwap<Wishlist> on state.models.wishlist, refreshed via
crate::model::auto_manage::refresh_wishlist(state).
Status taxonomy (WishlistStatus enum):
| Status | Meaning | CTA |
|---|---|---|
hosting |
This node hosts ≥1 shard | "You're helping host this" (no action) |
serveable |
Network has every shard at least once — can route today | "Help host" (one-click contribute) |
aspirational |
Partial swarm coverage; gathering in progress | "Help unlock this" |
candidate (R141) |
HF trending model the swarm hasn't adopted yet | "Set this up" → routes to HF browse pre-filtered |
unreachable |
Larger than the whole swarm pool VRAM | Informational only |
blocked |
Trust gate / private mode / explicit user-ignore | "Awaiting verification" |
R141 — Candidate entries. compute_wishlist merges HfTrending entries
the swarm hasn't adopted (cap MAX_CANDIDATE_ENTRIES = 24) as Candidate
rows. Distinguishes from Blocked (trust-gated existing entries) and
Aspirational (real partial coverage). Candidate entries populate two
extra fields the frontend uses for routing:
hf_repo_id: Option<String>— the HF repo identifier (publisher/repo). Frontend deep-links the user into the HF browse view pre-filtered to this repo so they pick the quant variant themselves (no auto-pick, preserving the user-controlled adoption flow).task_tags: Vec<String>— capability tokens (chat,code,vision,multilingual,reasoning) sourced fromHfWatcher::infer_task_tags. Drives optional filter chips in the swarm-tab Search subtab.
Synthetic key format: model_id = "hf-candidate:" + repo_id so Candidate
rows dedup cleanly against the registry-walking loop without colliding
with real ModelIds. Both fields use #[serde(skip_serializing_if = ...)]
so the wire payload stays small for the 99% case (non-Candidate).
Score blend (0..100, advisory only — frontend renders a heat bar):
- Coverage component (0..40): fully serveable hits the cap; partial scales linearly.
- Popularity component (0..25): log-scaled unique holder count.
- Demand component (0..25): regional
region_demandgossip. - VRAM-fit component (0..10): pool VRAM / model VRAM ratio, clamped.
- HF trending boost (0..15): log10(downloads) when matching the cached HF trending feed.
- Foreign-wishlist boost (0..10, R130): cross-pool demand breadth × depth.
- First-host nudge (+10): no holders yet → strong "be the first" signal.
- R141 Candidate bonus (+10): models from
is_trusted_publisherget a flat bonus so curator releases rank above unknown publishers with similar download counts.
Why-tags (each is an i18n key under wishlist.why.*):
be_first_host, exceeds_swarm_capacity, fits_your_memory,
needs_review, no_regional_replica, other_nodes_want_this,
parts_missing|missing=N (with params), popular_on_hf,
popular_on_swarm, swarm_already_serves, you_already_host,
your_region_needs_this, plus R141 additions trusted_publisher and
candidate_one_click.
Models are loaded into VRAM only when needed, not eagerly at startup.
Trigger: When execute_request() in the inference router encounters a model that has shards on disk but no worker subprocess running for it:
ModelProcessPool.get_or_spawn()spawns aswarmllm model-workersubprocess- Worker connects to the daemon's Unix socket and sends
WorkerMsg::Ready - First
ForwardorGeneraterequest causes the worker to load shards from disk - VRAM budget is tracked via
SplitModelEntry.estimated_vram_mb; LRU eviction kills the oldest worker subprocess
Loading coordination: the process pool Mutex<WorkerSocket> serializes requests per model — if two requests arrive simultaneously for an unloaded model, the second waits for the first to complete spawning.
VRAM Budget: Configured via resources.max_gpu_vram_mb or auto-detected (80% of GPU VRAM). LRU eviction protects active pipeline models from eviction.
Startup behavior: Models are still auto-loaded at startup (in popularity order), but this is best-effort — if VRAM fills up, remaining models stay on disk and are loaded on first request.
┌─────────────────────────────────────────────────────────────┐
│ Three Encryption Tiers │
│ │
│ Tier 1: Pairwise Sessions (unicast) │
│ Ed25519 → X25519 → ECDH → ChaCha20-Poly1305 │
│ Forward secrecy: ephemeral X25519 re-keying every 10min │
│ Nonce reuse prevented by session clearing on disconnect │
│ Replay protection: RFC 6479 sliding window (128-bit │
│ bitmap) — allows reordered packets, rejects duplicates │
│ Pending ephemeral keys expire after 60s (memory safety) │
│ Static DH fallback for initial session before first reke │
│ │
│ Tier 2: Pipeline Sealing (inference prompts) │
│ Per-request ephemeral key → sealed prompt/response │
│ Wire tag: TENSOR_TAG_ENCRYPTED = 0x10 │
│ Final segment seals output tokens for requester's X25519 │
│ ⚠ Final-segment node sees tokens before sealing (must) │
│ Intermediate nodes see activations, not plaintext output │
│ │
│ Tier 3: Sealed Gossip (broadcasts) │
│ Mandatory Ed25519 signing — unsigned messages rejected │
│ Epoch-based group key + origin signature │
│ Transport-authenticated sender validation in dispatch │
│ 1hr rotation cycle │
│ │
│ Modules: src/crypto/{session, pipeline_seal, gossip_seal, │
│ key_rotation}.rs │
└─────────────────────────────────────────────────────────────┘
The LayerForward AAD bytes — request_id(16) | sequence_num(4 LE) | index_pos(4 LE) | fmt(1) | layer_start(4 LE) | layer_end(4 LE) | model_id_len(2 LE) | model_id — are produced by
network::protocol::build_layer_forward_aad. Both encrypt-side
(network::manager::tensors::handle_send_tensor) and decrypt-side
(network::protocol::decode_layer_forward_encrypted) call it. Any
field added to LayerForward that needs authentication MUST extend
this helper, not be re-appended on the encrypt side. Drift between
the two sides silently breaks every encrypted forward (AAD mismatch
fails AEAD verify; only a seal/open mismatch warn surfaces).
What each node sees in a distributed pipeline (Requester → A → B → C):
┌──────────────────────────────────────────────────────────────────┐
│ Data exposure by pipeline position │
│ │
│ Requester Node A Node B Node C │
│ (author) (first) (middle) (last) │
│ ──────────────────────────────────────────────────────────── │
│ Plaintext prompt ✓ * ✗ ✗ │
│ Raw token IDs ✓ * ✗ ✗ │
│ Activations in — ✓ ✓ ✓ │
│ Activations out — ✓ ✓ — │
│ Generated tokens ✓(dec) ✗ ✗ ✓(samples) │
│ Final response ✓(dec) ✗ ✗ ✓(seals) │
│ │
│ * Without local_embedding_privacy: Node A sees raw tokens │
│ With local_embedding_privacy: Node A sees FP32 activations │
│ │
│ ⚠ The final-segment node ALWAYS sees generated output. │
│ This is inherent — sampling happens on the last node. │
│ Pipeline sealing encrypts tokens on the wire, but the │
│ node that samples them must see them. │
│ │
│ ⚠ Activation inversion: early-layer activations (especially │
│ layer 0) can theoretically be reversed to recover tokens. │
│ local_embedding_privacy eliminates the trivial case. │
│ Deep-layer inversion is an open research problem. │
└──────────────────────────────────────────────────────────────────┘
Optional privacy enhancement (local_embedding_privacy: true in [inference] config):
Without privacy: Prompt text → [tokenize on first segment] → raw token IDs visible
With privacy: Prompt text → [tokenize + embed locally] → FP32 activations sent
Remote nodes see activation tensors, not token IDs
LocalEmbedderloadstoken_embd.weightfromshard_000.binat startup (~64MB for 7B Q4)- Embedding lookup is a simple matmul (~1ms), negligible overhead
- Wire protocol:
LayerForward.pre_embedded: bool(#[serde(default)]for backward compat) SplitModel::forward_pre_embedded()skips embedding lookup whenpre_embedded = true- Supports Gemma embedding scaling (
sqrt(hidden_dim)) - Trade-off: larger wire payloads (e.g., 512 tokens × 4096 dim × 4B = 8MB vs ~2KB text)
- Modules:
src/inference/local_embedder.rs,src/daemon/state/mod.rs(local_embeddersDashMap)
Pool-only inference restriction that guarantees your prompts never leave your devices. Toggle via dashboard shield icon, pool section toggle, or PUT /api/pool/private-mode.
Three scopes:
- Private (pool only) — inference restricted to device pool members. Works over WAN or LAN.
- Private + LAN — pool members plus any mDNS-discovered LAN peer (
private_mode_allow_lan: true, default). - Offline — air-gapped operation. No bootstrap peers, no HF downloads, mDNS-only discovery.
What Private Mode restricts (your outbound requests):
- Inference pipeline assembly: scheduler filters candidates to allowed node set
- Auto-manage shard scoring: only counts holders/replicas within allowed set
- Auto-manage pruning: only considers pool-scoped replication
- Auto-manage downloads: only downloads from pool peers (HF fallback in online mode)
- VRAM pool calculation: only sums allowed peers' GPU VRAM
What Private Mode does NOT restrict (you still contribute):
- Serving inference requests from other swarm nodes
- Hosting and seeding shards to the network
- Earning credits for work done
- P2P gossip, DHT, health pings
Implementation: Single allowed_node_set() helper in src/pool/scope.rs returns Option<HashSet<NodeId>>. None = unrestricted (normal mode), Some(set) = only these nodes. All filtering flows from this one function. Runtime-toggleable via AtomicBool on SharedState.credits.private_mode.
Shard Pinning: Pool owners can pin specific models/shards to specific devices via POST /api/pool/pin. Pinned shards get 1000x scoring bonus on the target node and are never pruned. Enables manual shard distribution (e.g. GPU machine gets the big model).
Coverage Dashboard: GET /api/pool/coverage returns per-model coverage within the pool (total_shards, pool_shards, coverage_pct, missing indices, est_download_mb). Frontend shows color-coded bars and disk usage.
Config:
[pool]
private_mode = false # Restrict inference to pool only
private_mode_allow_lan = true # Include LAN peers when private
offline_mode = false # Air-gapped: no internet, mDNS onlyAPI:
GET /api/pool/private-mode— state + coverage summaryPUT /api/pool/private-mode— toggle{ "enabled": true, "offline_mode": true }GET /api/pool/coverage— per-model pool coverageGET /api/pool/pins— list shard pinsPOST /api/pool/pin— pin model to deviceDELETE /api/pool/pin— remove pin
Error: SwarmError::PrivateModeUnavailable { model_id, missing_shards } → HTTP 503 with specific missing shard list so users know exactly what's needed.
All inbound network messages are wrapped in AuthenticatedMessage with the transport-verified sender NodeId (from libp2p Noise protocol). The MessageDispatcher validates sender identity against message claims for all security-sensitive message types (ShardAnnounce, CreditTransaction, CreditGossip, NicknameGossip, HealthPing/Pong, EphemeralKeyExchange). Mismatched messages are logged and dropped.
Kademlia DHT records for capability and shard announcements are Ed25519-signed:
- Format:
[32B pubkey][64B signature][payload] - Functions:
verify_dht_value()insrc/network/discovery.rs(signing is inline in NetworkManager) - Records expire after 1 hour with automatic re-publication
Kademlia provider records (S5) track shard holders at scale:
- Key:
/swarm/provide/<model_id>/<shard_index>per shard - Functions:
start_providing_shards()/stop_providing_shards()/query_shard_providers() - Provider TTL: 1 hour, republication: 20 minutes
- PeerId→NodeId via
peer_id_to_node_id()intransport.rs(production); the reverse direction is test-only since libp2p derives PeerIds from keypairs directly
DHT shard keys are per-node to prevent last-writer-wins collisions: records are keyed
as /swarm/shards/{model_id}/{node_id_hex} (one record per node per model), not a single
shared key that any node can overwrite. Each node publishes only its own shard holdings.
- Ed25519-signed nickname records with timestamp-wins conflict resolution
- Timestamp freshness check: rejects records older than 1 hour or >5min in future
- GossipSub topic
swarm/identityfor network-wide propagation - Collision handling:
nickname#ab12suffix from node ID prefix - redb tables:
"nicknames","identity_prefs"
- Dual-signature invitation/acceptance protocol (owner signs invite, member signs acceptance)
- Pool state gossip verifies each member's acceptance signature
- Member removal requires Ed25519-signed leave notice (prevents forged ejection)
- Credit forwarding: member inference earnings →
PoolCreditForward(dual-signed) →apply_credit_directto owner's balance - Pool leaderboard aggregates member contributions
- Invitation expiry checked at API layer with clear error messages
- Config: max_pool_size=10, invitation_ttl_hours=24, rate_limit_per_hour=10
Pool join security hardening:
- Join request signature verification is transport-authenticated: the dispatch layer sets the requester
NodeIdfrom the verified Noise-authenticated sender, not from a self-reported field in the message body. Forgery of join origin is not possible. - Capacity check before invitation consumption: pool size is validated before the invite code is marked as used, preventing invitee lockout when the pool is already full.
auto_acceptbound to specificcode_hash: auto-acceptance only fires for the exact invitation that matches the code the joiner used, preventing cross-pool or stale auto-acceptance.- Removal freshness: signed removal notices are rejected if their timestamp is more than 30 seconds in the future (previously
abs()allowed ±5 min, enabling timestamp spoofing). - Invite code DoS prevention: base64-encoded invite codes are capped at 512 characters before decode. Oversized payloads are rejected before any allocation.
pending_credit_earnatomics useAcqRelordering (wasRelaxed) — ensures credit accumulator writes are visible across threads without data races.
TrustManagerinsrc/credit/trust.rstracks per-peer trust scores (0.0–1.0, default 0.5)- Trust-affecting events: InferenceSuccess (+0.01), SpotCheckFail (-0.1), InvalidGossip (-0.05), ValidTransaction (+0.02), SignatureViolation (-0.2)
- Decay toward 0.5 over time (1% per health ping cycle) — prevents permanent punishment
- Persisted in redb
trust_scorestable, hydrated on startup - Trust factors into pipeline scheduling and credit tier weighting
EscrowManagerinsrc/credit/escrow.rsholds credits for large requests (> threshold)- Lifecycle:
create_escrow()→release_escrow()(success) orrefund_escrow()(failure) - Entries expire after 10 minutes with automatic refund
- Persisted in redb
escrowtable
- Balance reports are Ed25519-signed with timestamp freshness check (5 min window)
- Only signed reports accepted; unsigned reports rejected outright
- Stale/replayed reports rejected
- Subnet clustering detection: >5 nodes per /24 → elevated spot-check rate (25% vs 5%)
- SubnetClustering trust penalty (-0.03 per cycle while clustered)
- Transaction replay protection: UUID deduplication checked against DB before accepting
- Balance arithmetic uses
saturating_add(no overflow/underflow panics) - Priority tier calculation consistent between scheduler and display
- AntiGaming wired into credit flow: atomic check+record prevents TOCTOU
- Peer balance gossip rejects implausible values (abs > 100M)
- Signed-message freshness centralised in
credit::ledger::check_signed_freshness(one-sided staleness, NEVER.abs()). Sharedpub(crate)constantsCLOCK_SKEW_TOLERANCE_SECS = 30andBALANCE_REPORT_MAX_AGE_SECS = 300apply to balance reports AND credit transactions, so a single tuning changes the replay window for every signed credit-typed message at once - Regional gossip (
RegionShardSummary,ModelDemandGossip) freshness goes throughdaemon::dispatch::gossip_timestamp_fresh— same one-sided invariant onu64millisecond timestamps.saturating_subreturns 0 whents > now, so the future-rejection branch is required separately from the staleness-rejection branch. Both that helper AND the GossipSub wire-level pre-filter innetwork/manager/events.rsroute through the genericdaemon::dispatch::timestamp_fresh_one_sided, so the one-sided invariant has a single implementation - Pool removal freshness (
pool::manager::handle_inbound_removal) routes throughcredit::ledger::check_signed_freshnessso the same replay-window constants apply to every signed timestamp the daemon accepts
crate::error::classify_error(&SwarmError) -> (StatusCode, message, error_type)
is the single definition of what a failure IS to a caller. Every surface derives
from it, so the same failure cannot be named two things:
| surface | how it consumes the classification |
|---|---|
| HTTP envelope | ApiError::into_response — status + error.type/code |
| OpenAI SSE | StreamEvent::Error { message, error_type } |
| Anthropic SSE | AnthropicSseEvent::Error, translated by anthropic_error_type |
| Responses API | responses::stream::classify_error_code (both the streaming and background paths) |
| MCP | mcp::types::tool_error_code → JSON-RPC -32602 / -32000 / -32603 |
Two refinements are deliberate and documented at their definition: Responses
names a provider failure upstream_error, and MCP maps 503 to
RESOURCE_UNAVAILABLE. A refinement names something more precisely than the
canonical answer; a divergence is the same meaning under a different word and
is a bug.
crate::error::reclassify_flattened_error(&str) -> Option<SwarmError> recovers a
class across a boundary that carries no types — SwarmError survives neither the
worker IPC hop nor the network hop, both of which deliver a String that would
otherwise be re-wrapped as Inference → HTTP 500. Used at both boundaries. It
matches on SwarmError's own #[error(...)] Display prefixes, which are part of
the type; it must never be extended to match user-facing prose (gotcha #295).
- Bearer token middleware in
src/api/middleware.rs(constant-time comparison) - Auto-generated 32-byte hex API key on first run, persisted in redb
- Protected paths:
/v1/*(inference),/api/admin/config(PUT),/api/admin/shutdown,/api/admin/hf/*(downloads),/api/admin/api-key,/api/admin/provider-models - Exempt paths:
/,/health,/admin,/chat,/setup,/static/*, read-only admin dashboard endpoints (GET/api/admin/stats,/api/admin/models, etc.) - Loopback-only actions:
POST /api/admin/update/check,/api/admin/update/applyand/api/admin/shutdownadditionally require the request to originate on the node's own machine. A valid API key is not sufficient — the first two write to disk and replace the running binary, so a remote key-holder must not be able to drive them. Refusal isSwarmError::LocalOnly→ 403permission_error, never 401: the caller authenticated fine and is being refused on origin. Filing it underUnauthorizedmeant the hint told a remote admin to go and fetch an API key they had already used successfully, which could not work (gotcha #309). Note this is the one placeaddr.ip().is_loopback()is the right predicate — it is an origin restriction, not the "may we hand over the key?" question thatapi::dashboard_trust::classifyanswers (gotcha #195). - Request body size limit: 32MB (configurable via
DefaultBodyLimit, raised from 2MB for VLM image payloads) - Content-Security-Policy:
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data: blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' - Dashboard key bootstrap (
src/api/dashboard_trust.rs) — the dashboard has no Bearer token on page load and fetches one fromGET /api/admin/api-key. That handout requires BOTH a trusted source network AND a valid single-useX-Dashboard-Nonce.classify()is the single decision point:Loopbackalways;Overlayfor100.64.0.0/10/fd7a:115c:a1e0::/48whenapi.dashboard_trust_overlay(default true) AND this node itself holds such an address (the IPv4 range is shared CGNAT space, so the peer's address alone proves nothing);LocalNetworkfor RFC1918/ULA only when thestate.dashboard_trust_lanruntime atomic is set (default false, toggled live viaPUT /api/admin/config). Untrusted origins are not blocked — the page prompts for the key and stores it per-origin. Do NOT re-derive this withaddr.ip().is_loopback(): that predicate means "the last TCP hop began in this daemon's netns", which a same-host reverse proxy satisfies on a remote client's behalf and a container publish / Tailscale subnet router never satisfies even from the host's own localhost (subnet routers SNAT by default). Threat model: on a trusted network, reachability of the API port is equivalent to admin access — the nonce only stops a non-browser local process that cannot read the served HTML, since/adminis unauthenticated and a nonce can simply be scraped. - WebSocket Origin validation (prevents cross-site WebSocket hijacking) —
websocket.rs::ws_origin_allowedcomparesOriginagainst the request's ownHost, plus the loopback forms for proxies that rewriteHost. It is deliberately NOT a fixed localhost allowlist: that refused the legitimate same-originOriginof any dashboard served at a LAN or Tailscale address, so remote dashboards silently lost every live update and fell back to polling. The upgrade's real gate is the single-use ticket from the Bearer-authedPOST /api/admin/ws-ticket. - Input validation: model name 256 chars, tools max 128, stop sequences max 16
- HuggingFace inputs validated (repo_id format, filename .gguf extension, no path traversal)
- HTTP timeout: 5 minutes (tower-http TimeoutLayer, Slowloris protection).
Routes that can run a model (
/v1/chat/completions,/v1/responses,/v1/messages,/mcp) are merged OUTSIDE this layer — generation has no bounded duration, and prefill alone can exceed five minutes on a long prompt. They are bounded instead by the prompt-scaled first-token budget, client-disconnect cancellation and TCP keepalive. The merge sits before the auth layer, so they still require a key (pinned bygeneration_routes_still_require_a_key). - Per-IP rate limiter with periodic 5-minute cleanup of stale entries
- Inference queue depth cap: 512 requests
- CORS:
OPTIONSpreflight requests explicitly allowed (required for cross-origin browser clients) - Connectivity probe (health check): narrowed to reject responses with content length > 20 chars, preventing false-positive pass-through from other services on the same port
- Total prompt cap: raised from 64KB to 4MB for Claude Code compatibility (tool call results and long context prompts can exceed 64KB)
- Anthropic→OpenAI proxy: now supports streaming — SSE events from the upstream OpenAI-compatible provider are translated to Anthropic SSE format and forwarded to the client in real time
Nodes can operate with just shard files + manifest.json + gguf_header.bin (~6MB), without needing the full multi-GB GGUF file:
~/.local/share/swarmllm/models/qwen2.5-coder-7b/
├── manifest.json # Model metadata + shard layout
├── gguf_header.bin # First ~6MB of GGUF (metadata + tensor index)
├── shard_000.bin # 512MB shard
├── shard_001.bin
├── tied_output_weight.bin # weight-tied models only — see below
└── ...
ShardReader in split/shard_reader.rs constructs a virtual GGUF from header + shard files,
allowing candle to parse the full tensor index while only loading assigned layers.
A weight-tied model (the Llama-3.2 family, Gemma-2, and most small models) reuses
token_embd.weight as its LM head and ships no separate output.weight. That
tensor physically lives in shard 0, but the node serving the last pipeline
segment is the one that needs it — and in a real swarm that node frequently does
not hold shard 0.
tied_output_weight.bin carries the raw tensor bytes so the head can be loaded
without shard 0. It is produced by extract_tied_output_weight (local GGUF) and
download_tied_output_weight (HF byte-range), and consumed by
resolve_tied_output → ShardReader::new, which maps it over the tensor's gguf
byte range. Reads resolve through the ordinary tensor map, so
ct.tensor(&mut reader, "token_embd.weight", …) works unchanged. When the node
does hold shard 0 the sidecar is ignored and the shard is used.
GgufTensorMeta::tied_output_location() is the single definition of "weight-tied",
shared by both writers and the reader so they cannot disagree about which tensor
the sidecar holds.
POST /v1/chat/completions— Chat completions (streaming + non-streaming, tool_calls).logprobsis refused for a model running locally — every local path pinstoken_logprobs: vec![], so it is only ever returned by a cloud provider (see Deferred Items).POST /v1/responses— OpenAI Responses API (gpt-5 / o-series default)GET /v1/responses/{id}— Retrieve a stored response (30-day TTL); pass?stream=true&starting_after={seq}to resume a background SSE streamDELETE /v1/responses/{id}— Delete a stored responsePOST /v1/responses/{id}/cancel— Cancel a background responseGET /v1/responses/{id}/input_items— Paginated list of the original input items (synthetic idsitem_N)POST /v1/messages— Anthropic Messages API (full Claude Code compatibility — tools, tool_choice, thinking, cache_control, metadata)POST /v1/embeddings— Text embeddingsGET /v1/models— List available models. Each entry carriesmax_model_len(the effective context this node will serve for that model — prompt plus reply, after the shipped 4096 default and anyinference.max_seq_len_override), which is the field vLLM added for the same purpose. Omitted when the model's declared context is unreadable, since a wrong figure is worse than an absent one.GET /v1/providers— List configured cloud providers and their available modelsGET /v1/status— SwarmLLM node status
OpenAI-compatible Responses endpoint — the 2026 default API for o-series / gpt-5 / reasoning-era callers:
- Request fields:
input(string or array of items),instructions,previous_response_id,max_output_tokens,tools(function),tool_choice,reasoning,text.format,text.verbosity,service_tier,include[],store,background,parallel_tool_calls,stream, plus arbitrary forward-compat fields via#[serde(flatten)] extras. - Input items:
message,function_call,function_call_output,reasoning(cloud-proxy path), with content partsinput_text,input_image,input_file,input_audio. Unknown item types round-trip via aRaw(Value)fallback. - Multimodal input (V2 of v2 plan):
input_image{image_url}(base64 data URIs pass through),input_file{file_data}(UTF-8 payloads inlined as text with a[File: name]header).input_image{file_id},input_file{file_id},input_audio, and non-UTF-8 file payloads are rejected with explicit errors pointing at the supported alternatives. 20 MiB cap per file. - Routing: OpenAI-compatible cloud model (gpt-5, o-series, nvidia/*, etc.) → proxy verbatim to upstream
/responses; Anthropic / claude-subscription provider → translate to Anthropic Messages, forward, translate back (V3 of v2 plan;src/api/openai/responses/anthropic_bridge.rs); otherwise local inference via Chat Completions translation. - Built-in tools (
web_search,file_search,computer_use_preview,code_interpreter,image_generation,mcp,custom): rejected on local path (400); forwarded verbatim on cloud path (OpenAI hosts them). - Streaming: SSE with monotonic
sequence_number. V1 of v2 plan emitsresponse.created+response.in_progressbefore the chat handler is awaited so the lifecycle events can never be blocked by preflight insidechat_completions(cold worker probe, queue wait, template build). Measured first-data:line arrival is ~2 ms on TinyLlama CPU at warmed-up steady state (seedocs/bench_results/README.mdfor the full pre/post comparison). Events:response.created,response.in_progress,response.output_item.added,response.content_part.added,response.output_text.delta,response.output_text.done,response.content_part.done,response.output_item.done,response.function_call_arguments.delta/done, terminalresponse.completed|response.incomplete|response.failed|response.cancelled. - Persistence: redb tree
responses, 30-day TTL, hourly background sweep.store=falseopts out. - Chaining:
previous_response_idloads the stored record and flattens prior request.input + response.output into chat messages. Reasoning items round-trip in the stored record (soencrypted_contentsurvives byte-for-byte for o-series chains) but are not re-injected as chat messages — local inference can't consume them and an empty assistant stub would confuse the prompt. - Background:
background=truespawns a tokio task, returnsstatus="queued"immediately;GET /v1/responses/{id}polls;POST /v1/responses/{id}/cancelflips the cancel flag (cancel-wins: worker's final result is discarded if cancelled). - Background streaming (V8 of v2 plan):
background=true && stream=truereturns 202 Accepted + aLocationheader pointing at/v1/responses/{id}?stream=true&starting_after=-1. The server runs the inference internally via a spawned task that writes every SSE event into a per-response buffer (cap 2000 events, oldest-first eviction). State lives inBACKGROUND_STATE: DashMap<id, Arc<BackgroundState>>(cancel flag + buffer + completion flag +tokio::sync::Notify). - Resumable SSE (V5 of v2 plan):
GET /v1/responses/{id}?stream=true&starting_after={seq}replays buffered events whosesequence_number > seq, then live-tails new events until the response is marked completed. If the response already finished and there's no liveBackgroundState, a synthetic minimal lifecycle (response.created+response.in_progress+ terminal) is built from the stored record so reconnecting clients still close cleanly. - input_items pagination (V4 of v2 plan):
GET /v1/responses/{id}/input_items?after={cursor}&limit={n}&order={asc|desc}. Synthetic idsitem_Nmap to the zero-based position in the original request. Returns the OpenAI list shape{object: "list", data: [...], first_id, last_id, has_more}. Default limitINPUT_ITEMS_DEFAULT_PAGE_SIZE = 20, maxINPUT_ITEMS_MAX_PAGE_SIZE = 100,MAX_INPUT_ITEMS_QUERY_LEN = 64on each query string (after/before/order/include).Textinput produces a single synthetic message item. - Ingress validation:
validate_responses_ingresscapsMAX_RESPONSES_INPUT_ITEMS = 1024items,MAX_RESPONSES_EXTRAS_COUNT = 32perextrasmap (top-level AND per-InputMessageItem),MAX_RESPONSES_EXTRA_VALUE_BYTES = 4 KiBper extras value. Closes a DoS surface where thousands of message items each carrying their own#[serde(flatten)]extras could bypass the top-level cap.
Full Anthropic Messages API compatibility for use as a Claude Code backend:
- Request fields:
tools,tool_choice,metadata,thinking(extended thinking),cache_controlon system blocks - Content blocks:
text,image,tool_use,tool_result,thinking,redacted_thinking - Routing: Claude models → Anthropic cloud (full pass-through); non-Claude models → Anthropic→OpenAI translation proxy (supports streaming — SSE events translated in real time); local GGUF models → tool calls/thinking converted to text for inference
- Total prompt cap: 4MB (raised from 64KB for Claude Code compatibility — tool results and long-context prompts can exceed the old limit)
- Claude Code usage:
ANTHROPIC_BASE_URL=http://localhost:8800 claude --model qwen2.5-coder-7b - Failure reporting (2026-08-12): a failure is an
event: errorSSE frame —{"type":"error","error":{"type":…,"message":…}}— and never assistant text. The frame is TERMINAL:build_anthropic_sse_responseends the keepalive ticker on it as it does onmessage_stop, and no epilogue follows.stop_reasoncarries only values the API defines (end_turn,max_tokens,stop_sequence,tool_use,pause_turn,refusal,model_context_window_exceeded). The errortypeis translated from the canonical classification into Anthropic's own set byanthropic_error_type, defaulting toapi_errorrather than inventing a name. Before this the surface had no error frame at all: the router path reported every failure as an emptyend_turnand the split path wrote[inference failed: …]into the message body (gotchas #300-#302).
A cloud provider handles tools natively. A local GGUF only emits text, so tool support is three pieces, shared by the OpenAI and Anthropic layers:
tool_parse::format_tool_promptdescribes the tools in a system message. This is the ONLY way a local model learns they exist — which makes it the only placetool_choicecan be enforced.tool_choice_forbids_tools(the OpenAI string"none"and the Anthropic{"type":"none"}) suppresses the injection entirely; every other value,"required"included, still describes them, because a local model cannot be compelled and refusing would be worse.tool_parse::parse_tool_callsrecovers calls from the model's text, trying the generic shape we prompt for, then Hermes/Qwen, Mistral and Llama-3 native formats. It does NOT repair truncated JSON: a generation cut off atmax_tokensis reported as text rather than as a call carrying invented arguments.- Call ids are assigned here, never taken from the model. The id is how a
client matches a result back to a request, so it must be unique across a
conversation; models do not do that (llama-3.2-3b emits
call_1,call_2,call_3for every tool-using reply it gives).
POST /mcp— JSON-RPC 2.0 MCP endpoint for AI agent frameworks (Claude Code, VS Code Copilot, Cursor, etc.)- Tools:
chat,models,compare,research,batch_prompts,delegate,node_info delegatepicks a model for you by tier.fastranks already loaded above local above smallest — loading a cold model costs tens of seconds, which dominates every other difference (fast_tier_rank).- Resources:
swarmllm://status(node status) - All tools include tool annotations (
readOnlyHint,destructiveHint, etc.) compare: sends the same prompt to up to 10 models concurrently, returns side-by-side resultsresearch: fan-out a question to multiple models (auto-selects if models omitted), returns all responses with token usagebatch_prompts: execute up to 20 independent {id, model, prompt} tasks in paralleldelegate: offload a task to the best model for a given tier (fast/cheap/smart) — auto-selects modelnode_info: detailed node status (loaded model, peers, credits, registry models, cloud providers). The loaded-model object carriesservable_now:loaded_model_infois cached when the node starts and says what was loaded, NOT whether a request can be routed now — those differ, and an operator reported the reported-loaded model failing every request while two others answered (2026-08-10).servable_nowis whether any reachable node currently holds that model's first shard;falsemeans requests will fail whatever "loaded" says.
When a requested model isn't available locally or on the swarm, requests can optionally be routed to 12 cloud providers:
- Providers: OpenAI, Anthropic, DeepSeek, Mistral, Groq, NVIDIA NIM, Cerebras, SambaNova, Fireworks AI, Together AI, DeepInfra, Moonshot/Kimi
- Model prefix routing:
claude-*→ Anthropic,gpt-*→ OpenAI,deepseek-*→ DeepSeek,mistral-*→ Mistral,moonshot-*/kimi*→ Moonshot - Explicit syntax:
provider:model(e.g.,openai:gpt-4o,groq:llama-3.1-70b) - Custom providers via
[providers.custom]config section - Provider health probes with per-model availability checking
- Admin API:
GET/PUT /api/admin/providers— view/configure provider API keys
Routes Claude model requests through a locally-authenticated claude CLI subprocess, using the user's existing Pro/Max/Team/Enterprise subscription — no API key or per-token charges needed.
- Feature flag:
--features claude-subscription(compile-time opt-in, isolated for easy removal) - How it works: Spawns
claude -p --output-format stream-jsonper request, parses NDJSON stdout, translates to OpenAI/Anthropic SSE or JSON responses - Multi-turn: Full conversation serialized per request using XML tags (
<system>,<previous_response>) — stateless, no server-side session state required - Routing priority: Claude subscription (if enabled) > Anthropic API key > error. Configured via
providers.claude_subscription.enabled - Concurrency: Semaphore-limited (default 3 concurrent subprocesses) to respect subscription rate limits
- Working directory: Defaults to
/tmpfor clean context (no project hooks/CLAUDE.md). Configurable viaworking_dirfor project-aware completions - Admin API:
GET /api/admin/claude-subscription/status— CLI detection, version, subscription type, rate limit tier - Dashboard: Settings → Cloud Providers → Claude Subscription card with step-by-step setup guide, status detection, enable/disable toggle
Long-lived sessions (src/api/claude_session.rs) — a persistent claude subprocess driven with --input-format stream-json, for interactive use where re-sending the whole conversation per turn would be wasteful. All five routes exist ONLY under --features claude-subscription; without it the router serves none of them.
GET /api/claude-code/sessions— List live sessionsPOST /api/claude-code/session— Start a session (spawns the subprocess)GET /api/claude-code/session/{id}— Session stateDELETE /api/claude-code/session/{id}— Close it and reap the subprocessPOST /api/claude-code/session/{id}/message— Send a turnPOST /api/claude-code/session/{id}/permission— Answer a tool-permission prompt the CLI raised
GET/PUT /api/admin/config— Configuration read/updateGET /api/admin/stats— Node statistics + hardware infoGET /api/admin/swarm/capacity— R110: collective capacity snapshot (online_nodes, total_vram_mb, serveable/aspirational/hosted_locally model lists, redundancy)GET /api/admin/swarm/capacity-plan— R113: what-if scenarios + headline_target with concretecontributors_neededcountGET /api/admin/storage/breakdown— R110: stacked-bar data (total_mb, used_mb, auto_target_mb, free_mb)GET /api/admin/wishlist— R111: ranked list of models the swarm wants (status, score, why_tags, swarm_replicas, target_replicas)GET /api/admin/hf/trending— R112: cached HuggingFace trending-GGUF snapshot from HfWatcherGET /api/admin/quant-recommendations— R133: per-family quant-choice recommendations with rationale tagsGET /api/admin/foreign-pool-catalog— R134: discovery-only cache of models advertised by other pools (gated onpool.share_model_catalog)GET /api/admin/responses— List stored/v1/responsesrecords for the dashboard (filter by?status=…&limit=…)GET /api/admin/models— Model list with shard status, VRAM estimates, acquisition state.encrypted_pipelineis the EFFECTIVE state (flag && has_first && has_last) so the UI's "privacy is on" indicator never claims privacy that is not happening;encrypted_pipeline_blockedreports the case that masking hides — the setting is on and this node cannot satisfy it, so every request for that model fails. Both are needed: without the second, the failing state appears on no surface a user looks at (gotcha #286).POST /api/admin/models/{id}/add— Trigger model acquisitionGET /api/admin/models/{id}/status— Model acquisition progressGET /api/admin/peers— Connected peers with latency/trustGET /api/admin/diagnostics— Plain-text support dump, address-redacted unless?full=1(it is written to be pasted into a bug report;swarmllm diagnosticsis the CLI wrapper). Sections: this machine (CPU, GPU, measured memory bandwidth, and the advertised 7B tok/s every peer's scheduler ranks this node on — the answer to "why does nobody route work to me?", which appeared on no pasteable surface before), reachable addresses, peers, recent inference failures (last 20: model, elapsed, and which peer served each — the field that separates "this node is broken" from "one peer is broken", plus a repeated-peer note), NAT traversal (publicly reachable,donating relay capacity, hole-punch success/failure counts with a reading of what zero means in context), peer cache (stored vs dialable), models, recent completed requests (last 50: route, per-phase timings, per-segment attribution), per-peer serving performance (RTT, ms/layer, EWMA latency, samples, region — slowest first), and served for others (segments/layers computed for peers, compute time, bytes out). This is the single most useful thing to include in a bug report.GET /api/admin/performance— The JSON sibling ofdiagnostics, for the dashboard's Performance panel:recent(up to 50 traces),peers,served, andhourly(one bucket per hour for a week, persisted). Pulled on demand, not pushed on the WS stats tick — see § Request Tracing, cardinality rule.GET /api/admin/credits— Credit balance and tier infoGET /api/admin/shard-storage— Per-model storage breakdown, disk/VRAM usageGET /api/admin/api-key— Retrieve API key (Bearer auth required; the dashboard's key-less bootstrap needs a trusted source network perapi::dashboard_trustAND a valid single-useX-Dashboard-Nonce)POST /api/admin/ws-ticket— Issue a single-use 30s ticket (Bearer auth) — required pre-step for the WS upgradeGET /api/admin/ws— WebSocket for live updates (consumes a ws-ticket)GET /api/admin/downloads— Download queue with priorities and progress
GET /api/admin/hf/search?q=...— Search HuggingFace for GGUF models (grouped by repo with quant variants). Note the parameter isq, NOTquery(axum deserialises via#[serde(rename = "q")]).GET /api/admin/hf/probe?repo_id=...&filename=...— Probe remote GGUF (size, shard layout)POST /api/admin/hf/download— Download full GGUF model. ⚠ Deprecated for normal use — the frontend and all new code MUST use/api/admin/hf/download-shards. Full-GGUF download exists only for offline-inference / seeding workflows; never call it implicitly. See CLAUDE.md § "No implicit full model downloads".POST /api/admin/hf/download-shards— Download specific shard indices (supportspeer_fair_sharefor smart distribution). Preferred entry point.GET /api/admin/hf/source/{model_id}— Lookup HuggingFace source info for a modelGET /api/admin/hf/search?q=...&tasks=chat,code,...— R114: optionaltasksfilter narrows results to chat/code/vision/multilingual/reasoning task tags (server-side filter)
GET/PUT/DELETE /api/identity/nickname— Manage local nicknameGET /api/identity/leaderboard— Network-wide credit leaderboardGET /api/identity/peers— Peer identity directory
GET /api/pool/state— Current pool membership statePOST /api/pool/create— Create a new device poolPOST /api/pool/invite— Invite a node to the pool (by node_id)POST /api/pool/accept— Accept a pool invitationPOST /api/pool/remove— Remove a member from the poolPOST /api/pool/leave— Leave the current poolGET /api/pool/invitations— List pending invitationsGET /api/pool/leaderboard— Pool member contribution rankingsPOST /api/pool/generate-code— Generate a v2swarmpool://invite code (owner only). R140: bundles the 8-char join token with the device's reachable listen multiaddrs so joiners can bootstrap without a shared DHT.POST /api/pool/join— Join a pool via invite code. Accepts either a v2swarmpool://...blob (dials embedded multiaddrs then broadcasts the join request) or the legacy 8-char form (broadcast-only — assumes joiner is already on the swarm).POST /api/pool/device-name— Set this device's display namePUT /api/pool/credit-split— Set credit split percentage (owner only)PUT /api/pool/contribution— Set per-member contribution level (owner only,{"node_id": "...", "level": 75}where level is 0–100)
GET /api/admin/network-code— Get shareable invite code, multiaddr, and network phasePOST /api/admin/join-network— Join network via invite code or multiaddr
POST /api/admin/shutdown— Gracefully shut down the node (localhost only)POST /api/admin/config/reload— Re-read config.toml into the live config; response splitsappliedfromrestart_requiredPOST /api/admin/downloads/{model_id}/cancel— Cancel in-progress HF downloadDELETE /api/admin/models/{model_id}— Remove model (shards + manifest + state)POST /api/admin/models/{id}/unload— Unload model from VRAM (keep shards on disk)DELETE /api/admin/models/{id}/shards/{index}— Delete a single shardGET/PUT /api/admin/models/{id}/auto-manage— Per-model auto-manage policy (incl. prune toggle)PUT /api/admin/models/{id}/shards/{index}/lock— Lock/unlock a shard (prevent auto-pruning)POST /api/admin/models/{id}/shards/{index}/download— Download a single shard from P2P networkPOST /api/admin/models/{id}/shards/{index}/load— Load a shard into memory (expands shard window, restarts worker)POST /api/admin/models/{id}/shards/{index}/unload— Unload a shard from memory (narrows shard window, restarts worker, frees RAM/VRAM)GET /api/admin/models/{id}/pipeline-plan— Pipeline assembly plan: ordered segments + holder candidates per shard windowPOST /api/admin/models/{id}/enable-privacy— Fetch the first and last shards of a model so the encrypted "boomerang" pipeline can engage; privacy then turns itself onPOST /api/admin/api-key/rotate— Issue a new API key and invalidate the old one. Takes effect on the next daemon start (the running server holds the current key in immutable state). Until this existed the key could not be rotated at all: it lives in the database anddata/api_keyis only a published copy, so deleting that file republished the same value — leaving no remedy for a leaked key short of destroying the node's identity.GET /api/admin/credits/transactions— Bounded log of recent balance movements (delta, kind, reason, resulting balance), oldest first. Added because only the running totals were kept, so a node reporting large spend/refund figures against zero requests could not be investigated by anyone. Notelifetime_refundedis partly synthetic —backfill_historical_refundsattributes unexplained gaps to refunds, so the books close by construction rather than as evidence the movements were understood.GET /api/admin/reference-models— The pinned smoke/standard/stress models fromdocs/REFERENCE_MODELS.md, for cross-swarm comparison (opt-in viaswarmllm get-model)GET/PUT /api/admin/schedule— Resource schedule managementGET /api/admin/prune-history— Recent auto-prune eventsGET/POST /api/admin/adapters— List/register LoRA adaptersDELETE /api/admin/adapters/{id}— Delete a LoRA adapterGET/PUT /api/admin/providers— View/configure cloud provider API keysGET /api/admin/provider-models— List models available from cloud providersGET /api/admin/provider-health— Probe cloud provider availabilityPOST /api/admin/provider-model-status— Check specific model availability on providerGET /api/admin/version— Version info (binary version, git hash, build features), plusrestart_required: set when a NEWER version is installed on disk than the one running, i.e. the restart into it did not take effect.nullwhen they agree or nothing has been installed. Exists because an in-place updateexecs and therefore keeps the process id AND the kernel's start time, sopscannot distinguish "updated" from "never restarted" (gotcha #277/#287) — an operator concluded twice from exactly that evidence that their node had missed eight releases, and nothing could contradict it. The node knows what it installed and what it runs; this is the comparison. A deliberate rollback (running newer than the record) is not flagged.POST /api/admin/update/check— Check for new SwarmLLM releasesPOST /api/admin/update/apply— Download and apply updateGET /api/admin/network-map— Network topology heatmap dataGET /api/admin/models/{id}/metadata— GGUF metadata (context length, quantization, layers)GET/PUT /api/admin/models/{id}/encrypted-pipeline— Per-model encrypted pipeline policyPOST /api/admin/rescan-shards— Hot-reload shard files from disk without restartGET/PUT /api/admin/pools/{id}/rates— Get/set credit rate configuration for a poolGET /metrics— Prometheus/OpenMetrics endpoint (no auth)GET /health/ready— Readiness probe with subsystem status (no auth)
/admin— Dashboard SPA (single-page app — all routes serve index.html)/chat— Chat interface/setup— First-run wizard/static/*path— Embedded static assets (CSS, JS, i18n JSON)/static/i18n/{lang}.json— Translation files (21 languages)/health— Liveness probe ({"status": "ok"}, no auth)/→ redirect to/admin
- No build step: Vanilla HTML/CSS/JS — no framework, no bundler, no Node.js
- Component architecture:
Appglobal namespace with component sub-objects (App.chat,App.dashboard, etc.)frontend/js/core/state.js— App namespace, shared mutable state, theme, storage keysfrontend/js/core/utils.js— format helpers (formatBytes,formatDlProgress,escapeHtml, etc.), DOM builders (appendMessageToDOM,createEmptyState),extractErrorMessage,getApiErrorMessagefrontend/js/core/data.js— data store with in-flight deduplication,authFetchwrapperfrontend/js/core/tooltip.js— unified popover replacing nativetitle=attributesfrontend/js/components/ui.js— tab switching, banners, mode indicator, sidebarfrontend/js/components/chat.js— sessions, messages, SSE streaming, image upload, layout togglefrontend/js/components/claude-code.js— Claude Code interactive sessions (subprocess, permission flow, SSE)frontend/js/components/dashboard.js— stats, hardware, model cards, peers, shard grid live updatesfrontend/js/components/dashboard-shards.js— pure-function shard HTML builders (progress bar, shard row, matrix, coverage ribbon); exposesApp.dashboardShards, loaded beforedashboard.jsfrontend/js/components/models.js— model dropdown, HF search/download, auto-manage, metadata panelfrontend/js/components/auto-manage-status.js— auto-manage scan/VRAM-pressure status displayfrontend/js/components/settings.js— settings panel (API keys, config, contribution)frontend/js/components/setup.js— first-run setup wizard (3-step configuration)frontend/js/components/downloads.js— download queue, prune history, resource schedulefrontend/js/components/notifications.js— unified event handler, toasts, WebSocket, REST polling, provider healthfrontend/js/components/identity.js— network invite code, nickname, leaderboardfrontend/js/components/network-map.js— regional network map visualizationfrontend/js/components/compare.js— multi-model comparison toolfrontend/js/components/responses.js—/v1/responsesdashboard panel: retrieve-by-id, status-filtered list, cancel/delete/view per row, 5-second polling refresh while visiblefrontend/js/components/pool.js— device pool management (create, join, members, contribution)frontend/js/components/swarm-tab.js— Swarm tab: wishlist + Capacity Plan view (R111)frontend/js/init.js— event binding, initialization, public API export (window.SwarmLLM)
- HTML templates: 10
<template id="tmpl-*">elements for repeating UI structures (session items, chat messages, toasts, compare cards, compare model chips, leaderboard rows, download queue items, prune rows, storage model rows, pool member rows). Components clone templates viatemplate.content.cloneNode(true)instead of innerHTML string building. (The peer table is built bydashboard.js renderPeersvia string concatenation, not a<template>.) - Cross-component calls:
App.componentName.method(). Shared state:App.state.*. Utilities:App.utils.*.
- i18n: 1319 translation keys (1321 entries per locale incl.
_lang+_dir) across 21 languages (en, es, fr, de, pt, it, nl, ru, zh, ja, ko, ar, tr, pl, sv, th, hi, vi, id, uk, cs). Auto-detects browser language.I18n.t()+data-i18nDOM attributes. Interpolation via{variable}placeholders. Fallback chain: current language → English → raw key. "Continue in English" UX for non-English users who prefer English. - Theme: Light / Dark / System toggle.
[data-theme="light"]CSS overrides. Persisted in localStorage. - Neural network background: Animated canvas particle network behind dashboard tiles (
frontend/js/neural-bg.js). ~60 nodes with connecting edges, gentle drift, mouse repulsion/glow. State-reactive coloring: blue (idle) → cyan (active inference) → red-orange (unhealthy/disconnected). Peer count boosts vibrancy, active requests trigger node firing pulses. Pauses when tab hidden; reduced opacity in light theme.
Three defects found on 2026-08-06, all invisible without measurement, all in how work reaches the CPU rather than in what work is done.
vendor/candle/candle-core/src/quantized/k_quants.rs::matmul is patched. Upstream
makes the batch row the OUTER loop, so the full weight matrix is re-streamed for
every row and batching amortizes nothing — measured ms/row was flat from m=1
to m=128. The patch makes the weight column the outer loop for m > 1, so each
column is read once and applied to every row while it is still in cache, and
parallelizes the activation-quantize loop that then becomes the serial fraction.
m == 1 keeps the original path, so decode is untouched by construction.
Measured on a 3072x3072 Q4_K shape: 3.00 -> 1.06 ms at m=4,
101.4 -> 11.4 ms at m=128. Verified bit-identical to the original
ordering by examples/qmatmul_bench.rs, which reimplements the upstream loop and
asserts exact equality — each output element is one vec_dot over the same
operands either way, so there is no reduction reordering.
This is why vendor/candle exists as a patched copy; the pre-existing reason was
a cudarc linking hardcode (see Cargo.toml [patch.crates-io]).
run_attention (src/inference/layers/mod.rs) picks between a fused/flash CPU
kernel and standard_attention. Both choices were wrong, in opposite directions:
| phase | was | now | why |
|---|---|---|---|
prefill (seq_len > 1) |
fused | standard | fused parallelizes over KV tiles of 16 inside a per-query-row loop with a scratch allocation per tile; standard batches into two matmuls per head |
GQA decode (seq_len == 1) |
standard below a 2048 crossover | fused always | standard materializes the KV cache expanded to n_head every token every layer, so cost grows with the conversation |
Multi-head (non-GQA) decode keeps standard, where the expansion is a no-op. SWIFT/spec sessions still force standard so draft and verify share numerics.
Prefill has many query rows and wants batched matmuls; decode has one row against a long cache and wants the kernel that never materializes the expansion. There is no single "faster kernel" — ask per phase.
inference::attn_softmax::scaled_masked_softmax does the scale, the optional
Gemma-2 logit soft-cap, the causal mask and the softmax in ONE pass over each
score row. Expressed as separate candle ops, each materialised its own
[batch, heads, q_len, kv_len] temporary — 11 MB at llama-3.2-3b prefill
shapes — so the tail moved ~90 MB per layer per chunk to do ~3 MB of
arithmetic: 34.6 ms against a matmul-plus-softmax floor of 11.4.
The mask is additive f32 everywhere (0.0 visible, -inf masked), produced
only by SplitModel::causal_mask. It used to be a u8 predicate for the
standard path plus a float copy the CPU flash arm rebuilt per call. Masks handed
onward must be contiguous — a narrow() view costs 2.1x in broadcast_add and
the fused kernel declines it outright.
Measured: attention 22.4% of a prompt chunk -> 9.5%, prompt processing 1.19x end to end, decode unchanged. Prompt processing is now 84.5% quantized matmul.
inference::cpu_pools::in_phase_pool, bound at SplitModel::forward_inner_impl
and forward_batch so every entry point inherits it. Prompt processing scales
to 1.83x past decode's optimum; decode is bandwidth-bound and gets 2.0x worse at
the same setting. One pool for both made contribution perverse — donating more
of the machine slowed replies down. Only decode is capped, only downward, and
not at all at the default contribution.
SWARMLLM_PROFILE=1 (src/inference/prof.rs) prints a non-overlapping per-stage
breakdown of each forward pass, including what the stages do NOT account for. It
is what found the attention defects: attention was 2.3% of the arithmetic and 45%
of prompt-processing time. See docs/DIAGNOSTICS.md.
Decode is bandwidth-bound at ~69% of the memory roofline (72% of its time is
the quantized matmul moving the weights), so faster arithmetic will not help much.
Threads pull the phases apart — decode peaks at 4 and is 2.0x worse at 14, while
prompt processing keeps improving to 14. That split is now handled by
inference::cpu_pools rather than being a tradeoff the operator has to pick.
Prompt processing is 84.5% quantized matmul after the attention work, so the
next lever is fewer bytes per token or more tokens per weight read, NOT another
elementwise fusion. Full numbers, plus two measured dead ends (self-speculative
decoding is 3.3x slower; raising the global thread count hurts DECODE), in
docs/FUTURE_WORK.md. Re-profile before optimising any stage — three rounds
have now begun with a stage that turned out to be a minority of the total.
Every inference request carries one RequestTrace (src/inference/trace.rs),
and that record is the sole input to every observability surface. Four
response paths each assembling their own timing struct is the recurring
"one invariant, N paths" defect in .claude/rules/architecture.md, and
observability is its worst home: the drift is invisible because nothing fails —
the numbers are just quietly wrong.
Lifecycle. Created at admission in router/mod.rs::handle_submit so
queue_ms measures real user-visible wait; mark_dequeued at dispatch;
mark_assembled in distributed_exec::execute_request with the route and
segment layout recorded together so they cannot disagree; mark_first_token
from the token channel; mark_finished + publish_request_trace at the single
completion arm.
Route (trace::Route) is classified from the pipeline assignment by
classify_route, never inferred from segment count — a one-segment remote
pipeline and a one-segment local one are different routes, and that distinction
is exactly what a user asking "why was that slow" needs. Relayed outranks
Distributed when any hop goes through an application-level relay.
Time to first token is stamped by the token channel, not by the emit
sites. Tokens leave from seven places (local_exec, process_pool, dsd,
speculative, ngram_only_spec, pipeline/mod), so StreamingTokenTx is a
newtype around mpsc::Sender that mirrors send/try_send/clone/
is_closed/closed — call sites read unchanged, and a new emit site
inherits the stamp with no author action. Only events carrying non-empty text
count, so a zero-token response reports no TTFT.
Cost. Phase boundaries only, plus one relaxed atomic load per token.
.claude/rules forbids hot-path overhead in pipeline.rs,
split/executor.rs::forward and forward_through_segments; a Vec<SegmentTrace>
allocated once per request is inside budget, per-token work is not.
Per-segment attribution. state.active_traces maps request id → in-flight
trace and has exactly the same lifetime as active_pipelines — inserted and
removed at the same sites — so it inherits that mechanism's already-correct
cleanup including the panic path via ActivePipelineGuard::drop. Deep pipeline
code calls state.record_segment_timing(...) unconditionally; it is a no-op when
no trace is registered.
| Surface | Contents |
|---|---|
DIAG: request complete log line |
The whole route and timing set on one greppable line |
| Response headers | x-swarm-route, x-swarm-segments, x-swarm-peers, x-swarm-nodes, x-swarm-regions + W3C Server-Timing, attached by api::attach_route_headers |
GET /api/admin/diagnostics |
Plain text for a shell: recent requests, per-peer performance, served-for-others, failures. Addresses redacted unless ?full=1 |
GET /api/admin/performance |
Same as JSON, plus the hourly trend |
/metrics |
OTel-named TTFT/TPOT histograms + requests_by_route{route,outcome} + serving-side counters |
| Dashboard | Chat route line; Models → Performance panel |
Headers flush before the body, so on a streaming response only pre-body facts (route, nodes, queue, schedule) can be sent. TTFT and decode are omitted rather than reported as zero — a plausible-looking zero is worse than an absent header — and a test asserts it.
Prometheus carries (route, outcome) and nothing else: both are closed sets, so
that metric is 20 series regardless of swarm size. Per-peer, per-model and
per-shard dimensions are unbounded (50 peers × 10 models × 10 shards = 5 000
series from a single node, growing with the swarm) and live only in
/api/admin/performance, which is pulled on demand and retains nothing beyond
the bounded rings. Getting this wrong takes down the scrape long before anyone
benefits from the extra detail.
SharedState::peer_performance_rows joins the three places peer speed was
already known and none of which was readable from outside the scheduler: the
health-ping round trip (PeerInfo.latency_ms), the per-layer EMA the Parallax
router uses (peer_segment_latency_ms_per_layer), and hedge_tracker's
per-(model, segment, holder) EWMA with variance and sample counts — collected
since R136 with zero consumers until this. Sorted slowest first; only peers that
have actually served something appear.
In a pipeline the segments are serialised: every token traverses node A's
layers, then node B's. There is no independent per-node token rate —
tokens / A_time would show both nodes producing the full stream and the figures
would not compose. What is real and exported:
- per-segment share of inter-token latency (
SegmentTrace.elapsed_ms) — these sum toward the total, so they identify the bottleneck hop - ms per layer per token — comparable across peers serving differently-sized segments
- derived node capacity =
1000 / (ms_per_layer × layers_served), useful for scheduling and leaderboards but labelled derived, not measured
For a non-pipelined route the request's tok/s is that node's tok/s and is reported plainly.
Not a time-series database — monitoring/ ships Prometheus + Grafana for that.
PerfHistory (daemon/state/perf_history.rs) keeps one bucket per hour capped
at a week (168), holding sums rather than averages so buckets can be merged
without averaging averages, persisted to redb only when the hour rolls over. Only
aggregates are persisted; per-request and per-peer rows stay in the in-memory
rings and are intentionally lost on restart.
A lightweight cross-subsystem event bus for real-time dashboard observability.
Backend (ActivityEvent defined in src/daemon/state/activity.rs, re-exported from state/mod.rs):
ActivityEventstruct with fields:category(&'static str),kind(&'static str, e.g."shard_pruned"),message(English), plus optionalmodel_id,model_name,node_id,detail_num,detail_str,toast_level,toast_duration_ms,shard_index,freed_bytes,holder_count_before,holder_count_after,remaining_local_shards,timestamp(ISO 8601)activity_tx: broadcast::Sender<ActivityEvent>instate.eventssub-struct (capacity 256, oldest events dropped on overflow)- All 12 subsystems emit events via the
state.emit_activity(ActivityEvent::new(...))builder — fire-and-forget (send errors ignored) - Example event kinds (snake_case strings; see
ACTIVITY_ICONSinfrontend/js/components/notifications.jsfor the canonical list):shard_download_complete,shard_pruned,inference_request,inference_completed,peer_connected,peer_disconnected,model_loaded,model_unloaded,pool_device_joined,pool_created,config_updated,daemon_started,hf_sources_cap_reached(R141 — throttled 1st + every 50th, surfaces dropped HfSourceGossip due toMAX_HF_SOURCES = 1024cap), and many more
WebSocket delivery (src/api/websocket.rs):
- ApiServer subscribes to
state.events.activity_txon WebSocket upgrade - Events sent to client as
{"type":"activity_event","data":{...}}JSON messages - Dropped messages (slow client) are non-fatal — buffer overflow discards oldest events
Frontend (js/components/dashboard.js, js/components/notifications.js):
- Global activity log persisted to
sessionStorage(survives tab refresh within the session) - Category-based color coding by event kind (inference = blue, download = green, prune = orange, error = red, etc.)
- Per-model activity ticker: latest event shown inline on each model card; hover expands to last 5 events
- Global Activity panel: chronological log of all events with relative timestamps ("just now", "5m ago")
- Shard flash animation: model card shard cells glow white on
ShardDownloaded/ShardPrunedevents
| Tier | Requirements | Role |
|---|---|---|
| Super Node | Full model in VRAM/RAM, high bandwidth | Full inference, backbone |
| Standard Node | Partial VRAM/RAM, moderate bandwidth | Shard hosting, pipeline participation |
| Light Node | Minimal resources | Consumer, bandwidth contribution |
| Platform | Priority | GPU Support |
|---|---|---|
| Linux x86_64 | P0 | CUDA (llama.cpp + candle) + ROCm (llama.cpp) |
| macOS aarch64 | P1 | Metal (via llama.cpp) |
| Windows x86_64 | P1 | Vulkan (llama.cpp, all vendors) + CUDA static (candle, NVIDIA) |
| macOS x86_64 | P2 | CPU only |
| Linux aarch64 | P3 | CPU only |
Windows uses a three-binary installer (SwarmLLM-Setup.exe) to support all GPU vendors without requiring users to install CUDA Toolkit or Vulkan SDK:
swarmllm-gpu.exe— built with--features windows-gpu:- llama.cpp local inference via Vulkan (NVIDIA, AMD, Intel —
vulkan-1.dllbundled with all GPU drivers) - candle distributed/split inference via CUDA with static runtime (
cudart_staticlinked in — needs only GeForce drivers, not CUDA Toolkit)
- llama.cpp local inference via Vulkan (NVIDIA, AMD, Intel —
swarmllm-cpu.exe— CPU-only, works on any Windows PCswarmllm.exe(launcher) — detectsnvcuda.dllin System32 at startup, transparently execs the appropriate binary
AMD/Intel on Windows: Local inference is GPU-accelerated via Vulkan. Split/distributed inference falls back to CPU — acceptable since serious multi-GPU distributed setups are predominantly NVIDIA.
The current candle-based split/distributed inference path supports CUDA (NVIDIA) only. A wgpu backend would enable cross-platform GPU acceleration (NVIDIA, AMD, Intel) for distributed inference using Vulkan, DX12, or Metal under the hood — eliminating the NVIDIA-only limitation for the distributed path.
Tracked upstream in huggingface/candle. No stable wgpu backend exists as of 2026-03. When available, enable via a new candle-wgpu feature that activates candle-core/wgpu. The windows-gpu feature would then include it alongside llama-vulkan, giving full cross-vendor GPU support for both local and distributed inference.
Environments with multiple virtual network interfaces may experience connection races. Recommended settings for config.toml:
[network]
enable_autonat = false # Prevents protocol noise on virtual adapters
enable_dcutr = false # Hole punching unreliable through VM NAT
enable_mdns = false # mDNS discovers multiple interfaces, causing connection races
enable_quic = false # QUIC can win connection races but fail on large payloads
listen_address = "127.0.0.1" # Bind to loopback only; avoids virtual NAT adaptersWhy: With max_established_per_peer=1, simultaneous connection attempts via different interfaces cause mutual rejection. Disabling competing transports and binding to a single interface avoids this.
Use SWARMLLM_NODE_DATA_DIR for per-node isolation:
# Node 1
SWARMLLM_NODE_DATA_DIR=/tmp/node1 ./swarmllm run -p 8800
# Node 2 (bootstrap via TCP, port+10)
SWARMLLM_NODE_DATA_DIR=/tmp/node2 ./swarmllm run -p 8801 \
--bootstrap /ip4/127.0.0.1/tcp/8810See CONTRIBUTING.md for development setup details.
Single-node inference performance, measured with swarmllm bench (100 output tokens, 3-run average, Q4_K_M quantization).
Hardware: 8-core CPU, NVIDIA RTX 3070 (8GB VRAM)
| Model | Parameters | GPU (RTX 3070) | CPU (Ryzen 7 5800H) | GPU Speedup |
|---|---|---|---|---|
| TinyLlama 1.1B | 1.1B | 27.2 tok/s | 4.2 tok/s | 6.5x |
| Gemma-2 2B IT | 2.5B | 20.6 tok/s | 3.5 tok/s | 5.9x |
| Phi-3.5 Mini | 3.8B | 46.4 tok/s | 1.8 tok/s | 25.8x |
| Qwen2.5-Coder 7B | 7.6B | 29.0 tok/s | 2.4 tok/s | 12.1x |
Notes:
- GPU inference uses candle with CUDA (
--features candle-cuda). CPU uses candle with native BLAS. - Phi-3.5 benefits most from GPU due to its fused QKV/FFN architecture.
- With 8GB VRAM, only one 7B model can be loaded at a time. Multiple smaller models (1-3B) can coexist.
- On-demand model loading with LRU eviction loads models into VRAM only when requested.
The list is split into open (will be addressed) and won't fix unless a concrete caller appears (the work is understood but not justified by current demand). Per-finding history (status, resolution, deferral) is tracked in .claude/sweep-log.jsonl.
-
Split-KV (FlashDecoding) kernels for CUDA decode —
candle-flash-attn0.10.1 ships none, so a single-token decode launches a grid of only(1 × n_head × batch)blocks and cannot fill the card. Measured on an RTX 3070: flash is 4x-25x slower thanstandard_attentionfor MHA decode at every KV length, which is whycuda_decode_prefers_standardroutes MHA decode away from it. (Short-context GQA decode was also routed away until 2026-08-08, when a forward-pass measurement showed flash winning at every length.) With split-KV, MHA decode could take the fused path too. Upstream flash-attention has the kernels (flash_fwd_splitkv_*); adding them tovendor/candle-flash-attnis the highest-value follow-on in this area. Full measurement table indocs/FUTURE_WORK.md. -
Binary signature on auto-update (audit_2026-04-29 C1) —
src/update.rsverifies the SHA256 sidecar fetched from the same GitHub release as the binary; a compromised maintainer account/CI token can publish a matching pair. Real fix: generate an offline signing keypair, embed the public key at compile time, publish a detached signature as a third release asset, and verify it before applying the rename. Deferred until a key-custody decision is made — seememory/signing_options.mdfor the three concrete options (raw Ed25519, minisign, or Sigstore/Cosign keyless), recommended approach (minisign), and step-by-step rollout plan. Until landed, defence-in-depth fixes keep the blast radius local:update/check+update/applyare loopback-only (2e1c5b1),apply_updatere-checkslatest_version > running_versionat apply time (post-cb2c688),info.downloadedonly flips true when the staging path is on the same filesystem as the binary, and auto-update is opt-in viaconfig.updates.auto_update(defaultDisabled).
-
Per-token logprobs from local inference — the machinery exists at both ends and is not joined up in the middle.
sampling::sample_token_with_logprobscan compute them,SamplingParamscarrieslogprobs/top_logprobsfrom both API layers, andChoiceLogProbs/TokenLogProbserialize correctly (pinned bylogprobs_response_serializes). But every local execution site pinstoken_logprobs: vec![]— see the note onInferenceOutput::from_gen_result— so nothing ever reaches the response. Completing it means returning per-token logits across the worker IPC boundary for the split path, and across the wire for the distributed path, on every token. Until then/v1/chat/completionsREFUSESlogprobsfor a locally-served model rather than answering 200 with the field absent, which is indistinguishable from a request that never asked for it (reject_unsupported_local_options). Cloud-routed models are unaffected and still return logprobs. -
seedis accepted and ignored for local models. It rides inextrasand is forwarded verbatim to a cloud provider, but nothing seeds the local sampler, so two requests with the same seed give different text (measured 2026-08-06). Unlikenandlogit_bias, this is NOT refused: OpenAI documentsseedas best-effort and explicitly does not guarantee determinism, so a caller cannot rely on it in the first place. Wiring it through would mean threading the seed into the sampler's RNG per request. -
Speculative decoding in subprocess — IPC scaffolding for routing speculative decoding through worker subprocesses was removed; the path runs through the direct executor only. Speculative decoding is experimental and the worker-subprocess plumbing would need to track per-position logit returns, KV-cache state, and partial-accept truncation — substantial complexity for a feature whose target audience overlaps tightly with the user base that already has the legacy in-process executor working. Revisit if subprocess isolation becomes a hard requirement (e.g., per-model crash containment for a hosted deployment).
-
Local executor streaming serialization —
executor.lock().awaitinapi/openai/streaming.rs::stream_responseandapi/anthropic/mod.rsholds the mutex for the entire streaming inference duration, serializing concurrent local streaming requests. Only affects the legacy single-GGUF executor path; the modern split-model path (split_stream_response) and distributed paths route throughModelProcessPooland are unaffected. The "fix" of routing legacy throughModelProcessPoolis misleading: the pool only handles shard-based models, so closing this gap requires either teaching the pool to load full GGUFs (large rearchitecture) or retiring the legacy executor entirely. Documented limitation: legacy GGUF mode is single-stream-at-a-time; users who need concurrency should switch to shard mode. -
V9:
POST /v1/responses/compact— Responses-API summary/compaction endpoint. No concrete caller has asked for it. Implement when one shows up. -
Server-side
conversationresource CRUD — OpenAI'sconversationparameter forwards through cloud proxy verbatim. A local conversation type with its own endpoints is a separate design that nobody is currently blocked on. -
Built-in tools on the local path (
web_search,file_search,computer_use_preview,code_interpreter,image_generation,mcp,custom) — rejected with 400 on local; forwarded verbatim on cloud. Each requires backing infrastructure (web crawler, code sandbox, image-gen model, etc.) that SwarmLLM intentionally does not run. -
customtools with Lark / regex grammars — rejected on local, forwarded on cloud. Local grammar-constrained generation is a candle-side project; we can't ship until candle exposes the necessary sampler hooks. -
Audio input on
/v1/responses—input_audioreturns 400. Needs a Whisper-class transcription model SwarmLLM doesn't currently expose. -
Binary file inputs —
input_file{file_data}accepts UTF-8 only; PDF / docx / image-bytes payloads are rejected with a clear hint pointing atinput_image(for images) or server-side text extraction (for documents). A PDF parser is a deferred call-site question. -
Synthetic tiny-model fixture (
tests/fixtures/tiny_model/) — empty placeholder. Originally specced as a 2-layer / 128-hidden / 2-shard llama-arch GGUF (~1 MB) committed to the repo so a multi-process spawn-and-infer integration test could run in CI without network. Stays unbuilt for two reasons: (1) generating a valid GGUF + matchingmanifest.json+gguf_header.bin+ tokenizer requires a Pythongguf-library generator script we don't maintain and that would version-drift against candle-transformers / our split loader; (2) random-weight outputs are gibberish, so the test would only catch GGUF-parse and worker-IPC plumbing bugs — both already covered bytests/integration/end_to_end.rsandinference::splitunit tests. Pragmatic substitute: the env-var-gatedlocal_embedder_load_from_real_modeltest (SWARMLLM_TEST_MODEL_DIR) and manual smoke tests against the TinyLlama-1.1B / Phi-3.5 / Qwen2.5-7B installs at~/.local/share/swarmllm/models/. Revisit if a CI worker-subprocess regression slips past the unit + in-process layers.
- ChaCha session encryption on speculative / DSD / remote-generate fast paths (closed 2026-05-07) — the gate in
speculative_common_eligiblewas stale conservatism; the encryption layer was already wired throughhandle_send_tensorandencode_forward_for_wireafter the original fast-path commits, but the eligibility check was never re-evaluated. Removed theenable_encryptioncheck; spec verify trailers (draft_tokens marker 0x03, truncate_kv_to marker 0x04) ride alongside the sealed activations in the encrypted envelope. - DSD multi-segment requires all-remote segments (closed 2026-05-07) —
pipeline/dsd.rs::forward_verify_through_segmentsnow branches onpeer_id_for_segment[idx]:Some(peer_bytes)keeps the existingNetworkCommand::SendTensorrendezvous;Nonedispatches tomodel_process_pool.forward(layer_forward). The worker'sforward_verify_all_positions[_pre_embedded]already gatesspec_logitsemission onis_last, so mixed-local pipelines produce hidden state for non-last local segments andspec_logitsonly on the final segment. - Spec-trailer fields now in AAD (closed 2026-05-07) —
build_layer_forward_aadwas extended to append the spec trailer (marker 0x03 + flags + num_drafts + drafts) and kv-truncate trailer (marker 0x04 + target_len) bytes whenever those trailers are emitted on the wire.decode_layer_forward_encryptednow reconstructs AAD via the helper after parsing trailers instead of slicing the wire-bytes header. An active MITM cannot flipspec_logits_requestedor modifytruncate_kv_towithout invalidating Poly1305. Wire-protocol bump for encrypted mode; mixed-version encrypted clusters fail decrypt during upgrade (acceptable for alpha — encrypted mode is opt-in). - API-key bootstrap nonce (closed 2026-05-07) —
/api/admin/api-keypreviously acceptedSec-Fetch-Site: same-originas a browser-only signal, but curl/python can set that header. Replaced with per-page nonces:/admin(and/chat,/setup, plus catchalls) now routes throughserve_dashboard_with_nonce, which substitutes a fresh 32-byte nonce into the served HTML's<meta name="bootstrap-nonce">tag and registers it inAppState.bootstrap_nonceswith a 60s TTL. The dashboard JS sends the nonce asX-Dashboard-Nonceon its bootstrap fetch; the middleware validates and consumes it (one-time use). Cross-UID local attackers must now first scrape/adminto obtain a nonce — strictly raises the bar; bare curl/spoofed-header attacks return 401. Same-UID processes can still read theapi_keyfile directly (mode 0o600), so this is a partial fix targeting the cross-UID threat model.
Tested up to 5 nodes on real hardware (Proxmox). Estimated capacity: ~10K nodes.
| Mechanism | Description | Impact |
|---|---|---|
| Gossip frequency scaling (S4) | Broadcast interval = log(peer_count) × 30s |
~8× less gossip at 10K nodes |
| Shard announce delta compression (S1) | Only broadcasts when shard set changes + periodic re-announce | ~90% less shard announce traffic |
| P2P shard fallback (S2) | Auto-manage tries P2P when no HF source available | Distributes download load |
| Peer registry cap (S3) | Max 200 peers, evicts highest-latency non-LAN | Memory bounded O(1) not O(N) |
| Target replicas | ceil(log2(pool_size)) × demand_factor |
10 replicas at 1K, 14 at 10K |
| Consistent hash ring | 10 virtual slots per node for shard assignment | Prevents thundering herd |
| Regional gossip summaries | O(regions × models) not O(nodes × shards) | Scales with regions not nodes |
| GossipSub mesh auto-scale | mesh_n/mesh_n_high scale with known_peers | Handles 1K+ peers |
- GossipSub message volume (linear with models × publishers)
- May need topic sharding (per-model-family topics instead of single
swarm/models) - mDNS doesn't scale beyond LAN — bootstrap peers or DHT-only discovery needed
Four features for intelligent VRAM and model management:
Workers load only a subset of on-disk shards into GPU memory. Shards stay on disk (still advertised to the network) but VRAM is freed by killing and restarting the worker with a narrower window.
ModelProcessPool.active_shard_windows: per-modelVec<u32>of allowed shard indices--shard-window 0,1,7CLI arg passed tomodel-workersubprocess- Auto-triggered by prune at VRAM pressure 0.7–0.95 (before hard-delete at 0.95+)
compute_optimal_shard_window()always prefers shard 0 (embeddings) and last shard (output head) for boomerang inference- API:
in_vramfield per shard in model detail; frontend shows V/D badges
gpu_memory_bandwidth_gbps(): 30-GPU lookup table (RTX 20/30/40, A100, H100, Apple M-series, AMD)estimate_tokens_per_sec_7b():bandwidth / 4.4GB * efficiency(0.30 GPU, 0.15 CPU)- Gossiped via
NodeCapability.est_tokens_per_sec_7b - Used as scheduler tie-breaker (after latency, region, load, trust)
estimate_model_vram_mb_arch():active_fraction = 0.40 + 0.60 * (experts_per_token / num_experts)- Supports Mixtral, DeepSeek, Llama4, Qwen35Moe architectures
- Used in auto-manage scoring for accurate VRAM fitness
composite_score = quality × fit × demand × size × 100(0–150 range)quality = log10(downloads + 10) / 7.0;fit= boomerang:1.0, shard:0.6, none:0.1- Server-side default sort by score; client-side sort dropdown (score/downloads/size)
- Score badge with color coding;
score_breakdownin API response