// 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 "../types/plan.h" #include "../types/surpluscontext.h" /*! * \file optimizerplan.h * \brief Plan d'un optimiseur externe : validation, conversion, substitution au plan ombre et * comptabilité après dispatch (OPTIMIZER_PROTOCOL.md 1.1 §6-§7, brief 4 T3). * * Fonctions PURES : ni E/S, ni horloge (« maintenant » est \c ctx.timestamp), ni accès à * l'arbitre ou à un adaptateur. Le \c SocketScheduler les enchaîne ; les tests les éprouvent * seules. * * \par Le chemin des actions — un seul arbitre * \c valider() convertit le \c result JSON en \c LoadAction internes ; \c substituer() les met * à la place de celles du plan ombre (rule-based) dans le créneau courant ; l'arbitre les * dispatche par \c applyActionsToAdapters(), exactement comme celles du rule-based — mêmes * bornes, mêmes verrous, même L4 ; \c finaliser() relit ensuite ce que les adaptateurs ont * RENDU pour publier la puissance appliquée, son financement et le budget du cycle (Q3, Q4). */ namespace OptimizerPlan { //! Valeur de \c Plan::strategy d'un plan d'optimiseur. constexpr const char *StrategieOptimiseur = "optimizer"; //! Niveau UNIQUE porté par une action d'optimiseur (décision Q3) : jamais ceux du plan ombre. constexpr const char *NiveauOptimiseur = "optimizer"; //! Longueur maximale gardée d'un \c reason libre (journal, traces). constexpr int TexteMax = 200; /*! \brief Issue de la validation d'une réponse \c GetPlan. */ enum class Issue { Valide, //!< Le créneau courant peut être appliqué. Abstention, //!< Pas de plan pour maintenant (\c abstain, plan vide, aucun créneau courant) — §6-§7, pas un échec. Expire, //!< \c validUntil dépassé — repli jusqu'au prochain plan valide, pas un échec. Invalide //!< Plan refusé en entier, jamais appliqué partiellement — un échec (§7). }; /*! \brief Une action reçue, convertie. */ struct ActionRecue { LoadAction action; //!< Action interne : mécanisme, motif filtré, niveau \c optimizer provisoire. QString adapter; //!< Mécanisme de la charge visée (\c LoadContext::adapter). QString texte; //!< \c reason libre, assaini : journal et traces, JAMAIS une charge utile RPC. QString codeRecu; //!< \c reasonCode tel que reçu (journal) ; vide s'il manquait. bool codeSubstitue = false; //!< Code remplacé par \c OPTIMIZER_DECISION (hors liste blanche). QStringList parametresSupprimes; //!< Clés de \c reasonParams retirées (non documentées ou valeur refusée). }; /*! \brief Un créneau reçu. */ struct CreneauRecu { QDateTime from; //!< Début, inclus. QDateTime to; //!< Fin, exclue. QList actions; //!< Actions converties, dans l'ordre reçu. QVariantMap expected; //!< Trajectoire prévue — affichage et traces, JAMAIS pour commander. }; /*! \brief Un plan reçu, validé. */ struct PlanRecu { QString planId; //!< \c planId reçu (peut être vide). QString strategy; //!< \c strategy reçue (informative). QDateTime computedAt; //!< Invalide si absente ou illisible (informatif). QDateTime validUntil; //!< Invalide = absente : valable tant qu'un créneau couvre maintenant. QList creneaux; //!< Ordonnés, sans chevauchement. QVariantMap diagnostics; //!< Conservé pour l'app et les traces, jamais pour commander. int indexCourant = -1; //!< Créneau couvrant \c ctx.timestamp ; -1 si aucun. }; /*! * \brief Verdict de \c valider(). * * Codes (\c code, stables, destinés au compteur d'échecs §7 de T4) : * - Abstention : \c abstain, \c empty-plan, \c no-current-slot ; * - Expire : \c expired ; * - Invalide : \c malformed-plan, \c bad-time, \c slots-unordered, \c malformed-action, * \c unknown-load, \c duplicate-load, \c unknown-kind, \c kind-mismatch, * \c kind-not-announced, \c missing-reason, \c bad-value, \c incomplete-slot. */ struct Verdict { Issue issue = Issue::Invalide; //!< Issue. QString code; //!< Vide si \c Valide. QString detail; //!< Précision en français — JOURNAL seulement. PlanRecu plan; //!< Plan converti ; complet seulement si \c Valide. QStringList champsReserves; //!< Champs réservés ou \c force vus, ignorés (annexe F, §6). //! Charges INDISPONIBLES dont le créneau courant portait une action : action rejetée, la //! charge garde celle du plan ombre (brief §T3). QStringList actionsSurIndisponibles; }; /*! * \brief Valide une réponse \c GetPlan et la convertit. * * Contrôles, dans cet ordre : forme (\c abstain, \c slots) ; chaque créneau (\c from < \c to, * décalage horaire explicite, ordonnés et sans chevauchement) ; chaque action de CHAQUE * créneau (\c loadId connu du contexte et unique dans son créneau, \c kind connu, cohérent avec * le mécanisme et annoncé dans \c capabilities.kinds s'il est déclaré, \c reason non vide, * valeurs numériques présentes et dans leur domaine) ; \c validUntil ; existence d'un créneau * courant ; enfin COUVERTURE (décision Q1) : toute charge \c available du contexte figure dans * le créneau courant. * * \param result \c result JSON-RPC de \c GetPlan, non validé. * \param ctx Contexte du cycle : charges connues, disponibilité, et maintenant (\c ctx.timestamp). * \param capabilities \c capabilities du handshake. * \param hasCapabilities Vrai si le handshake portait \c capabilities. * \return Le verdict. Un plan \c Invalide n'est JAMAIS appliqué, même en partie. * \note \c force et les champs réservés (annexe F) sont ignorés et listés ; un champ inconnu est * ignoré sans bruit (§9). Le texte \c reason ne quitte jamais \c ActionRecue::texte. */ Verdict valider(const QVariantMap &result, const SurplusContext &ctx, const QVariantMap &capabilities, bool hasCapabilities); /*! * \brief Motif transmissible à l'app depuis un couple reçu (décision du 2026-10-16). * \param code \c reasonCode reçu ; absent, inconnu ou hors liste blanche → \c OPTIMIZER_DECISION sans paramètres. * \param params \c reasonParams reçus : seules les clés documentées du code passent * (\c DecisionCode::optimizerParamKeys), avec une valeur nombre fini, booléen ou code \c [A-Za-z]+. * \param[out] supprimes Clés retirées. * \param[out] substitue Vrai si le code a été remplacé. * \return Le motif — jamais de texte libre. */ DecisionReason motifFiltre(const QString &code, const QVariantMap ¶ms, QStringList *supprimes = nullptr, bool *substitue = nullptr); /*! * \brief Texte libre d'un optimiseur rendu sûr pour une ligne de journal. * \param v Valeur reçue. \return Caractères de contrôle remplacés par des espaces, blancs * resserrés, coupé à \c TexteMax ; vide si \p v n'est pas une chaîne. */ QString assainirTexte(const QVariant &v); /*! * \brief Plan du cycle : le créneau courant du plan reçu À LA PLACE des actions de l'ombre. * * Pour chaque charge \c available visée par le créneau courant, l'action de l'optimiseur * remplace celle de l'ombre, à la même place (l'ordre de priorité est conservé). Les charges * indisponibles, et celles que le contexte ne connaît pas, gardent l'action de l'ombre. Les * bornes commandées par l'optimiseur rejoignent \c waterfallEvIds : le proxy ne les commande * plus (une charge, un commandeur). * * \param ombre Plan du rule-based de ce cycle (ses effets de bord ont eu lieu). * \param recu Plan validé (\c Issue::Valide, \c indexCourant ≥ 0). * \param ctx Contexte du cycle. * \return Un plan \c strategy == \c "optimizer". \c budget est remis à zéro et \c draw.committedW * aussi : ils sont recalculés après le dispatch par \c finaliser() — ceux de l'ombre ne sont * jamais publiés (décision Q4). Les champs d'autorisation de \c draw, qui viennent du contexte * (\c ctx.drawMargin) et non d'une décision, sont gardés. */ Plan substituer(const Plan &ombre, const PlanRecu &recu, const SurplusContext &ctx); /*! \brief Une action d'optimiseur écrêtée, pour le journal. */ struct Ecretage { QString loadId; //!< Charge. QString demande; //!< Ce que l'optimiseur demandait (texte de journal). QString applique; //!< Ce que l'adaptateur a appliqué (texte de journal). }; /*! * \brief Après le dispatch : puissance APPLIQUÉE, financement et budget du cycle (Q3, Q4). * * Pour chaque action d'optimiseur du créneau (niveau \c optimizer), dans l'ordre de priorité : * la puissance publiée est celle que l'adaptateur a rendue (\c appliquees) ; elle est financée * au surplus tant que la mesure le couvre (export net + consommation courante des charges * commandées, recrédit anti-clignotement), au réseau pour le reliquat ; \c levels devient un * niveau unique \c optimizer, \c counts suit le partage (Σ = \c estimatedPowerW) ; \c clamped * est posé si l'appliqué diffère de la demande, ou si rien n'a été appliqué. Une charge délestée * par L4 est laissée à L4, qui remplace son action ensuite. Les actions de l'ombre gardées * (charges indisponibles) entrent dans le budget par leurs \c counts. * * \param plan Plan du cycle (\c strategy == \c "optimizer") ; \c budget et \c draw réécrits. * \param slot Créneau dispatché ; ses actions d'optimiseur sont réécrites. * \param appliquees Retours d'\c applyAction() du dispatch (\c m_actionsAppliquees). * \param delestees Charges revendiquées par L4 ce cycle. * \param ctx Contexte du cycle (mesure, consommation courante). * \return Les écrêtages, pour le journal. * \invariant budget : \c surplusW + \c recreditedW − \c allocatedW == \c remainingW. */ QList finaliser(Plan &plan, Slot &slot, const QHash &appliquees, const QSet &delestees, const SurplusContext &ctx); /*! \brief \param action Action du créneau. \return Vrai si elle vient d'un optimiseur (niveau \c optimizer). */ bool estActionOptimiseur(const LoadAction &action); /*! \brief \param issue Issue. \return Son nom de journal (« valide », « abstention »…). */ QString nomIssue(Issue issue); }