Deux défauts de moi, révélés en quatre minutes par un plan de test que le
producteur a ajouté puis retiré pour une raison sans rapport.
1. MON ÉDITION DU CONTRAT AVAIT SUPPRIMÉ LA DÉCLARATION DU RPC. GetPvForecast
répondait {} — pas même une liste vide. En réécrivant sa description par une
substitution de texte, j'avais emporté les params.insert et returns.insert qui
la suivaient ; nymea valide la réponse contre le schéma déclaré, donc sans
returns la charge utile est éliminée en silence.
Aucun test ne couvre la surface RPC : 31 unitaires, la simulation et ci-quality
passaient pendant que la méthode ne rendait plus rien. Ce n'est pas un défaut de
logique mais de DÉCLARATION, et rien ici ne les regarde.
2. LA COURBE COURANTE ÉTAIT LA DERNIÈRE APPARUE, PAS LA DERNIÈRE VUE. Le retrait
du plan a fait réapparaître un contenu déjà connu ; son firstSeenAt n'a pas bougé
— c'est le principe du champ — mais le tri était fait dessus. Quatre minutes
après la disparition du plan fictif, le moteur servait encore la courbe de
36 kWh : une installation qui n'existe pas, à +80 % de la production mesurée.
Ce qui a cours est ce qu'on a vu EN DERNIER. Le tri passe sur lastSeenAt ;
firstSeenAt dit quand un contenu est apparu, pas s'il a cours.
Limite écrite plutôt que découverte : lastSeenAt est mis à jour en place, donc
une lecture historique traversant un RETOUR à un contenu antérieur est
approchée. L'exactitude demanderait une table d'épisodes — différée.
Et une correction sur mon relevé précédent : « huit fenêtres par jour, jusqu'à
une heure chacune » est DÉDUIT du code, pas mesuré — le producteur le dit
lui-même. Ce qui est certain est que la dérive était monotone et sans rappel.
L'écrire comme une mesure en ferait la prémisse de quelqu'un.
Fenêtre à écarter de toute comparaison prévu/réalisé : 08:07 → 08:11:36.
loadmodel 31/31, charging 48/48, spotmarket 32/32, simulation 69/69,
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
Réponse mesurée du producteur, et elle renverse le §13 comme le §19. +etm63
GELAIT la courbe : c'était le seul endroit où le lot était plus faux qu'avant.
Les faits établis : issuedAt vient de meteofrance_arome_france_hd, seul domaine
AROME exposant un meta.json — qui sert 0 valeur de rayonnement sur 72, donc ne
contribue RIEN à la courbe. Le domaine qui la fournit (arome_france) n'expose
aucun meta.json (HTTP 500) : il n'existe aucune identité de run pour la donnée
qui forme p. Et à 07:30, seamless diverge d'arome_france de 08:00 à 13:00 (144
contre 356, 326 contre 530) et coïncide à partir de 14:00 — le serveur
recompose les premières heures autrement que par le seul run.
Ce n'est pas un défaut du producteur : c'est ce que l'API permet.
LA RÈGLE N'A PAS BOUGÉ, LA CLÉ SI. « Une révision n'existe que si la PRÉVISION a
changé » reste intacte ; ce qui était faux, c'est la prémisse — que le run
identifie la prévision. Regarder le contenu y est plus fidèle que de regarder
une étiquette d'émission.
PRIMARY KEY (targetAt, siteId, contentId)
firstSeenAt / lastSeenAt : observation, en MILLISECONDES
issuedAt / model : attributs, issuedAt pouvant être NUL
Migration v1 -> v2 : table REFAITE, pas convertie. Les lignes v1 sont clés sur
un run qui n'identifie pas leur contenu et ont pu être écrasées ; leur donner
une empreinte calculée sur des valeurs dont on ignore l'origine serait inventer
une provenance.
TROIS CONSÉQUENCES IMPOSÉES PAR LA MESURE :
- issuedAt == 0 n'est PLUS un refus. Il l'a été deux heures, tant qu'il servait
de clé. Devenu attribut, refuser une courbe exploitable parce qu'une
métadonnée manque reviendrait à jeter la donnée pour son étiquette — et
best_match le met à zéro pour toutes ses courbes. Stocké NUL, aucun instant
substitué.
- siteId entre dans la clé : deux plans publient des courbes différentes sous le
même issuedAt, donc la garde d'hier devient NÉCESSAIRE. Sans elle, changer de
site désigné mêlerait deux historiques.
- t0 ancré sur le jour local de la requête est couvert sans rien ajouter : les
targetAt changent, donc l'empreinte aussi.
ET UN DÉFAUT TROUVÉ PAR LE TEST, qui n'était pas dans le test : deux révisions
écrites dans la même seconde n'étaient pas ordonnables — MAX(firstSeenAt) en
rendait deux et la courbe retournait des doublons. Les instants d'observation
passent en millisecondes. Un instant d'observation doit distinguer deux
observations, sinon il n'ordonne rien ; un cycle par minute l'aurait caché
indéfiniment sur la box.
Le producteur n'a PAS établi pourquoi le serveur recompose — nowcast, mélange
court terme, ou deux domaines sur des runs différents. Il a mesuré QUE ça diverge
et SUR QUELLES HEURES. L'explication reste une hypothèse, et rien ici n'en
dépend : la clé de contenu est correcte quelle qu'en soit la cause.
loadmodel 30/30, charging 48/48, spotmarket 32/32, simulation 69/69,
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
On attendait la déduplication ; on a trouvé une écrasure.
06:03 RÉVISION du run 00Z — 48 créneaux
07:02 RÉVISION du run 00Z — 48 créneaux <- MÊME RUN
Deux écritures pour la même identité de run à une heure d'écart : les valeurs
avaient changé sous un issuedAt inchangé, et INSERT OR REPLACE les a remplacées
sans rien dire.
LA PREUVE A ÉTÉ DÉTRUITE PAR LE DÉFAUT LUI-MÊME. À 07:14 la table et l'état
publié coïncidaient parfaitement — l'écrasure avait déjà aligné les deux, et la
question « quels créneaux ont changé, les passés ou les futurs ? » est devenue
impossible à poser.
CORRECTION : un run est immuable. Des valeurs neuves sous une identité connue
n'écrasent plus rien — écriture refusée, premières valeurs conservées, anomalie
journalisée AVEC ce qui manquait pour décider : combien de créneaux, et le
partage passé/futur.
Ce partage est LE discriminant et il commande deux conduites opposées. Passés
seulement : le producteur fusionne l'observé, légitime chez lui, et c'est NOTRE
clé qui est trop courte — il lui faudrait une composante de contenu. Futurs :
c'est un run qui n'identifie pas son contenu, donc un défaut à lui remonter.
Et un cas neuf pour la famille des modes d'érosion : un défaut qui écrase la
donnée qui le prouverait ne laisse pas de preuve, il laisse une COHÉRENCE. À
07:14 tout concordait — exactement ce qu'on aurait vu si tout allait bien.
Au passage, le geste du motif s'adapte au nombre de candidats : « désignez-le »
devant un seul, « choisissez-en un parmi les N » au-delà. Envoyer chercher une
liste qui n'existe pas fait perdre le temps que l'aide prétend gagner, et c'est
le pire endroit pour se tromper puisque celui qui la lit ne sait pas déjà.
loadmodel 33/33, charging 48/48, spotmarket 32/32, simulation 69/69,
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
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
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
La déduplication porte sur les VALEURS ; un run qui reconduit ne crée pas de
ligne, il met à jour la fraîcheur.
Le raisonnement qui tranche est celui de meta.json, appliqué une seconde fois :
on ne stocke pas pour un consommateur qui n'existe pas. L'apprentissage n'existe
pas, le consommateur d'aujourd'hui est un affichage, et un affichage ne lit pas
une confirmation. Même déclencheur de réouverture, pour la même raison.
UNE CONTRADICTION DANS LA CONSIGNE, ET COMMENT ELLE EST LEVÉE. Deux instructions
incompatibles : « issuedAt est mis à jour sur la ligne existante » (= le dernier
run) et « il devient l'horodatage du premier run, pas du dernier » (= le
premier). Et l'avertissement joint — « quelqu'un lira prévu à 06Z sur une ligne
que le run 12Z a confirmée » — ne décrit un danger QUE si l'instant n'est pas
mis à jour.
Aucune des deux n'a été choisie, parce qu'aucune ne suffit : un seul champ ne
peut pas porter les deux faits sans mentir sur l'un. Ce serait exactement le
piège receivedAt/issuedAt de la veille — deux instants voisins, un nom qui
promet le mauvais — et le remède est le même : nommer ce que c'est.
firstIssuedAt run qui a produit ces valeurs EN PREMIER (CLÉ, immuable)
lastConfirmedAt dernier run qui les a RECONDUITES (mis à jour)
La FRAÎCHEUR est lastConfirmedAt ; l'ANCIENNETÉ DE L'AVIS est firstIssuedAt.
Une valeur émise à 06Z et reconduite à 12Z est fraîche ; une valeur émise à 06Z
que personne n'a reconduite EST vieille de six heures. Les deux phrases sont
vraies et ne parlent pas de la même chose. Les deux sont publiées au contrat :
n'en publier qu'une obligerait le client à choisir laquelle il croit.
firstIssuedAt ne bouge jamais — une clé qui se déplace change l'identité de la
ligne sous elle. Les deux instants coïncident à l'ouverture et divergent à la
première reconduction, ce que le test vérifie explicitement.
DERNIER REMPART, PAS FILET : celle du producteur compare la charge utile
entière, où issuedAt figure, donc elle ne mord jamais sur ce cas. Si celle-ci se
trompe, personne ne rattrape — raison de plus pour qu'elle ne dépende d'aucun
comportement du voisin : intersection des créneaux, et communs > 0 requis.
loadmodel 30/30, charging 48/48, spotmarket 32/32, simulation 69/69,
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
Le canal est livré (openmeteo 1.15.2+etm11) : forecastCurveTransport, une
QVariantMap TYPÉE déclarée dans la ThingClass — au contrat, sans seconde couche
d'encodage. Mieux que la chaîne JSON envisagée : le §12.5 réclamait une version
dans la charge utile faute de schéma ; ici la charge utile a un schéma ET porte
sa version.
issuedAt VOYAGE DANS LA CHARGE UTILE. La clé de révision devient exacte, et tout
le §13 est levé : receivedAt, l'interdiction d'en calculer un horizon, la dette
meta.json. targetAt - issuedAt EST l'horizon. Le coût que je chiffrais disparaît
avec sa cause.
CE QUE J'AVAIS FAUX. Ma déduplication REJETAIT une révision aux valeurs
identiques. Avec une vraie heure de run, c'est faux : un run plus récent portant
les mêmes chiffres CONFIRME, il ne répète pas. Le rejeter rendrait indistincts
« le modèle a confirmé quatre fois » et « le modèle n'a rien dit depuis ce
matin ».
Le mécanisme change donc de nature et se simplifie : la CLÉ déduplique par
construction, et la comparaison de contenu ne fait plus que JOURNALISER (« le
run confirme le précédent »). Si issuedAt bougeait un jour sans que la prévision
bouge, cela se verrait dans le nombre de révisions — un défaut à la source,
qu'il faut voir et non masquer. Une déduplication de contenu l'aurait effacé.
La comparaison sur l'intersection reste, mais comme précaution : la fenêtre
couvre désormais les journées entières et les révisions sont alignées. Elle ne
dépend d'aucun comportement du voisin dont personne ne nous préviendrait.
model porte la provenance réelle, repli best_match compris. La colonne cesse
d'être nulle ; la règle de non-remplissage rétroactif reste.
L'INDEXATION EST LUE, PAS SUPPOSÉE. `convention` dit que p[i] est la moyenne sur
[t0 + i·step, t0 + (i+1)·step) : l'énergie vaut p[i] x step / 3600, sans
supposer que le pas vaut une heure. `unit` est VÉRIFIÉE — lire des Wh comme des
W donnerait une courbe plausible, fausse d'un facteur qui dépend du pas.
Lecture branchée dans le cycle, et c'est sans coût : un run déjà connu n'écrit
rien. Attendre une notification serait plus élégant et MOINS SÛR — un état
publié avant notre démarrage ne notifierait jamais, et la courbe resterait
invisible jusqu'au run suivant. L'état est cherché par son NOM, jamais par un
identifiant de ThingClass. Un refus est journalisé une fois par MOTIF, pas une
fois par cycle.
Deux tests : l'identité du run est celle de la révision (avec l'assertion
centrale — un run qui confirme EST conservé), et le refus de charge utile plutôt
que l'interprétation au mieux (version, unité, vide, tronquée, et un créneau
illisible qui reste un TROU).
loadmodel 30/30, charging 48/48, spotmarket 32/32, simulation 69/69,
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
Ce qui ne dépend pas du canal, écrit maintenant. La lecture se branchera quand
la forme du canal openmeteo sera connue ; le magasin est vide d'ici là, et une
courbe vide EST la réponse honnête à « que prévoit-on ? » quand personne n'a
rien publié.
LA CLÉ EST LA PAIRE (targetAt, receivedAt). Avec targetAt seul, chaque
rafraîchissement écraserait le précédent : on perdrait les révisions, donc la
comparaison prévu/réalisé — ce que ce lot existe pour montrer. Le GPL n'apprend
pas, il rend l'apprentissage possible : cette table est la matière, et l'écraser
la détruirait pour toujours sans que rien ne le signale.
receivedAt, JAMAIS issuedAt, et l'interdiction est écrite et non seulement
nommée : aucun horizon ne se calcule depuis ce champ. Entre l'initialisation
d'un run et sa mise à disposition s'écoule la latence de publication du modèle —
des heures. targetAt - receivedAt est donc trop court, systématiquement et dans
le même sens. « Quelle prévision » est juste, « quel horizon » est faux.
LA DÉDUPLICATION PORTE SUR L'INTERSECTION, pas sur la charge utile. Si la
fenêtre glisse, la courbe de 11 h a un point de moins que celle de 10 h : une
égalité de charge utile verrait une révision toutes les heures, exactement ce
que la déduplication existe pour éviter. Et `communs > 0` est requis — sans
heure commune il n'y a rien à comparer, et conclure « identique » ferait
disparaître une révision dont on ne sait rien (cas du passage de minuit).
Ce choix ne dépend pas de la forme que le producteur publie aujourd'hui : il est
correct que la fenêtre glisse ou non, donc il ne peut pas être invalidé par un
changement chez le voisin dont personne ne nous préviendrait.
RÉTENTION : sept jours pleins, puis les DEUX BOUTS par heure visée — la première
révision (horizon le plus long) et la dernière (meilleure estimation). Les deux,
et pas une : elles répondent aux deux seules questions qui se posent, et n'en
garder qu'une détruirait la révision.
model reste NULL tant que le producteur publie une INTENTION et non une
provenance. Une chaîne vide serait déjà une valeur ; NULL dit « on ne sait pas »,
ce qui est vrai. Aucun remplissage rétroactif à prévoir.
GetPvForecast : un Get* SANS *Changed — amendement assumé à l'invariant 11, qui
vise l'ÉTAT DE L'ARBITRE, celui qui bouge à chaque cycle et laisse un client
déconnecté en retard. Une courbe historisée se lit sur demande, comme
GetPowerBalanceLogs ; notifier chaque révision produirait un message par heure
sur une donnée que personne n'attend en direct. `knownAt` rend la lecture
reproductible : « ce qu'on prévoyait ce matin » se demande en le fixant.
Base SÉPARÉE, jamais energylogs.sqlite : celle-là appartient à l'amont qui en
migre le schéma, et surtout la clé y est un instant de mesure quand la nôtre est
une paire. Une prévision révisée n'a rien à faire dans la base du mesuré.
Trois tests unitaires, et le premier vise le cas qu'un == naïf raterait : une
fenêtre glissée d'une heure, valeurs communes inchangées, charge utile
différente — ce n'est PAS une révision. Avec ses contre-épreuves : une valeur
commune modifiée EN EST une, et une fenêtre sans heure commune aussi.
loadmodel 29/29, charging 48/48, spotmarket 32/32, simulation 69/69,
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