feat(prévision PV): la fraîcheur ne date jamais la prévision, et elle n'est jamais absente

Deux gardes sur lastConfirmedAt, demandées et tranchées.

JAMAIS UNE CLÉ DE RÉVISION. firstIssuedAt identifie la prévision ; la fraîcheur
dit seulement jusqu'à quand elle a tenu. Dater avec elle ferait paraître une
prévision née au moment où elle a été reconduite — et deux prévisions
différentes reconduites par le même run porteraient la même date. Écrit dans
l'en-tête, au contrat ET dans un test : une interdiction qui ne vit qu'à un
endroit n'est pas une interdiction.

SUR UNE LIGNE JAMAIS RECONDUITE, ELLE VAUT firstIssuedAt — jamais absente. Et la
tranche vient de ce que le champ RÉPOND : il ne dit pas « quand quelqu'un a
confirmé » mais « quel est le dernier run qui a AFFIRMÉ ces valeurs ». Le run
émetteur les a affirmées. La réponse est vraie, et uniformément — il n'y a pas
un cas confirmation et un cas copie, il y a une seule question avec une seule
sorte de réponse.

L'ambiguïté redoutée ne se lève pas en choisissant une convention : elle se lève
en DÉFINISSANT correctement le champ. Une fois la définition posée, les deux cas
n'en font plus qu'un.

Et ici l'absence serait le mensonge, à rebours de la règle habituelle du dépôt :
elle se lirait « on ne sait pas jusqu'à quand elle a tenu » alors qu'on le sait,
et forcerait chaque client à écrire `confirmedAt ?? issuedAt` — ce calcul-ci,
ailleurs, une fois par client, avec une chance de le faire de travers.

Invariant publié : lastConfirmedAt >= firstIssuedAt, toujours. Une prévision ne
peut pas avoir tenu avant d'avoir été émise.

Sur le nom : lastConfirmedAt et non confirmedAt — le préfixe dit qu'il s'agit du
DERNIER d'une suite, là où confirmedAt se lirait comme l'instant d'un événement
unique, et une ligne reconduite quatre fois n'a pas « un » instant de
confirmation.

loadmodel 31/31, charging 48/48, spotmarket 32/32, ci-quality 4/4,
build 0 erreur 0 warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-09-09 05:56:18 +02:00
parent da58d3906a
commit 11635b8e6d
5 changed files with 110 additions and 3 deletions

View File

