180 Commits

Author SHA1 Message Date
Patrick Schurig
e28e6375fd Heures creuses : la somme éco + confort de la borne tient dans son plafond (écart 4)
En heures creuses, le niveau éco d'une borne à pleine puissance vaut son
plafond de cycle, qui porte la limite de phase. À l'aube, la passe confort
lui ajoutait le surplus, et seul le courant maximal de la borne écrêtait
la somme : borne monophasée sur A, limite 16 A, plafond 13 A, 2 540 W de
surplus — 16 A servis, la phase A à 18 A. L'achat publié valait 2 990 W
quand le compteur n'importait que 450 W.

Pour une borne qui porte EV_OFF_PEAK_FULL_POWER :
- la passe confort sert le surplus DANS le plafond ;
- l'achat cède ce que le surplus finance (gridW, drawCommittedW,
  draw.committedW) : la somme reste le plafond ;
- la part confort n'a pas de plancher : il porte sur la somme, qui vaut
  le plafond. Sinon un surplus sous le plancher serait refusé, ou démarré
  par EV_GRID_START, un second achat en plus du premier.
Hors heures creuses et sans tarif, rien ne change.

Tests : testOffPeakFullPowerAtDawnStaysInTheCeiling (nuit, aube, plein
soleil : 13 A, achat 2 990 / 450 / 0 W, achat ≤ import du compteur) ;
testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter étendu au total
aveugle (13, 15, 15… au lieu de 13, 16, 16…) ;
testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases, la garde de
bascule enfin éprouvée (12 A, phase B à 24 A ; sans elle 15 A, B à 27 A).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:41:48 +01:00
Patrick Schurig
b1c0adec00 decisions : référence étendue au lot e2, suite (consigne posée près de la limite de phase)
Sans tarif à heures creuses : 79 fonctions, 24 056 lignes identiques
octet pour octet à d62365f. En heures creuses, deux fonctions changent
(testBlockedDischargeShowsAtTheMeterAndL4Decides, 2 cycles ;
testOffPeakFullPowerYieldsToThePhaseLimit, réécrit), plus les deux
nouvelles. check et ETM_DECISIONS_TRACES=1 check : IDENTIQUE
(24 168 lignes ; 22 254 lignes de trace).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 07:27:13 +01:00
Patrick Schurig
679e64b5b3 Heures creuses : le plafond de la borne lui rend ce qu'elle tire, moins 1 A (lot e2, suite)
Près de la limite de phase, la consigne d'une borne à pleine puissance
alternait à chaque cycle : le plafond était la place libre au compteur,
sans lui rendre sa propre consommation. Maison à 2 A par phase, borne
16 A : 16, 7, 16, 7 A sous 25 A ; 16, 14 sous 32 A ; 14, 6, 8, 6 sous 16 A.

En heures creuses, et pour la pleine puissance du lot e2 seulement, le
plafond est lu par EnergyArbitrator::evOffPeakAllowanceW() : limite moins
le tirage de la phase sans la borne, moins 1 A, à l'ampère inférieur. La
règle de l'échéance chez l'amont (adjustEvChargers()). Elle converge en
un cycle : 16 A tenus sous 25 et 32 A, 13 A sous 16 A.

- Courant rendu MESURÉ (currentPower), sur les phases où la borne le
  mesure ; rien rendu pendant une bascule ou sans mesure.
- Tirage de la maison sans la borne écrêté à zéro : une borne qui
  annonce plus que ce que le compteur voit reste à 1 A sous la limite.
- En dépassement, le plafond vaut au plus le courant mesuré moins 1 A :
  il baisse à chaque cycle, jusqu'au plancher, où L4 décide.
- Hors heures creuses et sans tarif : evPhaseAllowanceW(), inchangée.

