[ADD] engine: end-to-end runs on the demonstrations, measured thresholds

The whole chain — search, both verifiers, measure, ceilings, ranking —
runs end to end on the demonstrations. At 500 000 moves the large one
reaches its proven floor: largest gap 1, no collision, no repeat. Its
quality thresholds were set after measurement, with the measurement in
the comment, never guessed.

Checked: small at 8 for all and certified; conflict variant spreads its 4
collisions over 4 pairs; large at gap 1 with 99 imposed returns; the
ranking holds when its order differs from id order.

--- FR ---

[ADD] moteur : la chaîne de bout en bout, seuils posés après mesure

Toute la chaîne — recherche, les deux vérificateurs, mesure, plafonds,
classement — tourne de bout en bout sur les démonstrations. À 500 000
mouvements, la grande atteint son plancher prouvé : écart maximal 1,
aucune collision, aucune répétition. Ses seuils de qualité sont posés
après mesure, la mesure en commentaire, jamais de tête.

Vérifié : petite à 8 pour tous et certifiée ; variante en conflit à 4
collisions sur 4 paires ; grande à l'écart 1 avec 99 retours imposés ; le
classement tient quand son ordre diffère de celui des identifiants.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-05 22:52:30 -04:00
parent f8b3e8b0a9
commit c7cd5f9142
2 changed files with 419 additions and 0 deletions

View file

