Patrick Schurig a8c58d02f0 feat(optimiseur): validateur pur du plan d'un optimiseur — OptimizerPlan (brief 4 T3)
Fonctions pures (ni E/S ni horloge, « maintenant » = ctx.timestamp) qui
portent toute la sémantique du plan reçu ; le SocketScheduler les enchaînera.

- valider() : JSON → plan interne ou verdict à code stable. Créneaux à
  décalage horaire explicite, ordonnés, sans chevauchement ; loadId connu et
  unique ; kind cohérent avec le mécanisme et annoncé dans capabilities.kinds ;
  reason non vide ; valeurs dans leur domaine ; validUntil ; créneau courant ;
  COUVERTURE Q1 (toute charge disponible nommée). Abstention / expiré /
  invalide distingués ; un plan invalide n'est jamais converti en partie.
  `force` et champs réservés (annexe F) ignorés et listés.
- motifFiltre() : code hors liste blanche → OPTIMIZER_DECISION sans
  paramètres ; sinon seules les clés documentées (optimizerParamKeys, testé
  contre le tableau d'INTERFACE.md), valeurs nombre/booléen/code [A-Za-z]+.
  Le texte libre reste dans ActionRecue::texte, assaini, jamais dans le motif.
- substituer() : le créneau courant remplace les actions de l'ombre à leur
  place ; charges indisponibles → action de l'ombre ; bornes commandées
  sorties du proxy (waterfallEvIds) ; budget/draw de l'ombre jamais gardés.
- finaliser() : après dispatch, puissance APPLIQUÉE, niveau unique
  « optimizer », financement dérivé de la mesure (surplus puis réseau),
  counts cohérents, budget et draw recalculés (Q3, Q4), `clamped`.

LoadAction::clamped ajouté (interne, non publié dans ce lot).
Aucun appelant dans ce commit : le moteur est inchangé.

Tests (loadmodel) : 7 fonctions, dont 31 verdicts data-driven.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:06:33 +02:00

195 lines
10 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QHash>
#include <QList>
#include <QSet>
#include <QString>
#include <QStringList>
#include <QVariantMap>
#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<ActionRecue> 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<CreneauRecu> 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 &params,
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<Ecretage> finaliser(Plan &plan, Slot &slot, const QHash<QString, LoadAction> &appliquees,
const QSet<QString> &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);
}