From e7485e76f958b523e546cb430d7a38dd8ddad54e Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Thu, 3 Sep 2026 06:58:42 +0200 Subject: [PATCH] =?UTF-8?q?audit:=20la=20question=20pass=C3=A9e=20=C3=A0?= =?UTF-8?q?=20toute=20la=20suite=20=E2=80=94=20ce=20qu'on=20croyait=20v?= =?UTF-8?q?=C3=A9rifi=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » passée à la suite existante. Le résultat n'est pas une liste de tests à supprimer. Contrôles qui ne contrôlent rien : les assertions conditionnelles dans une boucle sont à zéro — la seule qui existait a été corrigée avant-hier. Sur dix identités auto-référentielles relevées, UNE SEULE est vaine : authorisedW − committedW == remainingW relisait une affectation d'une ligne. Elle est retirée AVEC SA RAISON à sa place, et avec la distinction d'avec l'identité du budget — qui croise remainingSurplusW filé le long de la cascade et allocatedW accumulé en trois sites distincts, donc attrape un site qui décrémenterait sans accumuler. Supprimer sans dire pourquoi aurait invité quelqu'un à la remettre. La moitié qui compte : l'index annonçait 47 règles sans porteur. Il mesurait la CITATION, pas la couverture — douze étaient couvertes par un test qui ne les nommait pas. Citées par un test : 22 → 40. Les liens sont maintenant vérifiables par la machine au lieu de reposer sur la mémoire de qui a écrit quoi ; c'est la doctrine du dépôt appliquée à elle-même. Sur les 31 restantes, 23 relèvent de familles non implantées — écrire leur test poserait un invariant sur du code que rien n'émet. Six sont du cadrage mal classé. Et UNE manque vraiment : LM-1210-a, la pire phase faute de masque, qu'aucun test n'exerce parce que le mock ne sait pas produire une triphasée déséquilibrée. Même angle mort que l'inventaire, nommé deux fois. tools/audit-controles.py pérennise les deux motifs. Il ne remplace pas la question, il la pose là où elle se pose souvent — et il ne juge pas : la lecture tranche, l'écart entre les dix relevées et la seule vaine le montre. Le résultat le plus utile n'est pas le défaut trouvé, c'est le chiffre corrigé. On croyait 47 règles non couvertes ; il y en avait 31, dont 23 légitimes et une seule qui manque. Savoir lesquelles change ce qu'on surveille. Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la référence, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p --- debian-qt5/changelog | 31 ++++++++++ docs/BALAYAGE_CONTROLES_20260903.md | 78 ++++++++++++++++++++++++++ tests/auto/loadmodel/testloadmodel.cpp | 5 ++ tests/auto/simulation/simulation.cpp | 36 +++++++++++- tools/audit-controles.py | 65 +++++++++++++++++++++ 5 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 docs/BALAYAGE_CONTROLES_20260903.md create mode 100644 tools/audit-controles.py diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 154e4b4..c415865 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,34 @@ +powersync-energy-plugin-nymea (1.15.2+etm50) trixie; urgency=medium + + * BALAYAGE — « qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » passée à toute la + suite. Ce n'est pas une liste de tests à supprimer, c'est la liste de ce qu'on croyait + vérifié et qui ne l'était pas (docs/BALAYAGE_CONTROLES_20260903.md). + * ASSERTIONS CONDITIONNELLES DANS UNE BOUCLE : 0. La seule qui existait a été corrigée le + 2026-09-01 ; l'outil le confirme et le surveillera. + * IDENTITÉS AUTO-RÉFÉRENTIELLES : 10 relevées, UNE seule vaine. Le motif signale, la lecture + tranche. `authorisedW − committedW == remainingW` relisait une affectation d'UNE LIGNE : + retirée, avec sa raison écrite à sa place — et la distinction d'avec l'identité du BUDGET, + qui croise `remainingSurplusW` filé le long de la cascade avec `allocatedW` accumulé en TROIS + sites, et qui mérite donc d'être assertée. Supprimer sans dire pourquoi aurait invité + quelqu'un à la remettre. + * LA MOITIÉ QUI COMPTE — l'index annonçait 47 règles « spécifiées seules ». Il mesurait la + CITATION, pas la couverture : DOUZE étaient couvertes par un test qui ne les nommait pas. + Citées par un test : 22 → 40. Spécifiées seules : 47 → 31. Les liens sont désormais + vérifiables par la machine au lieu de reposer sur la mémoire de qui a écrit quoi. + * Sur les 31 restantes, 23 appartiennent à des familles NON IMPLANTÉES (sonde thermique, + transport §14, optimiseur externe) : écrire leur test poserait un invariant sur du code que + rien n'émet. Six sont du cadrage mal classé. Et UNE SEULE manque vraiment — LM-1210-a, la + pire phase faute de masque, qu'aucun test n'exerce PARCE QUE le mock ne sait pas produire une + triphasée déséquilibrée. Même angle mort que l'inventaire, nommé deux fois désormais. + * `tools/audit-controles.py` pérennise les deux motifs. Il ne remplace pas la question, il la + pose là où elle se pose souvent. + * Le résultat le plus utile n'est pas le défaut trouvé, c'est le CHIFFRE CORRIGÉ : on croyait + 47 règles non couvertes, il y en avait 31, dont une seule qui manque vraiment. + * Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la référence, + spotmarket 32/32, doxygen 0 avertissement. + + -- Patrick Schurig Thu, 03 Sep 2026 10:00:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm49) trixie; urgency=medium * CORRECTIF — sur `measured → unmeasurable → measured` DANS LA MÊME PÉRIODE, `deliveredWh` diff --git a/docs/BALAYAGE_CONTROLES_20260903.md b/docs/BALAYAGE_CONTROLES_20260903.md new file mode 100644 index 0000000..605b27f --- /dev/null +++ b/docs/BALAYAGE_CONTROLES_20260903.md @@ -0,0 +1,78 @@ +# BALAYAGE — « qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » + +*(2026-09-03. La question passée à toute la suite existante. Ce qui suit n'est pas une liste de +tests à supprimer : c'est la liste de **ce qu'on croyait vérifié et qui ne l'était pas**.)* + +## Pourquoi ce balayage + +Trois défauts de cette classe ont été rencontrés **sans les chercher**, sous trois formes : + +| Forme | Cas réel | +|---|---| +| La contre-épreuve **inerte** | perturber `deliveredWh` sous `unmeasurable`, où il vaut 0 — la perturbation ne pouvait rien changer | +| L'identité qui se **referme sur elle-même** | `Σ levels[].targetW == estimatedPowerW` : deux termes de la même source | +| Le test qui échoue **à côté** | l'horodatage « après échéance » visait la MÊME échéance — rien ne basculait | + +Trois formes, **un seul défaut** : le voyant est vert et n'a rien mesuré. + +## Résultat 1 — les contrôles qui ne contrôlent rien + +**Assertions conditionnelles dans une boucle : 0.** La seule qui existait — « sans mesure, pas de +`command` » — a été corrigée le 2026-09-01. L'outil (`tools/audit-controles.py`) le confirme et +le surveillera. + +**Identités auto-référentielles : 10 relevées, 1 vaine.** Le motif signale, il ne juge pas — la +lecture tranche, et le résultat est très inégal : + +| Assertion | Verdict | +|---|---| +| `surplus + recrédit − alloué == restant` | **contrôle** — croise `remainingSurplusW` filé le long de la cascade avec `allocatedW` accumulé en **trois sites distincts**. Attraperait un site qui décrémente sans accumuler | +| `lockMin == lockMax`, `minState == maxState` | **contrôlent** — champs différents, ils épinglent le gel d'une charge | +| `mapping.size() == levels.size()` | **contrôle** — une dérivation qui produirait un compte différent | +| `authorisedW − committedW == remainingW` | **NE CONTRÔLE RIEN** — le moteur l'établit sur **une ligne** ; l'assertion relisait l'affectation | +| `remainingWh == targetWh` sous `unmeasurable` | **faible, et documenté** — corollaire de `delivered == false`, pas une garde indépendante | +| `expectedW == observedW` sous `device` | **faible, et délibéré** — le mock renvoie sa consigne ; l'assertion documente le piège plutôt qu'elle ne contrôle | + +**Action** : l'assertion vaine est retirée, **avec sa raison écrite à sa place** — et la +distinction d'avec l'identité du budget, qui, elle, mérite d'être assertée. Supprimer sans dire +pourquoi aurait invité quelqu'un à la remettre. + +## Résultat 2 — les invariants que personne ne couvrait (la moitié qui compte) + +L'index des règles annonçait **47 règles « spécifiées seules »** — ni code, ni test. Le balayage +montre que **l'index mesure la CITATION, pas la couverture** : douze de ces règles étaient +couvertes par un test qui ne les nommait pas. + +| | avant | après | +|---|---|---| +| citée par un test | 22 | **40** | +| spécifiée seule | 47 | **31** | + +Les liens sont désormais **vérifiables par la machine** au lieu de reposer sur la mémoire de qui a +écrit quoi. C'est la doctrine du dépôt appliquée à elle-même : *quelle machine rejouera cette +décision ?* + +**Sur les 31 restantes, 23 appartiennent à des familles NON IMPLANTÉES** — sonde thermique +(`ECS-5xx`/`6xx`), transport réseau du §14 (`LM-14xx`), optimiseur externe (`LM-4xx`/`5xx`), +ancien lot ECS. Elles sont légitimement sans porteur : écrire leur test aujourd'hui poserait un +invariant sur un code que rien n'émet. + +**Restent huit règles du lot courant sans porteur**, et elles se répartissent en deux tas : + +- **Cadrage mal classé** — `LM-1000`, `LM-1001`, `LM-1005`, `LM-1006`, `LM-1010`, `LM-1014` : + des règles de forme et de doctrine, dont le porteur naturel est la spec elle-même. À reclasser + en `cadrage`, ce qui est une correction d'index, pas un manque de test. +- **Un vrai manque, assumé** — `LM-1210-a` (la marge retenue est celle de la pire phase, faute de + masque de phases). Aucun test ne l'exerce **parce que le mock ne sait pas produire une + triphasée déséquilibrée** (cf. `INVENTAIRE_MOCK.md`, manque n°4). C'est le même angle mort, + et il est maintenant nommé deux fois. + +## Ce que le balayage a coûté et rapporté + +Coût : une heure. Rapport : **une assertion vaine retirée**, **dix-huit invariants dont la +couverture est passée de crue à démontrée**, et **un outil** qui repose la question à chaque +passage de `ci-quality`. + +> **Le résultat le plus utile n'est pas le défaut trouvé, c'est le chiffre corrigé.** On croyait +> 47 règles non couvertes ; il y en avait 31, dont 23 légitimes et une seule qui manque vraiment. +> Savoir lesquelles change ce qu'on surveille. diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 12d9d70..efd9118 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -540,6 +540,11 @@ void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite() /*! * \brief [§10 étape D] L'étalement uniforme, et le durcissement qu'il faut savoir DIRE. * + * \par Règles couvertes + * \rule{LM-1013-b-i} (recalculé à chaque cycle), \rule{LM-1013-b-ii} (le durcissement se dit), + * \rule{LM-1013-b-iii} (point de non-retour nommé), \rule{LM-1013-b-iv} (l'heure quotidienne + * vise la prochaine occurrence). + * * \par Ce que la mesure a montré avant que rien ne soit asserté * L'étape D est la seule des quatre qui change le comportement, et aucun scénario existant ne * portait d'obligation éco — `LoadContext::needs` n'était rempli par aucun adaptateur. Il n'y diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index dde060e..b555eb5 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -6434,6 +6434,11 @@ void Simulation::testARefusedStateIsDistinguishableFromAnApplied() /*! * \brief [Étape 4] L4 déleste une PAC en la faisant CHANGER D'ÉTAT, et dit qu'il a forcé. * + * \par Règles couvertes + * \rule{LM-1211} (délestage en ordre inverse du rang), \rule{LM-1211-a} (un seul commandeur par + * organe), \rule{LM-1211-b} (le franchissement se CONSTATE), \rule{LM-1211-d} (délester peut + * être changer d'état). + * * \par Le premier cas où délester ne veut pas dire retirer des watts * Une PAC ne se module pas. Le bornage de la cascade (\rule{LM-1210}) ne peut rien contre une * PAC déjà en état 4 : il empêche d'ALLOUER au-delà du plafond, il ne retire rien à personne. @@ -6658,9 +6663,19 @@ void Simulation::testDrawAuthorisationSurvivesTheBatteryReserve() QCOMPARE(qRound(nominal.draw.remainingW), 3450); QCOMPARE(nominal.draw.bindingCode, QString("connection")); QCOMPARE(nominal.draw.perPhaseBound, true); - // L'identité, qui est ce qui rend la charge utile vérifiable par un client. - QCOMPARE(qRound(nominal.draw.authorisedW - nominal.draw.committedW), - qRound(nominal.draw.remainingW)); + // L'identité `authorisedW − committedW == remainingW` N'EST PAS ASSERTÉE ICI, et c'est + // délibéré : le moteur l'établit sur UNE LIGNE (`rulebasedscheduler.cpp`, `draw.remainingW = + // draw.authorisedW - draw.committedW`). L'asserter reviendrait à relire cette affectation — + // les deux membres viennent de la même source, l'identité se referme sur elle-même, et le + // contrôle n'attraperait rien. + // + // Ce qui contrôle réellement, ce sont les TROIS attentes littérales ci-dessus : elles + // viennent du scénario, pas du moteur. Relevé au balayage du 2026-09-03. + // + // ⚠️ À ne pas confondre avec l'identité du BUDGET (`surplus + recrédit − alloué == + // restant`), qui, elle, croise DEUX calculs indépendants — `remainingSurplusW` filé le long + // de la cascade contre `allocatedW` accumulé en trois sites distincts. Celle-là attrape un + // site qui décrémenterait sans accumuler, et mérite d'être assertée. // 3. SOUS LA RÉSERVE — le cœur du test. Le surplus est ANNULÉ, l'autorisation INTACTE. const Plan sousReserve = planifier(true, true); @@ -6686,6 +6701,12 @@ void Simulation::testDrawAuthorisationSurvivesTheBatteryReserve() /*! * \brief [§10 étape C] L'avancement est une DIFFÉRENCE depuis une base, et le régime se publie. * + * \par Règles couvertes + * \rule{LM-1012-b} (l'avancement est une différence), \rule{LM-1012-b-i} (base à l'ouverture de + * l'obligation), \rule{LM-1012-b-ii} (recapture sur recul franc), \rule{LM-1014-c} (le cumul + * survit au changement de RÉGIME), \rule{LM-1014-c-i} (remise à zéro à la période), + * \rule{LM-1014-c-ii} (\c measuredSince garde le premier instant). + * * \par Le piège, mesuré sur le banc le 2026-08-31 * `sessionEnergy` est l'ACCUMULATEUR du plugin V2C, pas un compteur remis à zéro pour nous : * l'écart avec `chargeEnergy` est constant au dix-millième (7,2044 kWh sur deux échantillons à @@ -6693,6 +6714,10 @@ void Simulation::testDrawAuthorisationSurvivesTheBatteryReserve() * borne. **Le lire directement ferait croire toute obligation tenue de 7,2 kWh dès le premier * cycle** — un nombre crédible et faux que rien ne signalerait. * + * \par Règle couverte + * \rule{LM-1012-c} — le RÉGIME d'avancement se publie, porté par le niveau, et + * \c deliveredWh est absent sous \c unmeasurable plutôt que nul. + * * \par Les deux questions que la base pose, et leurs réponses * 1. **Quand est-elle prise ?** À l'ouverture de l'OBLIGATION, jamais au démarrage de nymead : * sinon un redémarrage en pleine session réinitialise l'avancement sur une valeur déjà @@ -6854,6 +6879,11 @@ void Simulation::testEcoProgressBaseAndRegime() /*! * \brief [§10 étape D] La passe ÉCO achète, et le motif dit la TRAJECTOIRE. * + * \par Règles couvertes + * \rule{LM-1002} (deux passes sur la même liste ordonnée), \rule{LM-1007} (une charge sans + * obligation ne porte pas d'entrée « eco » — l'absence EST l'information), \rule{LM-1013-b} + * (l'étalement uniforme), \rule{LM-1210} (l'autorisation borne la cascade). + * * \par Deux passes, une commande * Même liste, même ordre ; ce qui change est la BORNE — l'autorisation de soutirage pour l'éco, * le reliquat de surplus pour le confort. Les deux niveaux sont publiés, mais **une seule diff --git a/tools/audit-controles.py b/tools/audit-controles.py new file mode 100644 index 0000000..43aa367 --- /dev/null +++ b/tools/audit-controles.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Cherche les CONTRÔLES QUI NE CONTRÔLENT RIEN. + +La question, posée à chaque assertion : **qu'aurait-elle attrapé si elle avait échoué ?** +S'il n'y a pas de réponse, elle ne contrôle rien — et le voyant vert ment. + +Deux motifs, tous deux rencontrés en vrai : + + 1. ASSERTION CONDITIONNELLE DANS UNE BOUCLE. Si la condition n'est jamais vraie, l'assertion + ne s'exécute pas et le test passe. Vu le 2026-09-01 : « sans mesure, pas de `command` » + n'avait AUCUNE charge à examiner, et la garde retirée le test passait toujours. + Parade : compter les cas examinés, échouer si le compte est nul. + + 2. IDENTITÉ QUI SE REFERME SUR ELLE-MÊME. Quand les deux membres viennent de la même source, + l'identité est vraie par construction et rassure là où rien ne protège. Vu le 2026-09-03 : + `authorisedW − committedW == remainingW` relisait l'affectation d'une seule ligne. + + ⚠️ Toutes les identités auto-référentielles ne sont pas vaines : celle du BUDGET croise + `remainingSurplusW`, filé le long de la cascade, avec `allocatedW` accumulé en trois sites + distincts. Le motif signale, il ne juge pas — c'est à la lecture de trancher. + +Cet outil ne remplace pas la question, il la pose là où elle se pose souvent. +""" +import re +from pathlib import Path +ASSERT = r'(?:QCOMPARE|QVERIFY2?|QTRY_COMPARE|QTRY_VERIFY2?)' + +def fonctions(txt): + for m in re.finditer(r'\nvoid\s+\w+::(\w+)\(\)\s*\n\{', txt): + deb = m.end(); prof, i = 1, deb + while i < len(txt) and prof: + if txt[i] == '{': prof += 1 + elif txt[i] == '}': prof -= 1 + i += 1 + yield m.group(1), txt[deb:i], txt[:m.start()].count('\n')+2 + +cond, miroir = [], [] +for f in Path('tests/auto').rglob('*.cpp'): + if 'mock' in str(f) or 'experience' in str(f): continue + txt = f.read_text(encoding='utf-8') + for nom, corps, ligne in fonctions(txt): + if nom.endswith('_data'): continue + # MOTIF 1 — assertion sous un `if` DANS une boucle : si la condition n'est jamais + # vraie, l'assertion ne s'exécute pas et le test passe sans rien vérifier. + for m in re.finditer(r'for\s*\([^)]*\)\s*\{(.{0,900}?)\n\s*\}', corps, re.S): + bloc = m.group(1) + if re.search(r'\n\s*if\s*\([^)]*\)[^;{]*\n?\s*' + ASSERT, bloc): + garde = re.search(r'\b(examin|vus|compte|trouv)\w*\b', corps, re.I) + cond.append((f, ligne, nom, "gardé" if garde else "NON GARDÉ")) + break + # MOTIF 2 — comparaison dont les deux membres partent de la MÊME racine. + for m in re.finditer(r'QCOMPARE\(\s*([^,]+?)\s*,\s*([^;]+?)\s*\)\s*;', corps): + a, b = m.group(1), m.group(2) + ra = re.match(r'\s*(?:qRound\()?\s*([A-Za-z_][\w]*)', a) + rb = re.match(r'\s*(?:qRound\()?\s*([A-Za-z_][\w]*)', b) + if ra and rb and ra.group(1) == rb.group(1) and '.' in a and '.' in b: + miroir.append((f, ligne, nom, m.group(0)[:96])) + +print("=== MOTIF 1 — assertion conditionnelle dans une boucle : %d ===" % len(cond)) +print(" Si la condition n'est jamais vraie, rien ne s'exécute et le test PASSE.") +for f, l, n, g in cond: print(" [%s] %s:%d %s" % (g, f, l, n)) +print() +print("=== MOTIF 2 — les deux membres viennent de la MÊME racine : %d ===" % len(miroir)) +print(" L'identité se referme sur elle-même : vraie par construction.") +for f, l, n, t in miroir: print(" %s:%d %s\n %s" % (f, l, n, t))