From 8c7c33be39a92b2ff91a17a449295b32a306d2f6 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 23 Oct 2026 11:13:17 +0200 Subject: [PATCH] =?UTF-8?q?Optimiseur=20de=20r=C3=A9f=C3=A9rence=20:=20ali?= =?UTF-8?q?gn=C3=A9=20sur=20le=20protocole=201.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- tools/etm-powersync-ref-optimizer/README.md | 19 +++-- .../etm-powersync-ref-optimizer | 84 ++++++++++++++----- 2 files changed, 78 insertions(+), 25 deletions(-) diff --git a/tools/etm-powersync-ref-optimizer/README.md b/tools/etm-powersync-ref-optimizer/README.md index 3b98536..880a655 100644 --- a/tools/etm-powersync-ref-optimizer/README.md +++ b/tools/etm-powersync-ref-optimizer/README.md @@ -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 ` 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 diff --git a/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer b/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer index a3af737..1227ef2 100755 --- a/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer +++ b/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer @@ -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é