@ -0,0 +1,202 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuve d'intégration lourde du moteur, sur la grande démonstration
// (§ 5.5, § 12.10.4, § 15.1). Trois propositions traversent la chaîne
// d'integration.test.js, de rechercher au certificat. La configuration est
// exactement tendue et impose ses planchers, que le diagnostic chiffre avant
// la recherche : 99 retours imposés par les 33 ancrages ; un écart
// d'itinéraire maximal d'au moins 1, porté par au moins 24 mobiles ; un
// certificat « minimum atteint » hors d'atteinte. Les seuils de qualité sont
// posés après mesure, la mesure en commentaire (§ 14.1).
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE } from '../demo/catalogue.js';
import { indexerPlan, normaliser } from './configuration.js';
import { diagnostiquer } from './diagnostic.js';
import { mesurer } from './indicateurs.js';
import {
ecartsAuPlafondAPriori,
ecartsItineraire,
minimumAtteint,
troisChiffres,
} from './manque.js';
import { plafondsAPriori, plafondsRealises } from './plafond.js';
import { rechercher } from './recherche.js';
import { verifierIndicateurs, verifierInvariants } from './verification.js';
// Statut d'un ancré dans instance.statut (§ 4.2) ; les autres sont mobiles.
const ANCRE = 2;
// Compte d'arrêt choisi par la mesure. À 1 000 000 mouvements, chacune des
// trois propositions atteint, sur chaque seuil ci-dessous, la meilleure
// valeur que la configuration permette, sans rencontre répétée ni retour
// choisi. Avec cette graine, chaque seuil tient à partir de son propre
// compte :
// - rencontres minimales 24, sur tous comme sur les ancrés : déjà à
// 100 000 ;
// - écart au plafond a priori maximal 1, et rencontres minimales 27 des
// mobiles : dès 250 000 ; à 200 000, ils valent 2 et 26 ;
// - manque maximal 0, aucun retour choisi : dès 350 000 ; à 300 000, une
// proposition garde un manque de 1 et un retour choisi ;
// - aucune collision : dès 450 000 ; à 400 000, les trois propositions en
// portent 8, 1 et 2.
// 1 000 000 vaut ainsi 4 fois le compte où tiennent l'écart au plafond a
// priori et les mobiles, 2,9 fois celui du manque et des retours choisis,
// 2,2 fois celui des collisions. Sur les graines 1 à 8, les vingt-quatre
// propositions tiennent l'écart au plafond a priori et les mobiles dès
// 250 000, le manque et les retours choisis dès 350 000, les collisions dès
// 500 000 ; à 450 000, deux en portent encore 1 et 5.
const REGLAGES = Object.freeze({ graine: 314_159, arret: 1_000_000, nombre: 3 });
// Seuils de qualité, posés après mesure ; chacun est la meilleure valeur
// que la configuration permette.
//
// Rencontres minimales : 24 mesuré à chacune des trois propositions. C'est
// le plafond a priori des quatre animateurs des tables de 7, qu'aucune
// proposition ne dépasse (§ 15.1) : le minimum global ne peut pas le
// dépasser, et l'épreuve exige qu'il l'atteigne. Les deux lignes
// secondaires du § 5.4 se lisent à part : 24 chez les ancrés, que ces
// quatre animateurs gouvernent ; 27 chez les mobiles. Un mobile assis une
// fois à une table de 7 rencontre 27 personnes au plus, une de moins que
// son plafond a priori de 28, et au moins 24 mobiles y passent (§ 15.1) ;
// l'écart au plafond a priori maximal de 1 interdit à chacun d'en
// rencontrer moins. Un agrégat « tous » qui écarterait les ancrés vaudrait
// 27 (§ 5.4).
const RENCONTRES_MIN = 24;
const RENCONTRES_MIN_ANCRES = 24;
const RENCONTRES_MIN_MOBILES = 27;
// Écart au plafond a priori maximal : 1 mesuré à chacune des trois
// propositions. Il ne descend pas sous l'écart d'itinéraire maximal, dont le
// plancher vaut 1 (§ 12.10.4) : ce seuil aussi est la meilleure valeur
// possible.
const ECART_AU_PLAFOND_A_PRIORI_MAX = 1;
// Collisions cumulées : 0 mesuré à chacune des trois propositions, la
// meilleure valeur possible ; le diagnostic n'impose à cette configuration
// aucun plancher de collisions. Une recherche qui ne sépare pas les
// appartenances en laisse de 14 à 20 à chaque proposition.
const COLLISIONS_CUMULEES_MAX = 0;
// Plus grand élément d'une liste non vide.
const plusGrand = (valeurs) => valeurs.reduce((plus, valeur) => Math.max(plus, valeur));
// La génération et ses mesures, calculées au premier appel puis partagées
// par les épreuves du fichier : la recherche en est le coût. Chaque
// proposition porte la liste de ses violations ; ses mesures, ses plafonds
// réalisés, ses trois chiffres et ses écarts d'itinéraire ne sont calculés
// que pour un plan sans violation. indexerPlan n'examine ni capacités ni
// réservations et lève à la première faute de forme, là où le vérificateur
// les nomme toutes.
let generation = null;
function genererGrande() {
if (generation !== null) return generation;
const configuration = CATALOGUE.find(({ cle }) => cle === 'grande').construire();
const instance = normaliser(configuration);
const aPriori = plafondsAPriori(instance);
const propositions = rechercher(configuration, REGLAGES).map(({ id, plan }) => {
const violations = verifierInvariants(instance, plan);
if (violations.length > 0) return { id, violations };
const tableDe = indexerPlan(instance, plan);
const mesures = mesurer(instance, tableDe);
const realises = plafondsRealises(instance, tableDe);
return {
id,
violations,
mesures,
realises,
chiffres: troisChiffres(instance, mesures, aPriori, realises),
ecartsItineraire: ecartsItineraire(aPriori, realises),
};
});
generation = { instance, aPriori, diagnostic: diagnostiquer(configuration), propositions };
return generation;
}
// Les propositions de la génération, chacune jugée par verifierInvariants
// avant toute lecture de ses mesures. Un parcours vide ne prouverait rien
// (§ 14.2).
function propositionsValides() {
const { propositions } = genererGrande();
assert.equal(propositions.length, REGLAGES.nombre);
for (const { id, violations } of propositions) {
assert.deepEqual(violations, [], `proposition ${id}`);
}
return propositions;
}
describe('intégration : la grande démonstration de bout en bout (§ 5.5, § 12.10.4, § 15.1)', () => {
test('chaque proposition tient invariants et indicateurs, porte les 99 retours imposés du diagnostic et aucun retour choisi', () => {
const { aPriori, diagnostic } = genererGrande();
// 33 ancrages de 4 tours, 3 retours chacun : attribués aux réservations,
// les mêmes pour toute proposition (§ 5.4).
assert.equal(diagnostic.retoursImposes, 99);
for (const { id, mesures, realises } of propositionsValides()) {
assert.deepEqual(verifierIndicateurs(mesures, aPriori, realises), [], `proposition ${id}`);
assert.equal(mesures.totalRetoursImposes, diagnostic.retoursImposes, `proposition ${id}`);
// Retours choisis : 0 mesuré à chacune des trois propositions, la
// meilleure valeur possible ; avec cette graine, dès 350 000
// mouvements (REGLAGES). Les deux comptes restent séparés, chacun à sa
// valeur : leur somme afficherait 99 retours choisis à chaque
// proposition, une constante où la différence entre propositions ne se
// lit plus (§ 5.4, § 14.10).
assert.equal(mesures.totalRetoursChoisis, 0, `proposition ${id}`);
}
});
test("le plancher du diagnostic tient : écart d'itinéraire maximal ≥ 1, au moins 24 mobiles sous leur plafond a priori", () => {
const { instance, diagnostic } = genererGrande();
// Les tables de 7 offrent 96 sièges-tours mobiles, qu'un mobile n'occupe
// que 4 fois au plus : au moins 24 mobiles y passent, et chacun y perd au
// moins 1 sur son plafond a priori de 28 avant toute rencontre, son écart
// d'itinéraire (§ 12.10.4). Sans ce plancher écrit, les comparaisons
// ci-dessous n'éprouveraient rien.
assert.deepEqual(diagnostic.ecartItineraire, { plancher: 1, mobilesAuMoins: 24 });
const { plancher, mobilesAuMoins } = diagnostic.ecartItineraire;
for (const { id, chiffres, ecartsItineraire: ecarts } of propositionsValides()) {
assert.ok(
chiffres.ecartItineraireMax.tous >= plancher,
`proposition ${id} : écart d'itinéraire maximal ${chiffres.ecartItineraireMax.tous}, plancher ${plancher}`,
);
let mobilesSous = 0;
for (let p = 0; p < instance.N; p += 1) {
if (instance.statut[p] !== ANCRE && ecarts[p] >= plancher) mobilesSous += 1;
}
assert.ok(
mobilesSous >= mobilesAuMoins,
`proposition ${id} : ${mobilesSous} mobiles sous leur plafond a priori, au moins ${mobilesAuMoins} attendus`,
);
}
});
test('jamais « minimum atteint » (§ 15.1), même quand chacun atteint son plafond réalisé', () => {
const { aPriori } = genererGrande();
for (const { id, mesures, chiffres } of propositionsValides()) {
// Manque maximal, seuil posé après mesure : 0 à chacune des trois
// propositions, sur les 260 personnes. Chacun rencontre tout ce que son
// itinéraire permet ; un certificat lu sur le seul manque serait
// décerné, et il ne l'est pas : il se lit contre le plafond a priori
// (§ 5.5).
assert.equal(chiffres.manqueMax.tous, 0, `proposition ${id}`);
assert.equal(minimumAtteint(mesures, aPriori), false, `proposition ${id}`);
}
});
test('seuils de qualité posés après mesure : rencontres minimales et leurs lignes secondaires, écart au plafond a priori maximal, collisions cumulées', () => {
const { aPriori } = genererGrande();
for (const { id, mesures } of propositionsValides()) {
const { tous, ancres, mobiles } = mesures.aggRencontres;
assert.equal(tous.min, RENCONTRES_MIN, `proposition ${id} : rencontres minimales`);
assert.equal(ancres.min, RENCONTRES_MIN_ANCRES, `proposition ${id} : rencontres minimales des ancrés`);
assert.equal(mobiles.min, RENCONTRES_MIN_MOBILES, `proposition ${id} : rencontres minimales des mobiles`);
const ecartMax = plusGrand(ecartsAuPlafondAPriori(mesures, aPriori));
assert.ok(
ecartMax <= ECART_AU_PLAFOND_A_PRIORI_MAX,
`proposition ${id} : écart au plafond a priori maximal ${ecartMax}, seuil ${ECART_AU_PLAFOND_A_PRIORI_MAX}`,
);
assert.ok(
mesures.collisionsCumulees <= COLLISIONS_CUMULEES_MAX,
`proposition ${id} : ${mesures.collisionsCumulees} collisions cumulées, seuil ${COLLISIONS_CUMULEES_MAX}`,
);
}
});
});

