Principe directeur
Le nœud tire, il n’est jamais poussé. Un nœud qui meurt (batterie, doze, HyperOS, réseau coupé) est un non-événement : il cesse simplement de réclamer du travail. Aucun chemin de code ne suppose qu’un nœud est joignable.
Le coordinateur décide. Le nœud annonce son état ; il ne choisit pas ses jobs. C’est le coordinateur qui sait qu’un téléphone à 12 % de batterie et à 47 °C ne doit pas être réquisitionné.
Transport
NATS 2.x avec JetStream, sur le VPS.
| Usage | Accès |
|---|---|
| Nœuds (téléphone, laptop, PC) | wss://nats-cluster.vpsdashboard.space |
| Coordinateur (même réseau Docker) | nats://nats-cluster:4222 |
| Monitoring | http://nats-cluster:8222 |
plus large que l’écran — faites-le glisser
Le nœud ouvre une connexion sortante. Aucun port entrant, aucune IP publique, aucun VPN : le CGNAT de l’opérateur mobile n’est pas un sujet.
Authentification : un token par nœud, révocable individuellement.
Identité d’un nœud
nodeId — UUID v4 généré au premier lancement de l’app, persisté. Il survit aux mises à jour ; il change seulement si l’utilisateur réinitialise le nœud.
Sujets NATS
| Sujet | Sens | Qui publie |
|---|---|---|
cluster.v1.register | request/reply | nœud → coordinateur |
cluster.v1.telemetry.{nodeId} | publication | nœud |
cluster.v1.pop | request/reply | nœud → coordinateur |
cluster.v1.progress.{jobId} | publication | nœud |
cluster.v1.result.{jobId} | publication | nœud |
cluster.v1.control.{nodeId} | publication | coordinateur → nœud |
cluster.v1.logs.{nodeId} | publication | nœud |
cluster.v1.state.{nodeId} | publication | nœud |
plus large que l’écran — faites-le glisser
§1Enregistrement — cluster.v1.register
À chaque connexion (et après toute reconnexion), le nœud annonce ce qu’il sait faire. Idempotent : ré-enregistrer écrase la fiche précédente.
{ "protocol": 1, "nodeId": "9f2c...", "name": "Redmi Ichai", "platform": "android", "os": "Android 14 / HyperOS 1.0.7.0.UOOMIXM", "soc": "mt6878", "cpuCores": 8, "ramTotalMb": 12288, "appVersion": "0.1.0", "backends": ["cpu", "vulkan"], "models": [ { "id": "qwen2.5-3b-instruct-q4_k_m", "sizeMb": 1930, "ctxMax": 8192 } ], "maxCtx": 8192 }
Réponse :
{ "ok": true, "protocol": 1, "serverTime": 1756329600000, "popIntervalMs": 3000 }
Un protocol non reconnu entraîne { "ok": false, "error": "protocol_unsupported" }. Le nœud doit alors se mettre en pause et le signaler dans son interface.
§2Télémétrie — cluster.v1.telemetry.{nodeId}
Publiée toutes les 10 s tant que le service tourne. C’est le battement de cœur : c’est elle, et rien d’autre, qui prouve qu’un nœud est vivant.
{ "protocol": 1, "nodeId": "9f2c...", "ts": 1756329600000, "state": "idle", "batteryPct": 87, "charging": true, "thermalStatus": 1, "socTempC": 38.4, "cpuLoad": 0.12, "ramFreeMb": 6100, "network": "wifi", "screenOn": false, "loadedModel": "qwen2.5-3b-instruct-q4_k_m", "currentJobId": null, "tokensPerSec": 8.4 }
state ∈ idle | busy | paused | loading | error.
thermalStatus reprend PowerManager.THERMAL_STATUS_* d’Android (0 = aucun, jusqu’à 6 = shutdown). network ∈ wifi | cellular | none.
Un nœud sans télémétrie depuis 45 s est marqué stale et cesse d’être éligible. Ses jobs en vol sont remis en file (voir §6).
§3Réclamation de travail — cluster.v1.pop
Le nœud demande du travail, en joignant son état courant. Il n’y a pas de file par nœud : c’est le coordinateur qui décide, à cet instant précis, si ce nœud mérite un job.
Requête :
{ "protocol": 1, "nodeId": "9f2c...", "telemetry": { ... }, "canAccept": ["qwen2.5-3b-instruct-q4_k_m"] }
Réponse quand il n’y a rien (ou que le nœud est jugé inéligible) :
{ "job": null, "retryAfterMs": 3000, "reason": "no_work" }
reason ∈ no_work | battery_low | thermal | paused | network_policy | model_unavailable. Le nœud affiche cette raison dans son interface : c’est ce qui rend le système compréhensible au lieu d’opaque.
Réponse avec travail :
{ "job": { "jobId": "01J8...", "model": "qwen2.5-3b-instruct-q4_k_m", "messages": [{ "role": "user", "content": "..." }], "params": { "temperature": 0.7, "maxTokens": 512, "topP": 0.95, "stop": [] }, "stream": true, "deadlineMs": 120000 } }
Le nœud a deadlineMs pour terminer. Au-delà, le coordinateur considère le job perdu et le remet en file — le nœud doit abandonner sa génération s’il découvre qu’il a dépassé la deadline, pour ne pas produire un résultat en double.
Cadence de pop : popIntervalMs (défaut 3000 ms) en idle. Le nœud n’appelle jamais pop pendant qu’il est busy.
§4Progression — cluster.v1.progress.{jobId}
Publiée pendant la génération quand stream est vrai. Un message par lot de tokens (le nœud regroupe, il ne publie pas un message par token).
{ "protocol": 1, "jobId": "01J8...", "nodeId": "9f2c...", "seq": 12, "delta": "le texte généré", "done": false }
seq est strictement croissant à partir de 0. Le coordinateur réordonne sur seq et ignore tout doublon.
§5Résultat — cluster.v1.result.{jobId}
Publié une fois, à la fin.
{ "protocol": 1, "jobId": "01J8...", "nodeId": "9f2c...", "ok": true, "content": "réponse complète", "usage": { "promptTokens": 42, "completionTokens": 310 }, "timings": { "loadMs": 0, "promptMs": 890, "genMs": 36400, "tokensPerSec": 8.5 }, "finishReason": "stop" }
finishReason ∈ stop | length | abort | error. En cas d’échec : { "ok": false, "error": "oom" | "load_failed" | "aborted" | "..." }.
§6Reprise après mort d’un nœud
C’est le cœur du système, et la raison pour laquelle NATS/JetStream est là.
- Un job popé passe en vol avec une deadline.
- Le coordinateur balaie les jobs en vol toutes les 5 s.
- Un job dont la deadline est dépassée, ou dont le nœud est
stale, retourne en file,attempts += 1. - Au-delà de 3 tentatives, le job part en échec définitif et l’appelant reçoit une erreur — jamais une attente infinie silencieuse.
- Un job remis en file peut repartir sur un autre nœud. Les tokens déjà streamés sont abandonnés : le client reçoit la génération du nœud qui va au bout.
§7Contrôle — cluster.v1.control.{nodeId}
Le seul canal descendant. Le nœud y est abonné tant qu’il est connecté ; s’il est mort, le message est perdu — et c’est acceptable, aucun ordre n’est critique.
{ "protocol": 1, "action": "pause" | "resume" | "abort" | "reload_model" | "ping", "jobId": "01J8...", "model": "..." }
§8Politique d’éligibilité
Évaluée par le coordinateur à chaque pop, sur la télémétrie jointe. Valeurs par défaut, surchargeables par nœud :
| Règle | Seuil par défaut | reason renvoyé |
|---|---|---|
| Batterie sous seuil et non branché | < 40 % | battery_low |
| Statut thermique Android | >= 3 (severe) | thermal |
| Température SoC | > 45 °C | thermal |
| Réseau mobile alors que WiFi exigé | — | network_policy |
| Nœud en pause manuelle | — | paused |
| Modèle demandé non chargé ni disponible | — | model_unavailable |
plus large que l’écran — faites-le glisser
Ces seuils vivent côté coordinateur et sont réglables dans l’app : le nœud a le dernier mot sur lui-même. Un nœud qui se juge inapte ne poppe pas, quoi que pense le coordinateur.
§9API publique du coordinateur
POST /v1/chat/completions, compatible OpenAI, streaming SSE inclus.
Le champ model accepte un identifiant de modèle. Si aucun nœud ne le sert, la requête est mise en file d’attente durable — conformément à la politique arrêtée, il n’y a pas de repli vers une API cloud. Le client peut passer x-cluster-timeout: <ms> pour borner son attente ; sans en-tête, il attend.
GET /v1/models liste l’union des modèles annoncés par les nœuds vivants.
§10Journal distant — cluster.v1.logs.{nodeId}
Ajouté le 27/08/2026. Un nœud vit dans une poche : on ne peut pas aller lire son journal. Il le pousse donc lui-même, en continu, et le coordinateur le conserve. C’est ce qui rend un nœud diagnosticable à distance sans y toucher.
Publié par lots — jamais un message par ligne. Un lot part toutes les 5 s s’il y a quelque chose à dire, ou dès que 50 lignes sont en attente.
{ "protocol": 1, "nodeId": "9f2c...", "lines": [ { "ts": 1756329600000, "level": "info", "tag": "nats", "msg": "connecté à wss://..." }, { "ts": 1756329601000, "level": "error", "tag": "llama", "msg": "échec de chargement: ..." } ] }
level ∈ debug | info | warn | error.
Côté nœud : tampon circulaire en mémoire de 1000 lignes. Si la connexion est coupée, les lignes s’accumulent et partent à la reconnexion — un nœud qui a eu un problème réseau doit pouvoir raconter ce qui s’est passé pendant sa coupure. Au-delà du tampon, les plus anciennes sont perdues : le journal est un outil de diagnostic, pas une archive.
Côté coordinateur : tampon circulaire de 2000 lignes par nœud, en mémoire.
Exposition : GET /api/nodes/{nodeId}/logs?limit=200&level=warn et GET /api/logs pour le flux fusionné de tous les nœuds, le plus récent en tête.
Aucune donnée de conversation dans le journal. Ni prompt, ni réponse générée, jamais — seulement des événements techniques. Le journal traverse le réseau et se lit côté serveur : il ne doit pas devenir une fuite du contenu des requêtes.
§11État complet du nœud — cluster.v1.state.{nodeId}
Ajouté le 27/08/2026. La télémétrie (§2) est un battement de cœur : petite, fréquente, réduite à ce dont le scheduler a besoin pour décider. Cet objet-ci est l’inverse : l’inventaire complet de ce que le nœud est en train de vivre, pour qu’on puisse tout voir à distance sans jamais toucher l’appareil.
Publié toutes les 60 s, et immédiatement à chaque changement notable : modèle chargé ou déchargé, début ou fin de téléchargement, réglage modifié, job commencé ou terminé, permission accordée, reconnexion NATS, passage en pause.
{ "protocol": 1, "nodeId": "9f2c...", "ts": 1756329600000, "identity": { "name": "Redmi Ichai", "appVersion": "0.3.0", "versionCode": 3, "installedAt": 1756300000000 }, "device": { "manufacturer": "Xiaomi", "model": "24090RA29G", "soc": "mt6878", "abi": "arm64-v8a", "android": "14", "sdkInt": 34, "rom": "HyperOS 1.0.7.0.UOOMIXM", "cpuCores": 8, "ramTotalMb": 12288, "storageFreeMb": 41200 }, "runtime": { "serviceUptimeS": 4210, "serviceStartCount": 3, "connectedSince": 1756325400000, "natsReconnects": 2, "lastCrash": { "ts": 1756200000000, "message": "...", "stack": "..." } }, "models": { "installed": [ { "id": "qwen2.5-3b-instruct-q4_k_m", "fileName": "...gguf", "sizeBytes": 1929903264, "downloadedAt": 1756310000000, "sizeVerified": true } ], "loaded": { "id": "qwen2.5-3b-instruct-q4_k_m", "backend": "cpu", "ctxSize": 4096, "loadMs": 2400, "ramUsedMb": 2100 }, "downloading": { "id": "llama-3.2-3b-instruct-q4_k_m", "receivedBytes": 812000000, "totalBytes": 2019377696, "bytesPerSec": 1900000 } }, "settings": { "natsHost": "nats-cluster.vpsdashboard.space", "tokenSet": true, "minBatteryPct": 40, "wifiOnly": true, "allowWhenScreenOn": false, "paused": false, "logsEnabled": true, "contextSize": 4096 }, "permissions": { "notifications": true, "installPackages": true, "ignoringBatteryOptimizations": true, "bootCompleted": true }, "power": { "batteryPct": 87, "charging": true, "plugged": "ac", "thermalStatus": 1, "socTempC": 38.4, "screenOn": false, "network": "wifi", "deviceIdleMode": false }, "jobs": { "completed": 42, "failed": 1, "requeued": 0, "current": { "jobId": "01J8...", "startedAt": 1756329580000, "tokensSoFar": 120 }, "last": { "jobId": "01J8...", "model": "qwen2.5-3b-instruct-q4_k_m", "promptTokens": 42, "completionTokens": 310, "tokensPerSec": 8.5, "totalMs": 37290, "finishReason": "stop" } }, "lastReject": { "ts": 1756329500000, "reason": "thermal", "detail": "SoC 47 °C > 45 °C" } }
Jamais de contenu de conversation ici non plus — ni prompt, ni réponse, ni extrait. Des compteurs, des identifiants, des mesures. Le champ tokenSet est un booléen : le token lui-même ne quitte jamais l’appareil.
settings.natsHost est l’hôte seul, sans le token.
Côté coordinateur : conserver le dernier état de chaque nœud plus un historique des 200 derniers jobs par nœud (identifiants, timings, débits — pas de contenu). Exposition :
GET /api/nodes/{nodeId}/state— l’inventaire complet ci-dessusGET /api/nodes/{nodeId}/jobs?limit=50— l’historique des jobsGET /api/overview— une vue unique rassemblant, pour tous les nœuds : état, modèle chargé, éligibilité et sa raison, dernier job, erreurs récentes, profondeur de file. C’est la page à lire quand on veut tout savoir d’un coup.
§12Calibration — mesurer au lieu de croire les fiches techniques
Ajouté le 28/08/2026. Les specs mentent : le Redmi Note 14 Pro annonce 12 Go de RAM, ce qui laisse croire qu’un 8B passe, alors que sa bande passante de 51,2 Go/s le plafonne — mesuré à 4 à 5 tok/s sur un 3B. On ne déduit donc plus le modèle des specs : on mesure.
Au premier lancement, puis après tout changement matériel notable (nouvelle ROM, modèle différent), le nœud exécute une génération de calibration courte (~64 tokens, prompt fixe) et publie le résultat dans register :
"capability": { "measuredAt": 1756329600000, "model": "qwen2.5-3b-instruct-q4_k_m", "tokPerSec": 4.6, "promptTokPerSec": 8.1, "loadMs": 1460, "backend": "cpu", "tier": "leger" }
tier ∈ leger (< 8 tok/s) · moyen (8–25) · rapide (25–80) · gros (> 80). Le palier est déduit du débit MESURÉ, jamais de la RAM.
§13Identité, propriété et cloisonnement
Ajouté le 28/08/2026. Le cluster devient multi-utilisateur.
Deux familles de clés, révocables séparément. Une clé de nœud autorise un appareil à se connecter à NATS. Une clé d’API autorise une personne à envoyer des requêtes. Elles ne se mélangent jamais : perdre un téléphone ne compromet pas les requêtes, et retirer l’accès à quelqu’un ne coupe pas les appareils.
Codes d’invitation. Un code à usage unique généré par l’administrateur s’échange sur le site contre une clé d’API. Aucun compte, aucun mot de passe.
Propriétaire. Chaque nœud déclare un ownerId dans register. Chaque clé d’API porte le même identifiant pour son détenteur.
Cloisonnement d’une requête. Le champ visibility d’un job vaut public (défaut) ou private.
public: le job va sur n’importe quel nœud éligible. C’est le cercle de confiance assumé — le nœud qui génère voit le texte, et c’est dit clairement.private: le job n’est servi que par les nœuds dont l’ownerIdest celui de l’appelant. S’il n’en a aucun de disponible, le job attend — il ne bascule JAMAIS sur l’appareil de quelqu’un d’autre. Un refus de pop pour cette raison renvoiereason: "private_no_node".
Côté API OpenAI : en-tête x-cluster-private: 1, ou "visibility": "private" dans le corps.
§14Correctif de télémétrie — température
Ajouté le 28/08/2026. socTempC est inexploitable : Android interdit à une app ordinaire de lire /sys/class/thermal, le champ remonte vide. cpuLoad est mort pour la même raison (/proc/stat est bloqué).
La télémétrie (§2) porte donc désormais batteryTempC, lu via BatteryManager.EXTRA_TEMPERATURE — accessible sans privilège, au dixième de degré. Ce n’est pas le SoC, mais sur un téléphone les deux sont couplés, et c’est un signal continu là où thermalStatus n’est qu’un palier grossier qui ne bouge qu’une fois le bridage déjà commencé.
Seuil d’éligibilité : batteryTempC > 42 °C ⇒ thermal. socTempC et cpuLoad restent facultatifs et ne sont plus utilisés pour décider.
§15Profilage d’appareil — prédire au lieu d’essayer
Ajouté le 28/08/2026, remplace la calibration naïve du §12.
Le problème. On ne peut pas télécharger cinq modèles de 2 Go chacun pour découvrir lequel convient. Il faut mesurer l’APPAREIL une fois, puis PRÉDIRE le débit de chaque modèle du catalogue sans en télécharger aucun.
Le principe. La génération de tokens est bornée par la bande passante mémoire, pas par le processeur : produire un token oblige à relire tout le modèle. Le traitement du prompt, lui, est borné par le calcul. Ces deux grandeurs se mesurent sans aucun modèle, en quelques secondes.
§15.1Le profil — six mesures, aucun modèle requis
| Mesure | Méthode | Ce qu’elle prédit |
|---|---|---|
memBandwidthGBs | lecture en flux d’un tampon nettement plus grand que le cache de dernier niveau, en montant le nombre de fils jusqu’au plateau | le débit de génération |
computeGFLOPS | produit matriciel dense dont les opérandes tiennent en cache | la vitesse de traitement du prompt |
usableMemMB | plus grande allocation réellement obtenue, par doublements puis dichotomie, immédiatement libérée | quels modèles tiennent vraiment |
sustainRatio | la même charge tenue 90 s, débit échantillonné toutes les 5 s : rapport fin/début, et instant du décrochage | ce qu’il reste après l’échauffement |
storageReadMBs + storageFreeMB | lecture séquentielle d’un fichier temporaire | le temps de chargement, et si le fichier tient |
backends[] | pour CHAQUE backend présent (cpu, vulkan, opencl, metal, cuda, rocm), le même produit matriciel, et le gain RÉEL mesuré | lequel utiliser — jamais déduit de sa présence |
plus large que l’écran — faites-le glisser
Durée totale visée : moins de 30 secondes, sans réseau, sans modèle. Le profil est daté et refait si le matériel ou le système change.
§15.2L’étalonnage — une seule génération réelle
Les mesures brutes surestiment : un moteur d’inférence n’atteint jamais la bande passante théorique. Une seule génération réelle, sur le premier modèle installé, donne les deux rendements :
etaDecode = tokPerSecMesure / (memBandwidthGBs * 1e9 / tailleModeleOctets) etaPrefill = promptTokPerSec / (computeGFLOPS * 1e9 / (2 * nbParametres))
Typiquement etaDecode tombe entre 0,15 et 0,45. Il est propre au couple appareil + moteur, et il se réutilise pour tous les autres modèles.
§15.3La prédiction — pour chaque modèle du catalogue
decodePrevu_tokS = etaDecode * memBandwidthGBs * 1e9 / tailleModeleOctets prefillPrevu_tokS = etaPrefill * computeGFLOPS * 1e9 / (2 * nbParametres) decodeSoutenu = decodePrevu * sustainRatio memoireRequise = tailleModele + cacheKV(ctx) + surcout moteur
Le cache KV n’est pas négligeable : il croît avec la taille de contexte et peut dépasser le modèle lui-même sur un contexte long. Il entre dans le calcul.
§15.4Le choix — une fonction d’aptitude, pas un seuil
Un modèle est réalisable si memoireRequise <= usableMemMB * 0,8 — la marge de 20 % existe parce que le système peut reprendre de la mémoire à tout moment, et qu’un nœud tué en pleine génération est pire qu’un nœud lent.
Parmi les réalisables, on retient le plus grand dont decodeSoutenu reste au-dessus du plancher d’usage :
| Usage | Plancher | Pourquoi |
|---|---|---|
| Interactif | 6 tok/s | en dessous, l’attente devient pénible |
| Assistant | 3 tok/s | acceptable si la réponse arrive |
| Traitement par lots | 1 tok/s | la nuit, personne ne regarde |
plus large que l’écran — faites-le glisser
Le plus grand modèle qui tient, pas le plus rapide : à débit suffisant, la qualité prime. C’est l’inverse de la règle naïve actuelle.
§15.5Portabilité
Ces six mesures ne dépendent d’aucune plateforme : ce sont de la mémoire, du calcul, du disque et de la chaleur. La même spécification vaut pour Android aujourd’hui, et demain pour un portable, une tour avec GPU, un iPhone ou un Raspberry Pi. L’implémentation doit donc isoler la mesure du système : un noyau natif portable, et une fine couche par plateforme pour l’énumération des backends et la lecture de la mémoire disponible.
Le profil est publié dans register sous deviceProfile, à côté de capability (§12) qui reste le résultat étalonné.
§16Enrôlement sans friction — un code court, ou rien du tout
Ajouté le 28/08/2026. Aujourd’hui, rejoindre le parc demande de coller à la main une clé de nœud de 48 caractères hexadécimaux, une clé d’API, et de vérifier deux URL. Sur un téléphone, chacune de ces étapes perd des gens. Le but de cette section est qu’il n’en reste aucune.
§16.1Le code d’enrôlement — court, lisible, jetable
Un code de la forme FLK-4H2K : préfixe fixe, puis 4 à 6 caractères d’un alphabet sans ambiguïté visuelle (ni 0/O, ni 1/I/l). Il se dicte au téléphone, se lit sur un écran, se tape sans erreur.
POST /api/enroll { code } — sans authentification, mais strictement limité en débit et à usage unique. Il renvoie TOUT ce dont l’appareil a besoin :
{ "nodeKey": "...", "apiKey": "...", "ownerId": "own_...", "natsUrl": "wss://nats.floky.dev:443", "coordinatorUrl": "https://api.floky.dev", "nodeName": "Téléphone de Malka" }
L’appareil n’a donc plus jamais rien à saisir : ni URL, ni clé, ni propriétaire. Le code expire (24 h par défaut) et meurt à la première utilisation.
§16.2Le lien profond — zéro saisie
https://floky.dev/rejoindre/FLK-4H2K
- App installée : le lien l’ouvre directement, elle lit le code et s’enrôle seule.
- App absente : la page propose le téléchargement et retient le code, pour que le premier lancement le retrouve.
C’est le chemin normal : on envoie un lien par message, la personne appuie, c’est fini.
§16.3Le QR — quand on est dans la même pièce
L’app affiche un QR contenant ce même lien. L’autre le scanne à l’installation. Aucun réseau tiers, aucun message à envoyer.
§16.4Ce que ça impose à l’interface
- Les champs « clé du nœud », « clé d’API », « URL NATS » et « URL du coordinateur » quittent le chemin principal et passent dans une section « Avancé », repliée. Ils restent modifiables — on ne retire pas le contrôle, on retire l’obligation.
- Le parcours de premier lancement propose d’abord « J’ai un code » et « Scanner un QR ». La saisie manuelle est un troisième choix, discret.
- Un code refusé dit pourquoi : expiré, déjà utilisé, inconnu — jamais un échec muet.
§16.5Sécurité
Le code est un secret de courte durée qui donne accès au parc : usage unique, expiration, limitation de débit par IP, et révocable depuis l’administration. Il ne remplace pas les clés — il sert à les livrer, une fois.
fin du protocole v1 · 16 articles · révision 5950f1a du 28 août 2026
Toute divergence entre ce texte, le coordinateur et l’app est un bug, pas une variante.