Optimiseur de référence : aligné sur le protocole 1.3

- Annonce 1.3. error.data du -32602 d'un contexte invalide dans la forme normée :
  {"problems":[{"path":"params.timestamp","problem":"missing"|"type"|"format"}]}, 50 problèmes
  au plus et truncated au-delà — plus de chemin « /timestamp » ni de phrase libre.
- Handshake : le token était déjà jugé avant la majeure ; l'ordre est désormais écrit et nommé.
- Pour les contre-épreuves de la conformité : --announce VERSION (se présenter en 1.2) et trois
  modes, old-data (forme d'avant 1.3, {"problemes": [phrases]}), echo-value (recopie de la valeur
  reçue), major-first (la majeure jugée avant le token).

Suite refoptimizer 8/8 ; test du validateur vert avec l'outil inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-23 11:13:17 +02:00
parent 09043fb7c7
commit 8c7c33be39
2 changed files with 78 additions and 25 deletions

View File

@ -1,7 +1,7 @@
# `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`
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
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.
@ -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
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) :
| Reçu | Réponse | Connexion |
|---|---|---|
| `Handshake` sans le token attendu, ou avec un autre | `-32001` | fermée |
| `Handshake` d'une autre majeure | `-32602` | 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, token accepté ou non exigé | `-32602` | fermé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 |
| 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 |
@ -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`
(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
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
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
# SPDX-License-Identifier: GPL-3.0-or-later
# 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]
[--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
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)
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é
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
(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
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)
GetPlan avant un handshake réussi -32600, connexion gardée
contexte GetPlan invalide -32602, `error.data.problems` = [{path, problem}], jamais la
valeur reçue — une erreur, pas une abstention
contexte GetPlan invalide -32602, `error.data` = {"problems": [{path, problem}]} (1.3 :
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
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
@ -88,8 +101,7 @@ import time
from datetime import datetime, timedelta
ENDPOINT_DEFAUT = 'unix:///run/powersync/optimizer.sock'
VERSION = '1.2'
MAJEURE = VERSION.split('.')[0]
VERSION = '1.3'
NOM = 'etm-powersync-ref-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.
@ -101,9 +113,11 @@ CODE_REQUETE_INVALIDE = -32600
CODE_METHODE_INCONNUE = -32601
CODE_PARAMS_INVALIDES = -32602
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',
'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
# 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):
"""Ce que cet optimiseur ne peut pas lire dans un contexte GetPlan : [{path, problem}], jamais
la valeur reçue (§7, 1.2). Seul `timestamp` est exigé — tout le reste se lit avec un défaut."""
if instant(ctx.get('timestamp')) is None:
return [{'path': '/timestamp', 'problem': 'absent, illisible ou sans décalage horaire'}]
"""Ce que cet optimiseur ne peut pas lire dans un contexte GetPlan : [{path, problem}] (§7, 1.3),
chemin depuis `params`, nature dans la liste fermée — jamais la valeur reçue. Seul `timestamp`
est exigé : tout le reste se lit avec un défaut."""
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 []
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):
"""La réponse à un GetPlan (`result`) ; le contexte a passé problemes_contexte()."""
t0 = time.monotonic()
@ -498,19 +531,27 @@ class Connexion(socketserver.StreamRequestHandler):
mode = mode_courant(cfg)
if methode == 'Handshake':
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é.
journal('handshake refusé : token absent ou différent')
self.erreur(ident, CODE_TOKEN_REFUSE, 'token refusé')
return
version = params.get('protocolVersion')
if not isinstance(version, str) or version.split('.')[0] != MAJEURE:
if majeure_refusee:
journal(f'handshake refusé : majeure incompatible ({str(version)[:32]})')
self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible')
return
token = attendu
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'],
'usesForecast': [], 'reasonCodes': True}}
if mode == 'wrong-major':
@ -526,8 +567,9 @@ class Connexion(socketserver.StreamRequestHandler):
continue
problemes = problemes_contexte(params)
if problemes:
journal(f'contexte invalide : {", ".join(p["path"] for p in problemes)}')
self.erreur(ident, CODE_PARAMS_INVALIDES, 'contexte invalide', {'problems': problemes})
journal(f'contexte invalide : {", ".join(p["path"] + " " + p["problem"] for p in problemes)}')
self.erreur(ident, CODE_PARAMS_INVALIDES, 'contexte invalide',
donnees_contexte_invalide(problemes, params, mode))
continue
if mode == 'drop':
journal('mode drop : connexion fermée sans réponse')
@ -582,7 +624,7 @@ def ecouter(endpoint):
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,
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)')
@ -592,12 +634,16 @@ def main(argv=None):
p.add_argument('--horizon-s', type=int, default=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('--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',
help='écrit le catalogue des codes émissibles (JSON) et sort')
args = p.parse_args(argv)
if args.list_codes:
print(json.dumps(CODES_EMISSIBLES, sort_keys=True))
return 0
if not args.announce.split('.')[0].isdigit():
p.error(f'--announce : version semver attendue, pas « {args.announce} »')
def arreter(*_):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, arreter) # arrêt propre : le socket unix est retiré