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:
parent
da58d3906a
commit
11635b8e6d
@ -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.)*
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user