// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #include #include #include #include #include #include #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; /*! * \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 &appliquees, const QSet &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 m_motifVu; // loadId → dernière ligne de motif annoncée QSet m_indisponiblesVus; // charges indisponibles visées, dernier jeu annoncé QHash m_ecretageVu; // loadId → dernier écrêtage annoncé QSet 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 };