Protocole optimiseur 1.3 : forme normée d'error.data, le token avant la majeure

Contrat (docs/etm-powersync-optimizer-protocol.openrpc.json) :
- composants InvalidContextData, ContextProblem, ContextProblemCode : error.data du -32602 de
  GetPlan = {"problems":[{path, problem}]}, 1 à 50 problèmes, truncated au-delà, chemin depuis
  params (motif), nature dans une liste fermée (missing, type, range, format, enum, constraint),
  formes fermées : aucune valeur reçue ne peut s'y glisser ;
- x-etm-data-schema et x-etm-data-required-from (1.3) sur cette erreur ;
- Handshake : token faux et autre majeure dans le même handshake → -32001, le token d'abord ;
- tolérance : error.data jamais lu par le moteur.

Prose (docs/OPTIMIZER_PROTOCOL.md) : v1.3, changelog §0 avec la compatibilité 1.2 ↔ 1.3, §2
(un lot reçu par le moteur reste sans réponse, à dessein), §3 (le token d'abord), §7 (forme
d'error.data, et ce que le moteur en fait : rien), annexes A, D, E, G. Blocs générés régénérés.

Générateur : un `|` dans une cellule (alternative d'un motif) est échappé ; une forme de
l'optimiseur fermée à dessein dit pourquoi (x-etm-closed-why) au lieu de « émise par le moteur ».
Test ajouté, vérifié échouant avec l'ancien générateur.

Moteur : version annoncée au handshake 1.3 (OptimizerProtocol::ProtocolVersion,
OptimizerClient::kProtocolVersion). Rien d'autre ne change dans le client. Tests qui citent la
version mis à jour (optimizerclient, socketscheduler, test du validateur).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-23 11:11:55 +02:00
parent d296e5f323
commit 09043fb7c7
9 changed files with 244 additions and 45 deletions

View File

@ -1,4 +1,4 @@
# OPTIMIZER_PROTOCOL — v1.2
# OPTIMIZER_PROTOCOL — v1.3
Contrat entre le moteur d'énergie **etm-powersync-energy-plugin-etm** (couche A, GPL-3.0)
et un **optimiseur** externe (couche B : l'optimiseur ETM, ou toute implémentation tierce).
@ -8,7 +8,7 @@ Ce document fait autorité. Il sert trois usages :
2. Spécification du protocole socket pour un optimiseur en process séparé.
3. Documentation publique : n'importe qui peut implémenter son propre optimiseur.
Statut : **v1.2** — figé à la fin de la beta. Versionné en semver (`protocolVersion`).
Statut : **v1.3** — figé à la fin de la beta. Versionné en semver (`protocolVersion`).
> ### La forme est un contrat MACHINE
>
@ -42,6 +42,34 @@ Statut : **v1.2** — figé à la fin de la beta. Versionné en semver (`protoco
## 0. Changelog
### 1.3 (2026-10)
Mineure. Norme la **forme d'`error.data`** de l'erreur `-32602` à un `GetPlan` dont le contexte est
invalide, et écrit deux règles que la 1.2 laissait à déduire. **Côté moteur, seule la version
annoncée change** : il n'émet aucun message nouveau, juge les réponses exactement comme en 1.2, et
ne lit pas `error.data` (§7).
**Compatibilité.** Même majeure : un moteur 1.3 accepte un optimiseur 1.0 à 1.2, et un optimiseur
1.3 accepte un moteur 1.0 à 1.2 (seule la majeure est comparée, §3). Les deux règles nouvelles
portent sur des situations que le moteur ne produit pas — un contexte invalide, un handshake d'une
autre majeure. Un optimiseur 1.2 qui renvoie un autre `data`, ou qui juge la majeure avant le token,
**continue de fonctionner avec ce moteur** : le moteur ne lit pas `error.data`, et traite toute
réponse `error` au handshake comme un refus, quel que soit son code. Il n'est pas conforme 1.3 sur
ces points, et s'aligne avant de s'annoncer 1.3 — la suite de conformité l'exige d'un optimiseur qui
s'annonce 1.3 ou plus, et se contente d'un avertissement pour un optimiseur 1.2 (annexe G).
- §7 **`error.data` d'un contexte invalide** : `{"problems":[{"path","problem"}]}` — liste non vide
d'au plus 50 problèmes (`truncated: true` à côté s'il y en avait davantage) ; `path` depuis
`params`, points et crochets ; `problem` dans une liste fermée (`missing`, `type`, `range`,
`format`, `enum`, `constraint`). Jamais la valeur reçue, dans aucun champ. Le moteur ignore un
code qu'il ne connaît pas (§9) et ne lit pas `error.data` pour décider.
- §3 **Token faux et autre majeure dans le même handshake** : le **token est jugé d'abord** —
`-32001`, puis fermeture.
- §2 **Un lot reçu par le moteur reste sans réponse** : c'est l'intention, désormais écrite (le
comportement est celui de 1.2).
- Contrat machine : composants `InvalidContextData`, `ContextProblem`, `ContextProblemCode` ;
`x-etm-data-schema` et `x-etm-data-required-from` sur l'erreur `-32602` de `GetPlan` ; ordre
token puis majeure dans les erreurs du `Handshake`.
### 1.2 (2026-10)
Mineure. Norme les points que le premier optimiseur réel a trouvés ouverts : erreurs renvoyées par
l'optimiseur, fermeture après un refus, token non exigé, sens d'`UpdateLearned`, longueur de ligne,
@ -156,7 +184,10 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr
(`[…]`) est interdit en émission. Reçu par l'optimiseur : erreur `-32600` à `id: null`, aucun
message du lot n'est traité, connexion conservée. Reçu par le moteur : ligne écartée
(`invalid-json` : ce n'est pas un objet), comptée, connexion conservée ; une réponse contenue
dans le lot n'est pas lue, et la requête qu'elle visait finit en délai dépassé (§4).
dans le lot n'est pas lue, et la requête qu'elle visait finit en délai dépassé (§4). **Le moteur
n'y répond pas, et c'est voulu** (écrit en 1.3) : client du protocole, il ne renvoie jamais de
réponse sur ce socket — une erreur à `id: null` n'aurait personne pour l'attendre. Le délai
dépassé de la requête visée est le seul effet visible, et il est compté (§7).
- **Longueur de ligne : `maxLineBytes` = 1 Mio (1 048 576 octets, `\n` non compté), dans les deux
sens** (1.2). Reçue trop longue par l'optimiseur : jetée sans être gardée en mémoire, erreur
`-32600` à `id: null`, connexion conservée. Reçue par le moteur : ligne écartée
@ -176,7 +207,7 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr
```json
{"jsonrpc":"2.0","id":1,"method":"Handshake","params":{
"protocolVersion":"1.2",
"protocolVersion":"1.3",
"engine":"etm-powersync-energy-plugin-etm/<version>",
"token":"<secret partagé, optionnel — requis si configuré côté plugin>"
}}
@ -186,7 +217,7 @@ Réponse attendue :
```json
{"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"1.2",
"protocolVersion":"1.3",
"name":"etm-optimizer",
"planning":true,
"capabilities":{
@ -224,6 +255,11 @@ Réponse attendue :
est fermé **des deux côtés** : l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa
réception. Le moteur traite **toute** réponse `error` au handshake comme un refus, quel que soit
son code (`handshake-refused`), et retente au backoff (§2).
- **Le token d'abord** (1.3). Un handshake qui porte à la fois un token faux (ou absent alors
qu'il est exigé) et une autre majeure reçoit **`-32001`**, puis la connexion est fermée :
l'optimiseur juge le token avant la version. Un pair qui ne présente pas le secret n'apprend
rien d'autre — pas même quelle majeure l'optimiseur parle. `-32602` ne répond donc qu'à un
handshake dont le token est accepté, ou qui n'en exige pas.
- **`GetPlan` avant un handshake réussi** (1.2) : l'optimiseur répond `-32600` et **garde** la
connexion — le client peut encore envoyer `Handshake`. Le moteur n'en envoie jamais : il
n'appelle `GetPlan` qu'une fois le handshake accepté.
@ -236,7 +272,7 @@ Réponse attendue :
| Champ | Type | Obligatoire | Valeurs | Sens |
|---|---|---|---|---|
| `protocolVersion` | `ProtocolVersion` | oui | `"1.2"` | Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme). |
| `protocolVersion` | `ProtocolVersion` | oui | `"1.3"` | Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme — -32001 si le token est faux aussi, 1.3). |
| `engine` | string | oui | motif `^etm-powersync-energy-plugin-etm/` | Moteur et sa version. |
| `token` | string | non | ≥ 16 car. | Secret partagé (§3, E-T2-8) : 16 caractères au moins, 4 096 octets au plus (borne en octets non exprimable ici : contrôlée par le moteur), imprimable, sans blanc ni caractère de contrôle, UTF-8 admis. Envoyé seulement s'il est configuré ; jamais journalisé. |
@ -1227,15 +1263,69 @@ une abstention, à ce qu'il ne peut pas traiter. En particulier :
protocole. Une situation que le tableau ne nomme pas prend le code standard qui lui correspond
(`-32603` erreur interne, par exemple).
**Forme d'`error.data` pour un contexte invalide** (1.3). Exigée d'un optimiseur qui s'annonce 1.3 ou
plus :
```json
{"jsonrpc":"2.0","id":42,"error":{"code":-32602,"message":"contexte invalide","data":{
"problems":[{"path":"params.loads[0].id","problem":"missing"},
{"path":"params.forecast.pv[3].from","problem":"format"}]}}}
```
- `problems` : liste **non vide**, de **50 entrées au plus**. S'il y a davantage de problèmes, les 50
premiers, et **`"truncated": true`** à côté de `problems` — champ absent sinon.
- `path` : chemin du champ fautif **depuis `params`**, noms séparés par des points, indices de liste
entre crochets (`params.forecast.pv[3].from`, `params.loads[0].declared.estimatedPowerW.3`). Pour
un champ absent, le chemin où il était attendu.
- `problem` : un code d'une **liste fermée** — `missing` (obligatoire absent), `type` (type faux),
`range` (hors bornes), `format` (chaîne mal formée : instant, durée…), `enum` (valeur hors liste),
`constraint` (règle entre champs, ordre des créneaux…).
- **Jamais la valeur reçue**, dans aucun champ : aucun autre champ que ceux-là, un chemin qui suit son
motif, une nature prise dans la liste. La forme fermée rend la recopie inexprimable — c'est le but :
une valeur du site n'a rien à faire dans une erreur qu'un journal peut garder.
- **Ce que le moteur en fait : rien.** Il ne lit pas `error.data` pour décider — l'erreur reste un
échec `rpc-error`, règle des deux échecs inchangée —, ne le journalise pas (le code JSON-RPC seul),
et ne le recopie pas dans la trace (annexe D : la ligne porte `rpc-error`). Un code `problem` qu'il
ne connaît pas, ou un `data` d'une autre forme, n'y change donc rien (§9). La forme sert celui qui
lit l'échange : le validateur, la suite de conformité, l'installateur qui capture une session.
<!-- BEGIN GENERATED table:InvalidContextData -->
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
**`InvalidContextData`** — error.data de la réponse -32602 à GetPlan (§7, 1.3). Exigé d'un optimiseur qui s'annonce 1.3 ou plus ; un optimiseur 1.2 peut répondre un autre data, le moteur ne le lit pas. Aucune valeur reçue n'y figure, dans aucun champ : la forme est fermée, le chemin suit un motif, la nature est un code d'une liste fermée.
| Champ | Type | Obligatoire | Valeurs | Sens |
|---|---|---|---|---|
| `problems` | [`ContextProblem`] | oui | | Les problèmes trouvés, de 1 à 50 ; s'il y en a davantage, les 50 premiers, et truncated. |
| `truncated` | boolean | non | `true` | Présent, et vrai, seulement si des problèmes ont été omis (problems en compte alors 50) ; absent sinon. |
Champs non déclarés : **refusés** (forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser).
<!-- END GENERATED -->
<!-- BEGIN GENERATED table:ContextProblem -->
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
**`ContextProblem`** — Un problème du contexte GetPlan (1.3) : où, et de quelle nature — jamais la valeur reçue.
| Champ | Type | Obligatoire | Valeurs | Sens |
|---|---|---|---|---|
| `path` | string | oui | motif `^params(\.[A-Za-z0-9_]+\|\[[0-9]+\])*$` | Chemin du champ fautif depuis params : noms séparés par des points, indices de liste entre crochets (params.forecast.pv[3].from). Champ absent : son chemin attendu. |
| `problem` | string | oui | `missing` · `type` · `range` · `format` · `enum` · `constraint` | Nature d'un problème du contexte (1.3), liste fermée : missing (obligatoire absent), type (type faux), range (hors bornes), format (chaîne mal formée : instant, durée…), enum (valeur hors liste), constraint (règle entre champs, ordre des créneaux…). Un code inconnu est ignoré par le moteur (§9). |
Champs non déclarés : **refusés** (forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser).
<!-- END GENERATED -->
<!-- BEGIN GENERATED errors -->
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
| Code | Méthode | `message` | Connexion après l'erreur | Quand |
|---|---|---|---|---|
| `-32001` | Handshake | token refusé | fermée | Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ. |
| `-32602` | Handshake | majeure incompatible | fermée | Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2). |
| `-32001` | Handshake | token refusé | fermée | Token configuré côté optimiseur, et absent ou différent dans le Handshake — jugé AVANT la majeure (1.3) : token faux et autre majeure dans le même Handshake → -32001. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ. |
| `-32602` | Handshake | majeure incompatible | fermée | Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2), le token étant accepté ou non exigé (1.3 : le token est jugé d'abord). |
| `-32600` | GetPlan | handshake requis | conservée | GetPlan reçu avant un Handshake réussi sur cette connexion (1.2). Le moteur n'en envoie jamais : il attend d'être prêt. |
| `-32602` | GetPlan | contexte invalide | conservée | Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait. |
| `-32602` | GetPlan | contexte invalide | conservée | Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Forme normée en 1.3 : InvalidContextData. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait. |
| `-32603` | GetPlan | erreur interne | conservée | Code JSON-RPC 2.0 standard pour toute autre panne de l'optimiseur. |
| `-32700` | toute | erreur d'analyse | conservée | Ligne qui n'est pas du JSON (ou pas de l'UTF-8). Réponse à id null. Code standard JSON-RPC 2.0. |
| `-32600` | toute | lot interdit | conservée | Ligne qui est un lot JSON-RPC ([…]) (1.2). Une seule réponse, à id null ; aucun message du lot n'est traité. |
@ -1247,7 +1337,7 @@ Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que
Ce que fait le moteur d'une réponse error :
- **Handshake** : Toute réponse error au Handshake, quel que soit son code : handshake-refused, le moteur ferme, nouvelle tentative au backoff.
- **GetPlan** : Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7).
- **GetPlan** : Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7). error.data n'est PAS lu : ni décision, ni journal (le code seul), ni trace (le code de l'échec seul, rpc-error).
<!-- END GENERATED -->
@ -1278,7 +1368,7 @@ Ce que le moteur refuse EN PLUS de la forme, dans l'ordre de ses contrôles (Opt
| `expired` | GetPlan | expired | `expired` | non | request | non | validUntil ≤ timestamp de la requête. |
| `no-current-slot` | GetPlan | abstention | `no-current-slot` | non | request | non | aucun créneau ne couvre le timestamp de la requête. |
| `incomplete-slot` | GetPlan | invalid | `incomplete-slot` | oui | request | non | une charge available de la requête n'a pas d'action dans le créneau courant (décision Q1). |
| `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff. |
| `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error, quel que soit son code (token refusé : -32001, jugé d'abord ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff. |
| `invalid-handshake` | Handshake | connection-refused | `no-plan` | non | — | oui | result non objet, protocolVersion absent ou vide, capabilities présent et non objet. |
| `major-mismatch` | Handshake | connection-refused | `no-plan` | non | — | non | MAJEURE de protocolVersion (ce qui précède le premier point) différente de celle du moteur. |
@ -1298,7 +1388,7 @@ qu'elle affiche de ces trois champs.
- **Sens unique : de l'optimiseur vers le moteur** (1.2). Le moteur n'en envoie jamais. Reçue par
un optimiseur : en notification (sans `id`), ignorée sans réponse ; en requête (avec `id`),
erreur `-32601` (méthode inconnue).
- **La forme d'`UpdateLearned` n'est pas normée en 1.2.** Le tableau ci-dessous dit ce que le
- **La forme d'`UpdateLearned` n'est pas normée** (ni en 1.2, ni en 1.3). Le tableau ci-dessous dit ce que le
contrat admet, pas ce qu'un moteur en fera : la forme sera fixée avec la première stratégie qui
apprend.
- Tant que `confidence < 0.7` (seuil indicatif), l'app peut afficher un badge
@ -1312,7 +1402,7 @@ qu'elle affiche de ces trois champs.
| Champ | Type | Obligatoire | Valeurs | Sens |
|---|---|---|---|---|
| `loads` | [object] | non | | Non normé en 1.2 : intention §8 (learned + confidence par charge). |
| `loads` | [object] | non | | Non normé (1.2, 1.3) : intention §8 (learned + confidence par charge). |
Champs non déclarés : **refusés** (forme émise par le moteur, fermée).
@ -1326,7 +1416,7 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée).
majeure** et elle seule (§3) : le moteur refuse un optimiseur d'une autre majeure, et
l'optimiseur un moteur d'une autre majeure (`-32602`, 1.2). Toute mineure est acceptée.
- Une mineure n'ajoute que des champs optionnels, ou des règles sur des situations que l'autre
côté conforme ne produit pas (1.2 en est un exemple, §0). Elle se lit comme une liste de choses
côté conforme ne produit pas (1.2 et 1.3 en sont des exemples, §0). Elle se lit comme une liste de choses
à aligner avant de s'annoncer de cette mineure, pas comme une rupture.
- Champs inconnus dans `params`/`result` : **ignorés silencieusement**.
- Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0.
@ -1336,13 +1426,13 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée).
## Annexe A — Session minimale
```
→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.2","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm71"}}
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2","name":"mon-optimiseur","planning":false}}
→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.3","engine":"etm-powersync-energy-plugin-etm/<version>"}}
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.3","name":"mon-optimiseur","planning":false}}
→ {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ ...SurplusContext... }}
← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}}
```
Ce que l'optimiseur répond à ce que le moteur n'envoie jamais (1.2, §2, §3, §7) — c'est ce
Ce que l'optimiseur répond à ce que le moteur n'envoie jamais (1.2, 1.3 ; §2, §3, §7) — c'est ce
qu'éprouve la suite de conformité, par une connexion de sonde :
```
@ -1354,6 +1444,26 @@ qu'éprouve la suite de conformité, par une connexion de sonde :
← {"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"majeure incompatible"}} puis fermeture
```
Sur une session ouverte, un contexte invalide (1.3) — ici un `timestamp` sans décalage et rien
d'autre :
```
→ {"jsonrpc":"2.0","id":4,"method":"GetPlan","params":{"timestamp":"2026-10-25T02:30:00"}}
← {"jsonrpc":"2.0","id":4,"error":{"code":-32602,"message":"contexte invalide","data":{"problems":[
{"path":"params.timestamp","problem":"format"},{"path":"params.trigger","problem":"missing"},…]}}}
```
L'optimiseur qui ne lit que l'instant s'arrête au premier problème ; celui qui valide tout le
contexte les liste tous, 50 au plus. Le chemin dit où ; la valeur `2026-10-25T02:30:00` n'apparaît
nulle part.
Token faux **et** autre majeure (1.3, si l'optimiseur exige un token) : le token d'abord.
```
→ {"jsonrpc":"2.0","id":5,"method":"Handshake","params":{"protocolVersion":"2.0","engine":"…","token":"<faux>"}}
← {"jsonrpc":"2.0","id":5,"error":{"code":-32001,"message":"token refusé"}} puis fermeture
```
Un optimiseur conforme minimal tient en ~50 lignes de Python : répondre au handshake,
renvoyer un plan à un créneau (ou `abstain`), et les erreurs du §7.
@ -1414,7 +1524,9 @@ But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un inciden
telle requête n'est pas envoyée (soumise au client, elle a pour réponse `request-too-long`),
elle n'est pas recopiée, et le rejeu passe la ligne ; `response` : ce qui a
répondu à **cette** requête — `result` brut, ou `error.code` (`no-response` si elle a été
remplacée sans réponse) ; `executed` : actions appliquées au cycle ; `latencyMs` : optimiseur
remplacée sans réponse) ; `error.code` est le code d'échec du **moteur** (`rpc-error` pour une
réponse `error` de l'optimiseur, `timeout`…), jamais le code JSON-RPC, et `error.data` n'est
jamais recopié (§7) ; `executed` : actions appliquées au cycle ; `latencyMs` : optimiseur
seulement. La réponse à la requête N sert au cycle N+1 : la ligne N l'attend.
- Jamais le token (il ne voyage que dans le handshake ; recopié par une réponse, il est masqué).
- **Rejeu** : `tools/etm-powersync-trace-replay` relit `request`, l'envoie à un optimiseur
@ -1426,7 +1538,7 @@ But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un inciden
## Annexe E — Optimiseur de référence
`tools/etm-powersync-ref-optimizer/` : serveur Python minimal (bibliothèque standard seule)
conforme 1.2, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un
conforme 1.3, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un
plan à un créneau qui reproduit le waterfall rule-based. Usages : test de bout en bout du
`SocketScheduler` (suite `etm-powersync-refoptimizer`) ; point de comparaison du banc ; exemple
communautaire. Mode d'emploi et modes d'injection de défauts : son `README.md`.
@ -1477,5 +1589,8 @@ Ils deviendront normatifs avec le lot qui les implémente.
- **Conformité d'un optimiseur quelconque** : suite `etm-powersync-optimizer-conformance`
(`tests/auto/conformance/README.md`) — le vrai client du moteur branché sur l'optimiseur que
lance `ETM_POWERSYNC_OPTIMIZER_CMD`. Depuis 1.2, elle éprouve aussi, par une connexion de sonde,
les erreurs du §7 que le moteur ne provoque pas (annexe A).
les erreurs du §7 que le moteur ne provoque pas (annexe A). Depuis 1.3, la forme d'`error.data`
d'un contexte invalide et l'ordre token puis majeure : **exigés** d'un optimiseur qui s'annonce
1.3 ou plus, **avertis** seulement pour un optimiseur 1.2 ; une valeur reçue recopiée dans
`error.data` est refusée quelle que soit la version (la règle date de 1.2).
- **Prose générée** : `tools/etm-powersync-gen-protocol-doc` (`--check` dans `tools/ci-quality.sh`).

View File

@ -2,12 +2,12 @@
"openrpc": "1.3.2",
"info": {
"title": "etm-powersync — protocole moteur ↔ optimiseur",
"version": "1.2",
"version": "1.3",
"license": {
"name": "GPL-3.0-or-later",
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
},
"description": "Contrat MACHINE du protocole entre le moteur d'énergie etm-powersync-energy-plugin-etm (client) et un optimiseur externe (serveur) — docs/OPTIMIZER_PROTOCOL.md en est la prose. Ce document est la source de la forme des messages ; il est servi par le moteur qui tourne (NymeaEnergy.GetOptimizerProtocol) et les deux côtés sont testés contre lui.\n\nCe qu'OpenRPC ne dit pas, et où cela se trouve :\n- TRANSPORT : unix:// (défaut, socket local 0660) ou tcp:// (LAN), JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8 — x-etm-transport ;\n- SENS : le moteur est le CLIENT, il se connecte et envoie Handshake puis GetPlan ; la box n'ouvre aucun port. UpdateLearned va dans l'autre sens (x-etm-direction) ;\n- ERREURS de l'optimiseur (1.2) : par méthode dans methods[].errors, et pour un message qu'il ne peut pas traiter (ligne illisible, lot, ligne trop longue, méthode inconnue) dans x-etm-message-errors ;\n- HEARTBEAT IMPLICITE et machine de repli §7 — x-etm-fallback-machine ;\n- VALIDATION SÉMANTIQUE du plan : le JSON Schema dit la forme ; ce que le validateur du moteur refuse en plus (charge inconnue, créneau courant incomplet…) est listé, règle par règle, dans x-etm-semantic-rules avec son fallbackCode ;\n- listes fermées : régénérées DEPUIS LE CODE (x-etm-generated), jamais recopiées à la main.\n\nForme côté moteur (requêtes) : STRICTE — additionalProperties: false ; un champ ajouté au sérialiseur sans le schéma fait échouer les tests. Forme côté optimiseur (réponses) : ouverte — un champ inconnu est ignoré (§9) ; le schéma dit ce qu'un optimiseur CONFORME envoie, x-etm-tolerances ce que le moteur accepte en plus."
"description": "Contrat MACHINE du protocole entre le moteur d'énergie etm-powersync-energy-plugin-etm (client) et un optimiseur externe (serveur) — docs/OPTIMIZER_PROTOCOL.md en est la prose. Ce document est la source de la forme des messages ; il est servi par le moteur qui tourne (NymeaEnergy.GetOptimizerProtocol) et les deux côtés sont testés contre lui.\n\nCe qu'OpenRPC ne dit pas, et où cela se trouve :\n- TRANSPORT : unix:// (défaut, socket local 0660) ou tcp:// (LAN), JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8 — x-etm-transport ;\n- SENS : le moteur est le CLIENT, il se connecte et envoie Handshake puis GetPlan ; la box n'ouvre aucun port. UpdateLearned va dans l'autre sens (x-etm-direction) ;\n- ERREURS de l'optimiseur (1.2) : par méthode dans methods[].errors, et pour un message qu'il ne peut pas traiter (ligne illisible, lot, ligne trop longue, méthode inconnue) dans x-etm-message-errors ; forme de error.data d'une erreur, quand elle est normée (1.3) : x-etm-data-schema de l'erreur, exigée à partir de x-etm-data-required-from ;\n- HEARTBEAT IMPLICITE et machine de repli §7 — x-etm-fallback-machine ;\n- VALIDATION SÉMANTIQUE du plan : le JSON Schema dit la forme ; ce que le validateur du moteur refuse en plus (charge inconnue, créneau courant incomplet…) est listé, règle par règle, dans x-etm-semantic-rules avec son fallbackCode ;\n- listes fermées : régénérées DEPUIS LE CODE (x-etm-generated), jamais recopiées à la main.\n\nForme côté moteur (requêtes) : STRICTE — additionalProperties: false ; un champ ajouté au sérialiseur sans le schéma fait échouer les tests. Forme côté optimiseur (réponses) : ouverte — un champ inconnu est ignoré (§9) ; le schéma dit ce qu'un optimiseur CONFORME envoie, x-etm-tolerances ce que le moteur accepte en plus."
},
"servers": [
{
@ -33,7 +33,7 @@
{
"name": "Handshake",
"summary": "Ouverture de session, à chaque (re)connexion (§3).",
"description": "Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé. Refus (1.2) : token absent ou différent → -32001 ; majeure incompatible → -32602 ; l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa réception. Un token envoyé alors que l'optimiseur n'en exige pas est accepté, sans erreur.",
"description": "Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé. Refus (1.2) : token absent ou différent → -32001 ; majeure incompatible → -32602 ; les deux fautes dans le même handshake → -32001, le token est jugé AVANT la majeure (1.3) ; l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa réception. Un token envoyé alors que l'optimiseur n'en exige pas est accepté, sans erreur.",
"paramStructure": "by-name",
"params": [
{
@ -42,7 +42,7 @@
"schema": {
"$ref": "#/components/schemas/ProtocolVersion"
},
"description": "Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme)."
"description": "Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme — -32001 si le token est faux aussi, 1.3)."
},
{
"name": "engine",
@ -71,13 +71,13 @@
{
"code": -32001,
"message": "token refusé",
"data": "Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ.",
"data": "Token configuré côté optimiseur, et absent ou différent dans le Handshake — jugé AVANT la majeure (1.3) : token faux et autre majeure dans le même Handshake → -32001. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ.",
"x-etm-connection": "fermée"
},
{
"code": -32602,
"message": "majeure incompatible",
"data": "Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2).",
"data": "Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2), le token étant accepté ou non exigé (1.3 : le token est jugé d'abord).",
"x-etm-connection": "fermée"
}
],
@ -193,7 +193,11 @@
{
"code": -32602,
"message": "contexte invalide",
"data": "Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait.",
"data": "Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Forme normée en 1.3 : InvalidContextData. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait.",
"x-etm-data-schema": {
"$ref": "#/components/schemas/InvalidContextData"
},
"x-etm-data-required-from": "1.3",
"x-etm-connection": "conservée"
},
{
@ -205,12 +209,12 @@
],
"x-etm-direction": "engine→optimizer",
"x-etm-timeout-s": 10,
"x-etm-errors-engine": "Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7)."
"x-etm-errors-engine": "Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7). error.data n'est PAS lu : ni décision, ni journal (le code seul), ni trace (le code de l'échec seul, rpc-error)."
},
{
"name": "UpdateLearned",
"summary": "Estimations apprises de l'optimiseur (§8) — notification, sans id.",
"description": "Envoyée par l'OPTIMISEUR, et seulement par lui (1.2) : sens inverse des deux autres, sans id, sans réponse. Reçue PAR UN OPTIMISEUR : notification ignorée ; requête avec id → -32601 (x-etm-message-errors). État du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme NON NORMÉE en 1.2 : elle sera fixée avec la première stratégie qui apprend.",
"description": "Envoyée par l'OPTIMISEUR, et seulement par lui (1.2) : sens inverse des deux autres, sans id, sans réponse. Reçue PAR UN OPTIMISEUR : notification ignorée ; requête avec id → -32601 (x-etm-message-errors). État du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme NON NORMÉE (1.2, 1.3) : elle sera fixée avec la première stratégie qui apprend.",
"paramStructure": "by-name",
"params": [
{
@ -223,7 +227,7 @@
"additionalProperties": true
}
},
"description": "Non normé en 1.2 : intention §8 (learned + confidence par charge)."
"description": "Non normé (1.2, 1.3) : intention §8 (learned + confidence par charge)."
}
],
"x-etm-direction": "optimizer→engine",
@ -234,7 +238,7 @@
"schemas": {
"ProtocolVersion": {
"description": "Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).",
"const": "1.2"
"const": "1.3"
},
"Trigger": {
"description": "Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.",
@ -1804,6 +1808,62 @@
"$ref": "#/components/schemas/Plan"
}
]
},
"InvalidContextData": {
"description": "error.data de la réponse -32602 à GetPlan (§7, 1.3). Exigé d'un optimiseur qui s'annonce 1.3 ou plus ; un optimiseur 1.2 peut répondre un autre data, le moteur ne le lit pas. Aucune valeur reçue n'y figure, dans aucun champ : la forme est fermée, le chemin suit un motif, la nature est un code d'une liste fermée.",
"type": "object",
"properties": {
"problems": {
"type": "array",
"minItems": 1,
"maxItems": 50,
"items": {
"$ref": "#/components/schemas/ContextProblem"
},
"description": "Les problèmes trouvés, de 1 à 50 ; s'il y en a davantage, les 50 premiers, et truncated."
},
"truncated": {
"const": true,
"description": "Présent, et vrai, seulement si des problèmes ont été omis (problems en compte alors 50) ; absent sinon."
}
},
"required": [
"problems"
],
"additionalProperties": false,
"x-etm-closed-why": "forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser"
},
"ContextProblem": {
"description": "Un problème du contexte GetPlan (1.3) : où, et de quelle nature — jamais la valeur reçue.",
"type": "object",
"properties": {
"path": {
"type": "string",
"pattern": "^params(\\.[A-Za-z0-9_]+|\\[[0-9]+\\])*$",
"description": "Chemin du champ fautif depuis params : noms séparés par des points, indices de liste entre crochets (params.forecast.pv[3].from). Champ absent : son chemin attendu."
},
"problem": {
"$ref": "#/components/schemas/ContextProblemCode"
}
},
"required": [
"path",
"problem"
],
"additionalProperties": false,
"x-etm-closed-why": "forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser"
},
"ContextProblemCode": {
"description": "Nature d'un problème du contexte (1.3), liste fermée : missing (obligatoire absent), type (type faux), range (hors bornes), format (chaîne mal formée : instant, durée…), enum (valeur hors liste), constraint (règle entre champs, ordre des créneaux…). Un code inconnu est ignoré par le moteur (§9).",
"type": "string",
"enum": [
"missing",
"type",
"range",
"format",
"enum",
"constraint"
]
}
}
},
@ -2105,7 +2165,7 @@
"countsAsFailure": false,
"needs": [],
"schemaAlsoCatches": false,
"description": "réponse error, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff."
"description": "réponse error, quel que soit son code (token refusé : -32001, jugé d'abord ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff."
},
{
"name": "invalid-handshake",
@ -2140,7 +2200,8 @@
"diagnostics, expected : non objets ignorés ; valeurs inconnues filtrées avant l'app.",
"abstain : toute valeur « vraie » au sens de QVariant::toBool (true, chaîne non vide autre que « false »/« 0 », nombre non nul).",
"champs inconnus (params et result) : ignorés (§9) ; force et champs réservés (annexe F) : ignorés et signalés une fois.",
"state : 3.0 accepté comme 3 (entier au sens JSON Schema)."
"state : 3.0 accepté comme 3 (entier au sens JSON Schema).",
"error.data d'une réponse error (1.3) : jamais lu — un code problem inconnu, un data d'une autre forme (optimiseur 1.2) ou absent ne changent rien : l'erreur reste un échec rpc-error."
]
},
"x-etm-reason-params": {

View File

@ -144,7 +144,7 @@ public:
Q_ENUM(State)
/*! \brief Version du protocole annoncée au handshake. Seule la majeure est comparée. */
static constexpr const char *kProtocolVersion = "1.2";
static constexpr const char *kProtocolVersion = "1.3";
static constexpr qint64 kHandshakeTimeoutMs = 5000; //!< §3, depuis le début de la tentative.
static constexpr qint64 kRequestTimeoutMs = 10000; //!< §4, par requête \c GetPlan.
static constexpr qint64 kBackoffInitialMs = 1000; //!< §2, premier délai de reconnexion.

View File

@ -28,7 +28,7 @@
namespace OptimizerProtocol {
//! Version du protocole parlée par ce sérialiseur (§3, §9).
constexpr const char *ProtocolVersion = "1.2";
constexpr const char *ProtocolVersion = "1.3";
//! Ressource Qt du contrat OpenRPC embarqué (\c docs/etm-powersync-optimizer-protocol.openrpc.json).
constexpr const char *SchemaResource = ":/etm-powersync/optimizer-protocol.openrpc.json";

View File

@ -288,9 +288,9 @@ void TestOptimizerClient::testHandshakeAndPlan()
QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake"));
QCOMPARE(hs.value("id").toInteger(), 1);
const QJsonObject params = hs.value("params").toObject();
// Protocole 1.2 : la version ANNONCÉE au handshake est celle que le moteur sert
// Protocole 1.3 : la version ANNONCÉE au handshake est celle que le moteur sert
// (GetOptimizerProtocol) et celle du contrat — deux constantes, une seule valeur.
QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.2"));
QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.3"));
QCOMPARE(QString::fromLatin1(OptimizerClient::kProtocolVersion), QString::fromLatin1(OptimizerProtocol::ProtocolVersion));
QCOMPARE(params.value("engine").toString(), QStringLiteral("etm-powersync-energy-plugin-etm/9.9"));
QVERIFY(!params.contains("token"));
@ -354,7 +354,7 @@ void TestOptimizerClient::testWrongMajorIsRefused()
QCOMPARE(readySpy.count(), 0);
QCOMPARE(client.requestPlan({}), quint64(0));
QVERIFY(waitFor([&] { return server.disconnections == 1; }));
QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.2 — majeures différentes, refusé"), 1);
QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.3 — majeures différentes, refusé"), 1);
// Nouvelle tentative au backoff (§3), pas avant.
clock.advance(999);

View File

@ -2341,7 +2341,7 @@ void TestSocketScheduler::testTheEngineSpeaksTheContractItServes()
cycle(t0.addSecs(60 * i), -3000);
const QVariantMap r = rpc("GetOptimizerProtocol");
QCOMPARE(r.value("protocolVersion").toString(), QString("1.2"));
QCOMPARE(r.value("protocolVersion").toString(), QString("1.3"));
const QJsonObject servi = QJsonObject::fromVariantMap(r.value("schema").toMap());
const QJsonObject embarque = QJsonDocument::fromJson(OptimizerProtocol::schemaDocumentBytes()).object();
QVERIFY(!embarque.isEmpty());

View File

@ -122,6 +122,19 @@ class TestGenProtocolDoc(unittest.TestCase):
self.assertEqual(lancer(*self.args())[0], 0)
self.assertIn('| `-32099` | toute | erreur ajoutée | conservée | contre-épreuve |', self.lire(self.prose))
def test_la_forme_d_error_data_se_lit_dans_sa_table(self):
# Protocole 1.3 : le motif du chemin porte une alternative — un `|` nu fermerait la colonne,
# et la ligne aurait six cellules au lieu de cinq. Une forme de l'OPTIMISEUR fermée à dessein
# ne se dit pas « émise par le moteur ».
p = self.lire(self.prose)
ligne = next(l for l in p.splitlines() if l.startswith('| `path` | string |'))
self.assertEqual(len(ligne.replace('\\|', '').split('|')), 7, ligne)
self.assertIn('motif `^params(\\.[A-Za-z0-9_]+\\|\\[[0-9]+\\])*$`', ligne)
debut = p.index('<!-- BEGIN GENERATED table:InvalidContextData -->')
bloc = p[debut:p.index('<!-- END GENERATED -->', debut)]
self.assertIn('**refusés** (forme de l\'optimiseur fermée à dessein', bloc)
self.assertNotIn('émise par le moteur', bloc)
def test_des_balises_cassees_sont_une_erreur(self):
p = self.lire(self.prose)
i = p.index('<!-- END GENERATED -->')

View File

@ -37,6 +37,9 @@ GOLDEN = sorted(glob.glob(os.path.join(RACINE, 'tests', 'auto', 'loadmodel', 'da
TRACES = sorted(glob.glob(os.path.join(ICI, 'data', '*.ndjson')))
REQUETE = os.path.join(RACINE, 'tests', 'auto', 'loadmodel', 'data', 'optimizer', 'site-pv-ecs-borne-sgready.json')
TOKEN = 'jeton-de-test-0123456789'
with open(SCHEMA, encoding='utf-8') as _f:
# La version du MOTEUR, celle que le contrat impose à ses Handshake (const) — jamais recopiée.
VERSION_MOTEUR = json.load(_f)['components']['schemas']['ProtocolVersion']['const']
# mode → (qui rejette, verdict sémantique attendu). « schéma:<mot-clé> » : écart de forme ;
# « règle » : la forme passe, une règle x-etm-semantic-rules refuse ; « — » : rien ne rejette.
@ -100,7 +103,7 @@ def session(mode, dossier):
with open(REQUETE, encoding='utf-8') as f:
params = json.load(f)
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake',
'params': {'protocolVersion': '1.2', 'engine': 'etm-powersync-energy-plugin-etm/test',
'params': {'protocolVersion': VERSION_MOTEUR, 'engine': 'etm-powersync-energy-plugin-etm/test',
'token': TOKEN}}).encode() + b'\n')
hs = lire_ligne(s, tampon)
if hs and str(hs.get('result', {}).get('protocolVersion', '')).startswith('1'):

View File

@ -145,14 +145,15 @@ class Rendu:
chemin = f'{prefixe}{nom}'
r = self.resoudre(s)
sens = self.sens(s) or (self.sens(r) if self.nom_ref(s) and r.get('type') not in ('object',) else '')
# Un `|` dans une cellule (alternative d'un motif) fermerait la colonne : échappé.
out.append(f'| `{chemin}` | {self.type_de(s)} | {"oui" if nom in requis else "non"} | '
f'{self.valeurs(s)} | {sens} |')
f'{self.valeurs(s).replace("|", chr(92) + "|")} | {sens} |')
# Un objet EN LIGNE se déroule ; un composant nommé a sa propre table.
if self.nom_ref(s) is None and isinstance(s, dict) and isinstance(s.get('properties'), dict):
out += self.lignes(s['properties'], s.get('required', []), chemin + '.')
return out
def table(self, props, requis, titre=None, description=None, ouvert=None):
def table(self, props, requis, titre=None, description=None, ouvert=None, ferme_pourquoi=None):
out = []
if titre:
out.append(f'**{titre}**' + (f' — {description}' if description else ''))
@ -162,8 +163,13 @@ class Rendu:
out += self.lignes(props, requis)
if ouvert is not None:
out.append('')
out.append('Champs non déclarés : ' + ('**refusés** (forme émise par le moteur, fermée).' if ouvert is False
else 'ignorés (§9).'))
if ouvert is not False:
out.append('Champs non déclarés : ignorés (§9).')
else:
# Une forme fermée est d'ordinaire celle qu'émet le moteur ; une forme de l'optimiseur
# fermée À DESSEIN dit pourquoi (`x-etm-closed-why`, 1.3 : error.data).
out.append('Champs non déclarés : **refusés** ('
+ (ferme_pourquoi or 'forme émise par le moteur, fermée') + ').')
return out
def composant(self, nom):
@ -184,7 +190,8 @@ class Rendu:
if 'properties' not in s:
return [f'`{nom}` : {self.type_de(s)} — {self.valeurs(s)}. {self.sens(s)}'.rstrip()]
out = self.table(s['properties'], s.get('required', []), titre=f'`{nom}`',
description=self.sens(s) or None, ouvert=s.get('additionalProperties', True))
description=self.sens(s) or None, ouvert=s.get('additionalProperties', True),
ferme_pourquoi=s.get('x-etm-closed-why'))
if 'anyOf' in s and all(set(b) == {'required'} for b in s['anyOf']):
out.append('Au moins un de : ' + ', '.join('`' + '`+`'.join(b['required']) + '`' for b in s['anyOf']) + '.')
return out