Épingles : écarts correctifs face à l'épingle d'origine, écarts d'harmonisation refigés (05/10)

L'épingle d'origine (fixtures/sorties-origine-2026-09-28.json, générée depuis
le code d'astro-pro) porte les défauts corrigés par 91c755f (eg_phytostation ×3
sur C/eau, basse_planche_bec ×2 sur B et C/alim, lignes forcées orphelines,
FORCE ×4 dans le journal, A/eau par axe en INSUFFISANT). La regénérer depuis
astro-pro reproduirait les défauts ; la regénérer depuis ce paquet en ferait
une épingle sur soi-même. Elle reste donc telle quelle, et :

- scripts/ecarts-correctifs.mjs + fixtures/ecarts-correctifs-2026-10-05.json :
  chaque cellule (profil × mode × axe × champ) qui s'écarte de l'origine est
  figée, avec le détail des lignes ; une cellule absente doit rester identique.
- test/decoupage.test.mjs : test 1 compare aux écarts figés (et vérifie qu'un
  correctif n'introduit jamais un INSUFFISANT ni un total hors ±5 % en global ;
  budgets alloués et poids des axes inchangés) ; test 2 vérifie la propriété du
  découpage contre selectionner() de ce paquet ; test 3 inchangé dans l'esprit.
- Écarts d'harmonisation REFIGÉS (npm run ecarts -- --ecrire 2026-10-05) après
  relecture de docs/CONSTANTES.md : neuf lignes au lieu de onze. Les deux lignes
  perdues (A énergie global 22 → 6 %, C énergie par axe 10 → 3 %) étaient des
  taux calculés sur des kWc fantômes hérités par une batterie ; la couverture y
  est null avec les deux tables. Aucun coefficient ne change. Note datée posée
  dans CONSTANTES.md §1. (La suppression du fichier du 28/09 est partie par
  erreur dans 8465bf7, avec la version : même intention, deux commits.)
- fixtures/README.md et AGENTS.md (règles 6 et 8) : l'épingle d'origine ne se
  regénère pas pour absorber un correctif ; invariants tenus depuis v0.1.1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgtn7bJxTLiL2fRCkQMgtR
