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>
This commit is contained in:
Patrick Schurig 2026-10-17 14:39:12 +02:00
parent ce323a6e1f
commit e3b916e249
4 changed files with 65 additions and 21 deletions

View File

@ -50,6 +50,7 @@ SocketScheduler::SocketScheduler(IScheduler *ombre, OptimizerClient *client, Fou
return; // tentative échouée pendant une panne déjà notée
m_perteNotee = true;
oublierCache(QStringLiteral("connexion perdue"));
m_codeReception.clear(); // plus rien ne sera reçu sur cette connexion : `no-plan`
noterVivacite(OptimizerStatus::Vivacite::Evenement::ConnexionPerdue, m_client->lastErrorCode());
});
// Handshake accepté : nom, version et capacités ont changé — l'état publié se recompose.
@ -69,7 +70,12 @@ Plan SocketScheduler::getPlan(const SurplusContext &ctx)
envoyer(ctx);
if (!m_aCache) {
noterCycle(false, QStringLiteral("no-plan"), QStringLiteral("aucun plan en cache"));
// Pourquoi il n'y a pas de plan : le verdict de la dernière réponse non appliquée
// (abstention, plan expiré ou refusé à la réception), sinon « rien reçu ».
if (m_codeReception.isEmpty())
noterCycle(false, QStringLiteral("no-plan"), QStringLiteral("aucun plan en cache"));
else
noterCycle(false, m_codeReception, QStringLiteral("aucun plan en cache — dernière réponse non appliquée"));
return ombre;
}
// Rejugé sur le contexte DE CE CYCLE : maintenant, charges, disponibilités ont pu changer
@ -172,6 +178,9 @@ void SocketScheduler::onPlanRecu(quint64 id, const QVariantMap &result)
break;
}
// Le verdict d'une réponse non appliquée devient le code de repli publié (fallbackCode),
// jusqu'à l'issue de la requête suivante ; un plan valide l'efface.
m_codeReception = v.issue == OptimizerPlan::Issue::Valide ? QString() : v.code;
switch (v.issue) {
case OptimizerPlan::Issue::Valide: {
m_cache = result;
@ -220,6 +229,7 @@ void SocketScheduler::onPlanEchoue(quint64 id, const QString &code)
emit reponseTracee(id, QVariantMap{{QStringLiteral("error"), QVariantMap{{QStringLiteral("code"), code}}}},
m_chronoRequete.elapsed());
emit echecRequete(code);
m_codeReception.clear(); // aucune réponse utilisable : le repli, s'il vient, dit `no-plan`
noterVivacite(OptimizerStatus::Vivacite::Evenement::Echec, code);
}

View File

@ -104,7 +104,15 @@ public:
/*! \brief \return Vrai si le plan du dernier cycle était celui de l'optimiseur. */
bool optimiseurAuDernierCycle() const { return m_optimiseurAuCycle; }
/*! \brief \return Code du dernier cycle rendu au rule-based (vide si optimiseur). */
/*!
* \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; }
/*!
@ -180,6 +188,7 @@ private:
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

View File

@ -624,6 +624,10 @@ void TestRefOptimizer::testEachFaultModeGivesItsRow_data()
QTest::addColumn<bool>("redemarrer");
QTest::addColumn<QStringList>("lignes");
QTest::addColumn<QStringList>("notifications");
// GetPlan.fallbackCode après chacun des quatre cycles (vide = absent : l'optimiseur commande).
// Le verdict d'une réponse non appliquée reste le code de repli jusqu'à l'issue de la requête
// suivante ; `no-plan` ne dit que « rien reçu » (délai dépassé, connexion perdue).
QTest::addColumn<QStringList>("replis");
const QStringList abstention{"optimizer|vivant|0|", "rules|vivant|0|", "rules|vivant|0|", "optimizer|vivant|0|"};
const QStringList unePause{"attendu|vivant|rules", "attendu|vivant|optimizer"};
@ -633,29 +637,37 @@ void TestRefOptimizer::testEachFaultModeGivesItsRow_data()
};
const QStringList mortPuisVivant{"attendu|vivant|rules", "attendu|mort|rules", "attendu|vivant|rules",
"attendu|vivant|optimizer"};
QTest::newRow("abstain") << "abstain" << false << abstention << unePause;
QTest::newRow("expired") << "expired" << false << abstention << unePause;
QTest::newRow("invalid") << "invalid" << false << invalide("malformed-plan") << mortPuisVivant;
QTest::newRow("unknown-load") << "unknown-load" << false << invalide("unknown-load") << mortPuisVivant;
QTest::newRow("forbidden-kind") << "forbidden-kind" << false << invalide("kind-mismatch") << mortPuisVivant;
QTest::newRow("missing-load") << "missing-load" << false << invalide("incomplete-slot") << mortPuisVivant;
auto verdict = [](const QString &code) { return QStringList{"", code, code, ""}; };
QTest::newRow("abstain") << "abstain" << false << abstention << unePause << verdict("abstain");
QTest::newRow("expired") << "expired" << false << abstention << unePause << verdict("expired");
QTest::newRow("invalid") << "invalid" << false << invalide("malformed-plan") << mortPuisVivant
<< verdict("malformed-plan");
QTest::newRow("unknown-load") << "unknown-load" << false << invalide("unknown-load") << mortPuisVivant
<< verdict("unknown-load");
QTest::newRow("forbidden-kind") << "forbidden-kind" << false << invalide("kind-mismatch") << mortPuisVivant
<< verdict("kind-mismatch");
QTest::newRow("missing-load") << "missing-load" << false << invalide("incomplete-slot") << mortPuisVivant
<< verdict("incomplete-slot");
// slow (12 s > 10 s) : le premier délai dépassé garde le plan en cache, le second tue.
QTest::newRow("slow") << "slow" << false
<< QStringList{"optimizer|vivant|1|timeout", "rules|mort|2|timeout",
"rules|vivant|0|timeout", "optimizer|vivant|0|timeout"}
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"};
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"}
<< QStringList{"", "", "no-plan", ""};
// drop : la connexion fermée tue tout de suite (§7 « socket fermé »), l'échec de la requête
// en vol est compté (`disconnected`). La reconnexion qui suit n'est pas une nouvelle perte :
// le dernier code reste celui de l'incident.
QTest::newRow("drop") << "drop" << false
<< QStringList{"rules|mort|1|disconnected", "rules|mort|2|disconnected",
"rules|vivant|0|disconnected", "optimizer|vivant|0|disconnected"}
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"};
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"}
<< QStringList{"", "no-plan", "no-plan", ""};
// wrong-major : au handshake suivant (optimiseur redémarré) ; aucune requête ne part.
QTest::newRow("wrong-major") << "wrong-major" << true
<< QStringList{"rules|mort|0|major-mismatch", "rules|mort|0|major-mismatch",
"rules|vivant|0|major-mismatch", "optimizer|vivant|0|major-mismatch"}
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"};
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"}
<< QStringList{"no-plan", "no-plan", "no-plan", ""};
}
void TestRefOptimizer::testEachFaultModeGivesItsRow()
@ -664,6 +676,7 @@ void TestRefOptimizer::testEachFaultModeGivesItsRow()
QFETCH(bool, redemarrer);
QFETCH(QStringList, lignes);
QFETCH(QStringList, notifications);
QFETCH(QStringList, replis);
monter(Banc::Complet);
if (QTest::currentTestFailed())
return;
@ -684,6 +697,7 @@ void TestRefOptimizer::testEachFaultModeGivesItsRow()
QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged);
QStringList obtenu;
QStringList replisObtenus;
ecrireMode(mode);
if (redemarrer) {
ref.arreter();
@ -716,13 +730,19 @@ void TestRefOptimizer::testEachFaultModeGivesItsRow()
cycle(t);
}
obtenu << ligneStatut();
const QVariantMap publie = rpc("GetPlan");
// fallbackCode n'accompagne que le rule-based ; l'optimiseur qui commande n'en a pas.
QCOMPARE(publie.value("strategy").toString() == "rules", publie.contains("fallbackCode"));
replisObtenus << publie.value("fallbackCode").toString();
}
QCOMPARE(obtenu, lignes);
QCOMPARE(triplets(spy), notifications);
// Le plan publié dit qu'aucun plan d'optimiseur ne commande, et pourquoi — au cycle repli.
// Le plan publié dit qu'aucun plan d'optimiseur ne commande, et pourquoi.
QCOMPARE(replisObtenus, replis);
if (mode == "slow")
QVERIFY2(m_updateMaxMs < 250, qPrintable(QStringLiteral("update() a duré %1 ms").arg(m_updateMaxMs)));
qInfo().noquote() << "[mode]" << mode << obtenu.join(" ; ") << "— update() max" << m_updateMaxMs << "ms";
qInfo().noquote() << "[mode]" << mode << obtenu.join(" ; ") << "— fallbackCode" << replisObtenus.join(" ; ")
<< "— update() max" << m_updateMaxMs << "ms";
}
void TestRefOptimizer::testAProtocol10OptimizerIsAccepted()

View File

@ -473,13 +473,15 @@ void TestSocketScheduler::testRefusedPlanFallsBackToRules_data()
QTest::addColumn<QString>("cas");
QTest::addColumn<QString>("codeJuge");
QTest::addColumn<QString>("codeCycle");
QTest::newRow("incomplet (Q1)") << "incomplet" << "incomplete-slot" << "no-plan";
QTest::newRow("kind interdit") << "kind" << "kind-not-announced" << "no-plan";
QTest::newRow("loadId inconnu") << "inconnu" << "unknown-load" << "no-plan";
QTest::newRow("reason vide") << "reason" << "missing-reason" << "no-plan";
QTest::newRow("expiré à la réception") << "expire" << "expired" << "no-plan";
// codeCycle = GetPlan.fallbackCode : le verdict de la réponse non appliquée, gardé jusqu'à
// l'issue de la requête suivante (correctifs de fin de brief 4, point 3).
QTest::newRow("incomplet (Q1)") << "incomplet" << "incomplete-slot" << "incomplete-slot";
QTest::newRow("kind interdit") << "kind" << "kind-not-announced" << "kind-not-announced";
QTest::newRow("loadId inconnu") << "inconnu" << "unknown-load" << "unknown-load";
QTest::newRow("reason vide") << "reason" << "missing-reason" << "missing-reason";
QTest::newRow("expiré à la réception") << "expire" << "expired" << "expired";
QTest::newRow("expiré à l'application") << "expire-tard" << "" << "expired";
QTest::newRow("abstention") << "abstention" << "abstain" << "no-plan";
QTest::newRow("abstention") << "abstention" << "abstain" << "abstain";
}
void TestSocketScheduler::testRefusedPlanFallsBackToRules()
@ -518,6 +520,9 @@ void TestSocketScheduler::testRefusedPlanFallsBackToRules()
cycle(t0.addSecs(60), -3000);
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), codeCycle);
const QVariantMap publie = rpc("GetPlan");
QCOMPARE(publie.value("strategy").toString(), QString("rules"));
QCOMPARE(publie.value("fallbackCode").toString(), codeCycle);
// Jamais appliqué EN PARTIE : aucune charge ne porte le niveau de l'optimiseur.
for (const QVariantMap &t : telemetrie())
QVERIFY2(t.value("decision").toMap().value("level").toString() != "optimizer",
@ -758,7 +763,7 @@ void TestSocketScheduler::testANewInvalidPlanReplacesTheCache()
QCOMPARE(m_codesJuges.last(), QString("incomplete-slot"));
cycle(t0.addSecs(120), -3000);
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("no-plan"));
QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("incomplete-slot"));
}
void TestSocketScheduler::testConnectionLossDropsThePlan()
@ -1032,7 +1037,7 @@ void TestSocketScheduler::testStatusFollowsTheFallbackTable()
m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "recalcul"}}; };
cycle(t0.addSecs(120), -3000);
cycle(t0.addSecs(180), -3000);
QCOMPARE(s->dernierCodeDeRepli(), QString("no-plan"));
QCOMPARE(s->dernierCodeDeRepli(), QString("abstain"));
} else if (cas == "expire") {
m_repondre = nullptr;
cycle(t0.addSecs(200), -3000); // le plan reçu à t0 + 60 valait jusqu'à t0 + 150