fix(prévision PV): une clé de run inconnue se REFUSE, et ne se devine jamais

Suite à l'avis de l'agent openmeteo (2026-09-09).

`issuedAt == 0` signifie CLÉ INCONNUE — le producteur le publie ainsi quand le
repli best_match est actif, cette stratégie n'ayant pas d'identité de run
unique. Le refus est désormais NOMMÉ (ISSUED_AT_UNKNOWN) et distinct de
MALFORMED : la cause diffère et la conduite à tenir aussi ; les confondre ferait
chercher un défaut de forme là où il n'y a simplement pas de run.

Et l'heure de réception ne s'y substitue JAMAIS. Elle serait plausible et fausse
— trop courte de toute la latence de publication — et une fois écrite, plus rien
dans la donnée ne distinguerait la ligne devinée de la ligne datée. Même
raisonnement que celui qui a fait refuser receivedAt comme clé, appliqué au cas
dégradé.

PURGE RECOMMANDÉE : vérifiée, et sans objet ici. L'avis demandait de ne
conserver aucune ligne écrite avant openmeteo +etm12, au motif qu'une ligne
saine et une ligne fausse sont indistinguables après coup — raisonnement juste.
Mais la chronologie du banc le rend sans objet : le .so +etm13 a été posé à
06:01, notre première révision écrite à 06:03, et la concordance site/courbe est
vraie (2026-09-09 02:00 des deux côtés). Purger détruirait une ligne saine.

La consigne reste notée pour le jour où elle s'appliquera, avec le principe qui
la fonde : une révision manquante est une lacune visible, une révision mal datée
est un mensonge invisible.

Et le défaut du producteur rejoint une famille déjà nommée ici : il n'était
démontrable que sur la machine, par recoupement de DEUX états sur DEUX runs
consécutifs. Un défaut dont les deux valeurs sont plausibles ne se voit jamais
d'une seule source.

loadmodel 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 06:06:53 +02:00
parent 233832b251
commit 4c6f6e04ed
4 changed files with 115 additions and 1 deletions

View File

@ -638,3 +638,61 @@ 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.)*
---
## 16. Avis du producteur — provenance faussée avant `openmeteo +etm12` (2026-09-09)
Reçu de l'agent openmeteo : jusqu'à `+etm11` inclus, le champ `issuedAt` de la charge utile
portait le run **PRÉCÉDENT**. L'écart valait un intervalle de run (3 h AROME, 6 h ARPEGE), **jamais
aléatoire, toujours en retard**. Cause : la provenance venait d'un aller-retour `meta.json`
asynchrone qui atterrissait après la réponse de prévision, si bien que le plan lisait la valeur du
cycle précédent.
### 16.1 Recommandation de purge — VÉRIFIÉE, et sans objet ici
L'avis demandait de ne conserver aucune ligne écrite avant `+etm12`, au motif qu'une ligne saine
et une ligne fausse sont **indistinguables après coup**. Le raisonnement est juste. **Mais la
chronologie du banc le rend sans objet**, et c'est vérifié plutôt que supposé :
| | |
|---|---|
| `.so` openmeteo `+etm13` posé | **06:01** |
| `[Prévision] RÉVISION` écrite | **06:03** |
| concordance à la lecture | site `2026-09-09 02:00` = courbe `02:00` — **vraie** |
| ligne stockée | `firstIssuedAt = 2026-09-09T00:00Z` (= 02:00 locale) |
**La lecture n'a commencé qu'après le correctif.** La table ne contient aucune ligne suspecte, et
purger détruirait une ligne saine — c'est-à-dire échanger une lacune contre rien.
> **La consigne « purge et laisse se reconstruire » reste la bonne**, et elle est notée pour le
> jour où elle s'appliquera. Le principe qui la fonde vaut d'être gardé tel quel : *une révision
> manquante est une lacune visible ; une révision mal datée est un mensonge invisible.*
### 16.2 `issuedAt == 0` — refus NOMMÉ, et jamais deviné
Repris et implémenté. Le motif est **distinct de `MALFORMED`** : la cause diffère (le repli
`best_match` n'a pas d'identité de run unique) et la conduite à tenir aussi. Les confondre ferait
chercher un défaut de forme là où la stratégie du producteur n'a simplement pas de run.
Et **l'heure de réception ne s'y substitue jamais** : elle serait plausible et fausse — trop
courte de toute la latence de publication — et une fois écrite, plus rien dans la donnée ne
distinguerait la ligne devinée de la ligne datée. C'est le même raisonnement que celui qui a fait
refuser `receivedAt` comme clé, appliqué au cas dégradé.
### 16.3 Ce que l'avis confirme sur la déduplication
Celle du producteur porte sur la charge **entière**, `issuedAt` compris : elle attrape les cycles
répétés **à l'intérieur d'un même run** — la majorité — mais **pas** un run neuf aux valeurs
identiques. C'est exactement le partage déjà retenu au §15.2 : dédoublonner sur les `p` et faire
avancer la fraîcheur **reste à notre charge**, et nous sommes le dernier rempart.
### 16.4 Le défaut n'était démontrable que sur la machine
> *« Aucun test unitaire ne pouvait le montrer : il fallait deux runs consécutifs et la
> comparaison de deux états. »*
À ranger avec les autres de la même famille : la latence de négociation de la V2C, le double
écrêtage de la borne, l'`update()` imbriqué. **Un défaut dont les deux valeurs sont plausibles ne
se voit que par recoupement de deux sources**, et aucune des deux ne suffit seule.

