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

203 lines
10 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QElapsedTimer>
#include <QHash>
#include <QObject>
#include <QSet>
#include <QString>
#include <QVariantMap>
#include <functional>
#include "ischeduler.h"
#include "optimizerclient.h"
#include "optimizerplan.h"
#include "optimizerprotocol.h"
#include "optimizerstatus.h"
/*!
* \brief Stratégie « optimiseur externe » (OPTIMIZER_PROTOCOL.md 1.1, lot 3d, brief 4 T3).
*
* N'existe QUE si un endpoint est configuré (\c Optimizer/endpoint) : sans lui, l'arbitre
* n'instancie ni cette classe ni son \c OptimizerClient, et interroge le rule-based directement.
*
* **À chaque cycle**, \c getPlan() :
* 1. appelle le rule-based — le plan OMBRE. Il tourne toujours, parce que ses effets de bord sur
* l'arbitre (actions du proxy VE, comptabilité éco, réserve batterie) sont dus à chaque cycle,
* et parce qu'il est le repli immédiat ;
* 2. envoie \c GetPlan avec le contexte du cycle, SANS ATTENDRE : la réponse arrive par signal
* et sert au cycle SUIVANT (modèle cache, règle 5) ;
* 3. si le plan en cache est valide pour maintenant (\c OptimizerPlan::valider() sur le contexte
* du cycle : créneau courant, \c validUntil, couverture Q1), substitue ses actions à celles de
* l'ombre (\c OptimizerPlan::substituer()) ; sinon rend l'ombre telle quelle.
*
* Après le dispatch, \c apresDispatch() relit ce que les adaptateurs ont appliqué (Q3, Q4).
*
* **Repli (§7)** : plan absent, invalide, expiré, abstention, connexion perdue → le rule-based
* pour le cycle. La vivacité (\c optimizerAlive, brief 4 T4) est tenue par
* \c OptimizerStatus::Vivacite : deux requêtes consécutives en échec, ou une connexion perdue,
* déclarent l'optimiseur mort et abandonnent le plan en cache ; le premier plan valide le rend
* vivant. Chaque changement est signalé par \c etatChange() ; l'arbitre en compose l'état publié.
* Une connexion perdue se note UNE fois par panne (sortie de \c Ready ou \c Handshaking, ou
* premier échec d'un client jamais connecté) : les tentatives de reconnexion au backoff ne
* réécrivent ni le dernier code ni l'état publié.
*
* \invariant \c getPlan() n'attend jamais : aucune E/S n'y est bloquante.
* \invariant Le plan rendu est toujours valide : au pire, c'est celui du rule-based.
* \invariant Aucun appel à un adaptateur : les actions passent par le dispatch commun de
* l'arbitre (« un seul arbitre »), qui les écrête comme celles du rule-based.
* \invariant Le texte libre d'un optimiseur va au journal, jamais dans une action (invariant 12).
*/
class SocketScheduler : public QObject, public IScheduler
{
Q_OBJECT
public:
//! Ce que la requête porte en plus du contexte (déclencheur excepté) — fourni par l'arbitre.
using FournisseurFil = std::function<OptimizerProtocol::ContexteFil(const SurplusContext &)>;
/*!
* \brief Construit la stratégie et démarre le client.
* \param ombre Le rule-based, appelé à chaque cycle ; non possédé, doit survivre.
* \param client Transport vers l'optimiseur ; POSSÉDÉ (reparenté), démarré ici.
* \param fil Fournisseur du \c ContexteFil de la requête ; vide = tout inconnu.
* \param parent Propriétaire Qt.
*/
SocketScheduler(IScheduler *ombre, OptimizerClient *client, FournisseurFil fil,
QObject *parent = nullptr);
/*!
* \brief Plan du cycle — voir la description de la classe.
* \param ctx Contexte du cycle. \return Le plan d'optimiseur substitué, ou celui de l'ombre.
*/
Plan getPlan(const SurplusContext &ctx) override;
/*!
* \brief Après le dispatch : puissance appliquée, financement, budget et \c clamped (Q3, Q4).
*
* Sans effet si \p plan n'est pas un plan d'optimiseur. À appeler entre
* \c applyActionsToAdapters() et la fusion du délestage L4.
* \param plan Plan du cycle. \param slot Créneau dispatché.
* \param appliquees Retours d'\c applyAction(). \param delestees Charges revendiquées par L4.
* \param ctx Contexte du cycle.
*/
void apresDispatch(Plan &plan, Slot &slot, const QHash<QString, LoadAction> &appliquees,
const QSet<QString> &delestees, const SurplusContext &ctx);
/*!
* \brief Note la cause de la prochaine requête (§5.1 \c trigger).
*
* Plusieurs causes entre deux requêtes : la plus significative l'emporte —
* \c intent > \c load-change > \c config > \c forecast > \c cycle. Consommée par l'envoi.
* \param trigger Une des cinq valeurs ci-dessus ; toute autre est ignorée.
*/
void noterDeclencheur(const QString &trigger);
/*! \brief \return La cause que portera la prochaine requête. */
QString declencheurEnAttente() const { return m_declencheur; }
/*! \brief \return Le transport (lecture : état, capacités). */
OptimizerClient *client() const { return m_client; }
/*! \brief \return Vrai si un plan valide à sa réception est en cache. */
bool aUnPlanEnCache() const { return m_aCache; }
/*! \brief \return Vrai si le plan du dernier cycle était celui de l'optimiseur. */
bool optimiseurAuDernierCycle() const { return m_optimiseurAuCycle; }
/*!
* \brief Code du dernier cycle rendu au rule-based — \c GetPlan.fallbackCode.
*
* Plan en cache rejugé non applicable : le code de ce verdict (\c expired,
* \c no-current-slot…). Pas de plan en cache : le verdict de la dernière réponse NON
* appliquée (abstention, expiré ou refusé à la réception), gardé jusqu'à l'issue de la requête
* suivante ; \c no-plan si rien n'a été reçu (aucune réponse, délai, connexion perdue).
* \return Le code ; vide si l'optimiseur a commandé.
*/
QString dernierCodeDeRepli() const { return m_codeCycle; }
/*!
* \brief Le dernier plan d'optimiseur appliqué, tel que reçu et converti.
* \return \c expected (par créneau) et \c diagnostics y sont CONSERVÉS pour l'app et les
* traces ; rien de ce qu'ils contiennent ne commande quoi que ce soit.
*/
OptimizerPlan::PlanRecu planApplique() const { return m_planApplique; }
/*! \brief \return La vivacité de l'optimiseur (§7) : vivant, échecs, dernier code, dernier plan. */
const OptimizerStatus::Vivacite &vivacite() const { return m_vivacite; }
/*! \brief \return Identifiant de la requête envoyée au DERNIER \c getPlan(), 0 si aucune n'est
* partie (client non connecté) — traces, brief 4 T6. */
quint64 idRequeteDuCycle() const { return m_idDuCycle; }
/*! \brief \return Les paramètres \c GetPlan envoyés au dernier \c getPlan() (vide si aucun). */
QVariantMap requeteDuCycle() const { return m_requeteDuCycle; }
signals:
/*!
* \brief Une réponse \c GetPlan a été jugée — accroche du compteur d'échecs §7 (T4).
* \param issue \c valide | \c abstention | \c expiré | \c invalide.
* \param code Code du verdict (\c OptimizerPlan::Verdict), vide si valide.
*/
void reponseJugee(const QString &issue, const QString &code);
/*! \brief La requête attendue n'a pas eu de réponse utilisable. \param code Code du client
* (\c timeout, \c disconnected, \c rpc-error, \c invalid-response). */
void echecRequete(const QString &code);
/*!
* \brief Ce qui a répondu à la requête \p id, pour les traces (brief 4 T6).
* \param id Identifiant de la requête. \param reponse \c {"result": …} BRUT (texte libre
* compris : il ne va qu'aux traces, qui restent sur le site) ou \c {"error": {"code": …}}.
* \param latenceMs Délai envoi → réponse.
*/
void reponseTracee(quint64 id, const QVariantMap &reponse, qint64 latenceMs);
/*! \brief Stratégie retenue au cycle. \param optimiseur Vrai si le plan de l'optimiseur a
* été substitué. \param code Motif du repli, vide si optimiseur. */
void strategieDuCycle(bool optimiseur, const QString &code);
/*! \brief La vivacité, les échecs, le dernier code d'erreur ou l'identité de l'optimiseur
* (handshake) ont pu changer — l'arbitre recompose l'état publié. */
void etatChange();
private:
void envoyer(const SurplusContext &ctx);
void onPlanRecu(quint64 id, const QVariantMap &result);
void onPlanEchoue(quint64 id, const QString &code);
void oublierCache(const QString &pourquoi);
void noterCycle(bool optimiseur, const QString &code, const QString &detail);
void signalerReserves(const QStringList &champs);
void journaliserCreneau(const OptimizerPlan::Verdict &v, const SurplusContext &ctx);
void noterVivacite(OptimizerStatus::Vivacite::Evenement e, const QString &code,
const QDateTime &instant = QDateTime());
void onNotification(const QString &method);
IScheduler *m_ombre = nullptr;
OptimizerClient *m_client = nullptr;
FournisseurFil m_fil;
QString m_declencheur = QStringLiteral("cycle");
quint64 m_idRequete = 0;
SurplusContext m_ctxRequete; // contexte de la requête en vol : juge sa réponse
QElapsedTimer m_chronoRequete; // latence de la requête en vol (traces)
quint64 m_idDuCycle = 0; // requête partie au dernier getPlan() (traces)
QVariantMap m_requeteDuCycle;
bool m_aCache = false;
QVariantMap m_cache; // dernier `result` valide à sa réception, brut
bool m_optimiseurAuCycle = false;
QString m_codeCycle = QStringLiteral("no-plan");
bool m_cycleNote = false;
QString m_codeReception; // verdict de la dernière réponse non appliquée, vide sinon
OptimizerPlan::PlanRecu m_planApplique;
QString m_dernierPlanRecu; // planId de la dernière réponse valide annoncée
QHash<QString, QString> m_motifVu; // loadId → dernière ligne de motif annoncée
QSet<QString> m_indisponiblesVus; // charges indisponibles visées, dernier jeu annoncé
QHash<QString, QString> m_ecretageVu; // loadId → dernier écrêtage annoncé
QSet<QString> m_reservesSignales; // une fois par session
OptimizerStatus::Vivacite m_vivacite; // §7 : optimizerAlive, échecs consécutifs par requête
OptimizerClient::State m_etatClient = OptimizerClient::Stopped; // dernier état vu du client
bool m_perteNotee = false; // panne en cours déjà notée : ses tentatives n'en sont pas
bool m_updateLearnedSignale = false; // UpdateLearned : dit une fois par session
};