@ -604,3 +604,37 @@ Celle du producteur compare la **charge utile entière**, où `issuedAt` figure
dépendre d'un comportement du voisin : la comparaison porte sur l'**intersection** des créneaux,
correcte que la fenêtre glisse ou non, et `communs > 0` est requis — sans créneau commun il n'y a
rien à comparer, et conclure « identique » ferait disparaître une prévision dont on ne sait rien.
### 15.3 Les deux gardes sur la fraîcheur — tranchées le 2026-09-09
**`lastConfirmedAt` n'est JAMAIS une clé de révision.** `firstIssuedAt` identifie la prévision ;
la fraîcheur dit seulement **jusqu'à quand elle a tenu**. Dater avec elle ferait paraître une
prévision née au moment où elle a été reconduite — et **deux prévisions différentes reconduites
par le même run porteraient la même date**, ce qui rendrait l'historique illisible. Écrit au
contrat, dans l'en-tête et dans un test, parce qu'une interdiction qui ne vit qu'à un endroit
n'est pas une interdiction.
**Sur une ligne jamais reconduite, elle vaut `firstIssuedAt` — jamais absente.** Et la tranche
n'est pas arbitraire : elle vient de ce que le champ **répond**.
> Il ne dit pas « quand quelqu'un a confirmé ». Il dit **« quel est le dernier run qui a AFFIRMÉ
> ces valeurs »**. Le run émetteur les a affirmées. La réponse est donc **vraie**, et elle l'est
> **uniformément** — il n'y a pas un cas « confirmation » et un cas « copie », il y a une seule
> question avec une seule sorte de réponse.
L'ambiguïté que la consigne redoutait à juste titre ne se lève pas en choisissant une convention :
elle se lève en **définissant correctement le champ**. Une fois la définition posée, les deux cas
n'en font plus qu'un.
Et l'absence serait ici le **mensonge**, à rebours de la règle habituelle de ce dépôt : elle se
lirait « on ne sait pas jusqu'à quand elle a tenu », alors qu'on le sait — au moins jusqu'à son
émission. Elle forcerait en outre chaque client à écrire `confirmedAt ?? issuedAt`, c'est-à-dire
à refaire **ce calcul-ci, ailleurs, une fois par client**, avec une chance de le faire de travers.
**Invariant publié** : `lastConfirmedAt >= firstIssuedAt`, toujours. Une prévision ne peut pas
avoir tenu avant d'avoir été émise.
*(Sur le nom : `lastConfirmedAt` et non `confirmedAt`. Le préfixe dit qu'il s'agit du **dernier**
d'une suite, là où `confirmedAt` se lirait comme l'instant d'un événement unique — et une ligne
reconduite quatre fois n'a pas « un » instant de confirmation.)*

View File

@ -225,7 +225,13 @@ bool ForecastStore::enregistrer(const ForecastRevision &revision)
for (const ForecastPoint &p : revision.points) {
ins.addBindValue(p.targetAt.toUTC().toSecsSinceEpoch());
ins.addBindValue(run);
ins.addBindValue(run); // à l'ouverture, les deux instants coïncident
// À L'OUVERTURE, LES DEUX INSTANTS COÏNCIDENT — et ce n'est pas une copie de commodité.
// `lastConfirmedAt` répond à « quel est le dernier run qui a AFFIRMÉ ces valeurs ». Le
// run émetteur les a affirmées : la réponse est vraie, et elle l'est uniformément.
// Le laisser NUL se lirait « on ne sait pas jusqu'à quand elle a tenu », alors qu'on le
// sait — et forcerait chaque client à écrire le repli, ailleurs, avec une chance de le
// faire de travers.
ins.addBindValue(run);
ins.addBindValue(p.wh);
// NULL, jamais une chaîne vide : une chaîne vide serait déjà une valeur. Le producteur
// publie le modèle qui A RÉPONDU, repli compris — donc une provenance, pas une intention.

View File

@ -43,9 +43,32 @@ struct ForecastPoint {
QDateTime targetAt;
//! Énergie prévue sur l'heure (Wh). Une ABSENCE ne s'écrit pas : elle ne crée pas de point.
double wh = 0;
//! \brief Run qui a produit ces valeurs EN PREMIER. Renseigné à la lecture ; ignoré à l'écriture.
/*!
* \brief Run qui a produit ces valeurs EN PREMIER — **l'identité de la prévision**.
*
* Renseigné à la lecture ; ignoré à l'écriture. C'est LUI qui date la prévision, et lui seul.
*/
QDateTime firstIssuedAt;
//! \brief Dernier run qui les a RECONDUITES. Renseigné à la lecture ; ignoré à l'écriture.
/*!
* \brief Dernier run qui a AFFIRMÉ ces valeurs — la **fraîcheur**.
*
* \warning **JAMAIS une clé de révision.** \c firstIssuedAt identifie la prévision ;
* celui-ci dit seulement **jusqu'à quand elle a tenu**. Dater une prévision avec lui la
* ferait paraître née au moment où elle a été reconduite — et deux prévisions différentes
* reconduites par le même run porteraient la même date.
*
* \par Sur une ligne JAMAIS RECONDUITE, il vaut \c firstIssuedAt — jamais absent
* Et ce n'est pas une copie déguisée en confirmation : le champ ne dit pas « quand
* quelqu'un a confirmé », il dit **« quel est le dernier run qui a affirmé ces valeurs »**.
* Le run émetteur les a affirmées. La réponse est donc vraie, et elle l'est uniformément.
*
* L'absence serait le mensonge : elle se lirait « on ne sait pas jusqu'à quand elle a
* tenu », alors qu'on le sait — au moins jusqu'à son émission. Et elle obligerait chaque
* client à écrire le repli `confirmedAt ?? issuedAt`, c'est-à-dire à refaire ce calcul-ci,
* ailleurs, une fois par client, avec une chance de le faire de travers.
*
* \invariant Toujours valide, et toujours `>= firstIssuedAt`.
*/
QDateTime lastConfirmedAt;
};

View File

@ -1136,3 +1136,45 @@ void TestLoadModel::testAnUnknownPayloadIsRefusedNotBestEffort()
QVERIFY(refus.isEmpty());
QCOMPARE(troue.points.count(), 2);
}
void TestLoadModel::testFreshnessNeverDatesTheForecast()
{
QTemporaryDir dir;
ForecastStore magasin(dir.filePath("forecast.sqlite"));
QVERIFY(magasin.isValid());
const QDateTime run06 = QDateTime(QDate(2026, 9, 9), QTime(6, 0), QTimeZone::utc());
const QDateTime run10 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc());
const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(14, 0), QTimeZone::utc());
QVERIFY(magasin.enregistrer(revision(run06, t0, {800, 900})));
// 1. SUR UNE LIGNE JAMAIS RECONDUITE, la fraîcheur vaut l'émission — jamais absente.
// Ce n'est pas une copie déguisée en confirmation : le champ répond à « quel est le
// dernier run qui a AFFIRMÉ ces valeurs », et le run émetteur les a affirmées.
// L'absence, elle, se lirait « on ne sait pas jusqu'à quand elle a tenu » — c'est faux,
// et chaque client devrait écrire le repli à sa façon.
QList<ForecastPoint> vue = magasin.courbe(t0, t0.addSecs(3600), run06.addSecs(60));
QCOMPARE(vue.count(), 2);
QVERIFY2(vue.first().lastConfirmedAt.isValid(), "la fraîcheur n'est JAMAIS absente");
QCOMPARE(vue.first().lastConfirmedAt, run06);
QCOMPARE(vue.first().firstIssuedAt, run06);
// 2. Après reconduction, elle avance — et l'identité, NON.
QVERIFY(!magasin.enregistrer(revision(run10, t0, {800, 900})));
vue = magasin.courbe(t0, t0.addSecs(3600), run10.addSecs(60));
QCOMPARE(vue.first().lastConfirmedAt, run10); // fraîcheur : avancée
QCOMPARE(vue.first().firstIssuedAt, run06); // identité : IMMUABLE
// 3. L'INVARIANT qui rend le couple lisible : la fraîcheur ne précède jamais l'émission.
for (const ForecastPoint &p : vue)
QVERIFY2(p.lastConfirmedAt >= p.firstIssuedAt,
"une prévision ne peut pas avoir tenu AVANT d'avoir été émise");
// 4. LA FRAÎCHEUR NE DATE PAS LA PRÉVISION — c'est la garde que la règle demande. `revisions()`
// rend les instants d'ÉMISSION : deux prévisions différentes reconduites par le même run
// porteraient sinon la même date, et l'historique deviendrait illisible.
const QList<QPair<QDateTime, double>> revs = magasin.revisions(t0);
QCOMPARE(revs.count(), 1);
QCOMPARE(revs.first().first, run06); // l'émission, pas la reconduction
}

View File

@ -93,6 +93,8 @@ private slots:
void testTheCurveIsReproducibleAndKeepsItsHoles();
//! La rétention garde les DEUX bouts : n'en garder qu'un détruirait la révision.
void testRetentionKeepsBothEndsOfEachRevision();
//! La fraîcheur ne date JAMAIS la prévision, et elle n'est jamais absente.
void testFreshnessNeverDatesTheForecast();
//! La charge utile de transport se REFUSE plutôt que de s'interpréter au mieux.
void testAnUnknownPayloadIsRefusedNotBestEffort();
};