View File

@ -63,10 +63,22 @@ ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QStri
const int step = charge.value(QStringLiteral("step")).toInt();
const qint64 run = charge.value(QStringLiteral("issuedAt")).toLongLong();
const QVariantList p = charge.value(QStringLiteral("p")).toList();
if (t0 <= 0 || step <= 0 || run <= 0 || p.isEmpty()) {
if (t0 <= 0 || step <= 0 || p.isEmpty()) {
refus = QStringLiteral("MALFORMED");
return r;
}
// `issuedAt == 0` SIGNIFIE « CLÉ INCONNUE », et c'est un refus À PART — pas une charge utile
// mal formée. Le producteur le publie ainsi quand le repli `best_match` est actif : cette
// stratégie n'a pas d'identité de run unique, donc il n'y a rien à dater.
//
// ET SURTOUT : NE JAMAIS Y SUBSTITUER L'HEURE DE RÉCEPTION. Elle serait plausible et fausse
// — trop courte de toute la latence de publication — et une fois écrite, plus rien dans la
// donnée ne distinguerait la ligne devinée de la ligne datée. Une révision manquante est une
// lacune visible ; une révision mal datée est un mensonge invisible.
if (run <= 0) {
refus = QStringLiteral("ISSUED_AT_UNKNOWN");
return r;
}
r.issuedAt = QDateTime::fromSecsSinceEpoch(run, QTimeZone::utc());
r.model = charge.value(QStringLiteral("model")).toString();

View File

@ -1178,3 +1178,45 @@ void TestLoadModel::testFreshnessNeverDatesTheForecast()
QCOMPARE(revs.count(), 1);
QCOMPARE(revs.first().first, run06); // l'émission, pas la reconduction
}
void TestLoadModel::testAnUnknownRunKeyIsRefusedNotGuessed()
{
// Avis du producteur (2026-09-09) : `issuedAt == 0` signifie CLÉ INCONNUE — notamment quand
// le repli `best_match` est actif, car cette stratégie n'a pas d'identité de run unique.
//
// Le refus doit être NOMMÉ, et distinct de « charge utile mal formée » : la cause diffère,
// et la conduite à tenir aussi. Surtout, l'heure de réception ne doit JAMAIS s'y substituer —
// elle serait plausible et fausse, et une fois écrite, plus rien dans la donnée ne
// distinguerait la ligne devinée de la ligne datée.
const qint64 t0 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch();
auto charge = [&](qint64 run) {
return QVariantMap{{"v", 1}, {"t0", t0}, {"step", 3600}, {"unit", "W"},
{"convention", "mean over [t, t+step)"},
{"model", "best_match"}, {"issuedAt", run},
{"p", QVariantList{0, 100, 200}}};
};
QString refus;
// 1. CLÉ INCONNUE — refus nommé, et AUCUN point : rien ne sera enregistré.
const ForecastRevision sansCle = ForecastStore::depuisTransport(charge(0), refus);
QCOMPARE(refus, QString("ISSUED_AT_UNKNOWN"));
QVERIFY(sansCle.points.isEmpty());
QVERIFY2(!sansCle.issuedAt.isValid(),
"aucune heure ne doit être substituée à une clé inconnue");
// 2. Le motif est DISTINCT de MALFORMED : deux causes, deux conduites. Les confondre ferait
// chercher un défaut de forme là où la stratégie du producteur n'a simplement pas de run.
QVERIFY(refus != QLatin1String("MALFORMED"));
// 3. CONTRE-ÉPREUVE — la même charge utile avec une clé valide passe. Sans elle, on ne
// saurait pas si le refus vise la clé ou tout le reste.
const ForecastRevision avecCle = ForecastStore::depuisTransport(charge(t0 + 6 * 3600), refus);
QVERIFY(refus.isEmpty());
QCOMPARE(avecCle.points.count(), 3);
// 4. ET LE MAGASIN LA REFUSE AUSSI, bout en bout : une révision sans clé ne s'écrit pas.
QTemporaryDir dir;
ForecastStore magasin(dir.filePath("forecast.sqlite"));
QVERIFY(magasin.isValid());
QVERIFY2(!magasin.enregistrer(sansCle), "une révision sans clé de run ne s'enregistre pas");
}

View File

@ -95,6 +95,8 @@ private slots:
void testRetentionKeepsBothEndsOfEachRevision();
//! La fraîcheur ne date JAMAIS la prévision, et elle n'est jamais absente.
void testFreshnessNeverDatesTheForecast();
//! Une clé de run inconnue se REFUSE — l'heure de réception ne s'y substitue jamais.
void testAnUnknownRunKeyIsRefusedNotGuessed();
//! La charge utile de transport se REFUSE plutôt que de s'interpréter au mieux.
void testAnUnknownPayloadIsRefusedNotBestEffort();
};