View file

@ -0,0 +1,217 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuve d'intégration du moteur, de la configuration au classement, sur la
// petite démonstration et sa variante conflit (§ 5.5, § 5.6, § 5.7,
// § 15.3). Chaque proposition de rechercher traverse la chaîne entière :
// verifierInvariants juge son plan ; mesurer et plafondsRealises le
// mesurent ; verifierIndicateurs éprouve la ligne de chacun contre le
// plafond a priori ; classer ordonne les propositions, et minimumAtteint lit
// le certificat de la première ; diagnostiquer fournit les planchers que la
// meilleure respecte. Chaque quantité vient de son module (§ 13.2) :
// l'épreuve n'en recalcule aucune.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js';
import { CRITERES, classer } from './classement.js';
import { indexerPlan, normaliser } from './configuration.js';
import { diagnostiquer } from './diagnostic.js';
import { mesurer } from './indicateurs.js';
import { minimumAtteint } from './manque.js';
import { plafondsAPriori, plafondsRealises } from './plafond.js';
import { rechercher } from './recherche.js';
import { verifierIndicateurs, verifierInvariants } from './verification.js';
// Réglages de la génération : une graine fixée, écrite ici (§ 15.5, point 2).
// À 200 000 mouvements, avec cette graine, les trois propositions de chaque
// démonstration rencontrent 8 pour les douze, sans rencontre répétée ni
// retour choisi, et ne portent que les collisions que le diagnostic impose :
// aucune dans la petite démonstration, 4 dans sa variante. Les assertions
// des épreuves tiennent avec cette graine dès 80 000 mouvements : 200 000
// leur laisse une marge de 2,5. Sur les graines 1 à 64, elles tiennent
// toutes à 200 000 ; à 150 000, deux graines en font échouer.
const REGLAGES = Object.freeze({ graine: 314_159, arret: 200_000, nombre: 3 });
// La même génération, arrêtée au premier mouvement. Aucune de ses
// propositions n'atteint son plafond a priori : avec cette graine, leurs
// écarts au plafond a priori maximaux valent 2, 3 et 3, et sur les graines
// 1 à 64, aucune proposition brève ne l'atteint non plus.
const REGLAGES_BREVES = Object.freeze({ ...REGLAGES, arret: 1 });
// Configuration neuve d'une démonstration du catalogue.
const demo = (cle) => CATALOGUE.find((entree) => entree.cle === cle).construire();
// Génère les propositions d'une configuration et les fait traverser la
// chaîne. Chaque plan est jugé par verifierInvariants avant d'être indexé :
// indexerPlan n'examine ni capacités ni réservations et lève à la première
// faute de forme, là où le vérificateur les nomme toutes. Vient ensuite la
// ligne N − 1 ≥ a priori ≥ réalisé ≥ rencontres de chacun. decalage
// s'ajoute aux identifiants que rend rechercher, comme l'appelant qui
// accumule des générations donne à chaque proposition un identifiant unique
// (§ 5.7). Rend le plafond a priori de chacun, et les propositions dans
// l'ordre de leurs identifiants, avec leurs mesures.
function generer(configuration, etiquette, reglages, decalage = 0) {
const instance = normaliser(configuration);
const aPriori = plafondsAPriori(instance);
const propositions = rechercher(configuration, reglages).map(({ id, plan }) => {
const nom = `${etiquette}, proposition ${id + decalage}`;
assert.deepEqual(verifierInvariants(instance, plan), [], nom);
const tableDe = indexerPlan(instance, plan);
const mesures = mesurer(instance, tableDe);
const realises = plafondsRealises(instance, tableDe);
assert.deepEqual(verifierIndicateurs(mesures, aPriori, realises), [], nom);
return { id: id + decalage, mesures };
});
// Un parcours vide ne prouverait rien (§ 14.2).
assert.equal(propositions.length, reglages.nombre, etiquette);
return { aPriori, propositions };
}
// Le classement de propositions mesurées sur une même configuration, et
// celle qu'il met en tête.
function classerPropositions({ aPriori, propositions }) {
const classement = classer(
propositions.map(({ id, mesures }) => ({ id, mesures, plafondsAPriori: aPriori })),
);
const premiere = propositions.find(({ id }) => id === classement.ordre[0]);
return { classement, premiere };
}
// La génération de la variante conflit aux réglages de l'épreuve, calculée
// au premier appel puis partagée par les deux épreuves qui la lisent : la
// recherche en est le coût.
let variante = null;
function genererVariante() {
variante ??= generer(demo('petite-conflit'), 'petite-conflit', REGLAGES);
return variante;
}
// Le plan écrit du § 15.3 fait visiter les quatre tables à chacun : aucun
// retour choisi. Le classement ne lit pas les retours (§ 5.7) : entre
// propositions égales sur ses cinq critères, l'identifiant désigne la
// première, qu'elle garde des retours ou non. L'épreuve exige donc le zéro
// d'une proposition au moins, la preuve que la recherche tient compte de
// nouvelleTable.
function exigerUneSansRetourChoisi(etiquette, propositions) {
const retours = propositions.map(({ mesures }) => mesures.totalRetoursChoisis);
assert.ok(retours.includes(0), `${etiquette} : retours choisis par proposition ${retours.join(', ')}`);
}
describe('intégration : de la configuration au classement (§ 5.5, § 5.7, § 15.3)', () => {
test('petite : chaque proposition tient invariants et indicateurs et ne porte aucune collision, une au moins aucun retour choisi ; la première du classement porte « minimum atteint »', () => {
const generation = generer(demo('petite'), 'petite', REGLAGES);
const { classement, premiere } = classerPropositions(generation);
// Le plafond a priori est connu : aucun critère n'est sauté, et le
// certificat se lit contre lui (§ 5.5).
assert.deepEqual(classement.criteresAppliques, [...CRITERES]);
assert.equal(classement.critereSaute, null);
assert.equal(minimumAtteint(premiere.mesures, generation.aPriori), true);
// Le certificat ne lit que les rencontres : il se décerne aussi à un plan
// qui réunit des collègues. Chaque appartenance de quatre membres peut
// se répartir sur les quatre tables à chaque tour, comme dans le plan
// écrit du § 15.3 : aucune proposition ne réunit de collègues, et le
// compteur de collisions se mesure à zéro (§ 15.3).
for (const { id, mesures } of generation.propositions) {
assert.equal(mesures.collisionsCumulees, 0, `proposition ${id}`);
}
exigerUneSansRetourChoisi('petite', generation.propositions);
});
test('petite-conflit : chaque proposition porte les 4 collisions du plancher du diagnostic, une au moins aucun retour choisi ; la meilleure respecte les planchers, rencontre 8 pour les douze, sur 4 paires, excédent 0', () => {
const configuration = demo('petite-conflit');
// Le groupe de cinq ne se répartit pas sur quatre tables : au moins une
// collision par tour, 4 cumulées sur les 4 tours, portées par une paire
// distincte au moins (§ 15.3). Le diagnostic les chiffre ; une liste vide
// laisserait la boucle ci-dessous sans rien éprouver.
const { collisions } = diagnostiquer(configuration);
assert.deepEqual(
collisions.map(({ plancherCumulees, plancherPairesDistinctes }) => [
plancherCumulees,
plancherPairesDistinctes,
]),
[[4, 1]],
);
const generation = genererVariante();
const { premiere } = classerPropositions(generation);
// Tout plan valide respecte ces planchers : ils éprouvent l'accord du
// diagnostic et de la mesure, non la recherche.
for (const plancher of collisions) {
const mesure = premiere.mesures.parGroupe.find(({ groupe }) => groupe === plancher.groupe);
assert.ok(
mesure.collisionsCumulees >= plancher.plancherCumulees,
`${plancher.groupe} : ${mesure.collisionsCumulees} collisions cumulées, plancher ${plancher.plancherCumulees}`,
);
assert.ok(
mesure.pairesDistinctes >= plancher.plancherPairesDistinctes,
`${plancher.groupe} : ${mesure.pairesDistinctes} paires distinctes, plancher ${plancher.plancherPairesDistinctes}`,
);
}
assert.deepEqual(premiere.mesures.rencontres, Array(12).fill(8));
// La recherche se juge à ses collisions : chaque proposition atteint le
// plancher des collisions cumulées sans le dépasser. La meilleure les
// porte sur 4 paires distinctes, comme le plan écrit du § 15.3 (épreuve
// suivante).
for (const { id, mesures } of generation.propositions) {
assert.equal(mesures.collisionsCumulees, collisions[0].plancherCumulees, `proposition ${id}`);
}
assert.equal(premiere.mesures.pairesDistinctes, 4);
// Deux voisins par tour, quatre tours : qui rencontre 8 personnes n'en
// revoit aucune. Chaque paire se réunit donc une fois au plus, et
// l'excédent 0 découle de 8 pour les douze. Il éprouve mesurer, non la
// recherche.
assert.equal(premiere.mesures.excedentCollisions, 0);
exigerUneSansRetourChoisi('petite-conflit', generation.propositions);
});
test('le plan écrit du § 15.3, mesuré sur la variante conflit : 8 pour les douze, 4 collisions sur 4 paires, excédent 0', () => {
// Le témoin de l'épreuve précédente : la personne passée de C à A
// retrouve un membre de A à chacun des quatre tours, chaque fois un
// autre. Ce que l'épreuve exige de la recherche est donc atteignable.
const instance = normaliser(demo('petite-conflit'));
const mesures = mesurer(instance, indexerPlan(instance, PLAN_PARFAIT_PETITE));
assert.deepEqual(mesures.rencontres, Array(12).fill(8));
assert.equal(mesures.collisionsCumulees, 4);
assert.equal(mesures.pairesDistinctes, 4);
assert.equal(mesures.excedentCollisions, 0);
});
test('petite-conflit, deux générations accumulées : classer met la plus longue en tête, numérotée après la brève, et départage ses propositions égales par identifiant croissant (§ 5.7, § 15.5)', () => {
// La génération brève prend les identifiants 1 à 3, la longue 4 à 6 :
// l'ordre attendu contredit l'ordre des identifiants, et seul un
// classement qui applique ses critères peut le rendre.
const breve = generer(demo('petite-conflit'), 'petite-conflit, génération brève', REGLAGES_BREVES);
const variante = genererVariante();
const longue = {
aPriori: variante.aPriori,
propositions: variante.propositions.map(({ id, mesures }) => ({ id: id + REGLAGES.nombre, mesures })),
};
// Ce que l'ordre départage, mesuré avant d'être lu. Chaque proposition
// longue atteint son plafond a priori, et les trois sont égales sur les
// quatre autres critères du classement ; aucune brève n'atteint le sien.
// Sur une même population, la redondance moyenne ordonne comme la somme
// que lit le classement.
const autresCriteres = ({ mesures }) => [
mesures.excedentCollisions,
mesures.collisionsCumulees,
mesures.rencontresRepetees.choisies,
mesures.aggRedondance.tous.moyenne,
];
for (const proposition of longue.propositions) {
const nom = `proposition ${proposition.id}`;
assert.equal(minimumAtteint(proposition.mesures, longue.aPriori), true, nom);
assert.deepEqual(autresCriteres(proposition), autresCriteres(longue.propositions[0]), nom);
}
for (const { id, mesures } of breve.propositions) {
assert.equal(minimumAtteint(mesures, breve.aPriori), false, `proposition ${id}`);
}
// Le premier critère renvoie les brèves derrière les longues ; les
// longues, égales sur les cinq, se départagent par identifiant croissant
// (§ 15.5, point 4).
const { classement } = classerPropositions({
aPriori: longue.aPriori,
propositions: [...breve.propositions, ...longue.propositions],
});
assert.deepEqual(classement.ordre.slice(0, REGLAGES.nombre), [4, 5, 6]);
assert.deepEqual([...classement.ordre.slice(REGLAGES.nombre)].sort((a, b) => a - b), [1, 2, 3]);
});
});