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:
parent
09043fb7c7
commit
8c7c33be39
@ -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
|
||||
|
||||
@ -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é
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user