Fusion de feature/protocole-1.3 dans release/etm74 (protocole optimiseur 1.3 : forme d'error.data, token avant la majeure)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
This commit is contained in:
Patrick Schurig 2026-10-23 15:02:23 +02:00
commit 9b974ff974
15 changed files with 817 additions and 92 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) 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). 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é. 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. 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 > ### 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 ## 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) ### 1.2 (2026-10)
Mineure. Norme les points que le premier optimiseur réel a trouvés ouverts : erreurs renvoyées par 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, 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 (`[…]`) 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 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 (`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 - **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 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 `-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 ```json
{"jsonrpc":"2.0","id":1,"method":"Handshake","params":{ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{
"protocolVersion":"1.2", "protocolVersion":"1.3",
"engine":"etm-powersync-energy-plugin-etm/<version>", "engine":"etm-powersync-energy-plugin-etm/<version>",
"token":"<secret partagé, optionnel — requis si configuré côté plugin>" "token":"<secret partagé, optionnel — requis si configuré côté plugin>"
}} }}
@ -186,7 +217,7 @@ Réponse attendue :
```json ```json
{"jsonrpc":"2.0","id":1,"result":{ {"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"1.2", "protocolVersion":"1.3",
"name":"etm-optimizer", "name":"etm-optimizer",
"planning":true, "planning":true,
"capabilities":{ "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 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 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). 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 - **`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 connexion — le client peut encore envoyer `Handshake`. Le moteur n'en envoie jamais : il
n'appelle `GetPlan` qu'une fois le handshake accepté. n'appelle `GetPlan` qu'une fois le handshake accepté.
@ -236,7 +272,7 @@ Réponse attendue :
| Champ | Type | Obligatoire | Valeurs | Sens | | 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. | | `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é. | | `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 protocole. Une situation que le tableau ne nomme pas prend le code standard qui lui correspond
(`-32603` erreur interne, par exemple). (`-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 --> <!-- 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. --> <!-- 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 | | 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É. | | `-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). | | `-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. | | `-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. | | `-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. | | `-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é. | | `-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 : 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. - **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 --> <!-- 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. | | `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. | | `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). | | `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. | | `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. | | `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 - **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`), un optimiseur : en notification (sans `id`), ignorée sans réponse ; en requête (avec `id`),
erreur `-32601` (méthode inconnue). 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 contrat admet, pas ce qu'un moteur en fera : la forme sera fixée avec la première stratégie qui
apprend. apprend.
- Tant que `confidence < 0.7` (seuil indicatif), l'app peut afficher un badge - 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 | | 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). 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 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. 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 - 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. à aligner avant de s'annoncer de cette mineure, pas comme une rupture.
- Champs inconnus dans `params`/`result` : **ignorés silencieusement**. - Champs inconnus dans `params`/`result` : **ignorés silencieusement**.
- Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0. - 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 ## 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,"method":"Handshake","params":{"protocolVersion":"1.3","engine":"etm-powersync-energy-plugin-etm/<version>"}}
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2","name":"mon-optimiseur","planning":false}} ← {"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,"method":"GetPlan","params":{ ...SurplusContext... }}
← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}} ← {"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 : 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 ← {"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, 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. 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`), 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 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é 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. 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é). - 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 - **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 ## Annexe E — Optimiseur de référence
`tools/etm-powersync-ref-optimizer/` : serveur Python minimal (bibliothèque standard seule) `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 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 `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`. 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` - **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 (`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, 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`). - **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", "openrpc": "1.3.2",
"info": { "info": {
"title": "etm-powersync — protocole moteur ↔ optimiseur", "title": "etm-powersync — protocole moteur ↔ optimiseur",
"version": "1.2", "version": "1.3",
"license": { "license": {
"name": "GPL-3.0-or-later", "name": "GPL-3.0-or-later",
"url": "https://www.gnu.org/licenses/gpl-3.0.html" "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": [ "servers": [
{ {
@ -33,7 +33,7 @@
{ {
"name": "Handshake", "name": "Handshake",
"summary": "Ouverture de session, à chaque (re)connexion (§3).", "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", "paramStructure": "by-name",
"params": [ "params": [
{ {
@ -42,7 +42,7 @@
"schema": { "schema": {
"$ref": "#/components/schemas/ProtocolVersion" "$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", "name": "engine",
@ -71,13 +71,13 @@
{ {
"code": -32001, "code": -32001,
"message": "token refusé", "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" "x-etm-connection": "fermée"
}, },
{ {
"code": -32602, "code": -32602,
"message": "majeure incompatible", "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" "x-etm-connection": "fermée"
} }
], ],
@ -193,7 +193,11 @@
{ {
"code": -32602, "code": -32602,
"message": "contexte invalide", "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" "x-etm-connection": "conservée"
}, },
{ {
@ -205,12 +209,12 @@
], ],
"x-etm-direction": "engine→optimizer", "x-etm-direction": "engine→optimizer",
"x-etm-timeout-s": 10, "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", "name": "UpdateLearned",
"summary": "Estimations apprises de l'optimiseur (§8) — notification, sans id.", "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", "paramStructure": "by-name",
"params": [ "params": [
{ {
@ -223,7 +227,7 @@
"additionalProperties": true "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", "x-etm-direction": "optimizer→engine",
@ -234,7 +238,7 @@
"schemas": { "schemas": {
"ProtocolVersion": { "ProtocolVersion": {
"description": "Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).", "description": "Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).",
"const": "1.2" "const": "1.3"
}, },
"Trigger": { "Trigger": {
"description": "Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.", "description": "Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.",
@ -1804,6 +1808,62 @@
"$ref": "#/components/schemas/Plan" "$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, "countsAsFailure": false,
"needs": [], "needs": [],
"schemaAlsoCatches": false, "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", "name": "invalid-handshake",
@ -2140,7 +2200,8 @@
"diagnostics, expected : non objets ignorés ; valeurs inconnues filtrées avant l'app.", "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).", "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.", "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": { "x-etm-reason-params": {

View File

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

View File

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

View File

@ -15,11 +15,13 @@ adaptateurs, sur les mocks de la suite `simulation` — et vérifie, une fonctio
| `testARefusedHandshakeIsClosedByTheOptimizer` | protocole 1.2, §3 : handshake d'une autre majeure → `-32602`, puis l'optimiseur **ferme** ; si un token est attendu, token faux → `-32001`, fermé pareil | | `testARefusedHandshakeIsClosedByTheOptimizer` | protocole 1.2, §3 : handshake d'une autre majeure → `-32602`, puis l'optimiseur **ferme** ; si un token est attendu, token faux → `-32001`, fermé pareil |
| `testABatchIsRefused` | protocole 1.2, §2 : un lot `[…]` → **une** erreur `-32600` à `id: null`, rien du lot traité, connexion gardée | | `testABatchIsRefused` | protocole 1.2, §2 : un lot `[…]` → **une** erreur `-32600` à `id: null`, rien du lot traité, connexion gardée |
| `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue | | `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue |
| `testAnInvalidContextIsRefusedWithItsProblems` | protocole 1.3, §7 : un `GetPlan` dont le contexte est invalide (`{"timestamp": "valeur-sonde:7Qz9"}`) → `-32602` à son `id`, connexion **gardée** ; `error.data` jugé par le validateur publié contre le contrat embarqué (`--responses-only`) : forme `{"problems":[{path, problem}]}` **exigée** d'un optimiseur qui s'annonce 1.3 ou plus, **avertie** (`AVERTISSEMENT`, test vert) d'un optimiseur 1.2 ; la valeur reçue recopiée dans `error.data` est **refusée** quelle que soit la version (règle de 1.2) |
| `testTheTokenIsCheckedBeforeTheMajor` | protocole 1.3, §3 : token faux **et** autre majeure dans le même handshake → `-32001` (le token d'abord), puis l'optimiseur ferme. Exigé à partir de 1.3 ; `-32602` d'un optimiseur 1.2 est averti. **SKIP** sans `ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE` : sans token attendu, l'ordre ne s'éprouve pas |
| `testTheSessionMatchesTheContractOffUtc` | la fonction 3, le moteur sous un fuseau **non UTC** (`Europe/Berlin`) et cadencé en heure locale comme en production, à travers le passage à l'heure d'hiver (02:58 CEST → 02:01 CET) : aucun échec de requête, session conforme au contrat embarqué (tout instant porte son décalage, §5.2) | | `testTheSessionMatchesTheContractOffUtc` | la fonction 3, le moteur sous un fuseau **non UTC** (`Europe/Berlin`) et cadencé en heure locale comme en production, à travers le passage à l'heure d'hiver (02:58 CEST → 02:01 CET) : aucun échec de requête, session conforme au contrat embarqué (tout instant porte son décalage, §5.2) |
Ces deux dernières lignes valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**. Les lignes `testEveryPlanIsAccepted` et `testTheSafetyLoopStaysOnTop` valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**.
Les fonctions 6 à 9 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce Les fonctions 6 à 9, 11 et 12 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce
que son client n'envoie jamais, et lisent la réponse brute (un lot renvoyé se verrait). Elles que son client n'envoie jamais, et lisent la réponse brute (un lot renvoyé se verrait). Elles
utilisent des requêtes dont la réponse est normée sans contexte — `UpdateLearned` avec `id` → utilisent des requêtes dont la réponse est normée sans contexte — `UpdateLearned` avec `id` →
`-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité. `-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité.
@ -42,7 +44,8 @@ export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {end
for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \ for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \
testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \ testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \
testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused \ testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused \
testTheSessionMatchesTheContractOffUtc; do testTheSessionMatchesTheContractOffUtc testAnInvalidContextIsRefusedWithItsProblems \
testTheTokenIsCheckedBeforeTheMajor; do
systemd-run --user --scope -q -p MemoryMax=2G \ systemd-run --user --scope -q -p MemoryMax=2G \
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \ bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \
--bind <sources> <sources> --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn" --bind <sources> <sources> --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn"
@ -61,6 +64,19 @@ commande vit dans le même réseau privé (la boucle locale y fonctionne, `tcp:/
| `ETM_POWERSYNC_SOURCE_DIR` | sources du build | Racine où trouver le validateur et l'optimiseur de référence | | `ETM_POWERSYNC_SOURCE_DIR` | sources du build | Racine où trouver le validateur et l'optimiseur de référence |
| `ETM_POWERSYNC_CONFORMANCE_EXPECT` | `plan` | Ce que la suite attend : `plan` (l'optimiseur commande) ou `abstain` (il s'abstient à chaque cycle). Toute autre valeur fait échouer | | `ETM_POWERSYNC_CONFORMANCE_EXPECT` | `plan` | Ce que la suite attend : `plan` (l'optimiseur commande) ou `abstain` (il s'abstient à chaque cycle). Toute autre valeur fait échouer |
**Un optimiseur 1.2 face aux règles 1.3.** Une mineure ajoute des règles (protocole §9) : la suite
lit la version que l'optimiseur annonce au handshake et n'exige la forme d'`error.data` et l'ordre
token puis majeure qu'à partir de 1.3. En deçà, l'écart est écrit en `AVERTISSEMENT` et la fonction
reste verte — l'optimiseur fonctionne avec ce moteur, il s'aligne avant de s'annoncer 1.3. Pour
éprouver l'ordre, donner un token (`ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE`, et le même fichier à
l'optimiseur) :
```sh
printf 'jeton-de-conformite-0123456789' > /chemin/token
export ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE=/chemin/token
export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint} --token-file /chemin/token'
```
L'attente de démarrage ouvre une connexion de sonde, refermée sans handshake : un serveur conforme L'attente de démarrage ouvre une connexion de sonde, refermée sans handshake : un serveur conforme
la tolère. Un échec dit ce qui manque — code d'erreur du client, écart au contrat (chemin, mot-clé), la tolère. Un échec dit ce qui manque — code d'erreur du client, écart au contrat (chemin, mot-clé),
cycles non appliqués avec leur `fallbackCode`, latences. cycles non appliqués avec leur `fallbackCode`, latences.
@ -95,7 +111,7 @@ fondée sur ces champs jugerait faux dans les deux cas : le mode se donne, il ne
La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence
seulement), les bornes VE (aucune borne au banc), `UpdateLearned` émis par l'optimiseur (ignoré par le moteur ; sa seulement), les bornes VE (aucune borne au banc), `UpdateLearned` émis par l'optimiseur (ignoré par le moteur ; sa
forme n'est pas normée en 1.2), le token envoyé à un optimiseur qui n'en exige pas (§3, 1.2 : accepté ; forme n'est pas normée en 1.2), le token envoyé à un optimiseur qui n'en exige pas (§3, 1.2 : accepté ;
le moteur n'envoie de token que si l'installateur en a posé un), la réponse à un contexte `GetPlan` le moteur n'envoie de token que si l'installateur en a posé un), l'exhaustivité de la validation du
invalide (§7, 1.2 : `-32602` ; la forme d'`error.data` n'est pas normée), le comportement sous défaut contexte par l'optimiseur (un seul contexte invalide est envoyé ; il en suffit d'un problème pour
de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json` juger la forme), le comportement sous défaut de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json`
(prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G). (prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G).

View File

@ -22,7 +22,11 @@
// autre majeure (-32602 puis fermeture par l'optimiseur), lot (-32600 à id null, rien du lot // autre majeure (-32602 puis fermeture par l'optimiseur), lot (-32600 à id null, rien du lot
// traité), ligne de plus de 1 Mio (-32600 à id null, connexion gardée) ; // traité), ligne de plus de 1 Mio (-32600 à id null, connexion gardée) ;
// 10. le point 3, le moteur réglé sur un fuseau NON UTC (Europe/Berlin) et cadencé en heure // 10. le point 3, le moteur réglé sur un fuseau NON UTC (Europe/Berlin) et cadencé en heure
// locale comme en production, à travers le passage à l'heure d'hiver. // locale comme en production, à travers le passage à l'heure d'hiver ;
// 11 et 12 (protocole 1.3), par la sonde : un contexte GetPlan invalide → -32602 dont `error.data`
// a la forme normée, jugée par le validateur publié — exigée d'un optimiseur qui s'annonce 1.3
// ou plus, avertie pour un 1.2 ; la valeur reçue n'y est jamais recopiée. Token faux ET autre
// majeure dans le même handshake → -32001 (le token d'abord), puis fermeture.
// //
// Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur // Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur
// qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque // qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque
@ -420,6 +424,8 @@ private slots:
void testABatchIsRefused(); void testABatchIsRefused();
void testALineOverTheLimitIsRefused(); void testALineOverTheLimitIsRefused();
void testTheSessionMatchesTheContractOffUtc(); void testTheSessionMatchesTheContractOffUtc();
void testAnInvalidContextIsRefusedWithItsProblems();
void testTheTokenIsCheckedBeforeTheMajor();
private: private:
void monter(); void monter();
@ -431,8 +437,8 @@ private:
void sonder(std::unique_ptr<Sonde> &sonde); void sonder(std::unique_ptr<Sonde> &sonde);
//! Un Handshake de sonde en \p version, avec \p token s'il n'est pas vide. //! Un Handshake de sonde en \p version, avec \p token s'il n'est pas vide.
QByteArray handshake(int id, const QString &version, const QString &token) const; QByteArray handshake(int id, const QString &version, const QString &token) const;
//! Handshake accepté sur \p sonde, ou échec du test. //! Handshake accepté sur \p sonde, ou échec du test ; \p reponse : le `result` reçu, si demandé.
void ouvrirSession(Sonde &sonde, int id); void ouvrirSession(Sonde &sonde, int id, QJsonObject *reponse = nullptr);
//! Lance l'optimiseur et y branche le moteur (directement, ou par le relais). //! Lance l'optimiseur et y branche le moteur (directement, ou par le relais).
void brancher(bool parLeRelais); void brancher(bool parLeRelais);
void cycle(const QDateTime &t); void cycle(const QDateTime &t);
@ -846,7 +852,7 @@ QByteArray TestOptimizerConformance::handshake(int id, const QString &version, c
.toJson(QJsonDocument::Compact); .toJson(QJsonDocument::Compact);
} }
void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id) void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id, QJsonObject *reponse)
{ {
sonde.envoyer(handshake(id, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token)); sonde.envoyer(handshake(id, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token));
QByteArray ligne; QByteArray ligne;
@ -854,8 +860,33 @@ void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id)
const QJsonObject m = QJsonDocument::fromJson(ligne).object(); const QJsonObject m = QJsonDocument::fromJson(ligne).object();
QVERIFY2(m.value("id").toInt() == id && m.value("result").toObject().value("protocolVersion").isString(), QVERIFY2(m.value("id").toInt() == id && m.value("result").toObject().value("protocolVersion").isString(),
qUtf8Printable("handshake de sonde refusé : " + QString::fromUtf8(ligne.left(300)))); qUtf8Printable("handshake de sonde refusé : " + QString::fromUtf8(ligne.left(300))));
if (reponse)
*reponse = m;
} }
namespace {
//! « 1.3 » → 103 ; illisible → -1. Seules majeure et mineure comptent (§9).
int versionComparable(const QString &v)
{
bool okMajeure = false, okMineure = true;
const int majeure = v.section('.', 0, 0).toInt(&okMajeure);
const int mineure = v.contains('.') ? v.section('.', 1, 1).toInt(&okMineure) : 0;
return okMajeure && okMineure ? majeure * 100 + mineure : -1;
}
//! Version à partir de laquelle les règles 1.3 sont EXIGÉES ; en deçà, un écart est averti.
constexpr int kExigeDepuis = 103;
//! Vrai si \p annoncee est LISIBLE et antérieure à 1.3 — une version illisible est jugée 1.3.
bool avantRegles13(const QString &annoncee)
{
const int v = versionComparable(annoncee);
return v >= 0 && v < kExigeDepuis;
}
} // namespace
void TestOptimizerConformance::testGetPlanBeforeTheHandshakeIsRefused() void TestOptimizerConformance::testGetPlanBeforeTheHandshakeIsRefused()
{ {
// §3 (1.2) : GetPlan avant un handshake réussi → -32600, et la connexion est GARDÉE — le // §3 (1.2) : GetPlan avant un handshake réussi → -32600, et la connexion est GARDÉE — le
@ -983,5 +1014,111 @@ void TestOptimizerConformance::testTheSessionMatchesTheContractOffUtc()
jugerLaSession(); jugerLaSession();
} }
// --- 11 et 12 : protocole 1.3, par une connexion de sonde --------------------------------------------
void TestOptimizerConformance::testAnInvalidContextIsRefusedWithItsProblems()
{
// §7 (1.2) : un contexte GetPlan invalide → -32602, connexion gardée. §7 (1.3) : `error.data`
// = {"problems":[{path, problem}]}, jamais la valeur reçue. La forme est jugée par le VALIDATEUR
// PUBLIÉ, sur la session de sonde (--responses-only : la requête est fautive à dessein) et
// contre le contrat EMBARQUÉ ; c'est lui qui exige ou avertit selon la version que
// l'optimiseur a annoncée. Le contexte porte une valeur qu'aucun chemin conforme ne peut
// contenir : si elle revient dans `error.data`, c'est une recopie.
static const QString marqueur = QStringLiteral("valeur-sonde:7Qz9");
std::unique_ptr<Sonde> sonde;
sonder(sonde);
if (QTest::currentTestFailed())
return;
QJsonObject hsReponse;
ouvrirSession(*sonde, 1, &hsReponse);
if (QTest::currentTestFailed())
return;
const QString annoncee = hsReponse.value("result").toObject().value("protocolVersion").toString();
const QJsonObject getPlan{{"jsonrpc", "2.0"}, {"id", 2}, {"method", "GetPlan"},
{"params", QJsonObject{{"timestamp", marqueur}}}};
sonde->envoyer(QJsonDocument(getPlan).toJson(QJsonDocument::Compact));
QByteArray ligne;
QVERIFY2(sonde->recevoir(&ligne), "contexte invalide : aucune réponse (ou connexion fermée)");
const QString e = verifierErreur(ligne, 2, -32602);
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
const QJsonObject reponse = QJsonDocument::fromJson(ligne).object();
// Garde indépendante du validateur : la valeur reçue n'est nulle part dans `error.data`.
const QJsonValue data = reponse.value("error").toObject().value("data");
const QByteArray dataTexte = QJsonDocument(QJsonArray{data}).toJson(QJsonDocument::Compact);
QVERIFY2(!dataTexte.contains(marqueur.toUtf8()),
"error.data recopie la valeur reçue (params.timestamp) — jamais la valeur reçue (§7)");
// La session de sonde, jugée par le validateur publié. Le token y devient des astérisques.
QJsonObject hsParams = QJsonDocument::fromJson(handshake(1, QString::fromLatin1(OptimizerProtocol::ProtocolVersion),
m_token)).object();
if (!m_token.isEmpty()) {
QJsonObject p = hsParams.value("params").toObject();
p.insert("token", QString(m_token.size(), QLatin1Char('*')));
hsParams.insert("params", p);
}
QTemporaryDir dir;
QVERIFY(dir.isValid());
const Contrat::Verdict v = Contrat::juger({hsParams, hsReponse, getPlan, reponse},
Contrat::ecrireSchema(dir, OptimizerProtocol::schemaDocumentBytes()),
{QStringLiteral("--responses-only")});
QVERIFY2(v.code == 0, qUtf8Printable(QStringLiteral("optimiseur en %1 — error.data NON CONFORME au contrat :\n")
.arg(annoncee.left(16)) + v.sortie.right(4000)));
// Le contrôle a bien eu lieu : un validateur qui ne trouverait plus la forme dans le contrat
// passerait au vert sans rien juger.
QVERIFY2(v.compte("GetPlan.error.data") == 1, qUtf8Printable(v.sortie.right(1000)));
const int avertissements = v.resume.value("avertissements").toInt();
for (const QString &l : v.sortie.split('\n'))
if (l.startsWith(QStringLiteral("AVERTISSEMENT")))
qWarning().noquote() << "[Conformité]" << l;
// Un avertissement n'est permis qu'à un optimiseur d'avant 1.3 : au-delà, c'est un écart.
QVERIFY2(avertissements == 0 || avantRegles13(annoncee), qUtf8Printable(v.sortie.right(1000)));
qInfo().noquote() << "[Conformité] contexte invalide : -32602, optimiseur en" << annoncee
<< (avertissements ? QStringLiteral("— forme d'error.data AVERTIE (%1), à aligner avant 1.3").arg(avertissements)
: QStringLiteral("— forme d'error.data conforme"));
// Connexion gardée : la sonde peut encore parler.
sonde->envoyer(R"({"jsonrpc":"2.0","id":3,"method":"UpdateLearned","params":{}})");
QVERIFY2(sonde->recevoir(&ligne), "après le contexte invalide : aucune réponse (ou connexion fermée)");
const QString e2 = verifierErreur(ligne, 3, -32601);
QVERIFY2(e2.isEmpty(), qUtf8Printable(e2));
}
void TestOptimizerConformance::testTheTokenIsCheckedBeforeTheMajor()
{
// §3 (1.3) : token faux ET autre majeure dans le même handshake → -32001, le token d'abord,
// puis l'optimiseur ferme. Exigé d'un optimiseur qui s'annonce 1.3 ou plus ; un optimiseur 1.2
// qui juge la majeure d'abord (-32602) est averti. Sans token attendu, rien à éprouver.
std::unique_ptr<Sonde> sonde;
sonder(sonde);
if (QTest::currentTestFailed())
return;
if (m_token.isEmpty())
QSKIP("aucun token attendu (ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE) : ordre token puis majeure non éprouvé");
QJsonObject hsReponse;
ouvrirSession(*sonde, 1, &hsReponse); // la version annoncée, sur une session acceptée
if (QTest::currentTestFailed())
return;
const QString annoncee = hsReponse.value("result").toObject().value("protocolVersion").toString();
const QString autreMajeure = QString::number(QString::fromLatin1(OptimizerProtocol::ProtocolVersion).section('.', 0, 0).toInt() + 1) + ".0";
Sonde faux(endpoint());
QVERIFY(faux.ouverte());
faux.envoyer(handshake(1, autreMajeure, m_token + QStringLiteral("-faux")));
QByteArray ligne;
QVERIFY2(faux.recevoir(&ligne), "token faux et autre majeure : aucune réponse avant la fermeture");
const QString e = verifierErreur(ligne, 1, -32001);
if (!e.isEmpty()) {
// La majeure jugée d'abord : -32602 (règle 1.2), un écart d'ordre seulement.
const bool majeureDabord = verifierErreur(ligne, 1, -32602).isEmpty();
QVERIFY2(majeureDabord && avantRegles13(annoncee),
qUtf8Printable(QStringLiteral("optimiseur en %1 : ").arg(annoncee.left(16)) + e));
qWarning().noquote() << "[Conformité] AVERTISSEMENT optimiseur en" << annoncee.left(16)
<< ": token faux et autre majeure → -32602, la majeure jugée avant le token —"
" -32001 exigé à partir de 1.3, à aligner avant de s'annoncer 1.3";
} else {
qInfo().noquote() << "[Conformité] token faux et autre majeure : -32001, le token d'abord (optimiseur en"
<< annoncee.left(16) + ")";
}
QVERIFY2(faux.fermeeParLautre(5000), "handshake refusé : l'optimiseur n'a pas fermé la connexion en 5 s");
}
QTEST_MAIN(TestOptimizerConformance) QTEST_MAIN(TestOptimizerConformance)
#include "testconformance.moc" #include "testconformance.moc"

View File

@ -288,9 +288,9 @@ void TestOptimizerClient::testHandshakeAndPlan()
QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake")); QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake"));
QCOMPARE(hs.value("id").toInteger(), 1); QCOMPARE(hs.value("id").toInteger(), 1);
const QJsonObject params = hs.value("params").toObject(); 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. // (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(QString::fromLatin1(OptimizerClient::kProtocolVersion), QString::fromLatin1(OptimizerProtocol::ProtocolVersion));
QCOMPARE(params.value("engine").toString(), QStringLiteral("etm-powersync-energy-plugin-etm/9.9")); QCOMPARE(params.value("engine").toString(), QStringLiteral("etm-powersync-energy-plugin-etm/9.9"));
QVERIFY(!params.contains("token")); QVERIFY(!params.contains("token"));
@ -354,7 +354,7 @@ void TestOptimizerClient::testWrongMajorIsRefused()
QCOMPARE(readySpy.count(), 0); QCOMPARE(readySpy.count(), 0);
QCOMPARE(client.requestPlan({}), quint64(0)); QCOMPARE(client.requestPlan({}), quint64(0));
QVERIFY(waitFor([&] { return server.disconnections == 1; })); 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. // Nouvelle tentative au backoff (§3), pas avant.
clock.advance(999); clock.advance(999);

View File

@ -2341,7 +2341,7 @@ void TestSocketScheduler::testTheEngineSpeaksTheContractItServes()
cycle(t0.addSecs(60 * i), -3000); cycle(t0.addSecs(60 * i), -3000);
const QVariantMap r = rpc("GetOptimizerProtocol"); 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 servi = QJsonObject::fromVariantMap(r.value("schema").toMap());
const QJsonObject embarque = QJsonDocument::fromJson(OptimizerProtocol::schemaDocumentBytes()).object(); const QJsonObject embarque = QJsonDocument::fromJson(OptimizerProtocol::schemaDocumentBytes()).object();
QVERIFY(!embarque.isEmpty()); QVERIFY(!embarque.isEmpty());

View File

@ -122,6 +122,19 @@ class TestGenProtocolDoc(unittest.TestCase):
self.assertEqual(lancer(*self.args())[0], 0) self.assertEqual(lancer(*self.args())[0], 0)
self.assertIn('| `-32099` | toute | erreur ajoutée | conservée | contre-épreuve |', self.lire(self.prose)) 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): def test_des_balises_cassees_sont_une_erreur(self):
p = self.lire(self.prose) p = self.lire(self.prose)
i = p.index('<!-- END GENERATED -->') i = p.index('<!-- END GENERATED -->')

View File

@ -13,7 +13,9 @@ Trois choses :
« bons » sont valides, chaque mode « mauvais » est rejeté par le schéma OU par une règle « bons » sont valides, chaque mode « mauvais » est rejeté par le schéma OU par une règle
sémantique nommée — et laquelle (`TABLE_MODES`, recopiée au rapport) ; sémantique nommée — et laquelle (`TABLE_MODES`, recopiée au rapport) ;
3. le validateur lui-même ne peut pas se taire : un mot-clé non géré, une règle orpheline, un 3. le validateur lui-même ne peut pas se taire : un mot-clé non géré, une règle orpheline, un
champ ajouté, un mauvais type le font échouer. champ ajouté, un mauvais type le font échouer ;
4. `error.data` du -32602 de GetPlan (protocole 1.3) : forme exigée d'un optimiseur 1.3, avertie
d'un optimiseur 1.2, recopie d'une valeur reçue refusée des deux.
""" """
import copy import copy
@ -37,6 +39,9 @@ GOLDEN = sorted(glob.glob(os.path.join(RACINE, 'tests', 'auto', 'loadmodel', 'da
TRACES = sorted(glob.glob(os.path.join(ICI, 'data', '*.ndjson'))) 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') REQUETE = os.path.join(RACINE, 'tests', 'auto', 'loadmodel', 'data', 'optimizer', 'site-pv-ecs-borne-sgready.json')
TOKEN = 'jeton-de-test-0123456789' 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 ; # 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. # « règle » : la forme passe, une règle x-etm-semantic-rules refuse ; « — » : rien ne rejette.
@ -56,8 +61,16 @@ TABLE_MODES = {
'unknown-load': ('règle', 'unknown-load'), 'unknown-load': ('règle', 'unknown-load'),
'forbidden-kind': ('règle', 'kind-mismatch'), 'forbidden-kind': ('règle', 'kind-mismatch'),
'missing-load': ('règle', 'incomplete-slot'), 'missing-load': ('règle', 'incomplete-slot'),
# Modes 1.3 : sur un contexte VALIDE et le bon token, ils se comportent comme `normal`.
'old-data': ('—', 'valide'),
'echo-value': ('—', 'valide'),
'major-first': ('—', 'valide'),
} }
# Une valeur que nul chemin conforme ne peut contenir (tiret, deux-points) : si elle apparaît dans
# error.data, c'est une recopie.
MARQUEUR = 'valeur-sonde:7Qz9'
def verifier(*args): def verifier(*args):
p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60) p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60)
@ -100,7 +113,7 @@ def session(mode, dossier):
with open(REQUETE, encoding='utf-8') as f: with open(REQUETE, encoding='utf-8') as f:
params = json.load(f) params = json.load(f)
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake', 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') 'token': TOKEN}}).encode() + b'\n')
hs = lire_ligne(s, tampon) hs = lire_ligne(s, tampon)
if hs and str(hs.get('result', {}).get('protocolVersion', '')).startswith('1'): if hs and str(hs.get('result', {}).get('protocolVersion', '')).startswith('1'):
@ -114,6 +127,33 @@ def session(mode, dossier):
return capture return capture
def session_contexte_invalide(dossier, mode, annonce, params):
"""Handshake accepté puis UN GetPlan de contexte invalide ; rend le fichier capturé."""
sock_path = os.path.join(dossier, 'opt.sock')
capture = os.path.join(dossier, f'invalide-{mode}-{annonce}.ndjson')
proc = subprocess.Popen([sys.executable, '-I', '-B', REF, '--listen', f'unix://{sock_path}', '--mode', mode,
'--announce', annonce, '--capture', capture],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
try:
assert proc.stdout.readline().startswith('ÉCOUTE'), 'optimiseur de référence muet'
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(sock_path)
tampon = [b'']
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake',
'params': {'protocolVersion': VERSION_MOTEUR,
'engine': 'etm-powersync-energy-plugin-etm/test'}}).encode() + b'\n')
assert lire_ligne(s, tampon)['result']['protocolVersion'] == annonce
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 2, 'method': 'GetPlan', 'params': params}).encode() + b'\n')
reponse = lire_ligne(s, tampon)
assert reponse['error']['code'] == -32602, reponse
s.close()
finally:
proc.terminate()
proc.wait(5)
proc.stdout.close()
return capture
class TestCeQueLeMoteurEmet(unittest.TestCase): class TestCeQueLeMoteurEmet(unittest.TestCase):
def test_les_references_du_serialiseur_sont_conformes(self): def test_les_references_du_serialiseur_sont_conformes(self):
self.assertGreaterEqual(len(GOLDEN), 4, 'fichiers de référence du sérialiseur introuvables') self.assertGreaterEqual(len(GOLDEN), 4, 'fichiers de référence du sérialiseur introuvables')
@ -236,5 +276,135 @@ class TestLeValidateurNeSeTaitPas(unittest.TestCase):
self.assertNotIn('uniqueItems', sortie) self.assertNotIn('uniqueItems', sortie)
class TestDonneesDErreur(unittest.TestCase):
"""error.data du -32602 de GetPlan (protocole 1.3), sur de vraies sessions de l'optimiseur de
référence. La session porte un GetPlan de contexte invalide : le validateur la juge SANS
--semantic (sinon la réponse error est un refus, quel que soit son data)."""
# (mode, version annoncée) → (code de sortie, écarts > 0, avertissements > 0)
ATTENDU = {
('normal', '1.3'): (0, False, False),
('normal', '1.2'): (0, False, False),
('old-data', '1.3'): (1, True, False), # forme d'avant 1.3, annoncée 1.3 : refusée
('old-data', '1.2'): (0, False, True), # la même, annoncée 1.2 : avertie
('echo-value', '1.3'): (1, True, False),
('echo-value', '1.2'): (1, True, True), # forme avertie, RECOPIE refusée (règle de 1.2)
}
def test_forme_exigee_en_1_3_avertie_en_1_2_recopie_refusee(self):
params = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8')))
params['timestamp'] = MARQUEUR # sans décalage, illisible : `format`
obtenu = {}
with tempfile.TemporaryDirectory() as d:
for (mode, annonce) in self.ATTENDU:
capture = session_contexte_invalide(d, mode, annonce, params)
code, r, sortie = verifier('--ndjson', capture, '--responses-only')
self.assertEqual(r['comptes'].get('GetPlan.error'), 1, sortie)
self.assertNotIn('GetPlan.params', r['comptes'])
self.assertEqual(r['comptes'].get('GetPlan.error.data'), 1, sortie)
obtenu[(mode, annonce)] = (code, r['ecarts'] > 0, r['avertissements'] > 0)
print(f' {mode:11s} {annonce} → code {code}, écarts {r["ecarts"]}, avertissements '
f'{r["avertissements"]}', file=sys.stderr)
if mode == 'echo-value':
self.assertIn('recopie la valeur reçue en params.timestamp', sortie)
self.assertNotIn(MARQUEUR, sortie, 'le validateur recopie lui-même la valeur')
if (mode, annonce) == ('old-data', '1.2'):
self.assertIn('AVERTISSEMENT', sortie)
self.assertIn('à aligner avant de s\'annoncer 1.3', sortie)
self.assertEqual(obtenu, self.ATTENDU)
def test_la_forme_vient_du_contrat(self):
# Contre-épreuve : sans x-etm-data-schema, la forme d'avant 1.3 annoncée 1.3 passe — c'est
# bien le contrat qui juge, pas une règle cachée dans l'outil.
with open(SCHEMA, encoding='utf-8') as f:
s = json.load(f)
for e in next(m for m in s['methods'] if m['name'] == 'GetPlan')['errors']:
e.pop('x-etm-data-schema', None)
e.pop('x-etm-data-required-from', None)
with tempfile.TemporaryDirectory() as d:
schema = os.path.join(d, 's.json')
with open(schema, 'w', encoding='utf-8') as f:
json.dump(s, f, ensure_ascii=False)
capture = session_contexte_invalide(d, 'old-data', '1.3', {'timestamp': MARQUEUR})
self.assertEqual(verifier('--ndjson', capture, '--responses-only')[0], 1)
code, r, sortie = verifier('--schema', schema, '--ndjson', capture, '--responses-only')
self.assertEqual(code, 0, sortie)
self.assertNotIn('GetPlan.error.data', r['comptes'])
def ecrire(self, d, nom, contenu):
chemin = os.path.join(d, nom)
with open(chemin, 'w', encoding='utf-8') as f:
json.dump(contenu, f, ensure_ascii=False)
return chemin
def juger(self, d, data, requete=None, version='1.3'):
args = ['--method', 'GetPlan', '--error',
self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide', 'data': data}),
'--handshake', self.ecrire(d, 'h.json', {'protocolVersion': version})]
if requete is not None:
args += ['--request', self.ecrire(d, 'q.json', requete)]
return verifier(*args)
def test_la_forme_ligne_a_ligne(self):
p = {'path': 'params.loads[0].id', 'problem': 'missing'}
cas = [
({'problems': [p]}, 0, None),
({'problems': [{'path': 'params', 'problem': 'type'}]}, 0, None),
({'problems': [{'path': 'params.loads[0].declared.estimatedPowerW.3', 'problem': 'range'}]}, 0, None),
({'problems': [p] * 50, 'truncated': True}, 0, None),
({'problems': []}, 1, '[minItems]'),
({'problems': [p] * 51}, 1, '[maxItems]'),
({'problems': [p] * 50, 'truncated': False}, 1, '[const]'),
({'problems': [p] * 3, 'truncated': True}, 1, '[x-etm-truncated]'),
({'problems': [dict(p, problem='absent')]}, 1, '[enum]'),
({'problems': [dict(p, path='/loads/0/id')]}, 1, '[pattern]'),
({'problems': [dict(p, path='loads[0].id')]}, 1, '[pattern]'),
({'problems': [dict(p, path='params.loads.0.id ')]}, 1, '[pattern]'),
({'problems': [dict(p, value=3)]}, 1, '[additionalProperties]'),
({'problems': [p], 'detail': 'x'}, 1, '[additionalProperties]'),
({'problems': [{'path': 'params.timestamp'}]}, 1, '[required]'),
({'problemes': ['params.timestamp : format']}, 1, '[required]'),
('contexte invalide', 1, '[type]'),
]
with tempfile.TemporaryDirectory() as d:
for data, code_attendu, mot in cas:
code, r, sortie = self.juger(d, data)
self.assertEqual(code, code_attendu, f'{data!r}\n{sortie}')
if mot:
self.assertIn(mot, sortie, data)
# data absent : écart en 1.3
chemin = self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide'})
code, _, sortie = verifier('--method', 'GetPlan', '--error', chemin)
self.assertEqual(code, 1, sortie)
self.assertIn('« data » manquant', sortie)
# Un autre code d'erreur n'a pas de forme normée : rien à juger.
chemin = self.ecrire(d, 'e.json', {'code': -32603, 'message': 'erreur interne', 'data': [1, 'x']})
code, r, sortie = verifier('--method', 'GetPlan', '--error', chemin)
self.assertEqual((code, r['ecarts']), (0, 0), sortie)
def test_la_recopie_ne_peut_pas_accuser_un_chemin_conforme(self):
# Valeurs de la requête qui sont AUSSI des noms de champ (domain « battery », trigger
# « forecast », meter.source « meter ») : un chemin conforme les contient, ce n'est pas une
# recopie. Une valeur à tiret ou à deux-points recopiée, elle, l'est.
requete = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8')))
requete['trigger'] = 'forecast'
requete['loads'][0]['domain'] = 'battery'
data = {'problems': [{'path': 'params.battery.socPercent', 'problem': 'range'},
{'path': 'params.forecast.pv[3].from', 'problem': 'format'},
{'path': 'params.meter.source', 'problem': 'enum'}]}
with tempfile.TemporaryDirectory() as d:
code, r, sortie = self.juger(d, data, requete)
self.assertEqual(code, 0, sortie)
ident = requete['loads'][0]['id']
self.assertRegex(ident, '[^A-Za-z0-9_]')
code, r, sortie = self.juger(d, {'problemes': [f'charge {ident} : priorité hors bornes']}, requete, '1.2')
self.assertEqual(code, 1, sortie)
self.assertIn('recopie la valeur reçue en params.loads[0].id', sortie)
self.assertNotIn(ident, sortie.split('RÉSUMÉ')[0])
# Avant 1.2, la règle n'existait pas : avertie seulement.
code, r, sortie = self.juger(d, {'problemes': [f'charge {ident}']}, requete, '1.1')
self.assertEqual((code, r['ecarts'], r['avertissements'] > 0), (0, 0, True), sortie)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main(verbosity=2) unittest.main(verbosity=2)

View File

@ -60,6 +60,9 @@ Python 3, bibliothèque standard seule, aucun accès réseau. Le contrat :
./tools/etm-powersync-schema-check --method GetPlan --params requete.json ./tools/etm-powersync-schema-check --method GetPlan --params requete.json
./tools/etm-powersync-schema-check --method GetPlan --result plan.json --request requete.json --semantic ./tools/etm-powersync-schema-check --method GetPlan --result plan.json --request requete.json --semantic
./tools/etm-powersync-schema-check --ndjson session.ndjson --semantic ./tools/etm-powersync-schema-check --ndjson session.ndjson --semantic
./tools/etm-powersync-schema-check --ndjson sonde.ndjson --responses-only # requêtes fautives à dessein
./tools/etm-powersync-schema-check --method GetPlan --error erreur.json --request requete.json \
--handshake handshake.json # error.data d'un -32602 (protocole 1.3)
./tools/etm-powersync-schema-check --trace etm-powersync-optimizer-trace-2026-10-17.ndjson --semantic ./tools/etm-powersync-schema-check --trace etm-powersync-optimizer-trace-2026-10-17.ndjson --semantic
./tools/etm-powersync-schema-check --subset # le sous-ensemble de JSON Schema géré ./tools/etm-powersync-schema-check --subset # le sous-ensemble de JSON Schema géré
@ -77,6 +80,10 @@ ETM_POWERSYNC_CODE_LISTS=/tmp/listes.json <build>/tests/auto/protocolschema/etm-
mot-clé est une erreur de schéma (code 2), jamais ignoré. mot-clé est une erreur de schéma (code 2), jamais ignoré.
- **`--semantic`** applique `x-etm-semantic-rules` : ce que le validateur du moteur refuse en plus de - **`--semantic`** applique `x-etm-semantic-rules` : ce que le validateur du moteur refuse en plus de
la forme, dans son ordre, sous le code qu'il publie (`fallbackCode`). la forme, dans son ordre, sous le code qu'il publie (`fallbackCode`).
- **`error.data`** (protocole 1.3) : la forme d'`error.data` d'une erreur, quand le contrat la norme
(`x-etm-data-schema`, `-32602` de `GetPlan`), est exigée d'un optimiseur qui s'annonce
`x-etm-data-required-from` ou plus, et seulement AVERTIE en deçà (ligne `AVERTISSEMENT`, sans effet
sur le code de sortie) ; une valeur reçue recopiée dans `error.data` est un écart dès 1.2.
- **Code de sortie** : 0 conforme ; 1 écart de forme ou refus sémantique ; 2 erreur d'usage ou de - **Code de sortie** : 0 conforme ; 1 écart de forme ou refus sémantique ; 2 erreur d'usage ou de
schéma. schéma.
- Conformité d'un optimiseur de bout en bout, avec le vrai moteur : suite - Conformité d'un optimiseur de bout en bout, avec le vrai moteur : suite

View File

@ -145,14 +145,15 @@ class Rendu:
chemin = f'{prefixe}{nom}' chemin = f'{prefixe}{nom}'
r = self.resoudre(s) 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 '') 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"} | ' 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. # 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): 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 + '.') out += self.lignes(s['properties'], s.get('required', []), chemin + '.')
return out 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 = [] out = []
if titre: if titre:
out.append(f'**{titre}**' + (f' — {description}' if description else '')) out.append(f'**{titre}**' + (f' — {description}' if description else ''))
@ -162,8 +163,13 @@ class Rendu:
out += self.lignes(props, requis) out += self.lignes(props, requis)
if ouvert is not None: if ouvert is not None:
out.append('') out.append('')
out.append('Champs non déclarés : ' + ('**refusés** (forme émise par le moteur, fermée).' if ouvert is False if ouvert is not False:
else 'ignorés (§9).')) 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 return out
def composant(self, nom): def composant(self, nom):
@ -184,7 +190,8 @@ class Rendu:
if 'properties' not in s: if 'properties' not in s:
return [f'`{nom}` : {self.type_de(s)} — {self.valeurs(s)}. {self.sens(s)}'.rstrip()] 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}`', 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']): 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']) + '.') out.append('Au moins un de : ' + ', '.join('`' + '`+`'.join(b['required']) + '`' for b in s['anyOf']) + '.')
return out return out

View File

@ -1,7 +1,7 @@
# `etm-powersync-ref-optimizer` — optimiseur de référence # `etm-powersync-ref-optimizer` — optimiseur de référence
Python 3, bibliothèque standard seule, GPL-3.0 comme le dépôt. Protocole : `docs/OPTIMIZER_PROTOCOL.md` Python 3, bibliothèque standard seule, GPL-3.0 comme le dépôt. Protocole : `docs/OPTIMIZER_PROTOCOL.md`
1.2 (annexe E). Il répond un plan à **un créneau** qui reproduit le waterfall du rule-based (passe 1.3 (annexe E). Il répond un plan à **un créneau** qui reproduit le waterfall du rule-based (passe
confort : même ordre, même budget, mêmes écrêtages) en ne lisant **que** le contexte `GetPlan` — aucun confort : même ordre, même budget, mêmes écrêtages) en ne lisant **que** le contexte `GetPlan` — aucun
accès réseau sortant, aucun fichier hors le token. Usages : test de bout en bout du moteur, point de accès réseau sortant, aucun fichier hors le token. Usages : test de bout en bout du moteur, point de
comparaison d'un banc, exemple pour qui écrit son propre optimiseur. comparaison d'un banc, exemple pour qui écrit son propre optimiseur.
@ -29,16 +29,16 @@ Il écrit `ÉCOUTE <endpoint>` sur la sortie standard quand il écoute (le port
et son journal sur la sortie d'erreur. Plusieurs clients à la fois (un fil par connexion, aucun état et son journal sur la sortie d'erreur. Plusieurs clients à la fois (un fil par connexion, aucun état
partagé) : le moteur et un rejeu peuvent lui parler ensemble. SIGTERM l'arrête proprement. partagé) : le moteur et un rejeu peuvent lui parler ensemble. SIGTERM l'arrête proprement.
## Erreurs (protocole 1.2) ## Erreurs (protocole 1.2 et 1.3)
Codes JSON-RPC 2.0, plus `-32001` (protocole §7) : Codes JSON-RPC 2.0, plus `-32001` (protocole §7) :
| Reçu | Réponse | Connexion | | Reçu | Réponse | Connexion |
|---|---|---| |---|---|---|
| `Handshake` sans le token attendu, ou avec un autre | `-32001` | fermée | | `Handshake` sans le token attendu, ou avec un autre — **même d'une autre majeure** (1.3 : le token d'abord) | `-32001` | fermée |
| `Handshake` d'une autre majeure | `-32602` | fermée | | `Handshake` d'une autre majeure, token accepté ou non exigé | `-32602` | fermée |
| `GetPlan` avant un handshake réussi | `-32600` | gardée | | `GetPlan` avant un handshake réussi | `-32600` | gardée |
| `GetPlan` dont le `timestamp` est absent, illisible ou sans décalage | `-32602`, `error.data.problems` = `[{path, problem}]`, jamais la valeur | gardée | | `GetPlan` dont le `timestamp` est absent, n'est pas une chaîne, ou est illisible ou sans décalage | `-32602`, `error.data` = `{"problems":[{"path":"params.timestamp","problem":"missing"\|"type"\|"format"}]}` (forme 1.3, protocole §7), jamais la valeur | gardée |
| ligne non JSON | `-32700`, `id: null` | gardée | | ligne non JSON | `-32700`, `id: null` | gardée |
| lot `[…]` | `-32600`, `id: null`, aucun message du lot traité | gardée | | lot `[…]` | `-32600`, `id: null`, aucun message du lot traité | gardée |
| ligne de plus de 1 Mio (`\n` non compté) | `-32600`, `id: null`, jetée sans être gardée en mémoire | gardée | | ligne de plus de 1 Mio (`\n` non compté) | `-32600`, `id: null`, jetée sans être gardée en mémoire | gardée |
@ -80,7 +80,14 @@ Le rejeu compare, cycle par cycle, les actions du créneau courant au plan enreg
`constraint`) · `missing-load` (une charge disponible absente du créneau courant) · `echo-token` `constraint`) · `missing-load` (une charge disponible absente du créneau courant) · `echo-token`
(recopie le token dans `reason` et `planId`) · `protocol-1.0` (optimiseur 1.0 : sans champs 1.1) · (recopie le token dans `reason` et `planId`) · `protocol-1.0` (optimiseur 1.0 : sans champs 1.1) ·
`deviate` (cascade en ordre inverse : doit diverger) · `wrong-type` (`planId` numérique : le moteur `deviate` (cascade en ordre inverse : doit diverger) · `wrong-type` (`planId` numérique : le moteur
l'accepte, le contrat OpenRPC le refuse — contre-épreuve de la suite de conformité). l'accepte, le contrat OpenRPC le refuse — contre-épreuve de la suite de conformité) · `old-data`
(contexte invalide : `error.data` dans la forme d'avant 1.3, `{"problemes": [phrases]}`) ·
`echo-value` (contexte invalide : `error.data` recopie la valeur reçue) · `major-first` (la majeure
jugée avant le token).
`--announce VERSION` : la version annoncée au handshake, et dont la majeure est exigée du moteur
(défaut `1.3`). Avec `--announce 1.2`, il se présente en optimiseur 1.2 : la suite de conformité
n'exige plus de lui la forme 1.3 d'`error.data` ni l'ordre token puis majeure, elle avertit.
`--mode-file FICHIER` : le mode est relu dans ce fichier à chaque message — un test change de mode `--mode-file FICHIER` : le mode est relu dans ce fichier à chaque message — un test change de mode
sans reconnexion. `--capture FICHIER` : chaque message reçu et envoyé, une ligne JSON-RPC par sans reconnexion. `--capture FICHIER` : chaque message reçu et envoyé, une ligne JSON-RPC par

View File

@ -1,11 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync # Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
"""Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.2, annexe E). """Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.3, annexe E).
etm-powersync-ref-optimizer [--listen unix:///run/powersync/optimizer.sock | tcp://hôte:port] etm-powersync-ref-optimizer [--listen unix:///run/powersync/optimizer.sock | tcp://hôte:port]
[--token-file FICHIER] [--mode MODE | --mode-file FICHIER] [--token-file FICHIER] [--mode MODE | --mode-file FICHIER]
[--slow-s N] [--horizon-s S] [--capture FICHIER] [--slow-s N] [--horizon-s S] [--capture FICHIER] [--announce VERSION]
etm-powersync-ref-optimizer --list-codes etm-powersync-ref-optimizer --list-codes
Un serveur JSON-RPC 2.0 sur NDJSON qui répond au `Handshake` et à `GetPlan` par un plan à UN Un serveur JSON-RPC 2.0 sur NDJSON qui répond au `Handshake` et à `GetPlan` par un plan à UN
@ -37,6 +37,16 @@ Modes (`--mode`, pour les tests ; un seul à la fois) :
deviate cascade servie en ordre de rang INVERSE (contre-épreuve : doit diverger) deviate cascade servie en ordre de rang INVERSE (contre-épreuve : doit diverger)
wrong-type `planId` numérique au lieu d'une chaîne : le moteur l'ACCEPTE (il le lit comme wrong-type `planId` numérique au lieu d'une chaîne : le moteur l'ACCEPTE (il le lit comme
texte), le contrat OpenRPC le REFUSE — contre-épreuve de la suite de conformité texte), le contrat OpenRPC le REFUSE — contre-épreuve de la suite de conformité
old-data contexte invalide : `error.data` dans la forme d'avant 1.3 (`{"problemes": [chaînes]}`,
celle du premier lot de l'optimiseur ETM) — refusée d'un optimiseur 1.3, avertie
d'un optimiseur 1.2 (`--announce 1.2`)
echo-value contexte invalide : `error.data` recopie la valeur reçue — refusé quelle que soit
la version annoncée (règle de 1.2)
major-first handshake : la majeure jugée AVANT le token (token faux et autre majeure →
-32602 au lieu de -32001) — refusé d'un optimiseur 1.3
`--announce VERSION` : la version annoncée au handshake, et dont la majeure est exigée du moteur
(défaut : celle que parle cet optimiseur, 1.3). Pour les contre-épreuves : un optimiseur 1.2.
`--capture FICHIER` : chaque message reçu et envoyé, une ligne JSON-RPC par message, dans l'ordre `--capture FICHIER` : chaque message reçu et envoyé, une ligne JSON-RPC par message, dans l'ordre
(la session que `tools/etm-powersync-schema-check --ndjson` juge). Le token d'un handshake y est (la session que `tools/etm-powersync-schema-check --ndjson` juge). Le token d'un handshake y est
@ -55,11 +65,14 @@ se verrait dans `ps`) et jamais affiché. S'il est configuré, un handshake sans
autre, reçoit une erreur JSON-RPC -32001 puis la connexion est fermée. Sans --token-file, un autre, reçoit une erreur JSON-RPC -32001 puis la connexion est fermée. Sans --token-file, un
token reçu est accepté sans être lu. token reçu est accepté sans être lu.
Erreurs (protocole 1.2, §2, §3, §7 ; codes JSON-RPC 2.0, plus -32001) : Erreurs (protocole 1.2 et 1.3, §2, §3, §7 ; codes JSON-RPC 2.0, plus -32001) :
token absent ou différent -32001, puis fermeture — jugé AVANT la majeure (1.3)
Handshake d'une autre majeure -32602, puis fermeture (comme -32001 : les deux côtés ferment) Handshake d'une autre majeure -32602, puis fermeture (comme -32001 : les deux côtés ferment)
GetPlan avant un handshake réussi -32600, connexion gardée GetPlan avant un handshake réussi -32600, connexion gardée
contexte GetPlan invalide -32602, `error.data.problems` = [{path, problem}], jamais la contexte GetPlan invalide -32602, `error.data` = {"problems": [{path, problem}]} (1.3 :
valeur reçue — une erreur, pas une abstention chemin depuis `params`, code d'une liste fermée, 50 au plus et
`truncated` au-delà), jamais la valeur reçue — une erreur, pas
une abstention
ligne non JSON -32700, id null ligne non JSON -32700, id null
lot JSON-RPC (`[…]`) -32600, id null, aucun message du lot traité lot JSON-RPC (`[…]`) -32600, id null, aucun message du lot traité
ligne de plus de 1 Mio -32600, id null, jetée sans être gardée en mémoire ligne de plus de 1 Mio -32600, id null, jetée sans être gardée en mémoire
@ -88,8 +101,7 @@ import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
ENDPOINT_DEFAUT = 'unix:///run/powersync/optimizer.sock' ENDPOINT_DEFAUT = 'unix:///run/powersync/optimizer.sock'
VERSION = '1.2' VERSION = '1.3'
MAJEURE = VERSION.split('.')[0]
NOM = 'etm-powersync-ref-optimizer' NOM = 'etm-powersync-ref-optimizer'
# Nom annoncé au handshake : `powersync-optimizer`, l'exemple GPL de référence. `etm-optimizer` # Nom annoncé au handshake : `powersync-optimizer`, l'exemple GPL de référence. `etm-optimizer`
# est le nom de l'optimiseur ETM, propriétaire, hors de ce dépôt. # est le nom de l'optimiseur ETM, propriétaire, hors de ce dépôt.
@ -101,9 +113,11 @@ CODE_REQUETE_INVALIDE = -32600
CODE_METHODE_INCONNUE = -32601 CODE_METHODE_INCONNUE = -32601
CODE_PARAMS_INVALIDES = -32602 CODE_PARAMS_INVALIDES = -32602
TROP_LONGUE = object() # lire_ligne() : ligne de plus de LIGNE_MAX octets, déjà jetée TROP_LONGUE = object() # lire_ligne() : ligne de plus de LIGNE_MAX octets, déjà jetée
PROBLEMES_MAX = 50 # §7, 1.3 : au-delà, les 50 premiers et `truncated`
MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load', MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load',
'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type'] 'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type',
'old-data', 'echo-value', 'major-first']
# Catalogue (energyplugin/etm/types/decisionreason.cpp) : codes qu'un optimiseur peut émettre et # Catalogue (energyplugin/etm/types/decisionreason.cpp) : codes qu'un optimiseur peut émettre et
# leurs clés de paramètres documentées (INTERFACE.md). Le test d'intégration les confronte au # leurs clés de paramètres documentées (INTERFACE.md). Le test d'intégration les confronte au
@ -328,13 +342,32 @@ def iso(t):
def problemes_contexte(ctx): def problemes_contexte(ctx):
"""Ce que cet optimiseur ne peut pas lire dans un contexte GetPlan : [{path, problem}], jamais """Ce que cet optimiseur ne peut pas lire dans un contexte GetPlan : [{path, problem}] (§7, 1.3),
la valeur reçue (§7, 1.2). Seul `timestamp` est exigé — tout le reste se lit avec un défaut.""" chemin depuis `params`, nature dans la liste fermée — jamais la valeur reçue. Seul `timestamp`
if instant(ctx.get('timestamp')) is None: est exigé : tout le reste se lit avec un défaut."""
return [{'path': '/timestamp', 'problem': 'absent, illisible ou sans décalage horaire'}] if 'timestamp' not in ctx:
return [{'path': 'params.timestamp', 'problem': 'missing'}]
if not isinstance(ctx['timestamp'], str):
return [{'path': 'params.timestamp', 'problem': 'type'}]
if instant(ctx['timestamp']) is None:
return [{'path': 'params.timestamp', 'problem': 'format'}] # illisible, ou sans décalage
return [] return []
def donnees_contexte_invalide(problemes, ctx, mode):
"""`error.data` du -32602 de GetPlan. Forme 1.3 : les 50 premiers problèmes, `truncated` au-delà.
Les modes old-data et echo-value fabriquent ce qu'un optimiseur NON conforme répondrait."""
if mode == 'old-data':
# Forme du premier lot de l'optimiseur ETM (avant 1.3) : des phrases, sans liste fermée.
return {'problemes': [f'{p["path"]} : {p["problem"]}' for p in problemes]}
data = {'problems': problemes[:PROBLEMES_MAX]}
if len(problemes) > PROBLEMES_MAX:
data['truncated'] = True
if mode == 'echo-value':
data['problems'][0] = dict(data['problems'][0], received=ctx.get('timestamp'))
return data
def plan(ctx, mode, horizon_s, token): def plan(ctx, mode, horizon_s, token):
"""La réponse à un GetPlan (`result`) ; le contexte a passé problemes_contexte().""" """La réponse à un GetPlan (`result`) ; le contexte a passé problemes_contexte()."""
t0 = time.monotonic() t0 = time.monotonic()
@ -498,19 +531,27 @@ class Connexion(socketserver.StreamRequestHandler):
mode = mode_courant(cfg) mode = mode_courant(cfg)
if methode == 'Handshake': if methode == 'Handshake':
attendu = lire_token(cfg.token_file) attendu = lire_token(cfg.token_file)
if attendu is not None and params.get('token') != attendu: version = params.get('protocolVersion')
token_refuse = attendu is not None and params.get('token') != attendu
majeure_refusee = not isinstance(version, str) or version.split('.')[0] != cfg.announce.split('.')[0]
# §3, 1.3 : le token d'abord — un pair sans le secret n'apprend pas quelle majeure on
# parle. Le mode major-first inverse l'ordre (contre-épreuve de la conformité).
if mode == 'major-first' and majeure_refusee:
journal(f'handshake refusé : majeure incompatible ({str(version)[:32]}), mode major-first')
self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible')
return
if token_refuse:
# Le token reçu n'est jamais recopié ni journalisé. # Le token reçu n'est jamais recopié ni journalisé.
journal('handshake refusé : token absent ou différent') journal('handshake refusé : token absent ou différent')
self.erreur(ident, CODE_TOKEN_REFUSE, 'token refusé') self.erreur(ident, CODE_TOKEN_REFUSE, 'token refusé')
return return
version = params.get('protocolVersion') if majeure_refusee:
if not isinstance(version, str) or version.split('.')[0] != MAJEURE:
journal(f'handshake refusé : majeure incompatible ({str(version)[:32]})') journal(f'handshake refusé : majeure incompatible ({str(version)[:32]})')
self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible') self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible')
return return
token = attendu token = attendu
pret = True pret = True
reponse = {'protocolVersion': VERSION, 'name': NOM_ANNONCE, 'planning': False, reponse = {'protocolVersion': cfg.announce, 'name': NOM_ANNONCE, 'planning': False,
'capabilities': {'resolutionS': cfg.horizon_s, 'kinds': ['setpoint', 'state'], 'capabilities': {'resolutionS': cfg.horizon_s, 'kinds': ['setpoint', 'state'],
'usesForecast': [], 'reasonCodes': True}} 'usesForecast': [], 'reasonCodes': True}}
if mode == 'wrong-major': if mode == 'wrong-major':
@ -526,8 +567,9 @@ class Connexion(socketserver.StreamRequestHandler):
continue continue
problemes = problemes_contexte(params) problemes = problemes_contexte(params)
if problemes: if problemes:
journal(f'contexte invalide : {", ".join(p["path"] for p in problemes)}') journal(f'contexte invalide : {", ".join(p["path"] + " " + p["problem"] for p in problemes)}')
self.erreur(ident, CODE_PARAMS_INVALIDES, 'contexte invalide', {'problems': problemes}) self.erreur(ident, CODE_PARAMS_INVALIDES, 'contexte invalide',
donnees_contexte_invalide(problemes, params, mode))
continue continue
if mode == 'drop': if mode == 'drop':
journal('mode drop : connexion fermée sans réponse') journal('mode drop : connexion fermée sans réponse')
@ -582,7 +624,7 @@ def ecouter(endpoint):
def main(argv=None): def main(argv=None):
p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.2, annexe E).') p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.3, annexe E).')
p.add_argument('--listen', default=ENDPOINT_DEFAUT, p.add_argument('--listen', default=ENDPOINT_DEFAUT,
help=f'unix:///chemin ou tcp://hôte:port (défaut : {ENDPOINT_DEFAUT})') help=f'unix:///chemin ou tcp://hôte:port (défaut : {ENDPOINT_DEFAUT})')
p.add_argument('--token-file', help='fichier contenant le token attendu (jamais en argument)') p.add_argument('--token-file', help='fichier contenant le token attendu (jamais en argument)')
@ -592,12 +634,16 @@ def main(argv=None):
p.add_argument('--horizon-s', type=int, default=300, p.add_argument('--horizon-s', type=int, default=300,
help='durée du créneau et de validUntil, en s (défaut 300)') help='durée du créneau et de validUntil, en s (défaut 300)')
p.add_argument('--capture', help='écrit chaque message reçu et envoyé dans ce fichier (NDJSON)') p.add_argument('--capture', help='écrit chaque message reçu et envoyé dans ce fichier (NDJSON)')
p.add_argument('--announce', default=VERSION,
help=f'version annoncée au handshake, sa majeure exigée du moteur (défaut : {VERSION})')
p.add_argument('--list-codes', action='store_true', p.add_argument('--list-codes', action='store_true',
help='écrit le catalogue des codes émissibles (JSON) et sort') help='écrit le catalogue des codes émissibles (JSON) et sort')
args = p.parse_args(argv) args = p.parse_args(argv)
if args.list_codes: if args.list_codes:
print(json.dumps(CODES_EMISSIBLES, sort_keys=True)) print(json.dumps(CODES_EMISSIBLES, sort_keys=True))
return 0 return 0
if not args.announce.split('.')[0].isdigit():
p.error(f'--announce : version semver attendue, pas « {args.announce} »')
def arreter(*_): def arreter(*_):
raise KeyboardInterrupt raise KeyboardInterrupt
signal.signal(signal.SIGTERM, arreter) # arrêt propre : le socket unix est retiré signal.signal(signal.SIGTERM, arreter) # arrêt propre : le socket unix est retiré

View File

@ -6,7 +6,10 @@
etm-powersync-schema-check [--schema FICHIER] --method M --params FICHIER.json etm-powersync-schema-check [--schema FICHIER] --method M --params FICHIER.json
etm-powersync-schema-check [--schema FICHIER] --method M --result FICHIER.json etm-powersync-schema-check [--schema FICHIER] --method M --result FICHIER.json
[--request PARAMS.json] [--handshake RESULT.json] [--semantic] [--request PARAMS.json] [--handshake RESULT.json] [--semantic]
etm-powersync-schema-check [--schema FICHIER] --ndjson SESSION.ndjson [--semantic] [--requests-only] etm-powersync-schema-check [--schema FICHIER] --method M --error FICHIER.json
[--request PARAMS.json] [--handshake RESULT.json]
etm-powersync-schema-check [--schema FICHIER] --ndjson SESSION.ndjson [--semantic]
[--requests-only | --responses-only]
etm-powersync-schema-check [--schema FICHIER] --trace TRACE.ndjson [--semantic] etm-powersync-schema-check [--schema FICHIER] --trace TRACE.ndjson [--semantic]
etm-powersync-schema-check --subset etm-powersync-schema-check --subset
@ -28,18 +31,35 @@ Ce que l'outil lit en plus du JSON Schema :
- `x-etm-reason-params` : clés de `reasonParams` admises par `reasonCode` (contrôle de forme) ; - `x-etm-reason-params` : clés de `reasonParams` admises par `reasonCode` (contrôle de forme) ;
- `x-etm-semantic-rules` (avec `--semantic`) : ce que le validateur du moteur refuse EN PLUS de - `x-etm-semantic-rules` (avec `--semantic`) : ce que le validateur du moteur refuse EN PLUS de
la forme, dans le même ordre, sous le même nom. Une règle du schéma sans implantation ici, ou la forme, dans le même ordre, sous le même nom. Une règle du schéma sans implantation ici, ou
l'inverse, est une erreur de schéma (code 2) : le contrôle ne disparaît pas en silence. l'inverse, est une erreur de schéma (code 2) : le contrôle ne disparaît pas en silence ;
- `methods[].errors[].x-etm-data-schema` (protocole 1.3) : la forme de `error.data` d'une réponse
error de ce code — `InvalidContextData` pour le `-32602` de `GetPlan`. Exigée d'un optimiseur qui
s'annonce `x-etm-data-required-from` ou plus (version lue dans SON `Handshake`) ; plus ancienne,
un écart n'est qu'un AVERTISSEMENT, qui ne change pas le code de sortie. Une version inconnue (pas
de handshake) est jugée comme celle du contrat. En plus de la forme, deux contrôles :
`truncated` n'accompagne que 50 problèmes exactement ; et AUCUNE VALEUR REÇUE n'est recopiée dans
`error.data` (§7 — règle de 1.2, donc un écart dès 1.2, jamais un avertissement). Ce dernier
contrôle porte sur les valeurs textuelles de la requête qu'un chemin conforme ne peut pas
contenir (4 caractères au moins, dont un hors `[A-Za-z0-9_]` : instants, identifiants à tiret,
libellés) : il ne peut pas accuser un chemin conforme. Une recopie de nombre ou de mot nu lui
échappe ; la forme 1.3, fermée, la rend inexprimable.
Modes : Modes :
- `--ndjson` : une session, un message JSON-RPC par ligne, dans les deux sens. Une réponse est - `--ndjson` : une session, un message JSON-RPC par ligne, dans les deux sens. Une réponse est
rapprochée de sa requête par `id` ; un `GetPlan` est jugé avec SA requête et le dernier rapprochée de sa requête par `id` ; un `GetPlan` est jugé avec SA requête et le dernier
`Handshake` accepté. `--requests-only` ne juge que ce qu'émet le moteur (requêtes). `Handshake` accepté. `--requests-only` ne juge que ce qu'émet le moteur (requêtes) ;
`--responses-only` que ce qu'émet l'optimiseur (réponses) — pour une session de SONDE, dont les
requêtes sont fautives à dessein (un contexte invalide, pour juger le `-32602` qui lui répond).
- `--trace` : traces de décision du moteur (annexe D) — `request` contre les params de - `--trace` : traces de décision du moteur (annexe D) — `request` contre les params de
`GetPlan` ; `response.result` contre son résultat quand la ligne vient d'un optimiseur `GetPlan` ; `response.result` contre son résultat quand la ligne vient d'un optimiseur
(`latencyMs` présent) — le plan du rule-based tracé n'est pas une réponse d'optimiseur. (`latencyMs` présent) — le plan du rule-based tracé n'est pas une réponse d'optimiseur. Une
erreur n'y porte que le code d'échec du moteur : rien à juger de son `data`.
- `--error` : l'objet `error` d'une réponse à `--method` ; son `data` est jugé comme en session,
la version tirée de `--handshake` (le `result` du Handshake), la requête de `--request`.
Sortie : une ligne par écart, puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts, Sortie : une ligne par écart, une ligne `AVERTISSEMENT …` par écart toléré (forme normée après la
verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un version de l'optimiseur), puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts,
avertissements, verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un
verdict qui REFUSE (`invalid`, `connection-refused`) ; 2 erreur d'usage ou de schéma. verdict qui REFUSE (`invalid`, `connection-refused`) ; 2 erreur d'usage ou de schéma.
Les verdicts `abstention` et `expired` sont dits, sans échec : ce sont des réponses permises. Les verdicts `abstention` et `expired` sont dits, sans échec : ce sont des réponses permises.
""" """
@ -125,6 +145,10 @@ class Validateur:
racines.append((f'méthode {m.get("name")}, param {p.get("name")}', p.get('schema'))) racines.append((f'méthode {m.get("name")}, param {p.get("name")}', p.get('schema')))
if 'result' in m: if 'result' in m:
racines.append((f'méthode {m.get("name")}, result', m['result'].get('schema'))) racines.append((f'méthode {m.get("name")}, result', m['result'].get('schema')))
for e in m.get('errors', []):
if 'x-etm-data-schema' in e:
racines.append((f'méthode {m.get("name")}, erreur {e.get("code")}, data',
e['x-etm-data-schema']))
for ou, s in racines: for ou, s in racines:
self.auditer_schema(s, ou) self.auditer_schema(s, ou)
@ -284,6 +308,16 @@ class Contrat:
self.kinds = self.v.resoudre('#/components/schemas/ActionKind').get('enum', []) self.kinds = self.v.resoudre('#/components/schemas/ActionKind').get('enum', [])
self.version = str(self.v.resoudre('#/components/schemas/ProtocolVersion').get('const', '')) self.version = str(self.v.resoudre('#/components/schemas/ProtocolVersion').get('const', ''))
self.cles_motif = doc.get('x-etm-reason-params') or {} self.cles_motif = doc.get('x-etm-reason-params') or {}
# (méthode, code) → (schéma de error.data, version de l'optimiseur à partir de laquelle il est exigé)
self.donnees_erreur = {}
for m in doc.get('methods', []):
for e in m.get('errors', []):
if 'x-etm-data-schema' in e:
depuis = version_lue(e.get('x-etm-data-required-from'))
if depuis is None:
raise ErreurSchema(f'{m["name"]}, erreur {e.get("code")} : x-etm-data-required-from '
f'absent ou illisible')
self.donnees_erreur[(m['name'], e.get('code'))] = (e['x-etm-data-schema'], depuis)
def methode(self, nom): def methode(self, nom):
if nom not in self.methodes: if nom not in self.methodes:
@ -330,6 +364,85 @@ class Contrat:
return e return e
# --- error.data (protocole 1.3) ----------------------------------------------------------------------
def version_lue(v):
"""« 1.3 » → (1, 3) ; « 1 » → (1, 0) ; illisible → None."""
if not isinstance(v, str):
return None
morceaux = v.split('.')
try:
return int(morceaux[0]), int(morceaux[1]) if len(morceaux) > 1 else 0
except ValueError:
return None
TEXTE_HORS_CHEMIN = re.compile(r'[^A-Za-z0-9_]')
RECOPIE_DEPUIS = (1, 2) # « jamais la valeur reçue » : règle de 1.2 (§7)
MAX_PROBLEMES = 50
def valeurs_reconnaissables(params, chemin='params'):
"""Les valeurs textuelles de la requête qu'un chemin conforme ne peut pas contenir : (chemin, valeur)."""
if isinstance(params, dict):
for k, v in params.items():
yield from valeurs_reconnaissables(v, f'{chemin}.{k}')
elif isinstance(params, list):
for i, v in enumerate(params):
yield from valeurs_reconnaissables(v, f'{chemin}[{i}]')
elif isinstance(params, str) and len(params) >= 4 and TEXTE_HORS_CHEMIN.search(params):
yield chemin, params
def textes(v):
"""Toutes les chaînes d'un JSON, clés comprises."""
if isinstance(v, str):
yield v
elif isinstance(v, dict):
for k, x in v.items():
yield k
yield from textes(x)
elif isinstance(v, list):
for x in v:
yield from textes(x)
def juger_donnees_erreur(contrat, methode, erreur, requete, version):
"""error.data d'une réponse error : (écarts, avertissements), chacun (chemin, mot-clé, message).
`version` : protocolVersion annoncée par l'optimiseur au handshake (None : inconnue, jugée comme
celle du contrat). La forme n'est exigée qu'à partir de x-etm-data-required-from ; la recopie
d'une valeur reçue est un écart dès 1.2."""
if not isinstance(erreur, dict):
return [], []
forme = contrat.donnees_erreur.get((methode, erreur.get('code')))
if forme is None:
return [], []
schema, depuis = forme
v = version_lue(version) or version_lue(contrat.version)
if 'data' not in erreur:
ecarts_forme = [('/error', 'required', '« data » manquant')]
else:
data = erreur['data']
ecarts_forme = contrat.v.valider(data, schema, '/error/data')
if isinstance(data, dict) and data.get('truncated') is True and isinstance(data.get('problems'), list) \
and len(data['problems']) != MAX_PROBLEMES:
ecarts_forme.append(('/error/data/truncated', 'x-etm-truncated',
f'truncated avec {len(data["problems"])} problème(s) : seulement quand '
f'{MAX_PROBLEMES} sont donnés et d\'autres omis'))
recopies = []
if 'data' in erreur and isinstance(requete, dict):
vus = list(textes(erreur['data']))
for chemin, valeur in valeurs_reconnaissables(requete):
if any(valeur in t for t in vus):
recopies.append(('/error/data', 'x-etm-no-echo',
f'recopie la valeur reçue en {chemin} — jamais la valeur reçue (§7)'))
ecarts, avertissements = [], []
(ecarts if v >= depuis else avertissements).extend(ecarts_forme)
(ecarts if v >= RECOPIE_DEPUIS else avertissements).extend(recopies)
return ecarts, avertissements
# --- Règles sémantiques : le validateur du moteur, transcrit dans son ordre ----------------------- # --- Règles sémantiques : le validateur du moteur, transcrit dans son ordre -----------------------
REGLES_IMPLANTEES = ['abstain', 'malformed-plan', 'empty-plan', 'bad-time', 'slots-unordered', REGLES_IMPLANTEES = ['abstain', 'malformed-plan', 'empty-plan', 'bad-time', 'slots-unordered',
@ -500,6 +613,7 @@ class Bilan:
self.semantique = semantique self.semantique = semantique
self.comptes = {} self.comptes = {}
self.ecarts = [] self.ecarts = []
self.avertissements = []
self.verdicts = {} self.verdicts = {}
self.refus = 0 self.refus = 0
@ -512,6 +626,22 @@ class Bilan:
self.ecarts.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg}) self.ecarts.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
print(f'{ou} {partie} {chemin} : {msg} [{mot}]') print(f'{ou} {partie} {chemin} : {msg} [{mot}]')
def avertir(self, ou, partie, ecarts, pourquoi):
for chemin, mot, msg in ecarts:
self.avertissements.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
print(f'AVERTISSEMENT {ou} {partie} {chemin} : {msg} [{mot}] — {pourquoi}')
def juger_erreur(self, ou, methode, erreur, requete, version):
ecarts, avertissements = juger_donnees_erreur(self.c, methode, erreur, requete, version)
if (methode, erreur.get('code') if isinstance(erreur, dict) else None) not in self.c.donnees_erreur:
return
partie = f'{methode}.error.data'
self.noter(ou, partie, ecarts)
forme = self.c.donnees_erreur[(methode, erreur['code'])]
self.avertir(ou, partie, avertissements,
f'optimiseur en {str(version)[:16]}, forme exigée à partir de '
f'{forme[1][0]}.{forme[1][1]} : à aligner avant de s\'annoncer {forme[1][0]}.{forme[1][1]}')
def verdict(self, ou, code): def verdict(self, ou, code):
if code is None: if code is None:
code = 'valide' code = 'valide'
@ -529,6 +659,7 @@ class Bilan:
def resume(self): def resume(self):
print('RÉSUMÉ ' + json.dumps({'comptes': self.comptes, 'ecarts': len(self.ecarts), print('RÉSUMÉ ' + json.dumps({'comptes': self.comptes, 'ecarts': len(self.ecarts),
'avertissements': len(self.avertissements),
'verdicts': self.verdicts, 'refus': self.refus}, 'verdicts': self.verdicts, 'refus': self.refus},
ensure_ascii=False, sort_keys=True)) ensure_ascii=False, sort_keys=True))
@ -553,10 +684,11 @@ class ErreurLigne:
self.msg = msg self.msg = msg
def session(b, chemin, requetes_seules): def session(b, chemin, requetes_seules, reponses_seules=False):
"""Un message JSON-RPC par ligne ; réponses rapprochées par id.""" """Un message JSON-RPC par ligne ; réponses rapprochées par id."""
attente = {} # id → (méthode, params) attente = {} # id → (méthode, params)
handshake = None # dernier result de Handshake accepté handshake = None # dernier result de Handshake accepté
version = None # protocolVersion de l'optimiseur, lue dans ce result (error.data, 1.3)
for n, m in lignes(chemin): for n, m in lignes(chemin):
ou = f'ligne {n}' ou = f'ligne {n}'
if isinstance(m, ErreurLigne) or not isinstance(m, dict): if isinstance(m, ErreurLigne) or not isinstance(m, dict):
@ -569,7 +701,8 @@ def session(b, chemin, requetes_seules):
if nom not in b.c.methodes: if nom not in b.c.methodes:
b.noter(ou, 'enveloppe', [('/method', 'enum', f'méthode « {nom} » absente du contrat')]) b.noter(ou, 'enveloppe', [('/method', 'enum', f'méthode « {nom} » absente du contrat')])
continue continue
b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {}))) if not reponses_seules:
b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {})))
if m.get('id') is not None: if m.get('id') is not None:
attente[m['id']] = (nom, m.get('params')) attente[m['id']] = (nom, m.get('params'))
continue continue
@ -587,9 +720,11 @@ def session(b, chemin, requetes_seules):
b.verdict(ou, code) b.verdict(ou, code)
if code is None: if code is None:
handshake = m['result'] handshake = m['result']
version = handshake.get('protocolVersion')
elif 'error' in m: elif 'error' in m:
b.compter(f'{nom}.error') b.compter(f'{nom}.error')
print(f'{ou} {nom} : réponse error (compte comme un échec de la requête, rpc-error)') print(f'{ou} {nom} : réponse error (compte comme un échec de la requête, rpc-error)')
b.juger_erreur(ou, nom, m['error'], params, version)
if b.semantique: if b.semantique:
b.refus += 1 b.refus += 1
else: else:
@ -623,12 +758,15 @@ def main(argv=None):
p.add_argument('--method', help='méthode du message (--params / --result)') p.add_argument('--method', help='méthode du message (--params / --result)')
p.add_argument('--params', help='fichier JSON : les params d\'une requête') p.add_argument('--params', help='fichier JSON : les params d\'une requête')
p.add_argument('--result', help='fichier JSON : le result d\'une réponse') p.add_argument('--result', help='fichier JSON : le result d\'une réponse')
p.add_argument('--error', help='fichier JSON : l\'objet error d\'une réponse (forme de son data)')
p.add_argument('--request', help='fichier JSON : les params de la requête (règles sémantiques de GetPlan)') p.add_argument('--request', help='fichier JSON : les params de la requête (règles sémantiques de GetPlan)')
p.add_argument('--handshake', help='fichier JSON : le result du Handshake (kinds annoncés)') p.add_argument('--handshake', help='fichier JSON : le result du Handshake (kinds annoncés)')
p.add_argument('--ndjson', help='session JSON-RPC, un message par ligne') p.add_argument('--ndjson', help='session JSON-RPC, un message par ligne')
p.add_argument('--trace', help='traces de décision du moteur (annexe D)') p.add_argument('--trace', help='traces de décision du moteur (annexe D)')
p.add_argument('--semantic', action='store_true', help='applique x-etm-semantic-rules') p.add_argument('--semantic', action='store_true', help='applique x-etm-semantic-rules')
p.add_argument('--requests-only', action='store_true', help='--ndjson : ne juge que les requêtes') p.add_argument('--requests-only', action='store_true', help='--ndjson : ne juge que les requêtes')
p.add_argument('--responses-only', action='store_true',
help='--ndjson : ne juge que les réponses (session de sonde, requêtes fautives à dessein)')
p.add_argument('--subset', action='store_true', help='écrit le sous-ensemble géré et sort') p.add_argument('--subset', action='store_true', help='écrit le sous-ensemble géré et sort')
a = p.parse_args(argv) a = p.parse_args(argv)
if a.subset: if a.subset:
@ -639,10 +777,12 @@ def main(argv=None):
contrat = Contrat(lire_json(a.schema)) contrat = Contrat(lire_json(a.schema))
b = Bilan(contrat, a.semantic) b = Bilan(contrat, a.semantic)
if a.ndjson: if a.ndjson:
session(b, a.ndjson, a.requests_only) if a.requests_only and a.responses_only:
p.error('--requests-only et --responses-only s\'excluent')
session(b, a.ndjson, a.requests_only, a.responses_only)
elif a.trace: elif a.trace:
trace(b, a.trace) trace(b, a.trace)
elif a.method and (a.params or a.result): elif a.method and (a.params or a.result or a.error):
if a.params: if a.params:
b.noter(a.params, f'{a.method}.params', contrat.valider_params(a.method, lire_json(a.params))) b.noter(a.params, f'{a.method}.params', contrat.valider_params(a.method, lire_json(a.params)))
if a.result: if a.result:
@ -656,8 +796,14 @@ def main(argv=None):
b.verdict(a.result, juger_plan(r, req, hs, contrat.kinds)) b.verdict(a.result, juger_plan(r, req, hs, contrat.kinds))
elif a.semantic and a.method == 'Handshake': elif a.semantic and a.method == 'Handshake':
b.verdict(a.result, juger_handshake({'result': r}, contrat.version)) b.verdict(a.result, juger_handshake({'result': r}, contrat.version))
if a.error:
contrat.methode(a.method)
hs = lire_json(a.handshake) if a.handshake else None
b.compter(f'{a.method}.error')
b.juger_erreur(a.error, a.method, lire_json(a.error), lire_json(a.request) if a.request else None,
hs.get('protocolVersion') if isinstance(hs, dict) else None)
else: else:
p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result') p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result / --error')
except ErreurSchema as e: except ErreurSchema as e:
print(f'ERREUR DE SCHÉMA : {e}', file=sys.stderr) print(f'ERREUR DE SCHÉMA : {e}', file=sys.stderr)
return 2 return 2