This commit is contained in:
Jules
2026-10-05 16:39:51 +02:00
co-authored by Claude Fable 5.1
parent 8465bf7204
commit 4f593d2edf
7 changed files with 2042 additions and 26 deletions
+36 -22
View File
@@ -1,20 +1,26 @@
// Non-régression du découpage par axe (LOT P1.0 point 4, amendé le 28/09).
// Non-régression du découpage par axe (LOT P1.0 point 4, amendé le 28/09 et le 05/10).
//
// 1. Avec le jeu de coefficients D'ORIGINE (fixtures/coefficients-2026-05.json),
// le pipeline complet extrait rend, sur les trois profils de référence, la
// même sélection que selectionner() d'origine — épinglée dans
// sélection de selectionner() d'origine — épinglée dans
// fixtures/sorties-origine-2026-09-28.json, générée DEPUIS le code d'astro-pro
// (scripts/pin-origine.mjs), pas depuis ce paquet.
// 2. Chaque moteur d'axe, à budget d'axe égal, rend la sélection du mode
// « un seul axe actif » de selectionner() d'origine (même épingle).
// (scripts/pin-origine.mjs), pas depuis ce paquet — AUX ÉCARTS CORRECTIFS PRÈS :
// l'épingle d'origine porte les défauts corrigés en v0.1.1 (doublons,
// accessoires forcés orphelins, statut non monotone, journal répété ; SPEC menu
// §12) et n'est pas regénérée. Chaque cellule qui s'en écarte est figée dans
// fixtures/ecarts-correctifs-<date>.json (scripts/ecarts-correctifs.mjs) ; une
// cellule absente de ce fichier doit rester identique à l'origine.
// 2. Chaque moteur d'axe, à budget d'axe égal, rend la sélection du mode « un
// seul axe actif » de selectionner() de ce paquet (propriété du découpage).
// 3. Avec la table HARMONISÉE, les écarts de sélection et de taux par rapport à
// l'origine sont ceux figés dans fixtures/ecarts-harmonisation-2026-09-28.json
// l'origine sont ceux figés dans fixtures/ecarts-harmonisation-<date>.json
// (relus par Jules avec docs/CONSTANTES.md). Les modifier = refiger sciemment.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { eau, energie, alimentation, integrateur, autonomie, COEFFICIENTS } from '../dist/index.js'
import { calculeEcarts } from '../scripts/ecarts-harmonisation.mjs'
import { calculeEcartsCorrectifs, PIN_ORIGINE } from '../scripts/ecarts-correctifs.mjs'
const lire = (f) => JSON.parse(readFileSync(new URL(`../fixtures/${f}`, import.meta.url), 'utf8'))
const options = lire('options.json')
@@ -24,40 +30,48 @@ const algo_config = lire('algo-config.json')
const profils = lire('profils-reference.json')
const origine = lire('coefficients-2026-05.json')
const pin = lire('sorties-origine-2026-09-28.json')
const ecartsPin = lire('ecarts-harmonisation-2026-09-28.json')
const ecartsPin = lire('ecarts-harmonisation-2026-10-05.json')
const correctifsPin = lire('ecarts-correctifs-2026-10-05.json')
const AXES = ['eau', 'energie', 'alim']
const MOTEURS = { eau, energie, alim: alimentation }
const resumePostes = (liste) => liste.map((p) => ({ poste_id: p.poste_id, typologie_choisie: p.typologie_choisie, dimension: p.dimension ? { valeur: p.dimension.valeur, unite: p.dimension.unite } : null, cout_eur_ht: p.cout_eur_ht, force_par_couplage: !!p.force_par_couplage }))
test('pipeline complet extrait = selectionner() d\'origine, coefficients d\'origine, trois profils', () => {
test('pipeline complet extrait = selectionner() d\'origine aux écarts correctifs figés près (coefficients d\'origine, trois profils)', () => {
assert.equal(PIN_ORIGINE, 'sorties-origine-2026-09-28.json', 'l\'épingle d\'origine reste celle du code d\'astro-pro')
assert.equal(correctifsPin.epingle_origine, PIN_ORIGINE)
const { ecarts } = calculeEcartsCorrectifs()
assert.deepEqual(ecarts, correctifsPin.ecarts, 'les écarts avec l\'origine ont changé : ce n\'est voulu que pour corriger un défaut du noyau, avec test ; refiger avec node scripts/ecarts-correctifs.mjs --ecrire <date> et le dire dans le commit')
// Un correctif ne fait jamais apparaître un « budget insuffisant » là où l'origine n'en avait pas,
// ni un total hors de la bande ±5 % en mode global.
for (const e of ecarts) {
if (e.champ === 'status') assert.notEqual(e.corrige, 'BUDGET_INSUFFISANT', `${e.profil}/${e.mode}/${e.axe}`)
if (e.champ === 'ecart_budget_pct') assert.ok(Math.abs(e.corrige) <= 5, `${e.profil} : écart global ${e.corrige} %`)
}
// Ce que l'origine avait de juste reste : budgets alloués et poids des axes, inchangés
for (const profil of profils) {
const attendu = pin.profils[profil.nom].global
const r = autonomie.noyau.selectionner(profil.inputs, options, postes, france_climat, { config: algo_config, coefficients: origine }, false)
assert.equal(r.total_eur_ht, attendu.total_eur_ht, `${profil.nom} : total`)
assert.equal(r.status, attendu.status, `${profil.nom} : statut`)
assert.equal(r.ecart_budget_pct, attendu.ecart_budget_pct, `${profil.nom} : écart`)
assert.deepEqual(r.couplages, attendu.couplages, `${profil.nom} : journal des couplages`)
for (const axe of AXES) {
assert.equal(r.axes[axe].budget_alloue, attendu.axes[axe].budget_alloue, `${profil.nom}/${axe} : budget alloué`)
assert.equal(r.axes[axe].poids, attendu.axes[axe].poids, `${profil.nom}/${axe} : poids`)
assert.equal(r.axes[axe].sous_total_eur_ht, attendu.axes[axe].sous_total_eur_ht, `${profil.nom}/${axe} : sous-total`)
assert.deepEqual(resumePostes(r.axes[axe].postes), attendu.axes[axe].postes, `${profil.nom}/${axe} : postes`)
}
}
})
test('moteur d\'axe = mode « un seul axe actif » de selectionner() d\'origine, à budget d\'axe égal', () => {
test('moteur d\'axe = mode « un seul axe actif » de selectionner() de ce paquet, à budget d\'axe égal', () => {
for (const profil of profils) {
const contexte = autonomie.noyau.selectionner(profil.inputs, options, postes, france_climat, { config: algo_config, coefficients: origine }, false).contexte
for (const axe of AXES) {
const attendu = pin.profils[profil.nom].par_axe[axe]
const r = MOTEURS[axe].calculer({ autodiag: profil.inputs, budget_axe: attendu.budget_axe, contexte }, { postes, options, algo_config, coefficients: origine })
const budget = pin.profils[profil.nom].par_axe[axe].budget_axe
const actifs = Object.fromEntries(AXES.map((a) => [a, a === axe]))
const attendu = autonomie.noyau.selectionner({ ...profil.inputs, budget_eur: budget }, options, postes, france_climat, { config: autonomie.noyau.configPourAxes(algo_config, actifs), coefficients: origine }, false)
const r = MOTEURS[axe].calculer({ autodiag: profil.inputs, budget_axe: budget, contexte }, { postes, options, algo_config, coefficients: origine })
assert.equal(r.sorties.axe, axe)
assert.equal(r.sorties.sous_total_eur_ht, attendu.axes[axe].sous_total_eur_ht, `${profil.nom}/${axe} : sous-total`)
assert.equal(r.sorties.status, attendu.status, `${profil.nom}/${axe} : statut`)
assert.deepEqual(resumePostes(r.sorties.postes), attendu.axes[axe].postes, `${profil.nom}/${axe} : postes`)
assert.deepEqual(r.sorties.typologies, attendu.axes[axe].postes.map((p) => p.typologie_choisie))
assert.deepEqual(resumePostes(r.sorties.postes), resumePostes(attendu.axes[axe].postes), `${profil.nom}/${axe} : postes`)
assert.deepEqual(r.sorties.couplages, attendu.couplages, `${profil.nom}/${axe} : journal des couplages`)
assert.ok(!r.avertissements.some((a) => /inter-axes/.test(a)), 'aucun couplage inter-axes dans les données')
}
}
@@ -67,7 +81,7 @@ test('intégrateur mode global = selectionner() ; mode par_axe = somme des moteu
for (const profil of profils) {
const donnees = { postes, options, algo_config, france_climat, coefficients: origine }
const g = integrateur.calculer({ autodiag: profil.inputs, budget_total: profil.inputs.budget_eur, mode: 'global' }, donnees)
const attendu = pin.profils[profil.nom].global
const attendu = autonomie.noyau.selectionner(profil.inputs, options, postes, france_climat, { config: algo_config, coefficients: origine }, false)
assert.equal(g.sorties.mode, 'global')
assert.equal(g.sorties.total_eur_ht, attendu.total_eur_ht)
assert.equal(g.sorties.status, attendu.status)
@@ -78,8 +92,8 @@ test('intégrateur mode global = selectionner() ; mode par_axe = somme des moteu
assert.deepEqual(p.sorties.budgets, g.sorties.budgets, 'même allocation de budget dans les deux modes')
let somme = 0
for (const axe of AXES) {
const a = pin.profils[profil.nom].par_axe[axe]
assert.deepEqual(p.sorties.axes[axe].typologies, a.axes[axe].postes.map((x) => x.typologie_choisie), `${profil.nom}/${axe} : par_axe = moteur d'axe`)
const a = MOTEURS[axe].calculer({ autodiag: profil.inputs, budget_axe: g.sorties.budgets[axe], contexte: g.sorties.contexte }, donnees).sorties
assert.deepEqual(p.sorties.axes[axe].typologies, a.typologies, `${profil.nom}/${axe} : par_axe = moteur d'axe`)
somme += p.sorties.axes[axe].sous_total_eur_ht
}
assert.equal(p.sorties.total_eur_ht, somme)