From d8a6fb8202b850db62b95f06e1b0c3de59d8c2a7 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 9 Oct 2026 04:53:10 -0400 Subject: [PATCH] [ADD] bench: a priori ceiling cost on the largest and worst events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 17 point 4 asks whether the a priori ceiling is cheap enough to compute on every check. The bench times plafondsAPriori and diagnostiquer on the large demonstration, its variant without exceptions, and a pessimistic event of 260 people, 33 tables of 8 and 10 rounds with every signature distinct: about 0.075 ms, 0.04 ms and 11.5 ms. A long test guards each at four times its reference, so losing the memoisation turns it red. Checked: red first under that mutant; 3208 node and 71 long tests green. --- FR --- [ADD] banc : coût du plafond a priori, grand événement et pire cas Le spec 17, point 4, demande si le plafond a priori se calcule assez vite pour chaque contrôle. Le banc mesure plafondsAPriori et diagnostiquer sur la grande démonstration, sa variante sans exception, et un événement pessimiste de 260 personnes, 33 tables de 8 et 10 tours aux signatures toutes distinctes : environ 0,075 ms, 0,04 ms et 11,5 ms. Une épreuve longue garde chacun à quatre fois sa référence : perdre la mémoïsation la rend rouge. Vérifié : rouge d'abord sous ce mutant ; 3208 node et 71 longues vertes. Assisted-by: Claude Opus 5.5 --- scripts/banc/plafond.js | 174 ++++++++++++++++++++++++++ scripts/banc/plafond.test.js | 207 +++++++++++++++++++++++++++++++ src/moteur/plafond.long.test.js | 137 +++++++++++++++++++- test/fixtures/banc/pessimiste.js | 78 ++++++++++++ 4 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 scripts/banc/plafond.js create mode 100644 scripts/banc/plafond.test.js create mode 100644 test/fixtures/banc/pessimiste.js diff --git a/scripts/banc/plafond.js b/scripts/banc/plafond.js new file mode 100644 index 0000000..31d934b --- /dev/null +++ b/scripts/banc/plafond.js @@ -0,0 +1,174 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le relevé du coût du plafond a priori (§ 17, point 4, § 19.10), sous node : +// pour chaque configuration, la médiane de la durée de plafondsAPriori sur +// l'instance normalisée, et celle de diagnostiquer sur la configuration, qui +// rappelle le plafond a priori dans ses sondes. L'horloge est reçue en +// paramètre : le script lancé passe performance.now, les épreuves une +// horloge factice. Le relevé ne porte que la clé, la forme et les médianes : +// ni date, ni nom d'hôte, ni chemin, ni utilisateur. +// +// Lancé, il mesure les configurations du banc et imprime le relevé en JSON, +// les médianes arrondies à la microseconde : +// +// node scripts/banc/plafond.js [répétitions] (défaut : 21) +import { realpathSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { configurationPessimiste } from '../../test/fixtures/banc/pessimiste.js'; +import { LIVREES } from '../../src/demo/livrees.js'; +import { normaliser } from '../../src/moteur/configuration.js'; +import { diagnostiquer } from '../../src/moteur/diagnostic.js'; +import { plafondsAPriori } from '../../src/moteur/plafond.js'; +import { analyser, configurationDepuisCharge } from '../../src/stockage/document.js'; + +// Les démonstrations livrées que le § 19.10 nomme : la grande et sa variante. +const DEMONSTRATIONS_MESUREES = ['grande', 'grande-sans-exception']; + +// Le nombre de répétitions du script lancé : impair, la médiane est une +// durée mesurée. +const REPETITIONS_PAR_DEFAUT = 21; + +/** + * Médiane d'une liste de nombres : l'élément du milieu de la liste triée, + * ou la moyenne des deux du milieu quand elle est de longueur paire. Ne + * modifie pas la liste. + * + * @param {number[]} valeurs + * @returns {number} + * @throws {RangeError} sur une liste vide + */ +export function mediane(valeurs) { + if (valeurs.length === 0) throw new RangeError('médiane d’une liste vide'); + const triees = [...valeurs].sort((a, b) => a - b); + const milieu = Math.floor(triees.length / 2); + return triees.length % 2 === 1 ? triees[milieu] : (triees[milieu - 1] + triees[milieu]) / 2; +} + +// Les durées de repetitions appels de mesure, chacune la différence de deux +// lectures de maintenant, l'une juste avant l'appel, l'autre juste après. +function durees(mesure, maintenant, repetitions) { + const relevees = []; + for (let i = 0; i < repetitions; i += 1) { + const debut = maintenant(); + mesure(); + relevees.push(maintenant() - debut); + } + return relevees; +} + +// Refuse les options avant toute mesure, dans l'ordre : configurations, +// répétitions, horloge, puis chaque entrée. +function exigerOptions({ configurations, maintenant, repetitions }) { + if (!Array.isArray(configurations)) throw new TypeError('configurations : une liste est attendue'); + if (configurations.length === 0) throw new RangeError('configurations : au moins une configuration'); + if (!Number.isInteger(repetitions) || repetitions < 1) { + throw new RangeError(`repetitions : un entier ≥ 1 est attendu, reçu ${String(repetitions)}`); + } + if (typeof maintenant !== 'function') throw new TypeError('maintenant : une fonction est attendue'); + const vues = new Set(); + for (const { cle } of configurations) { + if (typeof cle !== 'string' || cle === '') throw new TypeError('cle : une chaîne non vide est attendue'); + if (vues.has(cle)) throw new RangeError(`cle en double : ${cle}`); + vues.add(cle); + } +} + +/** + * Mesure le coût du plafond a priori sur chaque configuration, dans l'ordre + * reçu. Pour chacune : normaliser, hors mesure ; un appel de chauffe de + * plafondsAPriori et de diagnostiquer, hors mesure, sans lire l'horloge ; + * puis repetitions appels mesurés de plafondsAPriori(instance), puis + * repetitions de diagnostiquer(configuration). L'horloge se lit deux fois par + * appel mesuré, et jamais ailleurs. + * + * @param {Object} options + * @param {Array<{cle: string, configuration: import('../../src/moteur/types.js').Configuration}>} options.configurations + * au moins une ; des clés non vides et distinctes + * @param {() => number} options.maintenant l'horloge, en millisecondes + * @param {number} options.repetitions entier ≥ 1 + * @returns {Array<{cle: string, N: number, T: number, R: number, msPlafonds: number, msDiagnostic: number}>} + * N, T, R de l'instance normalisée ; les médianes des durées, en millisecondes + * @throws {TypeError} configurations qui n'est pas une liste, maintenant qui + * n'est pas une fonction, une clé vide ou qui n'est pas une chaîne + * @throws {RangeError} une liste vide, repetitions qui n'est pas un entier + * ≥ 1, une clé en double ; et ce que normaliser lève + */ +export function mesurerPlafondAPriori({ configurations, maintenant, repetitions }) { + exigerOptions({ configurations, maintenant, repetitions }); + return configurations.map(({ cle, configuration }) => { + const instance = normaliser(configuration); + plafondsAPriori(instance); + diagnostiquer(configuration); + const plafonds = durees(() => plafondsAPriori(instance), maintenant, repetitions); + const diagnostic = durees(() => diagnostiquer(configuration), maintenant, repetitions); + return { + cle, + N: instance.N, + T: instance.T, + R: instance.R, + msPlafonds: mediane(plafonds), + msDiagnostic: mediane(diagnostic), + }; + }); +} + +/** + * Les configurations du banc, dans l'ordre du relevé : la grande + * démonstration et sa variante, lues dans leur fichier livré (src/demo/ + * livrees.js) comme l'opérateur les ouvre, puis la configuration pessimiste + * (test/fixtures/banc/pessimiste.js). Des objets neufs à chaque appel. + * + * @returns {Array<{cle: string, configuration: import('../../src/moteur/types.js').Configuration}>} + */ +export function configurationsDuBanc() { + const livrees = DEMONSTRATIONS_MESUREES.map((cle) => ({ + cle, + configuration: configurationDepuisCharge(analyser(LIVREES.find((livree) => livree.cle === cle).texte).charge), + })); + return [...livrees, { cle: 'pessimiste', configuration: configurationPessimiste() }]; +} + +// Le nombre de répétitions lu en argument, ou null s'il n'est pas un entier +// décimal ≥ 1. +function repetitionsLues(argument) { + if (argument === undefined) return REPETITIONS_PAR_DEFAUT; + if (!/^[1-9]\d*$/.test(argument)) return null; + return Number(argument); +} + +// Lance le relevé et l'imprime ; rend le code de sortie : 0, ou 2 sur un +// argument refusé, sans rien imprimer sur la sortie standard. +function executer(argumentsRecus) { + const repetitions = repetitionsLues(argumentsRecus[0]); + if (repetitions === null || argumentsRecus.length > 1) { + process.stderr.write('usage : node scripts/banc/plafond.js [répétitions, entier ≥ 1]\n'); + return 2; + } + const releve = mesurerPlafondAPriori({ + configurations: configurationsDuBanc(), + maintenant: () => performance.now(), + repetitions, + }); + const arrondi = (duree) => Math.round(duree * 1000) / 1000; + const imprime = releve.map((entree) => ({ + ...entree, + msPlafonds: arrondi(entree.msPlafonds), + msDiagnostic: arrondi(entree.msDiagnostic), + })); + process.stdout.write(`${JSON.stringify(imprime, null, 2)}\n`); + return 0; +} + +// Vrai quand ce fichier est le script que Node a lancé, et non un module +// importé. +function estLanceDirectement() { + if (process.argv[1] === undefined) return false; + try { + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (estLanceDirectement()) process.exitCode = executer(process.argv.slice(2)); diff --git a/scripts/banc/plafond.test.js b/scripts/banc/plafond.test.js new file mode 100644 index 0000000..8e017bc --- /dev/null +++ b/scripts/banc/plafond.test.js @@ -0,0 +1,207 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du relevé du coût du plafond a priori (§ 17, point 4, § 19.10), +// sous une horloge factice : la durée d'un appel est la différence de deux +// lectures, et ce que l'horloge rend fixe chaque durée, donc chaque médiane, +// à la valeur exacte. Les configurations mesurées sont petites ; celles du +// banc ne se mesurent ici qu'une fois, pour leur forme. +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from '../../test/lanceur.js'; +import { FORME_PESSIMISTE } from '../../test/fixtures/banc/pessimiste.js'; +import { configurationsDuBanc, mediane, mesurerPlafondAPriori } from './plafond.js'; + +const SCRIPT = fileURLToPath(new URL('./plafond.js', import.meta.url)); + +// Une salle de tables de 3, une personne fixée à la table 1 au tour 1, les +// autres libres ; contraintes inactives. +function salle({ tables, personnes, tours }) { + return { + participants: Array.from({ length: personnes }, (_, i) => ({ id: i + 1, nom: `Ombrelle ${i + 1}`, appartenance: null })), + tables: Array.from({ length: tables }, (_, t) => ({ id: t + 1, numero: t + 1, capacite: 3 })), + tours, + reservations: [{ participant: 1, table: 1, portee: 'tour', tour: 1 }], + contraintes: { + separerAppartenances: false, + nouveauxVoisins: false, + nouvelleTable: false, + varierAppartenances: false, + }, + }; +} + +const PETITE = { cle: 'petite', configuration: salle({ tables: 4, personnes: 12, tours: 3 }) }; +const MOYENNE = { cle: 'moyenne', configuration: salle({ tables: 6, personnes: 17, tours: 2 }) }; + +// Horloge qui avance de pas à chaque lecture, en partant de 0 ; lectures +// compte les lectures. +function horlogeAPas(pas) { + const horloge = () => { + horloge.lectures += 1; + return (horloge.lectures - 1) * pas; + }; + horloge.lectures = 0; + return horloge; +} + +// Horloge qui rend, lecture après lecture, le début puis la fin de chaque +// durée de la liste, un écart de 1 000 entre la fin d'une durée et le début +// de la suivante ; lève au-delà, une lecture de trop étant une faute. +function horlogeScriptee(durees) { + const instants = []; + let t = 0; + for (const d of durees) { + instants.push(t, t + d); + t += d + 1000; + } + let rang = 0; + const horloge = () => { + if (rang >= instants.length) throw new Error(`lecture ${rang + 1} de l'horloge, ${instants.length} prévues`); + return instants[rang++]; + }; + horloge.restantes = () => instants.length - rang; + return horloge; +} + +// Une configuration piégée : toute lecture de l'une de ses clés lève une +// Error, qui n'est ni un TypeError ni un RangeError. +const PIEGEE = new Proxy( + {}, + { + get() { + throw new Error('configuration lue'); + }, + }, +); + +// Une configuration dont les lectures se comptent, et une horloge qui note, +// à chaque lecture, le compte des lectures de la configuration faites +// depuis la sienne précédente : entre les deux lectures de l'horloge qui +// bornent un appel, le compte dit si l'appel a lu la configuration. +function configurationEspionnee(configuration) { + const espion = { lectures: 0 }; + espion.configuration = new Proxy(configuration, { + get(cible, cle, recepteur) { + espion.lectures += 1; + return Reflect.get(cible, cle, recepteur); + }, + }); + espion.horloge = () => { + espion.vues.push(espion.lectures - espion.dernier); + espion.dernier = espion.lectures; + return espion.vues.length; + }; + espion.vues = []; + espion.dernier = 0; + return espion; +} + +describe('banc : la médiane', () => { + test("rang du milieu d'une liste impaire, moyenne des deux du milieu d'une liste paire, sans modifier la liste", () => { + const liste = [7, 1, 5]; + assert.equal(mediane(liste), 5); + assert.deepEqual(liste, [7, 1, 5]); + assert.equal(mediane([5, 1, 4, 2]), 3); + assert.equal(mediane([9]), 9); + assert.throws(() => mediane([]), RangeError); + }); +}); + +describe('banc : mesurerPlafondAPriori', () => { + test("sous une horloge qui avance d'un pas fixe à chaque lecture, chaque médiane vaut exactement le pas, et l'horloge se lit deux fois par appel mesuré, jamais pendant la chauffe", () => { + const horloge = horlogeAPas(0.25); + const releve = mesurerPlafondAPriori({ configurations: [PETITE, MOYENNE], maintenant: horloge, repetitions: 5 }); + assert.deepEqual(releve, [ + { cle: 'petite', N: 12, T: 4, R: 3, msPlafonds: 0.25, msDiagnostic: 0.25 }, + { cle: 'moyenne', N: 17, T: 6, R: 2, msPlafonds: 0.25, msDiagnostic: 0.25 }, + ]); + // Deux configurations, deux grandeurs, cinq appels mesurés, deux lectures. + assert.equal(horloge.lectures, 2 * 2 * 5 * 2); + }); + + test("les durées d'une configuration : ses répétitions du plafond a priori, puis celles du diagnostic ; chaque grandeur rend la médiane des siennes, impaire comme paire", () => { + const impaire = horlogeScriptee([7, 1, 5, 2, 9, 3]); + assert.deepEqual(mesurerPlafondAPriori({ configurations: [PETITE], maintenant: impaire, repetitions: 3 }), [ + { cle: 'petite', N: 12, T: 4, R: 3, msPlafonds: 5, msDiagnostic: 3 }, + ]); + assert.equal(impaire.restantes(), 0); + + const paire = horlogeScriptee([5, 1, 4, 2, 10, 6, 8, 1, 3, 3, 3, 3, 1, 2, 3, 4]); + assert.deepEqual( + mesurerPlafondAPriori({ configurations: [PETITE, MOYENNE], maintenant: paire, repetitions: 4 }), + [ + { cle: 'petite', N: 12, T: 4, R: 3, msPlafonds: 3, msDiagnostic: 7 }, + { cle: 'moyenne', N: 17, T: 6, R: 2, msPlafonds: 3, msDiagnostic: 2.5 }, + ], + ); + assert.equal(paire.restantes(), 0); + }); + + test("chaque appel mesuré du plafond a priori porte sur l'instance normalisée, sans relire la configuration ; chaque appel mesuré du diagnostic la lit", () => { + const espion = configurationEspionnee(PETITE.configuration); + mesurerPlafondAPriori({ + configurations: [{ cle: 'petite', configuration: espion.configuration }], + maintenant: espion.horloge, + repetitions: 3, + }); + // vues[2i + 1] : les lectures de la configuration pendant le i-ème appel + // mesuré ; les trois du plafond a priori, puis les trois du diagnostic. + const pendant = espion.vues.filter((_, rang) => rang % 2 === 1); + assert.equal(pendant.length, 6); + assert.deepEqual(pendant.slice(0, 3), [0, 0, 0]); + assert.ok(pendant.slice(3).every((lectures) => lectures > 0), `lectures pendant le diagnostic : ${pendant.slice(3)}`); + }); + + test('une liste de configurations vide ou absente, des répétitions non entières ou sous 1, une horloge absente ou une clé en double sont refusées avant toute lecture des configurations et de l’horloge', () => { + const horloge = horlogeAPas(1); + const piegee = { cle: 'piegee', configuration: PIEGEE }; + const mesurer = (options) => () => mesurerPlafondAPriori({ configurations: [piegee], maintenant: horloge, repetitions: 1, ...options }); + assert.throws(mesurer({ configurations: [] }), RangeError); + assert.throws(mesurer({ configurations: undefined }), TypeError); + for (const repetitions of [0, -1, 0.5, Number.NaN, '3', undefined]) { + assert.throws(mesurer({ repetitions }), RangeError, `repetitions ${String(repetitions)}`); + } + assert.throws(mesurer({ maintenant: undefined }), TypeError); + assert.throws(mesurer({ configurations: [piegee, { cle: 'piegee', configuration: PIEGEE }] }), RangeError); + assert.throws(mesurer({ configurations: [piegee, { cle: '', configuration: PIEGEE }] }), TypeError); + assert.equal(horloge.lectures, 0); + // Le piège lui-même : une mesure qui atteint la configuration lève Error. + assert.throws(mesurer({}), (erreur) => erreur.constructor === Error && erreur.message === 'configuration lue'); + }); + + test("les configurations du banc : la grande démonstration et sa variante telles qu'elles sont livrées, puis la pessimiste ; une mesure les parcourt toutes", () => { + const configurations = configurationsDuBanc(); + assert.deepEqual( + configurations.map(({ cle }) => cle), + ['grande', 'grande-sans-exception', 'pessimiste'], + ); + const releve = mesurerPlafondAPriori({ configurations, maintenant: horlogeAPas(1), repetitions: 1 }); + assert.deepEqual( + releve.map(({ cle, N, T, R }) => ({ cle, N, T, R })), + [ + { cle: 'grande', N: 260, T: 33, R: 4 }, + { cle: 'grande-sans-exception', N: 260, T: 33, R: 4 }, + { cle: 'pessimiste', N: FORME_PESSIMISTE.personnes, T: FORME_PESSIMISTE.tables, R: FORME_PESSIMISTE.tours }, + ], + ); + }); +}); + +describe('banc : le script lancé', () => { + test('node scripts/banc/plafond.js 1 imprime le relevé en JSON, une entrée par configuration du banc, sans autre clé ; un nombre de répétitions invalide sort en 2 sans rien imprimer', () => { + const lance = spawnSync(process.execPath, [SCRIPT, '1'], { encoding: 'utf8' }); + assert.equal(lance.status, 0, lance.stderr); + const releve = JSON.parse(lance.stdout); + assert.deepEqual(releve.map(({ cle }) => cle), ['grande', 'grande-sans-exception', 'pessimiste']); + for (const entree of releve) { + assert.deepEqual(Object.keys(entree), ['cle', 'N', 'T', 'R', 'msPlafonds', 'msDiagnostic']); + assert.ok(entree.msPlafonds >= 0 && entree.msDiagnostic >= 0); + } + const refuse = spawnSync(process.execPath, [SCRIPT, 'zero'], { encoding: 'utf8' }); + assert.equal(refuse.status, 2); + assert.equal(refuse.stdout, ''); + assert.match(refuse.stderr, /répétitions/); + }); +}); diff --git a/src/moteur/plafond.long.test.js b/src/moteur/plafond.long.test.js index 2197edc..2cce7b0 100644 --- a/src/moteur/plafond.long.test.js +++ b/src/moteur/plafond.long.test.js @@ -5,11 +5,16 @@ // programmation dynamique et de l'énumération sur une grille plus large que // celle de la série node, jusqu'à 8 tables et 5 tours ; la forme de la grande // démonstration et sa variante, arbitrées par l'énumération ; la limite de -// l'énumération à sa frontière exacte. +// l'énumération à sa frontière exacte ; le coût du plafond a priori et du +// diagnostic sur la grande démonstration, sa variante et la configuration +// pessimiste, chacun sous un seuil tiré de sa référence (§ 17, point 4). import assert from 'node:assert/strict'; import fc from 'fast-check'; +import { configurationPessimiste, FORME_PESSIMISTE } from '../../test/fixtures/banc/pessimiste.js'; import { describe, test } from '../../test/lanceur.js'; +import { CATALOGUE } from '../demo/catalogue.js'; import { LIBRE, STATUT, normaliser } from './configuration.js'; +import { diagnostiquer } from './diagnostic.js'; import { ErreurConfiguration } from './erreurs.js'; import { plafondsAPriori, plafondsAPrioriParEnumeration } from './plafond.js'; @@ -197,3 +202,133 @@ describe("plafond a priori : la programmation dynamique contre l'énumération, ); }); }); + +// Le coût du plafond a priori (§ 17, point 4). L'application le calcule à +// chaque évaluation (src/application/evaluation.js) et diagnostiquer le +// rappelle dans ses sondes, dans le fil de l'interface : ces épreuves +// empêchent une régression de rendre nécessaire le chemin « inconnu » que le +// moteur garde (§ 12.10.9, § 5.7). Le relevé de la mesure est celui du banc +// (scripts/banc/plafond.js) ; ici, une garde. +// +// L'échantillonnage suit celui de src/stockage/depot.long.test.js : après un +// appel de chauffe, ESSAIS appels mesurés, puis d'autres tant que le plus +// court dépasse le seuil, jusqu'à ESSAIS_AU_PLUS appels ou la fin de +// FENETRE_MESURE_MS, ce qui vient d'abord. Le seuil porte sur le plus court : +// le minimum écarte ce qu'une machine occupée ajoute à un appel, une +// régression allonge chacun. +const ESSAIS = 5; +const ESSAIS_AU_PLUS = 25; +const FENETRE_MESURE_MS = 10_000; + +// Les références, en millisecondes, par configuration : le plus court des ESSAIS appels que +// mesurent ces épreuves, le fichier lancé seul, le plus grand de plusieurs +// lancements arrondi au-dessus à deux chiffres significatifs, sur un +// processeur x86-64 de bureau de génération Zen 3, seize cœurs virtuels sous +// KVM, sous Linux et Node 26. plafonds mesure plafondsAPriori sur l'instance +// normalisée ; diagnostic, diagnostiquer sur la configuration, normalisation +// et sondes comprises. +// +// MARGE, le facteur qui tire chaque seuil de sa référence, couvre quatre +// séries node-long lancées ensemble sur la machine des références, comme +// celle de depot.long.test.js. Elle reste assez basse pour que retirer la +// mémoïsation par signature de plafondsAPriori dépasse les seuils de la +// grande démonstration et de sa variante, où des centaines de personnes +// partagent une signature. La pessimiste n'a que des signatures distinctes : +// la mémoïsation n'y épargne rien, et son seuil garde la programmation +// dynamique elle-même, 260 fois sur 8 tours libres. +// +// Propriétaire (§ 14.1) : la série node-long, où ces épreuves relèvent la +// mesure à chaque exécution et la nomment quand elle dépasse son seuil. +const REFERENCES_MS = Object.freeze([ + Object.freeze({ cle: 'grande', plafonds: 0.19, diagnostic: 1.7 }), + Object.freeze({ cle: 'grande-sans-exception', plafonds: 0.085, diagnostic: 0.94 }), + Object.freeze({ cle: 'pessimiste', plafonds: 13, diagnostic: 8.8 }), +]); +const MARGE = 4; + +const croissant = (a, b) => a - b; +const ms = (duree) => `${duree.toFixed(3).replace('.', ',')} ms`; + +// La mesure échantillonnée d'un appel : faire s'exécute une fois pour +// chauffer, puis ESSAIS fois mesuré, puis tant que le plus court dépasse +// seuil, jusqu'à ESSAIS_AU_PLUS mesures ou la fin de FENETRE_MESURE_MS, +// comptée depuis la première. Rend les durées, dans l'ordre des appels, et +// ce qui a clos une mesure restée au-delà du seuil. +function mesurer(faire, seuil) { + faire(); + const durees = []; + const debut = performance.now(); + const ouverte = () => performance.now() - debut < FENETRE_MESURE_MS; + while (durees.length < ESSAIS || (Math.min(...durees) > seuil && durees.length < ESSAIS_AU_PLUS && ouverte())) { + const avant = performance.now(); + faire(); + durees.push(performance.now() - avant); + } + const arret = + durees.length >= ESSAIS_AU_PLUS + ? `plafond de ${ESSAIS_AU_PLUS} mesures atteint` + : `fenêtre de ${FENETRE_MESURE_MS / 1000} s écoulée`; + return { durees, arret }; +} + +// Le message d'un seuil dépassé : le minimum, le nombre de mesures, leur +// médiane et la plus longue, ce qui a clos la mesure, le seuil et la +// référence dont MARGE le tire. +function depasse(quoi, { durees, arret }, seuil, reference) { + const triees = [...durees].sort(croissant); + return ( + `${quoi} prend ${ms(triees[0])} au minimum de ${durees.length} mesures ` + + `(médiane ${ms(triees[Math.floor((triees.length - 1) / 2)])}, la plus longue ${ms(triees.at(-1))} ; ` + + `${arret}), au-delà du seuil de ${ms(seuil)} (§ 17, point 4) : référence ${ms(reference)}, MARGE ${MARGE}` + ); +} + +// La configuration de clé donnée : une démonstration du catalogue, ou la +// pessimiste. +function configurationDe(cle) { + if (cle === 'pessimiste') return configurationPessimiste(); + return CATALOGUE.find((entree) => entree.cle === cle).construire(); +} + +describe("plafond a priori : le coût, sous un seuil tiré d'une référence (§ 17, point 4)", () => { + test('la configuration pessimiste : 260 personnes sur 33 tables de 8 et 10 tours, deux tours fixés chacune, 260 signatures distinctes', () => { + const instance = normaliser(configurationPessimiste()); + assert.deepEqual( + { N: instance.N, T: instance.T, R: instance.R }, + { N: FORME_PESSIMISTE.personnes, T: FORME_PESSIMISTE.tables, R: FORME_PESSIMISTE.tours }, + ); + assert.deepEqual([...instance.capacite], Array(FORME_PESSIMISTE.tables).fill(FORME_PESSIMISTE.capacite)); + // La signature est le statut, l'ancrage et le multiensemble des tables + // fixées : sans ancré, chaque personne partiellement fixée, des + // multiensembles distincts font des signatures distinctes. + assert.equal(instance.k, 0); + const multiensembles = new Set(); + for (let p = 0; p < instance.N; p += 1) { + assert.equal(instance.statut[p], STATUT.PARTIELLEMENT_FIXE); + const fixees = [...instance.fixe.subarray(p * instance.R, (p + 1) * instance.R)].filter((t) => t !== LIBRE); + assert.equal(fixees.length, 2); + multiensembles.add(fixees.sort(croissant).join(',')); + } + assert.equal(multiensembles.size, FORME_PESSIMISTE.personnes); + }); + + for (const reference of REFERENCES_MS) { + const { cle } = reference; + const seuilPlafonds = reference.plafonds * MARGE; + const seuilDiagnostic = reference.diagnostic * MARGE; + test(`${cle} : plafondsAPriori en au plus ${ms(seuilPlafonds)}, diagnostiquer en au plus ${ms(seuilDiagnostic)}, chacun au minimum de ${ESSAIS} à ${ESSAIS_AU_PLUS} mesures après un appel de chauffe`, () => { + const configuration = configurationDe(cle); + const instance = normaliser(configuration); + const plafonds = mesurer(() => plafondsAPriori(instance), seuilPlafonds); + const diagnostic = mesurer(() => diagnostiquer(configuration), seuilDiagnostic); + assert.ok( + Math.min(...plafonds.durees) <= seuilPlafonds, + depasse(`plafondsAPriori sur ${cle}`, plafonds, seuilPlafonds, reference.plafonds), + ); + assert.ok( + Math.min(...diagnostic.durees) <= seuilDiagnostic, + depasse(`diagnostiquer sur ${cle}`, diagnostic, seuilDiagnostic, reference.diagnostic), + ); + }); + } +}); diff --git a/test/fixtures/banc/pessimiste.js b/test/fixtures/banc/pessimiste.js new file mode 100644 index 0000000..f88840f --- /dev/null +++ b/test/fixtures/banc/pessimiste.js @@ -0,0 +1,78 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La configuration pessimiste du coût du plafond a priori (§ 17, point 4) : +// la salle de la variante sans exception, 33 tables de 8, et 260 personnes +// qui portent chacune deux tours fixés, sur 10 tours. Le plafond a priori se +// mémoïse par signature — statut, ancrage, multiensemble des tables fixées +// (src/moteur/plafond.js) — : ici chaque personne a la sienne, et la +// programmation dynamique se refait 260 fois, chacune sur 8 tours libres. +// C'est le cas que la mémoïsation ne rattrape pas, là où les démonstrations +// livrées n'ont que quelques signatures. +// +// La personne de rang i, de 0 à 259, est fixée à la table a = i mod 33 au +// tour 1 + (i mod 10), et à la table b = (a + d) mod 33 au tour +// 1 + ((i + 5) mod 10), où d = 1 + ⌊i / 33⌋ va de 1 à 8. La paire {a, b} +// détermine (a, d) tant que d ≤ 16 : les 260 paires sont distinctes, et les +// 260 signatures aussi. Les deux tours d'une personne diffèrent de 5. Aucune +// table ne reçoit plus de 8 personnes fixées au même tour : le moteur la +// normalise sans SURRESERVATION. Personne n'a d'appartenance, et les quatre +// contraintes sont actives, comme dans les démonstrations. +// +// Les noms sont forgés : le produit de 20 prénoms et de 13 noms, hors des +// réservoirs de src/demo/noms.js. + +/** Les nombres de la configuration, que ses épreuves affirment. */ +export const FORME_PESSIMISTE = Object.freeze({ personnes: 260, tables: 33, capacite: 8, tours: 10 }); + +const PRENOMS = [ + 'Iris', 'Théo', 'Ondine', 'Basile', 'Capucine', 'Aurèle', 'Mélisande', 'Firmin', 'Clélia', 'Anselme', + 'Bérénice', 'Cyprien', 'Daphné', 'Eudes', 'Flavie', 'Gaspard', 'Hortense', 'Ignace', 'Jacinthe', 'Lisandre', +]; +const NOMS = [ + 'Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Tourmaline', 'Arrosoir', 'Cerfeuil', + 'Bruyère', 'Galet', 'Mistral', 'Sarriette', 'Quenouille', 'Vermeil', +]; + +/** + * La configuration pessimiste, sous la forme que le moteur reçoit + * (src/moteur/types.js › Configuration) : un objet neuf à chaque appel. + * Participants d'identifiants 1 à 260 ; tables d'identifiants et de numéros + * 1 à 33 ; réservations de portée « tour », deux par personne, dans l'ordre + * des personnes, la plus petite des deux tours d'abord. + * + * @returns {import('../../../src/moteur/types.js').Configuration} + */ +export function configurationPessimiste() { + const { personnes, tables, capacite, tours } = FORME_PESSIMISTE; + const participants = []; + const reservations = []; + for (let i = 0; i < personnes; i += 1) { + const id = i + 1; + participants.push({ + id, + nom: NOMS[i % NOMS.length], + prenom: PRENOMS[Math.floor(i / NOMS.length)], + appartenance: null, + }); + const a = i % tables; + const b = (a + 1 + Math.floor(i / tables)) % tables; + const fixations = [ + { table: a + 1, tour: 1 + (i % tours) }, + { table: b + 1, tour: 1 + ((i + 5) % tours) }, + ].sort((x, y) => x.tour - y.tour); + for (const { table, tour } of fixations) reservations.push({ participant: id, table, portee: 'tour', tour }); + } + return { + participants, + tables: Array.from({ length: tables }, (_, t) => ({ id: t + 1, numero: t + 1, capacite })), + tours, + reservations, + contraintes: { + separerAppartenances: true, + nouveauxVoisins: true, + nouvelleTable: true, + varierAppartenances: true, + }, + }; +}