The search recomputed the collision excess and the gaps that the measure already gives — two arithmetics for one quantity. It now reads them from the measure, and the redundancy total has one owner. Contract values, the indexed-plan guard and the population split are defined once. regenerer rebuilds a proposition from its derived seed, stop count and history length, which the spec now lists (§ 8.9). A property grid runs seeded random rooms through the whole chain. Checked: 435 node and 20 long tests; redefining the excess or the gaps in the measure now fails the search tests too. --- FR --- [IMP] moteur : propriétaires uniques, régénération, grille de propriétés La recherche recalculait l'excédent de collisions et les écarts que la mesure donne déjà — deux arithmétiques pour une grandeur. Elle les lit désormais dans la mesure, et le total de redondance a un seul propriétaire. Valeurs du contrat, garde du plan indexé et partition des populations sont définies une fois. regenerer reconstruit une proposition depuis sa graine dérivée, son arrêt et la longueur de son historique, que le spec énumère désormais (§ 8.9). Une grille de propriétés fait traverser toute la chaîne à des salles tirées d'une graine. Vérifié : 435 épreuves node et 20 longues ; redéfinir l'excédent ou les écarts dans la mesure fait désormais tomber aussi la recherche. Assisted-by: Claude Opus 5.5
250 lines
11 KiB
JavaScript
250 lines
11 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Épreuves du classement des propositions (§ 5.7). Une proposition
|
||
// synthétique ne porte que les champs que classer lit, pour trois personnes
|
||
// au plafond a priori de 8. Chaque ordre attendu est écrit en clair. Hors des
|
||
// épreuves où seul l'identifiant départage, chaque épreuve qui attend un
|
||
// ordre non vide en attend au moins un qui diffère de l'ordre des
|
||
// identifiants : un classement qui ne lirait aucun critère, et rangerait par
|
||
// identifiant, y échoue.
|
||
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 { mesurer } from './indicateurs.js';
|
||
|
||
// Les critères appliqués quand le premier est sauté.
|
||
const SANS_A_PRIORI = ['excedentCollisions', 'collisionsCumulees', 'rencontresRepetees', 'redondance'];
|
||
|
||
// Fige une valeur et, à toute profondeur, les objets et les tableaux qu'elle
|
||
// contient : une écriture du module éprouvé y lève TypeError.
|
||
function figer(valeur) {
|
||
if (typeof valeur !== 'object' || valeur === null || ArrayBuffer.isView(valeur)) return valeur;
|
||
for (const element of Object.values(valeur)) figer(element);
|
||
return Object.freeze(valeur);
|
||
}
|
||
|
||
// Proposition synthétique de trois personnes. rencontres fixe l'écart au
|
||
// plafond a priori de chacun, 8 − rencontres ; collisions est le triplet
|
||
// (cumulées, distinctes, excédent) du § 5.4 ; repetees et imposees comptent
|
||
// les rencontres répétées que le moteur a choisies et celles qu'imposent les
|
||
// réservations ; redondance est Σ_p r(p), le total que mesurer rend sous le
|
||
// nom totalRedondance. La liste des r(p) n'y figure pas : classer ne la lit
|
||
// pas.
|
||
function proposition(
|
||
id,
|
||
{
|
||
rencontres = [8, 8, 8],
|
||
collisions = [0, 0, 0],
|
||
repetees = 0,
|
||
imposees = 0,
|
||
redondance = 0,
|
||
} = {},
|
||
) {
|
||
const [collisionsCumulees, pairesDistinctes, excedentCollisions] = collisions;
|
||
return {
|
||
id,
|
||
mesures: {
|
||
rencontres,
|
||
collisionsCumulees,
|
||
pairesDistinctes,
|
||
excedentCollisions,
|
||
rencontresRepetees: { choisies: repetees, imposees },
|
||
totalRedondance: redondance,
|
||
},
|
||
plafondsAPriori: [8, 8, 8],
|
||
};
|
||
}
|
||
|
||
// La même proposition, son plafond a priori inconnu.
|
||
const inconnu = (entree) => ({ ...entree, plafondsAPriori: null });
|
||
|
||
// Les six ordres d'une liste de trois éléments.
|
||
const permutations = ([a, b, c]) => [[a, b, c], [a, c, b], [b, a, c], [b, c, a], [c, a, b], [c, b, a]];
|
||
|
||
describe('classer : les critères et leur ordre (§ 5.7)', () => {
|
||
test('CRITERES nomme les cinq critères dans leur ordre, et ne se modifie pas', () => {
|
||
assert.deepEqual(CRITERES, [
|
||
'ecartAuPlafondAPrioriMax',
|
||
'excedentCollisions',
|
||
'collisionsCumulees',
|
||
'rencontresRepetees',
|
||
'redondance',
|
||
]);
|
||
assert.ok(Object.isFrozen(CRITERES));
|
||
});
|
||
|
||
test("l'ordre de l'opérateur (§ 5.4) : l'excédent d'abord, le volume ensuite", () => {
|
||
// Les trois plans du § 5.4, au même écart au plafond a priori, en
|
||
// (cumulées, distinctes, excédent) : un collègue retrouvé quatre fois,
|
||
// deux collègues retrouvés deux fois chacun, six collègues retrouvés une
|
||
// fois chacun. Les rencontres répétées suivent : une paire réunie quatre
|
||
// fois, deux paires réunies deux fois, aucune.
|
||
const concentre = proposition(1, { collisions: [4, 1, 3], repetees: 1 });
|
||
const deuxFois = proposition(2, { collisions: [4, 2, 2], repetees: 2 });
|
||
const reparti = proposition(3, { collisions: [6, 6, 0] });
|
||
const entrees = [concentre, deuxFois, reparti];
|
||
for (const ordreRecu of permutations(entrees)) {
|
||
assert.deepEqual(classer(ordreRecu).ordre, [3, 2, 1]);
|
||
}
|
||
// La raison de la clé : un tri sur les seules collisions cumulées, ou sur
|
||
// les seules paires distinctes, met le plan réparti en dernier.
|
||
const triSur = (champ) =>
|
||
[...entrees].sort((a, b) => a.mesures[champ] - b.mesures[champ] || a.id - b.id).map(({ id }) => id);
|
||
assert.equal(triSur('collisionsCumulees').at(-1), 3);
|
||
assert.equal(triSur('pairesDistinctes').at(-1), 3);
|
||
});
|
||
|
||
test('chaque critère départage à son rang, avant tous les suivants', () => {
|
||
// Chaque proposition perd sur un seul critère et gagne sur tous les
|
||
// autres ; la proposition qui la suit perd sur le critère suivant. Seul
|
||
// l'ordre des critères les range, à l'envers de leurs identifiants.
|
||
const entrees = [
|
||
proposition(1, { rencontres: [7, 8, 8] }), // écart au plafond a priori 1
|
||
proposition(2, { collisions: [2, 1, 1], repetees: 1 }), // excédent 1
|
||
proposition(3, { collisions: [3, 3, 0] }), // 3 cumulées, excédent 0
|
||
proposition(4, { repetees: 1 }), // une rencontre répétée
|
||
proposition(5, { redondance: 1 }), // redondance 1
|
||
proposition(6),
|
||
];
|
||
assert.deepEqual(classer(figer(entrees)), {
|
||
ordre: [6, 5, 4, 3, 2, 1],
|
||
criteresAppliques: [...CRITERES],
|
||
critereSaute: null,
|
||
});
|
||
});
|
||
|
||
test('le premier critère est le plus grand écart au plafond a priori, ni sa somme ni son minimum', () => {
|
||
// Écarts au plafond a priori, personne par personne :
|
||
// 1 : 2, 0, 0 ; le plus grand 2, la somme 2, le minimum 0.
|
||
// 2 : 1, 1, 1 ; le plus grand 1, la somme 3, le minimum 1.
|
||
const entrees = [proposition(1, { rencontres: [6, 8, 8] }), proposition(2, { rencontres: [7, 7, 7] })];
|
||
assert.deepEqual(classer(entrees).ordre, [2, 1]);
|
||
});
|
||
|
||
test('seules les rencontres répétées que le moteur a choisies comptent (§ 5.4)', () => {
|
||
// 1 : une répétition choisie ; 2 : cinq qu'imposent les réservations.
|
||
const entrees = [proposition(1, { repetees: 1 }), proposition(2, { imposees: 5 })];
|
||
assert.deepEqual(classer(entrees).ordre, [2, 1]);
|
||
});
|
||
|
||
test('la redondance se lit sur totalRedondance, la somme que mesurer rend', () => {
|
||
// 1 : r(p) vaut 1, 1, 1, somme 3 et plus grand 1 ; 2 : r(p) vaut 2, 0,
|
||
// 0, somme 2 et plus grand 2. La somme range 2 devant 1, le plus grand
|
||
// ferait l'inverse.
|
||
const entrees = [proposition(1, { redondance: 3 }), proposition(2, { redondance: 2 })];
|
||
assert.deepEqual(classer(entrees).ordre, [2, 1]);
|
||
});
|
||
});
|
||
|
||
describe('classer : le critère sauté (§ 5.7, § 12.6)', () => {
|
||
test('un plafond a priori inconnu saute le premier critère pour toutes, et classer le nomme', () => {
|
||
// 1 : écart au plafond a priori 0, excédent 2.
|
||
// 2 : écart au plafond a priori 1, excédent 0.
|
||
const premiere = proposition(1, { collisions: [4, 2, 2], repetees: 2 });
|
||
const seconde = proposition(2, { rencontres: [7, 8, 8] });
|
||
assert.deepEqual(classer([premiere, seconde]), {
|
||
ordre: [1, 2],
|
||
criteresAppliques: [...CRITERES],
|
||
critereSaute: null,
|
||
});
|
||
// Un seul a priori inconnu suffit : l'excédent ouvre alors le classement
|
||
// de toutes, y compris de celle dont l'a priori est connu.
|
||
const attendu = { ordre: [2, 1], criteresAppliques: SANS_A_PRIORI, critereSaute: 'ecartAuPlafondAPrioriMax' };
|
||
assert.deepEqual(classer([premiere, inconnu(seconde)]), attendu);
|
||
assert.deepEqual(classer([inconnu(premiere), seconde]), attendu);
|
||
assert.deepEqual(classer([inconnu(premiere), inconnu(seconde)]), attendu);
|
||
});
|
||
});
|
||
|
||
describe('classer : le départage par identifiant (§ 15.5, point 4)', () => {
|
||
test("à égalité sur les cinq critères, l'identifiant croissant, quel que soit l'ordre reçu", () => {
|
||
const egales = [7, 3, 5].map((id) =>
|
||
proposition(id, { rencontres: [7, 8, 8], collisions: [2, 1, 1], repetees: 1, redondance: 1 }),
|
||
);
|
||
for (const ordreRecu of permutations(egales)) {
|
||
assert.deepEqual(classer(ordreRecu).ordre, [3, 5, 7]);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Un plan de la variante « conflit inévitable » (§ 15.3), où A = {1, 5, 8,
|
||
// 10, 12}, B = {2, 4, 9, 11} et C = {3, 6, 7}. 1 et 5 restent ensemble aux
|
||
// quatre tours, avec un membre de B ; chaque autre table réunit un membre de
|
||
// A, de B et de C. Quatre collisions cumulées sur une seule paire.
|
||
const CONCENTRE = {
|
||
tables: [1, 2, 3, 4],
|
||
tours: [
|
||
[[1, 2, 5], [3, 4, 8], [6, 9, 10], [7, 11, 12]],
|
||
[[1, 4, 5], [6, 8, 9], [7, 10, 11], [2, 3, 12]],
|
||
[[1, 5, 9], [7, 8, 11], [2, 3, 10], [4, 6, 12]],
|
||
[[1, 5, 11], [2, 6, 8], [3, 4, 10], [7, 9, 12]],
|
||
],
|
||
reserves: [[], [], [], []],
|
||
};
|
||
|
||
describe('classer : petite démonstration, conflit inévitable (§ 14.10, § 15.3)', () => {
|
||
test('le plan qui répartit ses quatre collisions passe devant celui qui les concentre', () => {
|
||
const instance = normaliser(CATALOGUE.find(({ cle }) => cle === 'petite-conflit').construire());
|
||
const mesures = (plan) => mesurer(instance, indexerPlan(instance, plan));
|
||
const collisions = (m) => [m.collisionsCumulees, m.pairesDistinctes, m.excedentCollisions];
|
||
// Le plan parfait de la petite démonstration répartit les siennes : 10
|
||
// rencontre 12 au tour 1, 5 au tour 2, 8 au tour 3 et 1 au tour 4.
|
||
const concentre = mesures(CONCENTRE);
|
||
const reparti = mesures(PLAN_PARFAIT_PETITE);
|
||
assert.deepEqual(collisions(concentre), [4, 1, 3]);
|
||
assert.deepEqual(collisions(reparti), [4, 4, 0]);
|
||
// Nul ne rencontre plus de 8 personnes sur 11 (§ 15.3).
|
||
const huit = new Array(12).fill(8);
|
||
const entrees = [
|
||
{ id: 1, mesures: concentre, plafondsAPriori: huit },
|
||
{ id: 2, mesures: reparti, plafondsAPriori: huit },
|
||
];
|
||
assert.deepEqual(classer(entrees).ordre, [2, 1]);
|
||
// Le plafond a priori inconnu, l'excédent décide seul : 0 contre 3.
|
||
assert.deepEqual(classer(entrees.map(inconnu)), {
|
||
ordre: [2, 1],
|
||
criteresAppliques: SANS_A_PRIORI,
|
||
critereSaute: 'ecartAuPlafondAPrioriMax',
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('classer : les entrées reçues', () => {
|
||
test("une liste vide rend un ordre vide, tous les critères appliqués", () => {
|
||
assert.deepEqual(classer([]), { ordre: [], criteresAppliques: [...CRITERES], critereSaute: null });
|
||
});
|
||
|
||
test("population vide, plafond a priori connu : le premier critère s'applique, sans rien départager", () => {
|
||
// Une population vide n'a pas de plus grand écart au plafond a priori ;
|
||
// ce n'est pas un plafond a priori inconnu, et rien n'est sauté.
|
||
const vide = (id) => ({ ...proposition(id, { rencontres: [] }), plafondsAPriori: [] });
|
||
assert.deepEqual(classer([vide(2), vide(1)]), {
|
||
ordre: [1, 2],
|
||
criteresAppliques: [...CRITERES],
|
||
critereSaute: null,
|
||
});
|
||
});
|
||
|
||
test('un identifiant répété lève RangeError ; un identifiant non entier, TypeError', () => {
|
||
assert.throws(() => classer([proposition(1), proposition(2), proposition(1)]), RangeError);
|
||
assert.throws(() => classer([proposition('1')]), TypeError);
|
||
assert.throws(() => classer([proposition(1.5)]), TypeError);
|
||
});
|
||
|
||
test("un plafond a priori undefined lève TypeError ; d'une autre longueur que les rencontres, RangeError", () => {
|
||
const sansAPriori = { ...proposition(2), plafondsAPriori: undefined };
|
||
assert.throws(() => classer([proposition(1), sansAPriori]), TypeError);
|
||
assert.throws(() => classer([{ ...proposition(1), plafondsAPriori: [8, 8] }]), RangeError);
|
||
});
|
||
|
||
test('des mesures de populations de tailles différentes lèvent RangeError', () => {
|
||
const quatre = {
|
||
...proposition(2, { rencontres: [8, 8, 8, 8] }),
|
||
plafondsAPriori: [8, 8, 8, 8],
|
||
};
|
||
assert.throws(() => classer([proposition(1), quatre]), RangeError);
|
||
});
|
||
});
|