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>
203 lines
10 KiB
C++
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
|
|
};
|