Tests : testOffPeakFullPowerSettlesUnderThePhaseLimit (sept cas, douze
cycles) et testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter, nouveaux ;
testOffPeakFullPowerYieldsToThePhaseLimit réécrit avec un compteur qui
voit la borne (mesure et non consigne, dépassement, plancher, L4) ;
testBlockedDischargeShowsAtTheMeterAndL4Decides : en dépassement, la
borne finit au plancher (L4 16 → 13 A, stratégie 9 A, L4 6 A) au lieu
d'être coupée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 07:15:30 +01:00
Patrick Schurig
d62365fe3d decisions : référence étendue au lot e2 (pleine puissance en heures creuses)
72 fonctions sans borne en heures creuses : 23 812 lignes identiques octet
pour octet à la référence de b144c09. Changent, en heures creuses : les six
fonctions du lot e1 qui ont une borne (ECO_FLOOR_GRID 1 380 W →
EV_OFF_PEAK_FULL_POWER 3 680 W, et leur compteur d'entrée, qui inclut
désormais ce que la borne tire) ; s'ajoutent les trois fonctions du lot e2.
check et ETM_DECISIONS_TRACES=1 check : IDENTIQUE (24 068 lignes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:32:16 +02:00
Patrick Schurig
1291f72d1f Heures creuses : la borne charge à la pleine puissance permise (lot e2)
Décision de Patrick du 2026-10-24. Dans les trois modes qui reportent en
heures creuses (Éco avec minimum, Éco min + échéance, Éco avec échéance) :

- le minimum servi au réseau en heures creuses est le PLAFOND de cycle de
  la borne (declared.maxPowerW), plus son plancher. Il porte le courant
  maximal, les phases et la limite de phase, et ne descend jamais sous le
  plancher : la stratégie rabaisse, L4 coupe. 3 680 W en monophasé 16 A,
  11 040 W en triphasé 16 A ;
- les phases se choisissent sur la pleine puissance, plus sur « courant +
  surplus » (EnergyArbitrator::enHeuresCreuses(), lu par EvAdapter). La nuit,
  une batterie qui couvre la maison et la voiture met le compteur à 0, et la
  règle de bascule gardait le monophasé : 3,7 kW au lieu de 11 ;
- la passe éco n'oppose plus à cette borne la marge d'UNE phase de la maison
  (LM-1500-h, déjà appliquée en tête de passe et en confort) : son plafond
  porte déjà l'autorisation, sur ses phases. Restreint aux heures creuses ;
- motif EV_OFF_PEAK_FULL_POWER {gridW, maxW, phases, until, endsAt,
  o:boundBy phaseLimit|floor}, rendu en français, à la place
  d'ECO_FLOOR_GRID sur la borne.

Hors heures creuses et sans heure creuse dans le tarif : rien ne change.
Le blocage de la décharge se lève toujours sur la puissance MESURÉE (230 W) :
une borne commandée à 16 A dont la voiture ne prend plus rien le lève.

Tests : testOffPeakChargesAtFullPower, testOffPeakFullPowerSwitchesToThreePhases,
testOffPeakFullPowerYieldsToThePhaseLimit. Les tests du lot e1 en heures
creuses attendent le plafond, et leur compteur inclut ce que la borne tire
désormais (TirageHcW) — sinon il fabriquait un surplus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:22:12 +02:00
Patrick Schurig
1813fe80cf decisions : référence étendue à la fonction des écritures refusées (lot e1, suite)
Les 77 fonctions existantes donnent 23 950 lignes identiques octet pour
octet à la référence de 2cde57c (cmp, lignes de la nouvelle fonction
retirées). La référence s'étend des 96 lignes de
testRefusedWritesAreSpacedAndNeverDelayTheRelease ; check : IDENTIQUE
(24 046 lignes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:25:05 +02:00
Patrick Schurig
b007aae720 Batterie : une consigne refusée est réessayée en s'espaçant, la levée jamais (lot e1, suite)
Un onduleur qui refuse la consigne (GEN24 sans « Allow Control », exception
0x04) la voyait reposée à chaque cycle, avec un qCWarning à chaque fois,
toute la nuit, et `held` la publiait comme tenue.

- Une pose refusée (taux ou limite) ouvre une série de refus : la pose
  suivante attend 1, 2, 5, 10 puis 15 min au plus (marge de 5 s pour la
  gigue du cycle). La série se clôt à la première séquence acceptée
  jusqu'au bout (retour au tiers du minuteur) ou à la levée.
- La levée n'est jamais espacée : elle part au cycle même où la décision
  tombe, série en cours ou non.
- Journal : un seul qCWarning par série, les refus suivants en qCDebug, un
  qCInfo à l'acceptation qui clôt la série.
- Télémétrie : pendant une série, `held` est faux (et `dischargeBlocked`
  avec lui), et la batterie publie o:refusedWrites, o:refusedSince,
  o:nextAttemptAt. `held` faux plutôt qu'un drapeau à côté : un client qui
  ignore le champ nouveau ne doit pas afficher une protection absente.

Mock : propriété dynamique `refuserEcritures` sur la batterie pilotable.
Test testRefusedWritesAreSpacedAndNeverDelayTheRelease ; les six
contre-épreuves (espacement, avertissement, held, levée, deux fins de
série) le font rougir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:19:15 +02:00
Patrick Schurig
2cde57ccf5 decisions : référence étendue aux six fonctions du lot e1
Sans elles, l'enregistrement est identique octet pour octet à la référence de
c5c3203 (23 799 lignes, cmp). Les 151 lignes ajoutées sont celles des six
scénarios d'heures creuses (testOffPeakDemoDay, …EndsAtSixAndOnDstDays,
testWithoutOffPeakTheEngineIsEtm74, testABatteryWithoutControlIsReadAsBefore,
testBlockedDischargeShowsAtTheMeterAndL4Decides,
testAMeterSilenceHandsTheBatteryBack). Deux enregistrements successifs
identiques.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 09:21:28 +02:00
Patrick Schurig
3cbcdeeca3 Test : le bloc storage se lit aussi par GetLoadTelemetry (lot e1)
La journée de la démo relit la télémétrie par la frontière RPC pendant le
blocage : le bloc passe la validation du schéma déclaré (o:storage), et le Get
rend ce qui a été publié.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 08:56:24 +02:00
Patrick Schurig
d9372c14ba Heures creuses : la borne attend les HC, la batterie ne se décharge pas dans la voiture (lot e1)
Décisions de Patrick du 2026-10-24. Sans heure creuse dans le tarif saisi
(aucun tarif, tarif unique, prix tous égaux), rien de ceci ne s'exécute.

LA BORNE — modes « Éco avec minimum », « Éco min + échéance », « Éco avec
échéance » (EvAdapter déclare needs.offPeakShift ; le scheduler décide) :
- hors heures creuses : surplus seulement. Le minimum réseau est retiré,
  EV_GRID_START aussi (le minimum par une autre porte). Motif
  EV_WAITING_OFF_PEAK {at "22:00", startsAt, budgetW}, rendu « … attend les
  heures creuses (22:00) ». Le plafond de soutirage et le refus gardent la tête.
- en heures creuses : le plancher de la borne est servi au réseau (passe éco,
  bornée par l'autorisation de soutirage). L'échéance reste au proxy, inchangée.
- Eco et Normal ne sont pas touchés.

LA BATTERIE — StorageDischargeGuard exécute, le rule-based décide (y compris en
ombre d'un optimiseur) :
- heure creuse ET bornes qui TIRENT ≥ 230 W (mesuré) ET batterie pilotable :
  dischargingRate = 0 PUIS enableDischarging = true (StorCtl_Mod bit DiSCHARGE
  = limite active, OutWRte = 0 : la séquence NO_DISCHARGE du plugin) ;
  rafraîchi au tiers du minuteur de retour (revertTime) ;
- sinon : levée EXPLICITE, enableDischarging = false puis dischargingRate au
  maximum. L2 lève aussi. Une limite à 0 % trouvée active au démarrage est
  reprise (rafraîchie ou levée).
- jamais gridCharging, enableCharging, chargingRate, ni un taux négatif.
- générique : interface energystorage et états présents. Sans les états
  (noDischargeControl), sans minuteur (noRevertTimer) ou minuteur < 120 s
  (revertTimerTooShort) : non commandée, STORAGE_NOT_PROTECTED.
- publié : bloc `storage` de la télémétrie (dischargeBlocked, decision,
  batteries[] avec ce qui est LU), absent sans heure creuse ou sans batterie ;
  ligne « [Arbitre] Batterie → … » au journal.

Le blocage ne verse rien au budget : surplus = compteur + PV (invariant 8),
le soutirage ajouté se mesure au cycle suivant, L4 garde le dernier mot.

Mock : classe controllableStorage (états modifiables du sunspecStorage,
paramètre revertTime). Tests : journée de la démo, 06:00 sur jours de 24/23/25 h,
etm74 sans heure creuse, batteries non pilotables ou absente, soutirage et L4,
repli L2 — les écritures lues à la frontière nymea (actionExecuted), l'onduleur
modélisé hors du moteur. Le commentaire du test §11 est réécrit comme il le
demandait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 08:34:21 +02:00
Patrick Schurig
920cf854da Tarif : les heures creuses sont la ou les zones au prix le plus bas (lot e1)
TariffConfig::zonesCreuses() : les zones au prix minimal d'un tarif à zones
horaires dont les prix diffèrent. Aucun tarif, tarif unique ou prix tous
égaux : aucune heure creuse. Le libellé ne décide pas — « HC » n'a pas de
code, le prix se compare.

enHeuresCreuses() et prochaineBascule() lisent l'heure murale minute
absolue par minute, comme importSeries() : bascules exactes les jours de
23 h (nuit de 7 h) et de 25 h (nuit de 9 h, 02:30 deux fois creuse),
épinglées sur des époques calculées hors de Qt (zoneinfo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 08:26:01 +02:00
Patrick Schurig
651181f84e Conformité : forme d'error.data et ordre token puis majeure (protocole 1.3)
Deux fonctions, par la connexion de sonde :
- testAnInvalidContextIsRefusedWithItsProblems : un GetPlan de contexte invalide
  ({"timestamp": "valeur-sonde:7Qz9"}) → -32602 à son id, connexion gardée ; la session de sonde
  jugée par le validateur publié contre le contrat EMBARQUÉ (--responses-only) : forme exigée
  d'un optimiseur qui s'annonce 1.3 ou plus, AVERTISSEMENT seulement pour un 1.2 ; le contrôle
  doit avoir eu lieu (GetPlan.error.data compté) ; garde indépendante : la valeur reçue n'est
  pas dans error.data, quelle que soit la version.
- testTheTokenIsCheckedBeforeTheMajor : token faux et autre majeure → -32001 puis fermeture ;
  -32602 d'un optimiseur 1.2 averti ; SKIP sans token attendu.

Éprouvé contre l'optimiseur de référence : 1.3 conforme (11 verts + 1 SKIP sans token, 3/3 avec
token) ; old-data annoncé 1.3 refusé, annoncé 1.2 averti ; echo-value refusé en 1.3 comme en 1.2 ;
major-first refusé en 1.3, averti en 1.2 ; référence annoncée 1.2 verte sans avertissement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:15:10 +02:00
Patrick Schurig
d961b73ed7 Validateur du contrat : juge error.data selon la version de l'optimiseur (protocole 1.3)
tools/etm-powersync-schema-check :
- lit methods[].errors[].x-etm-data-schema : error.data d'une réponse error de ce code est jugé
  contre ce schéma (audité comme les autres) ; exigé à partir de x-etm-data-required-from, la
  version étant celle que l'optimiseur annonce dans SON Handshake ; en deçà, écart écrit en
  AVERTISSEMENT, sans effet sur le code de sortie ; version inconnue = celle du contrat ;
- truncated n'accompagne que 50 problèmes exactement ;
- aucune valeur reçue recopiée dans error.data : écart dès 1.2 (règle de 1.2). Le contrôle ne
  porte que sur les valeurs qu'un chemin conforme ne peut pas contenir — il ne peut pas accuser
  un chemin conforme (domain « battery » / params.battery) ;
- --error (un objet error, avec --request et --handshake) et --responses-only (session de sonde,
  requêtes fautives à dessein) ; RÉSUMÉ compte les avertissements.

Tests : forme exigée en 1.3 et avertie en 1.2 sur de vraies sessions de l'optimiseur de
référence (normal, old-data, echo-value × 1.3, 1.2), forme ligne à ligne, recopie sans faux
positif, contre-épreuve « sans x-etm-data-schema, rien n'est jugé ». Les cinq nouveaux tests
échouent avec le validateur précédent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:13:57 +02:00
Patrick Schurig
09043fb7c7 Protocole optimiseur 1.3 : forme normée d'error.data, le token avant la majeure
Contrat (docs/etm-powersync-optimizer-protocol.openrpc.json) :
- composants InvalidContextData, ContextProblem, ContextProblemCode : error.data du -32602 de
  GetPlan = {"problems":[{path, problem}]}, 1 à 50 problèmes, truncated au-delà, chemin depuis
  params (motif), nature dans une liste fermée (missing, type, range, format, enum, constraint),
  formes fermées : aucune valeur reçue ne peut s'y glisser ;
- x-etm-data-schema et x-etm-data-required-from (1.3) sur cette erreur ;
- Handshake : token faux et autre majeure dans le même handshake → -32001, le token d'abord ;
- tolérance : error.data jamais lu par le moteur.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:11:55 +02:00
Patrick Schurig
76b348bf8b LoadConfigStore : un libellé hérité n'est cité que sur 64 caractères dans les refus d'ensemble (m-1)
appendConfig() ne borne que l'entrée ajoutée, mais validateSet() (THING_SHARED) et
validateRanks() (RANK_DUPLICATE) citaient en entier les libellés des deux entrées en
conflit — dont une héritée, sans borne. provisionEvChargerConfig() journalise cette phrase.

Les libellés y passent par libellePourMotif() : 64 caractères au plus (leftChars, jamais au
milieu d'une paire), suivis de « … ». Le motif publié (faultParams) ne change pas.

Test : testInheritedLabelIsCutInSetRefusals — héritée de 300 caractères ; l'ajout d'une borne
refusé sur THING_SHARED puis sur RANK_DUPLICATE : motif exact inchangé, phrase avec les 64
premiers caractères et sans le libellé entier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:22:58 +02:00
Patrick Schurig
58380dd7db LoadConfigStore::load() : les valeurs héritées sont citées échappées au journal
Même défaut que I-1, dans les avertissements de chargement ajoutés par Q7 : l'identifiant hors
règle et l'échéance illisible étaient cités en noquote(). Avant Q7, un client pouvait les
persister avec un saut de ligne (ni l'id ni l'échéance n'étaient bornés), et la lecture du
fichier fabriquait alors une fausse ligne de journal.

Ces valeurs passent par citee() : entre guillemets, échappées par QDebug (même rendu
qu'avant pour une valeur ordinaire). L'avertissement LIBELLÉ TROP LONG, qui cite l'identifiant
dans sa phrase, perd son noquote().

Test : testInheritedValuesAreEscapedInTheJournal charge un fichier dont l'id et l'échéance
valent "x\n[NymeaEnergy] FAUX" : les trois avertissements partent, citent la valeur échappée,
et aucun message n'ajoute de ligne commençant par [NymeaEnergy].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:22:41 +02:00
Patrick Schurig
2ce74892d3 SetLoadConfig : le journal du refus ne recopie plus la saisie du client (relecture q7-suite, I-1)
Le refus écrivait, en noquote(), la phrase détaillée du refus : elle cite mode, domaine,
rankOrigin, adaptateur, relays[].thingId, échéance et libellé tels que saisis. Un saut de ligne
dans l'un d'eux fabriquait une fausse ligne de journal (etm72 l'échappait par `<< why`).

Le journal ne porte plus que la phrase rendue depuis le motif (renderFr), comme SetTariff :
cause et endroit, un identifiant seulement s'il est bien formé. Échapper le détail aurait
suffi contre l'injection, pas contre le volume : relays[].thingId n'est pas borné.

La cause système d'un WRITE_FAILED, que seul le détail portait, est désormais écrite par
LoadConfigStore::commit() dans une ligne à part (chemin et erreur du fichier, aucune saisie).

Test : testSetLoadConfigRefusalIsPublished envoie dix refus dont un champ vaut
"x\n[NymeaEnergy] FAUX" et vérifie, message par message (JournalCapture::messages()), que le
refus est journalisé et qu'aucun message n'ajoute de ligne commençant par [NymeaEnergy].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:22:32 +02:00
Patrick Schurig
b793e07787 test(decisions) : référence étendue aux deux nouvelles fonctions de simulation
Les 69 fonctions existantes rendent un enregistrement identique, octet pour
octet, à la référence précédente (inchangée depuis fae7101). Les seules
lignes ajoutées sont celles de testSetLoadConfigRefusalIsPublished et de
testAutoProvisionedLabelIsCutNeverRefused. Après régénération : check
IDENTIQUE (71 fonctions, 23 728 cycles), traces activées comprises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:47:16 +02:00
Patrick Schurig
14b86de3c9 trace : une requête non envoyée n'est tracée que par sa taille (Q7)
Avec les traces actives, la ligne d'un cycle recopiait la requête GetPlan
même quand le client ne l'avait pas écrite (ligne de plus de 1 Mio,
request-too-long) : environ 1 Mio par cycle jusqu'au plafond journalier,
pour une requête que personne n'a reçue, et que le rejeu aurait renvoyée.

- La ligne porte alors `requestBytes` (taille de la ligne, octets) à la
  place de `request` ; sa réponse reste l'erreur request-too-long. Même
  règle pour la requête qui SERAIT partie sans optimiseur.
- La décision « part ou ne part pas » est celle du client :
  OptimizerClient::getPlanLine(), extraite de requestPlan() sans changer
  son comportement, est lue par la trace — pas une seconde estimation.
- Le commentaire « la requête RÉELLEMENT envoyée » est corrigé, la doc de
  la ligne de trace (optimizertrace.h) aussi.

Preuve : testARequestNotSentIsTracedBySizeOnly (avec et sans optimiseur ;
contre-épreuve : la requête envoyée est recopiée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:26:26 +02:00
Patrick Schurig
8988af0a16 test(socketscheduler) : ce qu'une configuration héritée hors règle envoie dans GetPlan
testInheritedOutOfRuleConfigWhatGetPlanSends : une configuration écrite sur
disque avant la règle (identifiant « ballon test », échéances « 07:00 » et
« 7:00 ») est chargée par un store neuf et signalée. Lu sur le faux
optimiseur : l'identifiant hors règle part tel quel ; l'échéance lisible part
avec ses energyTargets (contre-épreuve) ; l'échéance illisible ne part ni
brute ni résolue, minEnergyWhPerDay part encore. La requête est jugée
conforme au contrat embarqué (LoadNeeds.dailyDeadline impose
^[0-9]{2}:[0-9]{2}$, que « 7:00 » violait).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:26:17 +02:00
Patrick Schurig
a728a3c8da arbitre : borne ajoutée d'office même au nom trop long, libellé coupé à 256 caractères (Q7)
Écart « important » de la relecture d'etm72. provisionEvChargerConfig()
prenait le nom du Thing pour libellé et réécrivait l'ensemble complet : un
nom de plus de 256 caractères, ou un libellé hérité trop long sur une AUTRE
entrée, refusait l'ajout, et la borne restait arbitrée sans rang réglable.

- Le libellé est le nom coupé à 256 points de code (LoadConfigStore::
  leftChars), jamais au milieu d'une paire de substitution.
- L'ajout passe par appendConfig() : les bornes de saisie ne portent que
  sur l'entrée apportée. L'écriture cliente, elle, reste refusée tant que
  l'hérité n'est pas raccourci (règle d'etm72 inchangée).
- Le journal nomme la borne par ce libellé coupé.

Preuve : testAutoProvisionedLabelIsCutNeverRefused (nom de 300 caractères,
emoji en 256e position, hérité de 300 caractères sur une autre entrée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:23:37 +02:00
Patrick Schurig
f7aa370acb test(simulation) : le refus de SetLoadConfig est publié par RPC, cause par cause
testSetLoadConfigRefusalIsPublished : l'introspection déclare o:fault et
o:faultParams à côté d'energyError ; un succès rend toujours energyError
seul ; chacune des vingt causes atteignables rend EnergyErrorInvalidParameter,
fault LOAD_CONFIG_REFUSED et les faultParams attendus, sans rien changer à
GetLoadConfig ni au fichier, octet pour octet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:23:28 +02:00
Patrick Schurig
53a10b3062 SetLoadConfig : motif de refus publié (LOAD_CONFIG_REFUSED), id et échéance bornés à l'écriture (Q7)
Le refus garde EnergyErrorInvalidParameter et gagne, comme SetTariff, un
`fault` LOAD_CONFIG_REFUSED et des `faultParams` : une cause par motif
existant (ID, LABEL, DEADLINE, MODE, DOMAIN, RANK_ORIGIN, RANK_ORIGIN_AUTO,
ADAPTER, FIELD_*, SG_READY_STATE_2, ID_DUPLICATE, THING_SHARED,
RANK_DUPLICATE, WRITE_FAILED, STORE_UNAVAILABLE) et l'endroit (index, id
quand il est bien formé, field, state, indexes/ids, rank, length/max,
position). Jamais le libellé. Les deux champs sont déclarés optionnels dans
le schéma ; un succès rend exactement ce qu'il rendait.

- id : 1 à 128 caractères de [A-Za-z0-9._{}-], refusé à l'écriture ;
  hérité hors règle, chargé tel quel et signalé.
- needs.dailyDeadline : vide ou HH:MM (00:00-23:59), refusé à l'écriture ;
  hérité illisible, l'entrée est chargée, l'échéance retirée de la mémoire
  (ni l'arbitre ni l'optimiseur ne la reçoivent) et signalée.
- Les bornes de saisie passent avant la réconciliation de rankOrigin, dont le
  motif cite le libellé.
- appendConfig() : l'ajout par le moteur ne borne que l'entrée ajoutée.
- leftChars() coupe en caractères sans couper une paire de substitution ;
  l'id d'un motif n'est plus coupé par QString::left().
- Au chargement, chaque libellé trop long est signalé, pas seulement le
  premier ; loadconfigstore.h dit « n'écarte rien au chargement ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:20:07 +02:00
Patrick Schurig
3a4398f0b1 Fusion fix/instants-decalage : tout instant émis porte son décalage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
2026-10-22 19:30:58 +02:00
Patrick Schurig
f29d83e2f9 test(conformance) : la session jugée sous un fuseau non UTC
testTheSessionMatchesTheContractOffUtc : le moteur sous Europe/Berlin,
cadencé en heure locale comme en production, du 2026-10-25 02:58 CEST au
retour à 02:00 CET. Aucun échec de requête, et la session passe
tools/etm-powersync-schema-check --semantic contre le contrat embarqué.

Contre-épreuve, correctif retiré : 8 rpc-error (le constat VM), et le
validateur seul refuse chaque params.timestamp sur son motif `pattern`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:10:15 +02:00
Patrick Schurig
9d8b60fdd4 protocole optimiseur : tout instant émis porte son décalage
Constat VM du 2026-10-21 (Europe/Berlin) : l'optimiseur conforme refusait
chaque GetPlan, -32602 sur params.timestamp. `instant()` écrivait
toString(Qt::ISODate), qui n'émet aucun décalage pour une heure locale —
celle de currentDateTime(), donc de ctx.timestamp en production. Les
goldens, construits en UTC ou à décalage fixe, ne pouvaient pas le voir.

Le décalage de l'instant reçu est désormais rendu explicite : une heure
locale garde l'heure du site (+02:00), une heure UTC reste en Z. Il est lu
sur l'instant, donc 02:30 la nuit du retour à l'heure d'hiver sort +02:00
puis +01:00. Goldens inchangés.

Tests (loadmodel) : cinq fuseaux dont un négatif et un à la demi-heure,
été et hiver, tous les instants du contexte ; la nuit des deux changements
d'heure, minute par minute. Vérifiés échouant sans le correctif.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:10:15 +02:00
Patrick Schurig
6b1dd95e2e SetLoadConfig : libellé de charge borné à 256 caractères, à l'écriture seulement (Q7)
Le libellé part tel quel dans chaque GetPlan. Rien ne le bornait : une saisie de plus de
1 Mio suffisait à faire dépasser la limite de ligne du protocole. La borne arrête la
saisie en amont ; le refus d'émission du client (commit précédent) reste le filet.

- LoadConfigStore::validateLabels(), appelée en tête de setConfigs() : un libellé de plus
  de 256 points de code Unicode → refus en bloc, rien n'est modifié ; SetLoadConfig rend
  EnergyErrorInvalidParameter et journalise le motif en français, qui nomme la charge par
  son id et donne la longueur, jamais le libellé. Placée AVANT isValid() parce que les
  motifs suivants citent le libellé.
- Même partage que les rangs (LM-1209-a-bis) : load() ne l'applique pas. Un libellé plus
  long déjà persisté est chargé tel quel, ni tronqué ni écarté, et signalé ; la première
  écriture devra le raccourcir.
- Test : testLabelIsBoundedOnWriteNeverOnLoad (256 accepté, 257 refusé sans modification
  en mémoire ni sur disque, configuration héritée conservée entière).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:07:32 +02:00
Patrick Schurig
cd91baf98f optimiseur : le moteur n'émet jamais de ligne de plus de 1 Mio (Q7, protocole 1.2)
Ferme l'écart Q7 trouvé au lot protocole 1.2. Une requête GetPlan dont la ligne
dépasserait maxLineBytes n'est plus écrite sur le socket : OptimizerClient::requestPlan()
sérialise d'abord, rend l'identifiant, puis émet planFailed(id, "request-too-long") au
tour de boucle suivant. L'échec compte (règle des échecs consécutifs, §7), la connexion
est conservée, et le journal le dit une fois par connexion, avec la taille, jamais le
contenu. Avant : la ligne partait, l'optimiseur la jetait, et la requête finissait en
délai dépassé sans que la cause se voie.

Le handshake n'est pas contrôlé : sa ligne est bornée par construction (token de 4 096
octets au plus, 24 Kio une fois échappé).

- knownErrorCodes() : + request-too-long ; LastErrorCode du contrat régénéré
  (--enums-from), blocs générés de la prose régénérés.
- OPTIMIZER_PROTOCOL.md §0/§2 et x-etm-transport : l'écart connu devient la règle tenue.
  Pas de changement de version.
- Tests : testARequestOverTheLineLimitIsNeverSent remplace le XFAIL (aucun octet émis,
  limite exacte éprouvée des deux côtés), testARequestTooLongIsLoggedOncePerConnection,
  testARequestTooLongCountsAsAFailure (socketscheduler).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:07:11 +02:00
Patrick Schurig
8f24709874 protocole optimiseur 1.2 : les règles Q2 à Q9, et le moteur annonce 1.2
docs/OPTIMIZER_PROTOCOL.md passe en v1.2 (mineure) et écrit les décisions du
2026-10-21 à leur place :

- §2 : maxLineBytes (1 Mio, '\n' non compté) dans les deux sens ; lots
  interdits dans les deux sens ; /run/powersync appartient au paquet de
  l'optimiseur, le moteur ne le crée ni ne le suppose. Écart connu noté :
  le moteur ne borne pas encore ce qu'il émet.
- §3 : la majeure se compare des deux côtés (-32602 puis fermeture) ; -32001
  normé ; un token non exigé est accepté ; un handshake refusé est fermé des
  deux côtés ; GetPlan avant handshake → -32600, connexion gardée.
- §7 : contexte GetPlan invalide → -32602 avec chemins et natures, jamais les
  valeurs, compté comme un échec ; codes JSON-RPC 2.0 standard sinon ; table
  des erreurs GÉNÉRÉE depuis le contrat.
- §8 : UpdateLearned à sens unique, -32601 avec id ; forme non normée en 1.2.
- §9, §0 (changelog, compatibilité 1.1), annexes A, E, G.

Contrat OpenRPC : methods[].errors complétés (x-etm-connection),
x-etm-errors-engine, x-etm-message-errors (nouveau), x-etm-transport
(maxLineBytesScope, batches, runtimeDirectory), règle handshake-refused.
La version (info.version, ProtocolVersion) régénérée depuis le code par
--enums-from.

Moteur : OptimizerProtocol::ProtocolVersion et OptimizerClient::kProtocolVersion
passent à "1.2" ; testHandshakeAndPlan vérifie aussi que les deux constantes
sont égales. Aucun autre comportement ne change.

Tests suivis : version attendue dans testoptimizerclient et
testsocketscheduler, handshake de la session de schema-check-test, et un
test du bloc « errors » dans gen-protocol-doc-test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:01:46 +02:00
Patrick Schurig
7bb2b4a194 test(conformance) : sonde des règles 1.2 que le moteur ne provoque pas
Quatre fonctions, une connexion de SONDE sur l'optimiseur (sans le moteur),
pour ce que le client du moteur n'envoie jamais :

- testGetPlanBeforeTheHandshakeIsRefused (Q3) : -32600 à son id, puis le
  handshake sur la même connexion est accepté (connexion gardée) ;
- testARefusedHandshakeIsClosedByTheOptimizer (Q3, Q4) : autre majeure →
  -32602 puis l'optimiseur ferme ; avec un token attendu, token faux →
  -32001, fermé pareil ;
- testABatchIsRefused (Q8) : un lot → une erreur -32600 à id null, son
  contenu non traité, connexion gardée ;
- testALineOverTheLimitIsRefused (Q7) : maxLineBytes + 1 → -32600 à id null,
  une ligne d'exactement maxLineBytes est lue.

Le traitement d'un message se constate par une réponse normée sans contexte :
UpdateLearned avec id → -32601 (Q6). Réponses lues brutes : un lot renvoyé
se verrait. README : tableau, boucle de lancement, ce qui n'est pas vérifié.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:01:35 +02:00
Patrick Schurig
952f64d24b test(optimizerclient) : le moteur face aux règles 1.2 — fermeture, lots, longueur de ligne
Preuves, côté moteur, des points Q4, Q7 et Q8 du protocole 1.2. Aucun code du
moteur n'est modifié.

- testARefusedHandshakeIsClosedByTheEngine (Q4) : -32001 ou -32602 au
  handshake, faux optimiseur qui garde son socket ouvert → c'est le moteur qui
  ferme (handshake-refused), nouvelle tentative au backoff seulement.
- testAReceivedBatchIsNeverUnpacked (Q8, reçu) : un lot portant la réponse
  attendue est une ligne écartée (invalid-json), connexion conservée, réponse
  non lue ; la requête finit en délai dépassé.
- testEachLineSentIsOneObject (Q8, émis) : lu sur les octets, chaque ligne
  émise est UN objet JSON, une requête remplacée donne une ligne de plus.
- testAContextOverTheLineLimitIsStillSent (Q7, émis) : ÉCART constaté, non
  corrigé dans ce lot — requestPlan() ne borne pas ce qu'il émet ; un GetPlan
  de plus de 1 Mio part tel quel. QEXPECT_FAIL : le test passera en XPASS le
  jour où le moteur bornera l'émission.

Q7 en réception était déjà éprouvé par testLineLengthLimit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:01:16 +02:00
Patrick Schurig
0346775bb9 Fusion feature/droits-lecture-ecriture : droits de NymeaEnergy.* séparés
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:40:55 +02:00
Patrick Schurig
3ddf88efac test(decisions): référence régénérée après ECO_FLOOR_BELOW_STAGE
Moteur de cd7cd1a contre l'ancienne référence (61db6cf), comparateur
--sous-palier, lignes du nouveau test retirées : IDENTIQUE HORS MOTIFS ET HORS
MOTIF ÉCO SOUS PALIER — 23 792 lignes, 23 784 identiques à l'octet, 8 qui ne
diffèrent que par ECO_FLOOR_GRID → ECO_FLOOR_BELOW_STAGE (niveau éco, et
decision quand l'éco est en tête). purchasedWh identique, sans --achat.
Aucune consigne ne change. S'y ajoutent les 4 lignes de
testEcoBelowTheFirstStageBuysNothing : 69 fonctions, 23 796 lignes.
check : IDENTIQUE, 0 rejeu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:57:52 +02:00
Patrick Schurig
35808f17bd test(decisions): comparateur --sous-palier, seul ECO_FLOOR_GRID → ECO_FLOOR_BELOW_STAGE est admis
Retire le motif des niveaux « eco » portant l'un des deux codes et les
confronte deux à deux : tout autre passage, ou un ECO_FLOOR_GRID aux
paramètres modifiés, est un ÉCART. targetW, counts, progress du niveau restent
comparés. Les changements sont listés par scénario et par charge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:43:30 +02:00
Patrick Schurig
cd7cd1a37a fix(eco): ECO_FLOOR_BELOW_STAGE — sous le premier palier, le niveau éco dit « rien n'est acheté »
Sur une charge à paliers (powerLevels déclarés : relay-router, etmvariableload
fixed), une cadence éco sous le premier palier laisse le mécanisme au palier 0.
Le niveau éco publiait pourtant ECO_FLOOR_GRID « 83 W achetés au réseau ».
Il porte désormais ECO_FLOOR_BELOW_STAGE {targetW, stageW, gridW: 0,
o:remainingWh, o:remainingS}. Le premier palier est declared.powerLevels[1],
la source de BELOW_MIN_POWER en passe confort. Une charge continue garde
ECO_FLOOR_GRID.

Aucune consigne ne change : même targetW, mêmes counts, même draw, même
palier, même purchasedWh. Seul le motif du niveau change, et avec lui (R7,
inchangée) decision, decision.level et les lignes [Arbitre]/[RelayRouter].
Hors liste blanche optimiseur : l'optimiseur ne reçoit pas les paliers (E9).

Tests : testEcoBelowTheFirstStageBuysNothing (neuf, échoue sur 61db6cf),
testTheDecisionDescribesTheMergedAction mis à jour (il exigeait l'achat sur
un relais de 1000 W à 83 W), rendu FR et refus optimiseur en loadmodel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:43:24 +02:00
Patrick Schurig
6c68d17edc test(charging): table des droits NymeaEnergy.* figée, et droits mordant sur transport authentifié
testNymeaEnergyPermissionScopes compare le permissionScope PUBLIÉ par JSONRPC.Introspect (la
table que nymea consulte) à une table écrite dans le test : méthode ajoutée, retirée ou droit
changé → échec, avec tous les écarts listés.

testNymeaEnergyPermissionsEnforced crée de vrais comptes sur le MockTcpServer du harnais, dont
l'authentification est active (Hello : authenticationRequired vérifié) :
- sans droit : GetLoadTelemetry, GetTariff, GetBatteryLevelConsideration répondent ;
  SetTariff {zones: []} et SetLoadDerogation → « Permission denied. », tarif inchangé ;
- ControlThings : SetLoadDerogation posée puis levée ; SetLoadConfig refusé, config inchangée ;
- ConfigureThings : SetTariff passe ; SetPhasePowerLimit et GetOptimizerTrace refusés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:26:24 +02:00
Patrick Schurig
61db6cfd66 test(conformance): mode attendu plan | abstain, un optimiseur qui s'abstient est jugé conforme
S'abstenir est une réponse permise (§7). Avec ETM_POWERSYNC_CONFORMANCE_EXPECT=abstain,
testEveryPlanIsAccepted exige une abstention à chaque cycle (rules, fallbackCode abstain,
verdict abstention/abstain, aucun échec, optimizerAlive inchangé) ; testTheSafetyLoopStaysOnTop
vérifie L4 sous le rule-based (shed exigé) puis se termine en QSKIP, rien à écrêter. Mode `plan`
(défaut) inchangé. Le mode retenu est affiché en tête de chaque fonction.

Pas de déduction depuis le handshake : `planning` est informatif (multi-créneaux ou non) et
`kinds` absent veut dire « tout admis » ; la référence en mode abstain annonce des kinds, le mode
protocol-1.0 (planning false, sans capabilities) planifie. README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:46:53 +02:00
Patrick Schurig
308444f39d test: traces réelles du moteur jugées contre le contrat (refoptimizer)
Les 16 lignes de testTracesReplayWithZeroDivergence : chaque requête, et
chaque réponse de l'optimiseur rejugée sur sa requête (--trace --semantic).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:48:30 +02:00
Patrick Schurig
fc41c4ff40 test: suite etm-powersync-optimizer-conformance pour un optimiseur quelconque
Lance l'optimiseur de $ETM_POWERSYNC_OPTIMIZER_CMD (défaut : la référence,
mode normal) et y branche le vrai moteur : handshake direct accepté, GetPlan
répondus sous 10 s, session entière conforme au contrat embarqué (règles
sémantiques comprises), chaque plan appliqué sans fallbackCode, consigne hors
bornes écrêtée et surcharge délestée par L4. Relais transparent pour capturer
la session. README : la lancer contre un autre optimiseur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:46:53 +02:00
Patrick Schurig
ead662e044 feat(outils): etm-powersync-gen-protocol-doc, prose générée depuis le contrat
Tableaux de messages (champs, types, obligatoire, valeurs, sens), règles
sémantiques et codes de motif, entre balises BEGIN/END GENERATED de
OPTIMIZER_PROTOCOL.md. --check (code 1 si la prose ou la forme canonique du
contrat n'est pas à jour) branché en 5/5 de tools/ci-quality.sh. --enums-from
pose les listes fermées du contrat depuis le dump du code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:34:17 +02:00
Patrick Schurig
07cb44d35f test: les requêtes réelles du moteur jugées contre le contrat
optimizerclient : chaque Handshake envoyé ; socketscheduler : tout ce que le
moteur envoie au faux optimiseur, plus testTheEngineSpeaksTheContractItServes
(GetOptimizerProtocol == embarqué == docs, requêtes jugées avec le contrat
servi) ; refoptimizer : session capturée par la référence, requêtes toujours,
session entière et règles sémantiques en normal et en 1.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:30:44 +02:00
Patrick Schurig
d95a65cb71 test: suite etm-powersync-protocolschema (contrat == docs == code)
Contrat embarqué identique à docs/ ; listes x-etm-generated égales au code
(ancre côté test, dump pour régénérer) ; chaque code de verdict, d'erreur et
de repli lu dans les SOURCES figure dans les listes ; règles sémantiques ==
verdicts du validateur, même ordre ; sérialiseur GetPlan jugé conforme sur
toutes ses branches par le validateur publié.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:30:44 +02:00
Patrick Schurig
fb581218c8 feat(outils): etm-powersync-schema-check, validateur du contrat OpenRPC
Python, bibliothèque standard seule. Sous-ensemble de JSON Schema déclaré ;
un mot-clé non géré est une erreur de schéma, jamais ignoré. Valide un
message, une session NDJSON ou des traces ; --semantic applique
x-etm-semantic-rules dans l'ordre du validateur du moteur (une règle sans
implantation, ou l'inverse, est une erreur).

Test : références du sérialiseur et traces réelles du moteur conformes ;
optimiseur de référence mode par mode, et qui rejette chaque mode mauvais.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:15:51 +02:00
Patrick Schurig
5768c36953 fix(optimiseur): token d'au moins 16 caractères (correctifs brief 4, point 4)
Question 4 de T7 : le masquage à la réception remplace TOUTE occurrence du
token dans ce que l'optimiseur renvoie ; un token court, présent par hasard
dans un identifiant de charge, corromprait le plan reçu (unknown-load).

- SetOptimizerToken (writeToken) refuse un token non vide de moins de
  16 caractères (points de code UTF-8) : EnergyErrorInvalidParameter, fault
  `token-refused` comme les autres refus ; rien n'est modifié ; le motif est
  journalisé, jamais la valeur. Vide = retrait, inchangé.
- Le client relit le fichier au début de chaque tentative ; plus court que
  16 caractères, il ne se connecte PAS (ni avec ce token, ni sans token : les
  mesures du site ne partent pas vers un optimiseur que rien n'authentifie).
  lastErrorCode `token-too-short`, dit une fois par série, nouvelle tentative
  au backoff jusqu'au remplacement du fichier.

INTERFACE.md (SetOptimizerToken, lastErrorCode) et protocole §3 (E-T2-8,
forme du token). Tests : 15 refusé, 16 accepté, 15 « é » refusés (caractères,
pas octets), vide = retrait, fichier court sans connexion puis token conforme
envoyé ; tokens des suites portés à 16 au moins. Contre-épreuves M4a (refus
retiré) et M4b (fichier court envoyé) : mordent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:44:19 +02:00
Patrick Schurig
e3b916e249 fix(optimiseur): fallbackCode dit pourquoi on replie (correctifs brief 4, point 3)
Constat `fallbackCode` de T7 (E-T7-6) : une abstention, un plan expiré ou
refusé à la réception vidaient le cache, et le cycle suivant repliait avec
`no-plan` — l'app ne distinguait pas « l'optimiseur s'abstient » de « rien
reçu ».

Le code du verdict de réception d'une réponse non appliquée est gardé comme
code de repli jusqu'à l'issue de la requête suivante ; un plan valide
l'efface, un échec de requête ou une connexion perdue le ramènent à
`no-plan`, qui ne dit plus que « rien reçu ». Publication seule : aucune
décision ne change.

INTERFACE.md : liste fermée des valeurs de fallbackCode et leur lecture
(« pas d'optimiseur », « pas encore de plan », « l'optimiseur s'abstient »,
« plan refusé »). Tests : refoptimizer lit GetPlan.fallbackCode après chaque
cycle de chaque mode (abstain, expired, invalid, unknown-load, missing-load…,
puis retour normal sans fallbackCode) ; socketscheduler vérifie le code publié
pour chaque refus (les tests qui épinglaient `no-plan` sont mis à jour).
Contre-épreuve M3 (toujours `no-plan`) : mord.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:39:12 +02:00
Patrick Schurig
ce323a6e1f fix(optimiseur): une tentative de reconnexion n'est pas une nouvelle perte (correctifs brief 4, point 2)
Constat `drop` de T7 : SocketScheduler notait ConnexionPerdue à CHAQUE état
non connecté du client (Connecting, Waiting d'une tentative), réécrivait
lastErrorCode avec celui du client et émettait etatChange() à chaque
tentative au backoff (une par minute au plafond).

La perte se note désormais à la transition : sortie de Ready ou de
Handshaking (fermeture, handshake refusé), ou premier échec d'un client qui
n'a jamais été connecté (sinon un optimiseur absent dès le départ
n'aurait jamais de lastErrorCode). Les tentatives suivantes d'une panne déjà
notée ne touchent ni au dernier code, ni aux échecs, ni à l'état publié.
Publication seule : aucune décision ne change.

Test socketscheduler::testAReconnectionAttemptIsNotANewLoss : optimiseur
disparu, 3 tentatives au backoff → 1 seul etatChange, 1 seule notification,
lastErrorCode `connection-lost` (le client en est à `connect-failed`) ; au
retour, les transitions du §7 et rien d'autre. Ligne `drop` de refoptimizer :
le dernier code reste `disconnected`. Contre-épreuve M2 (ancien comportement) :
mord sur les deux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:34:38 +02:00
Patrick Schurig
8c0645d371 feat(tools): l'optimiseur de référence s'annonce powersync-optimizer (correctifs brief 4, point 1)
Décision du 2026-10-17 : l'exemple GPL annonce `powersync-optimizer` dans le
`name` du handshake ; `etm-optimizer` reste le nom de l'optimiseur ETM,
propriétaire, hors de ce dépôt. Le fichier et son répertoire gardent le
préfixe des artefacts (`tools/etm-powersync-ref-optimizer/`).

README de l'outil, annexe E et tools/README.md le disent en une phrase ;
refoptimizer vérifie le `name` publié par GetOptimizerStatus (1.1 et 1.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:32:51 +02:00
Patrick Schurig
fb017f87a7 test(intégration): L2 éprouvé sous un plan d'optimiseur qui commande tout à fond (brief 4 T7)
La contre-épreuve E4 (L2 ne suspend plus la planification) ne mordait que par la ligne de
trace : le plan en cache valait déjà 0 avant L2. Le test ramène l'optimiseur à 2000 / 3000 W
et PAC forcée juste avant la coupure du compteur ; E4 mord désormais sur les consignes de repli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:45:44 +02:00
Patrick Schurig
644741b0ef test(intégration): suite etm-powersync-refoptimizer, moteur réel + optimiseur de référence réel (brief 4 T7, §3)
Chaque test lance tools/etm-powersync-ref-optimizer en sous-processus (démarrage borné,
échec en clair, jamais bloqué).
- identité Q6 (banc restreint, passe confort seule) sur unix:// et tcp:// : ce que les mocks
  reçoivent au cycle i sous l'optimiseur == au cycle i-1 sous le rule-based seul, 36 cycles ;
- chaque mode de défaut → la ligne du tableau §7, sur le banc complet, et le retour ;
  notifications une par transition ; slow : update() ≤ 1 ms ;
- optimiseur 1.0 accepté ; traces rejouées sur un nouvel optimiseur : 0 divergence, puis
  mode deviate : divergence, code 1 ; L4 et L2 gagnent ; token recopié : nulle part ;
- catalogue des codes émissibles identique à celui du moteur.
8 fonctions, 33 / 0, 54 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:16:43 +02:00
Patrick Schurig
f42dc709e3 fix(tools): trace-replay compare une action state sur son état seul (brief 4 T7)
Le plan du rule-based tracé porte la puissance ESTIMÉE d'une action state ; un optimiseur ne
la donne pas (§6 ne la définit pas). Le rejeu d'une trace rule-based contre l'optimiseur de
référence voyait donc une divergence sur chaque PAC. Seuls l'état (state) et la puissance
d'une consigne (setpoint) sont comparés. Test unitaire ajouté (7 / 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:16:43 +02:00