diff --git a/src/moteur/classement.js b/src/moteur/classement.js new file mode 100644 index 0000000..7e50d65 --- /dev/null +++ b/src/moteur/classement.js @@ -0,0 +1,158 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Classement des propositions (§ 5.7) : un ordre explicite, celui que +// l'interface écrit, et non le scalaire que descend la recherche. Cinq +// critères, chacun à minimiser, s'appliquent dans l'ordre de CRITERES ; deux +// propositions égales sur tous se départagent par identifiant croissant +// (§ 15.5, point 4), jamais par l'ordre reçu. +// +// L'excédent de collisions précède les collisions cumulées : un plan qui +// répartit six collisions sur six paires passe devant un plan qui en +// concentre quatre sur une seule (§ 5.4). Les paires distinctes ne sont pas +// une clé ; elles valent les cumulées moins l'excédent. +import { ecartsAuPlafondAPriori } from './manque.js'; + +/** + * Les critères du classement, dans leur ordre d'application : + * - ecartAPrioriMax : le plus grand écart au plafond a priori, plafond a + * priori − rencontres, sur toutes les personnes ; + * - excedentCollisions et collisionsCumulees, tels que mesurer les rend ; + * - rencontresRepetees : les rencontres répétées que le moteur a choisies ; + * celles qu'imposent les réservations sont les mêmes pour toute + * proposition, et ne départagent rien (§ 5.4) ; + * - redondance : Σ_p r(p), la redondance d'appartenance sommée sur les + * personnes. + */ +export const CRITERES = Object.freeze([ + 'ecartAPrioriMax', + 'excedentCollisions', + 'collisionsCumulees', + 'rencontresRepetees', + 'redondance', +]); + +// Le critère qui lit le plafond a priori par personne : sauté quand il est +// inconnu (§ 17, point 4). +const CRITERE_A_PRIORI = 'ecartAPrioriMax'; + +// Le plus grand élément d'une liste ; null pour une liste vide. +function plusGrand(valeurs) { + let plus = null; + for (const valeur of valeurs) if (plus === null || valeur > plus) plus = valeur; + return plus; +} + +// La somme d'une liste ; 0 pour une liste vide. +function somme(valeurs) { + let total = 0; + for (const valeur of valeurs) total += valeur; + return total; +} + +// Valeur de chaque critère pour une proposition lue : { mesures, ecarts }, +// ecarts étant ses écarts au plafond a priori. La Map ne sert qu'à retrouver +// un critère par son nom ; CRITERES en fixe l'ordre. +const VALEUR = new Map([ + ['ecartAPrioriMax', ({ ecarts }) => plusGrand(ecarts)], + ['excedentCollisions', ({ mesures }) => mesures.excedentCollisions], + ['collisionsCumulees', ({ mesures }) => mesures.collisionsCumulees], + ['rencontresRepetees', ({ mesures }) => mesures.rencontresRepetees.choisies], + ['redondance', ({ mesures }) => somme(mesures.redondance)], +]); + +// Lève TypeError pour un identifiant qui n'est pas un entier, et RangeError +// pour un identifiant répété : deux entrées de même identifiant ne se +// départagent pas. L'ensemble ne sert qu'à retrouver un identifiant déjà vu. +function exigerIdentifiants(entrees) { + const vus = new Set(); + for (const { id } of entrees) { + if (!Number.isInteger(id)) { + throw new TypeError(`proposition d'identifiant ${String(id)} : entier attendu`); + } + if (vus.has(id)) throw new RangeError(`proposition ${id} : identifiant répété`); + vus.add(id); + } +} + +// Lève RangeError quand deux entrées mesurent des populations de tailles +// différentes : leurs chiffres ne se comparent pas. Les mesures ne portent +// aucun identifiant de personne : deux populations de même taille mais de +// personnes différentes passent, et l'appelant en répond (voir classer). +function exigerMemeTaille(entrees) { + const [premiere] = entrees; + for (const { id, mesures } of entrees) { + const taille = mesures.rencontres.length; + const attendue = premiere.mesures.rencontres.length; + if (taille !== attendue) { + throw new RangeError( + `proposition ${id} : ${taille} personnes mesurées, ` + + `${attendue} pour la proposition ${premiere.id}`, + ); + } + } +} + +// Ordre lexicographique de deux clés de même longueur. Sur une population +// vide, le plus grand écart au plafond a priori vaut null pour chaque entrée, +// toutes de même taille : deux null sont égaux. +function comparerCles(a, b) { + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; + } + return 0; +} + +/** + * Ordre des propositions (§ 5.7), la meilleure d'abord : critère par critère + * dans l'ordre de CRITERES, chacun croissant, puis identifiant croissant. + * + * Chaque entrée porte id, l'identifiant entier de la proposition ; mesures, + * telles que mesurer les rend ; plafondsAPriori, la liste de plafond.js, ou + * null quand le plafond a priori est inconnu (§ 17, point 4). Une seule + * entrée à plafond a priori inconnu fait sauter le premier critère pour + * toutes : critereSaute le nomme, et criteresAppliques s'ouvre sur le critère + * appliqué à sa place (§ 5.7). Sinon critereSaute vaut null et + * criteresAppliques reprend CRITERES. + * + * Toutes les entrées sont mesurées sur une même population : leurs chiffres + * ne se comparent qu'à cette condition. classer n'en contrôle que la taille, + * les mesures ne portant aucun identifiant de personne ; deux populations de + * même taille mais de personnes différentes, après une exclusion et un ajout + * (§ 9, § 12.6), passent sans bruit. Les identifiants sont uniques parmi les + * entrées, et rechercher numérote les propositions de chaque génération de 1 + * à nombre : l'appelant qui accumule des générations (§ 5.7) les partitionne + * par population, et donne à chaque proposition un identifiant unique à + * travers les générations. + * + * Lève TypeError pour un identifiant qui n'est pas un entier, ou un + * plafondsAPriori qui n'est ni une liste ni null ; RangeError pour un + * identifiant répété, pour des mesures de populations de tailles + * différentes, ou pour un plafond a priori qui n'a pas une case par + * personne mesurée. Ne modifie pas ce qu'il reçoit. + * + * @param {Array<{id: number, mesures: import('./indicateurs.js').Mesures, + * plafondsAPriori: ArrayLike|null}>} entrees + * @returns {{ordre: number[], criteresAppliques: string[], critereSaute: string|null}} + */ +export function classer(entrees) { + exigerIdentifiants(entrees); + exigerMemeTaille(entrees); + const lues = entrees.map(({ id, mesures, plafondsAPriori }) => ({ + id, + mesures, + ecarts: ecartsAuPlafondAPriori(mesures, plafondsAPriori), + })); + const saute = lues.some(({ ecarts }) => ecarts === null); + const criteresAppliques = CRITERES.filter((critere) => !saute || critere !== CRITERE_A_PRIORI); + const classees = lues.map((lue) => ({ + id: lue.id, + cle: criteresAppliques.map((critere) => VALEUR.get(critere)(lue)), + })); + classees.sort((a, b) => comparerCles(a.cle, b.cle) || a.id - b.id); + return { + ordre: classees.map(({ id }) => id), + criteresAppliques, + critereSaute: saute ? CRITERE_A_PRIORI : null, + }; +} diff --git a/src/moteur/classement.test.js b/src/moteur/classement.test.js new file mode 100644 index 0000000..515454b --- /dev/null +++ b/src/moteur/classement.test.js @@ -0,0 +1,246 @@ +// © 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 r(p) pour chacun. +function proposition( + id, + { + rencontres = [8, 8, 8], + collisions = [0, 0, 0], + repetees = 0, + imposees = 0, + redondance = [0, 0, 0], + } = {}, +) { + const [collisionsCumulees, pairesDistinctes, excedentCollisions] = collisions; + return { + id, + mesures: { + rencontres, + collisionsCumulees, + pairesDistinctes, + excedentCollisions, + rencontresRepetees: { choisies: repetees, imposees }, + 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, [ + 'ecartAPrioriMax', + '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, 0, 0] }), // 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 somme sur les personnes', () => { + // 1 : 1, 1, 1, somme 3 et plus grande 1 ; 2 : 2, 0, 0, somme 2 et plus grande 2. + const entrees = [proposition(1, { redondance: [1, 1, 1] }), proposition(2, { redondance: [2, 0, 0] })]; + 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: 'ecartAPrioriMax' }; + 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, 0, 0] }), + ); + 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: 'ecartAPrioriMax', + }); + }); +}); + +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: [], redondance: [] }), 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], redondance: [0, 0, 0, 0] }), + plafondsAPriori: [8, 8, 8, 8], + }; + assert.throws(() => classer([proposition(1), quatre]), RangeError); + }); +}); diff --git a/src/moteur/manque.js b/src/moteur/manque.js new file mode 100644 index 0000000..b680bc5 --- /dev/null +++ b/src/moteur/manque.js @@ -0,0 +1,343 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le manque, et ce qui s'en lit (§ 5.5, § 12.10). Chaque personne porte quatre +// quantités, rangées sur une ligne (§ 12.10.5) : +// +// N − 1 ≥ plafond a priori ≥ plafond réalisé ≥ rencontres +// +// Trois différences s'en lisent, sous les noms du glossaire du § 5.5 : +// manque = plafond réalisé − rencontres ; écart d'itinéraire = plafond a +// priori − plafond réalisé ; écart au plafond a priori = plafond a priori − +// rencontres, la somme des deux premières. Chacune a sa fonction : manques, +// ecartsItineraire, ecartsAuPlafondAPriori. Chacune est une soustraction sur +// des tableaux que mesurer (indicateurs.js) et plafond.js ont déjà calculés : +// aucune fonction ne relit le plan ni les paires qu'il réunit, et aucune ne +// modifie ce qu'elle reçoit. Tout tableau par personne suit l'ordre +// canonique, celui de instance.ids. +// +// Les agrégats portent sur les trois populations du § 5.4 : tous, les +// mobiles — partiellement fixés compris — et les ancrés. Une population vide +// rend null, jamais 0. +// +// Le plafond a priori vaut null quand il est inconnu (§ 12.10.9) : seul null +// le dit. undefined, la valeur d'un champ manquant, lève TypeError au lieu +// de passer pour inconnu. Une liste qui n'a pas une case par personne lève +// RangeError : elle décrit une autre population. + +/** + * @typedef {{tous: number|null, mobiles: number|null, ancres: number|null}} ParPopulation + * un chiffre par population ; null pour une population vide + * + * @typedef {Object} TroisChiffres les trois chiffres du § 12.10.4 + * @property {ParPopulation} manqueMax le plus grand manque + * @property {ParPopulation} effectifManque personnes dont le manque vaut au + * moins 1 ; 0 sur une population + * non vide sans manque + * @property {ParPopulation|null} ecartItineraireMax le plus grand écart + * d'itinéraire ; null quand le plafond a priori est inconnu + * + * @typedef {{id: number, manque: number, plafondRealise: number}} RangProfil + * la personne d'un rang du profil, désignée par son identifiant + * + * @typedef {Object} Comparaison + * @property {boolean} comparable + * @property {number} [rangsA] rangs où A sert mieux + * @property {number} [rangsB] rangs où B sert mieux + * @property {number} [egalite] rangs à manques égaux + * @property {number|null} [rangBascule] à partir de 1 + * @property {'A'|'B'|null} [domine] + */ + +// Statut d'un ancré dans instance.statut (§ 4.2) ; 0 marque un mobile, 1 un +// partiellement fixé. +const ANCRE = 2; + +// Lève TypeError quand valeurs n'est pas une liste, faute de longueur +// numérique ; attendu nomme ce que le paramètre nom admet. +function exigerListe(valeurs, nom, attendu = 'liste') { + if (typeof valeurs?.length !== 'number') throw new TypeError(`${nom} : ${attendu} attendue`); +} + +// Lève TypeError quand valeurs n'est pas une liste, et RangeError quand elle +// n'a pas N cases. +function exigerLongueur(valeurs, N, nom) { + exigerListe(valeurs, nom); + if (valeurs.length !== N) { + throw new RangeError(`${nom} : ${valeurs.length} cases, ${N} attendues`); + } +} + +// Lève TypeError quand plafondsAPriori n'est ni null ni une liste, et +// RangeError quand c'est une liste qui n'a pas N cases. +function exigerAPriori(plafondsAPriori, N) { + if (plafondsAPriori === null) return; + exigerListe(plafondsAPriori, 'plafondsAPriori', 'liste ou null'); + exigerLongueur(plafondsAPriori, N, 'plafondsAPriori'); +} + +// gauche[p] − droite[p] pour chaque personne, deux listes de même longueur. +function soustraire(gauche, droite) { + const differences = []; + for (let p = 0; p < gauche.length; p += 1) differences.push(gauche[p] - droite[p]); + return differences; +} + +// Replie valeurs[p] sur les trois populations en un passage : chacun compte +// dans « tous », puis parmi les ancrés ou parmi les mobiles. replier(cumul, +// valeur) rend le cumul suivant ; cumul vaut null au premier membre d'une +// population, et une population sans membre reste à null. +function parPopulation(valeurs, statut, replier) { + const chiffres = { tous: null, mobiles: null, ancres: null }; + for (let p = 0; p < valeurs.length; p += 1) { + const secondaire = statut[p] === ANCRE ? 'ancres' : 'mobiles'; + chiffres.tous = replier(chiffres.tous, valeurs[p]); + chiffres[secondaire] = replier(chiffres[secondaire], valeurs[p]); + } + return chiffres; +} + +// Le plus grand des valeurs repliées. +const plusGrand = (cumul, valeur) => (cumul === null || valeur > cumul ? valeur : cumul); + +// Le nombre de valeurs repliées qui valent au moins 1. +const compterAuMoinsUn = (cumul, valeur) => (cumul ?? 0) + (valeur >= 1 ? 1 : 0); + +/** + * Manque de chacun (§ 12.10.2) : plafond réalisé − rencontres, en personnes, + * dans l'ordre des deux listes. Un manque négatif est le signe d'un calcul + * faux, que verifierIndicateurs signale (DEPASSEMENT) ; manques le rend tel + * quel. + * + * Lève TypeError quand l'un des deux arguments n'est pas une liste, et + * RangeError quand les deux listes n'ont pas la même longueur. + * + * @param {ArrayLike} plafondsRealises + * @param {ArrayLike} rencontres + * @returns {number[]} + */ +export function manques(plafondsRealises, rencontres) { + exigerListe(plafondsRealises, 'plafondsRealises'); + exigerLongueur(rencontres, plafondsRealises.length, 'rencontres'); + return soustraire(plafondsRealises, rencontres); +} + +/** + * Écart d'itinéraire de chacun (§ 5.5, § 12.10.4) : plafond a priori − + * plafond réalisé, ce que l'itinéraire de la proposition retire avant toute + * rencontre. Une personne en réserve à plusieurs tours le porte quand son + * manque est nul (§ 12.6). Son plus grand est le troisième des trois chiffres. + * null quand le plafond a priori est inconnu. + * + * Lève TypeError quand plafondsRealises n'est pas une liste, ou que + * plafondsAPriori n'est ni une liste ni null, et RangeError quand + * plafondsAPriori n'a pas une case par élément de plafondsRealises. + * + * @param {ArrayLike|null} plafondsAPriori + * @param {ArrayLike} plafondsRealises + * @returns {number[]|null} + */ +export function ecartsItineraire(plafondsAPriori, plafondsRealises) { + exigerListe(plafondsRealises, 'plafondsRealises'); + exigerAPriori(plafondsAPriori, plafondsRealises.length); + return plafondsAPriori === null ? null : soustraire(plafondsAPriori, plafondsRealises); +} + +/** + * Écart au plafond a priori de chacun (§ 5.5) : plafond a priori − + * rencontres, soit le manque plus l'écart d'itinéraire. Le certificat de + * « minimum atteint » le lit, et son plus grand ouvre le classement (§ 5.7). + * null quand le plafond a priori est inconnu. + * + * Lève TypeError quand mesures.rencontres n'est pas une liste, ou que + * plafondsAPriori n'est ni une liste ni null, et RangeError quand + * plafondsAPriori n'a pas une case par élément de mesures.rencontres. + * + * @param {{rencontres: ArrayLike}} mesures seule la liste rencontres + * est lue + * @param {ArrayLike|null} plafondsAPriori + * @returns {number[]|null} + */ +export function ecartsAuPlafondAPriori(mesures, plafondsAPriori) { + const { rencontres } = mesures; + exigerListe(rencontres, 'mesures.rencontres'); + exigerAPriori(plafondsAPriori, rencontres.length); + return plafondsAPriori === null ? null : soustraire(plafondsAPriori, rencontres); +} + +/** + * Les trois chiffres de la page de qualité (§ 12.10.4), chacun sur les trois + * populations du § 5.4 : + * 1. manqueMax, le plus grand manque ; + * 2. effectifManque, le nombre de personnes dont le manque vaut au moins 1 : + * 0 sur une population non vide sans manque, un zéro mesuré ; + * 3. ecartItineraireMax, le plus grand écart d'itinéraire, plafond a priori + * − plafond réalisé, tel que ecartsItineraire le rend. Quand le plafond + * a priori est inconnu, ce chiffre vaut null tout entier, et non par + * population : il s'écrit « inconnu », non « — » (§ 12.10.9). + * Une population vide vaut null dans chacun. + * + * Le troisième chiffre voit ce que les deux premiers ne voient pas : une + * personne assise à peu de tours a un plafond réalisé bas, un manque nul, et + * un grand écart d'itinéraire. + * + * Lève RangeError quand mesures.rencontres, plafondsRealises ou + * plafondsAPriori n'a pas une case par participant de l'instance ; TypeError + * quand l'une d'elles n'est pas une liste, plafondsAPriori pouvant valoir + * null. + * + * @param {import('./types.js').Instance} instance N et statut sont lus + * @param {{rencontres: ArrayLike}} mesures seule la liste rencontres + * est lue + * @param {ArrayLike|null} plafondsAPriori + * @param {ArrayLike} plafondsRealises + * @returns {TroisChiffres} + */ +export function troisChiffres(instance, mesures, plafondsAPriori, plafondsRealises) { + const { N, statut } = instance; + // Ces gardes confrontent chaque liste à l'instance. Celles de manques et + // d'ecartsItineraire ne comparent les listes qu'entre elles, et laissent + // passer trois listes accordées mais mesurées sur une autre population, + // celles d'une proposition mesurée avant une exclusion par exemple. La case + // p n'y désigne plus la personne p de l'instance, et parPopulation lirait + // sans bruit le statut d'une autre personne, ou au-delà de N. + exigerLongueur(mesures.rencontres, N, 'mesures.rencontres'); + exigerLongueur(plafondsRealises, N, 'plafondsRealises'); + exigerAPriori(plafondsAPriori, N); + const manque = manques(plafondsRealises, mesures.rencontres); + const ecarts = ecartsItineraire(plafondsAPriori, plafondsRealises); + return { + manqueMax: parPopulation(manque, statut, plusGrand), + effectifManque: parPopulation(manque, statut, compterAuMoinsUn), + ecartItineraireMax: ecarts === null ? null : parPopulation(ecarts, statut, plusGrand), + }; +} + +/** + * Profil de manque d'une proposition (§ 12.10.6) : une entrée par + * participant, triée par manque décroissant, puis par plafond réalisé + * croissant — à manque égal, la personne au plafond le plus bas, + * proportionnellement la plus privée, d'abord (§ 12.10.3) —, puis par + * identifiant croissant. Deux participants n'ont jamais le même identifiant : + * la clé ordonne tout, sans dépendre de l'ordre reçu. + * + * Lève RangeError quand manques ou plafondsRealises n'a pas une case par + * participant. + * + * @param {import('./types.js').Instance} instance N et ids sont lus + * @param {ArrayLike} manques ordre canonique + * @param {ArrayLike} plafondsRealises ordre canonique + * @returns {RangProfil[]} + */ +export function profilManque(instance, manques, plafondsRealises) { + const { N, ids } = instance; + exigerLongueur(manques, N, 'manques'); + exigerLongueur(plafondsRealises, N, 'plafondsRealises'); + const profil = []; + for (let p = 0; p < N; p += 1) { + profil.push({ id: ids[p], manque: manques[p], plafondRealise: plafondsRealises[p] }); + } + return profil.sort( + (a, b) => b.manque - a.manque || a.plafondRealise - b.plafondRealise || a.id - b.id, + ); +} + +// Vrai quand les deux profils portent les mêmes identifiants, chacun autant +// de fois : leurs listes d'identifiants, triées, coïncident. +function memesPersonnes(profilA, profilB) { + if (profilA.length !== profilB.length) return false; + const croissants = (profil) => profil.map(({ id }) => id).sort((x, y) => x - y); + const idsB = croissants(profilB); + return croissants(profilA).every((id, i) => id === idsB[i]); +} + +/** + * Compare deux profils de manque rang par rang (§ 12.10.6, § 12.10.7). Chaque + * profil vient de profilManque et suit son propre ordre : le rang i désigne + * en général deux personnes différentes, et la comparaison ne dit rien d'une + * personne. + * + * Deux profils qui ne portent pas les mêmes identifiants rendent + * { comparable: false } : leurs rangs ne se correspondent pas (§ 12.6). + * Sinon, au rang i, la proposition au plus petit manque sert mieux, et la + * comparaison porte : + * - rangsA, rangsB, egalite : les rangs où A sert mieux, où B sert mieux, où + * les deux manques sont égaux ; + * - rangBascule : quand le signe de manqueA(i) − manqueB(i), les égalités + * omises, change une fois et une seule, le rang, compté à partir de 1, où + * la proposition qui servait moins bien commence à servir strictement + * mieux. Avant lui, elle ne sert mieux à aucun rang ; à partir de lui, + * l'autre ne sert plus mieux à aucun. null quand le signe ne change pas, ou + * change plusieurs fois ; + * - domine : 'A' quand A sert au moins aussi bien que B à chaque rang et + * strictement mieux à l'un d'eux, 'B' dans le cas symétrique, null sinon. + * Deux profils égaux à chaque rang ne se dominent pas. + * Un parcours des rangs, après la comparaison des identifiants, suffit. + * + * @param {RangProfil[]} profilA + * @param {RangProfil[]} profilB + * @returns {Comparaison} + */ +export function comparerProfils(profilA, profilB) { + if (!memesPersonnes(profilA, profilB)) return { comparable: false }; + let rangsA = 0; + let rangsB = 0; + let egalite = 0; + // Signe du dernier rang à manques différents : −1 quand A y sert mieux, +1 + // quand B y sert mieux, 0 avant le premier. + let signe = 0; + let changements = 0; + let bascule = null; + for (let i = 0; i < profilA.length; i += 1) { + const difference = profilA[i].manque - profilB[i].manque; + if (difference === 0) { + egalite += 1; + continue; + } + const signeRang = difference < 0 ? -1 : 1; + if (signeRang < 0) rangsA += 1; + else rangsB += 1; + if (signe !== 0 && signeRang !== signe) { + changements += 1; + if (changements === 1) bascule = i + 1; + } + signe = signeRang; + } + let domine = null; + if (rangsA > 0 && rangsB === 0) domine = 'A'; + if (rangsB > 0 && rangsA === 0) domine = 'B'; + return { + comparable: true, + rangsA, + rangsB, + egalite, + rangBascule: changements === 1 ? bascule : null, + domine, + }; +} + +/** + * Certificat de « minimum atteint » (§ 5.5) : vrai quand chacun atteint son + * plafond a priori, son écart au plafond a priori valant 0 ; faux sinon. Le + * certificat se lit contre le plafond a priori, jamais sur le seul manque : + * une proposition qui assoit chacun sur un itinéraire bas atteint partout son + * plafond réalisé, et ne prouve rien. + * + * null quand le plafond a priori est inconnu, le certificat restant hors + * d'atteinte (§ 12.6), et sur une population vide, où il n'y a personne à + * servir (§ 5.4). + * + * Lève TypeError quand mesures.rencontres n'est pas une liste, ou que + * plafondsAPriori n'est ni une liste ni null, et RangeError quand + * plafondsAPriori n'a pas une case par élément de mesures.rencontres. + * + * @param {{rencontres: ArrayLike}} mesures seule la liste rencontres + * est lue + * @param {ArrayLike|null} plafondsAPriori + * @returns {boolean|null} + */ +export function minimumAtteint(mesures, plafondsAPriori) { + const ecarts = ecartsAuPlafondAPriori(mesures, plafondsAPriori); + if (ecarts === null || ecarts.length === 0) return null; + return ecarts.every((ecart) => ecart === 0); +} diff --git a/src/moteur/manque.test.js b/src/moteur/manque.test.js new file mode 100644 index 0000000..0550d48 --- /dev/null +++ b/src/moteur/manque.test.js @@ -0,0 +1,705 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du manque et de ce qui s'en lit (§ 5.5, § 12.10). Chaque valeur +// attendue est écrite en clair : recopiée de la spécification, ou comptée à +// la main sur le plan qui la précède, jamais calculée par le module éprouvé. +// Un plan s'écrit par identifiants et passe par normaliser et indexerPlan +// comme un plan enregistré ; ses rencontres et ses plafonds viennent de +// indicateurs.js et de plafond.js, éprouvés à part. Quand une épreuve les +// recopie en clair, ce sont les termes de la ligne de décomposition +// (§ 12.10.5) dont ses chiffres sont les différences. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js'; +import { indexerPlan, normaliser } from './configuration.js'; +import { mesurer } from './indicateurs.js'; +import { + comparerProfils, + ecartsAuPlafondAPriori, + ecartsItineraire, + manques, + minimumAtteint, + profilManque, + troisChiffres, +} from './manque.js'; +import { plafondsAPriori, plafondsRealises } from './plafond.js'; + +const SANS_CONTRAINTE = { + separerAppartenances: false, + nouveauxVoisins: false, + nouvelleTable: false, + varierAppartenances: false, +}; + +// Un chiffre sur trois populations vides (§ 5.4) : null, jamais 0. +const AUCUN = { tous: null, mobiles: null, ancres: null }; + +// Huit pour chacun des douze participants de la petite démonstration : leur +// plafond a priori (§ 15.3), et leur plafond réalisé sur tout plan qui +// remplit les quatre tables à chaque tour. +const HUIT = Object.freeze(new Array(12).fill(8)); + +// Ce qui n'est pas une liste, pour les épreuves des gardes : un nombre plutôt +// qu'undefined. Lire undefined.length lève TypeError, garde ou non ; lire +// (8).length rend undefined sans lever, et seule une garde de liste lève +// alors TypeError. L'épreuve échoue donc quand la garde disparaît. +const PAS_UNE_LISTE = 8; + +// 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); +} + +// Participants d'identifiants donnés, sans appartenance. +const sansAppartenance = (ids) => ids.map((id) => ({ id, nom: `P${id}`, appartenance: null })); + +// Tables d'identifiant et de numéro 1 à n, n le nombre de capacités. +const tablesDe = (capacites) => + capacites.map((capacite, i) => ({ id: i + 1, numero: i + 1, capacite })); + +// La petite démonstration (§ 15.3) : douze participants, quatre tables de 3, +// quatre tours, aucune réservation. +const PETITE = normaliser(CATALOGUE.find(({ cle }) => cle === 'petite').construire()); + +// Le plan parfait, 3 et 4 échangés au tour 1 : 1 retrouve 4 au tour 2, et 3 +// retrouve 5 au tour 3. Ces quatre personnes rencontrent 7 personnes, les +// huit autres toujours 8 ; les tables restent pleines, et le plafond réalisé +// vaut 8 pour chacun. +const PLAN_DEGRADE = { + tables: [1, 2, 3, 4], + tours: [[[1, 2, 4], [3, 5, 6], [7, 8, 9], [10, 11, 12]], ...PLAN_PARFAIT_PETITE.tours.slice(1)], + reserves: [[], [], [], []], +}; + +// Le plan parfait, 1 et 5 échangés au tour 1. 1 y quitte 2 et 3, et 5 y +// quitte 4 et 6, qu'ils ne voient à aucun autre tour. Chacun s'assoit près de +// deux personnes qu'il revoit à un autre tour : 1 près de 4 et 6, revus aux +// tours 2 et 3 ; 5 près de 2 et 3, revus aux tours 2 et 3. 2, 3, 4 et 6 +// perdent celui des deux qui quitte leur table, et revoient ailleurs celui +// qui y arrive. Les tables restent pleines, et le plafond réalisé vaut 8 pour +// chacun. +// +// id 1 2 3 4 5 6 7 à 12 +// rencontres 6 7 7 7 6 7 8 +// manque 2 1 1 1 2 1 0 +const PLAN_DEUX_MANQUES = { + tables: [1, 2, 3, 4], + tours: [[[2, 3, 5], [1, 4, 6], [7, 8, 9], [10, 11, 12]], ...PLAN_PARFAIT_PETITE.tours.slice(1)], + reserves: [[], [], [], []], +}; + +// Deux tables de 3 sièges, quatre tours, six participants sans appartenance. +// 1 est ancré à la table 1 et 2 à la table 2 ; 3, réservé à la table 1 au +// tour 1, est partiellement fixé et compte parmi les mobiles (§ 5.4) ; 4, 5 et +// 6 sont mobiles. 6, assis au seul tour 1, est en réserve ensuite (§ 12.6). +const CONFIGURATION_ANCRAGES = { + participants: sansAppartenance([1, 2, 3, 4, 5, 6]), + tables: tablesDe([3, 3]), + tours: 4, + reservations: [ + { participant: 1, table: 1, portee: 'tous' }, + { participant: 2, table: 2, portee: 'tous' }, + { participant: 3, table: 1, portee: 'tour', tour: 1 }, + ], + contraintes: SANS_CONTRAINTE, +}; +const ANCRAGES = normaliser(CONFIGURATION_ANCRAGES); +const PLAN_ANCRAGES = { + tables: [1, 2], + tours: [ + [[1, 3, 4], [2, 5, 6]], + [[1, 3, 5], [2, 4]], + [[1, 4], [2, 3, 5]], + [[1, 3, 4], [2, 5]], + ], + reserves: [[], [6], [6], [6]], +}; +// Les termes de PLAN_ANCRAGES, comptés à la main. Occupation des tables 1 et +// 2 : 3 et 3 au tour 1, 3 et 2 au tour 2, 2 et 3 au tour 3, 3 et 2 au tour 4. +// N − 1 = 5 et n = 4 : n_p vaut 4 pour un ancré, 3 pour les autres. Plafond +// réalisé = Σ a_t sur les tables visitées + min(n_p, Σ (o − 1 − a_t) sur les +// tours assis), a_t valant 1 à chaque table, et 0 à la sienne pour un ancré. +// +// id rencontrés rencontres plafond réalisé a priori +// 1 3, 4, 5 3 0 + min(4, 2+2+1+2) = 4 4 +// 2 3, 4, 5, 6 4 0 + min(4, 2+1+2+1) = 4 4 +// 3 1, 2, 4, 5 4 2 + min(3, 1+1+1+1) = 5 5 +// 4 1, 2, 3 3 2 + min(3, 1+0+0+1) = 4 5 +// 5 1, 2, 3, 6 4 2 + min(3, 1+1+1+0) = 5 5 +// 6 2, 5 2 1 + min(3, 1) = 2 5 +// +// Le plafond a priori se lit sur les capacités : 0 + min(4, 4 × 2) pour un +// ancré ; les deux tables visitées, 2 + min(3, 4 × 1), pour les autres. +// Manque = réalisé − rencontres : 1, 0, 1, 1, 1, 0. Écart d'itinéraire = a +// priori − réalisé : 0, 0, 0, 1, 0, 3. +const RENCONTRES_ANCRAGES = [3, 4, 4, 3, 4, 2]; +const REALISES_ANCRAGES = [4, 4, 5, 4, 5, 2]; +const A_PRIORI_ANCRAGES = [4, 4, 5, 5, 5, 5]; + +// Quatre participants, deux tables de 3, un tour, deux à chaque table. Chacun +// rencontre la seule personne que sa table lui offre : plafond réalisé 1, +// manque 0. Une table pleine lui en offrait deux : plafond a priori 2, écart +// d'itinéraire 1. +const BAS = normaliser({ + participants: sansAppartenance([1, 2, 3, 4]), + tables: tablesDe([3, 3]), + tours: 1, + reservations: [], + contraintes: SANS_CONTRAINTE, +}); +const PLAN_BAS = { tables: [1, 2], tours: [[[1, 2], [3, 4]]], reserves: [[]] }; + +// Aucun participant ; le plan n'a aucune case. +const VIDE = normaliser({ + participants: [], + tables: tablesDe([2]), + tours: 1, + reservations: [], + contraintes: SANS_CONTRAINTE, +}); +const PLAN_VIDE = { tables: [1], tours: [[[]]], reserves: [[]] }; + +// Mesures, plafonds réalisés et plafonds a priori d'un plan par +// identifiants. +function chiffrer(instance, plan) { + const tableDe = indexerPlan(instance, plan); + return { + mesures: mesurer(instance, tableDe), + realises: plafondsRealises(instance, tableDe), + aPriori: plafondsAPriori(instance), + }; +} + +// Profil de manque d'un plan par identifiants. +function profilDe(instance, plan) { + const { mesures, realises } = chiffrer(instance, plan); + return profilManque(instance, manques(realises, mesures.rencontres), realises); +} + +// Profil écrit à la main : le manque de chaque rang, le rang 1 d'abord, et +// l'identifiant de la personne à chaque rang, 1 à n par défaut. Le plafond +// réalisé ne départage pas : comparerProfils ne lit que le manque. +const profil = (manquesParRang, ids = manquesParRang.map((_, i) => i + 1)) => + manquesParRang.map((manque, i) => ({ id: ids[i], manque, plafondRealise: 10 })); + +describe('manques (§ 12.10.2)', () => { + test('plan parfait de la petite démonstration : douze manques nuls', () => { + const { mesures, realises } = chiffrer(PETITE, PLAN_PARFAIT_PETITE); + assert.deepEqual(mesures.rencontres, HUIT); + assert.deepEqual(realises, HUIT); + assert.deepEqual(manques(figer(realises), figer(mesures.rencontres)), new Array(12).fill(0)); + }); + + test('plan dégradé : un manque de 1 pour les quatre personnes qui perdent une rencontre', () => { + const { mesures, realises } = chiffrer(PETITE, PLAN_DEGRADE); + assert.deepEqual(mesures.rencontres, [7, 8, 7, 7, 7, 8, 8, 8, 8, 8, 8, 8]); + assert.deepEqual(realises, HUIT); + assert.deepEqual(manques(realises, mesures.rencontres), [1, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]); + }); + + test("ancrés et réserve : plafond réalisé − rencontres, personne par personne", () => { + const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES); + assert.deepEqual(mesures.rencontres, RENCONTRES_ANCRAGES); + assert.deepEqual(realises, REALISES_ANCRAGES); + assert.deepEqual(aPriori, A_PRIORI_ANCRAGES); + assert.deepEqual(manques(realises, mesures.rencontres), [1, 0, 1, 1, 1, 0]); + // Les tableaux typés se lisent comme des listes. + assert.deepEqual( + manques(Int32Array.from(REALISES_ANCRAGES), Int32Array.from(RENCONTRES_ANCRAGES)), + [1, 0, 1, 1, 1, 0], + ); + }); + + test('deux listes de longueurs différentes lèvent RangeError', () => { + assert.throws(() => manques([8, 8], [8]), RangeError); + assert.throws(() => manques([8], [8, 8]), RangeError); + }); + + test("une valeur qui n'est pas une liste lève TypeError, à l'une ou l'autre place", () => { + assert.throws(() => manques(PAS_UNE_LISTE, [8]), TypeError); + assert.throws(() => manques([8], PAS_UNE_LISTE), TypeError); + }); +}); + +describe('ecartsItineraire (§ 5.5)', () => { + test('plafond a priori − plafond réalisé, personne par personne', () => { + const { realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES); + // 4 − 4, 4 − 4, 5 − 5, 5 − 4, 5 − 5, 5 − 2 : 6, en réserve à trois tours, + // porte l'écart d'itinéraire que son manque nul ne porte pas (§ 12.6). + assert.deepEqual(ecartsItineraire(figer(aPriori), figer(realises)), [0, 0, 0, 1, 0, 3]); + }); + + test('null quand le plafond a priori est inconnu ; undefined ne passe pas pour inconnu', () => { + const { realises } = chiffrer(ANCRAGES, PLAN_ANCRAGES); + assert.equal(ecartsItineraire(null, realises), null); + assert.throws(() => ecartsItineraire(undefined, realises), TypeError); + }); + + test("un plafond a priori d'une autre longueur lève RangeError ; un nombre seul pour plafonds réalisés, TypeError", () => { + const { realises } = chiffrer(ANCRAGES, PLAN_ANCRAGES); + assert.throws(() => ecartsItineraire(A_PRIORI_ANCRAGES.slice(1), realises), RangeError); + assert.throws(() => ecartsItineraire(null, PAS_UNE_LISTE), TypeError); + }); +}); + +describe('ecartsAuPlafondAPriori (§ 5.5)', () => { + test("plafond a priori − rencontres : le manque plus l'écart d'itinéraire, personne par personne", () => { + const { mesures, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES); + // 1 + 0, 0 + 0, 1 + 0, 1 + 1, 1 + 0, 0 + 3. + assert.deepEqual(ecartsAuPlafondAPriori(figer(mesures), figer(aPriori)), [1, 0, 1, 2, 1, 3]); + }); + + test('null quand le plafond a priori est inconnu ; undefined ne passe pas pour inconnu', () => { + const { mesures } = chiffrer(ANCRAGES, PLAN_ANCRAGES); + assert.equal(ecartsAuPlafondAPriori(mesures, null), null); + assert.throws(() => ecartsAuPlafondAPriori(mesures, undefined), TypeError); + }); + + test("un plafond a priori d'une autre longueur que les rencontres lève RangeError", () => { + const { mesures } = chiffrer(ANCRAGES, PLAN_ANCRAGES); + assert.throws(() => ecartsAuPlafondAPriori(mesures, [4, 4, 5, 5, 5]), RangeError); + }); + + test('des rencontres qui ne sont pas une liste lèvent TypeError, le plafond a priori connu ou inconnu', () => { + assert.throws(() => ecartsAuPlafondAPriori({ rencontres: PAS_UNE_LISTE }, A_PRIORI_ANCRAGES), TypeError); + assert.throws(() => ecartsAuPlafondAPriori({ rencontres: PAS_UNE_LISTE }, null), TypeError); + }); +}); + +describe('troisChiffres (§ 12.10.4)', () => { + test('plan parfait de la petite démonstration : 0, un vrai 0, et 0 ; « — » pour les ancrés', () => { + const { mesures, realises, aPriori } = chiffrer(PETITE, PLAN_PARFAIT_PETITE); + // Douze personnes mesurées, aucune en manque : l'effectif du manque est un + // zéro mesuré, non un « — ». Sans réservation, la population des ancrés + // est vide, et chacun des trois chiffres s'y écrit « — » (§ 15.3). + assert.deepEqual(troisChiffres(PETITE, figer(mesures), figer(aPriori), figer(realises)), { + manqueMax: { tous: 0, mobiles: 0, ancres: null }, + effectifManque: { tous: 0, mobiles: 0, ancres: null }, + ecartItineraireMax: { tous: 0, mobiles: 0, ancres: null }, + }); + }); + + test('plan dégradé : le plus grand manque vaut 1, et quatre personnes le portent', () => { + const { mesures, realises, aPriori } = chiffrer(PETITE, PLAN_DEGRADE); + assert.deepEqual(troisChiffres(PETITE, mesures, aPriori, realises), { + manqueMax: { tous: 1, mobiles: 1, ancres: null }, + effectifManque: { tous: 4, mobiles: 4, ancres: null }, + ecartItineraireMax: { tous: 0, mobiles: 0, ancres: null }, + }); + }); + + test("un manque de 2 : l'effectif compte les personnes, non la somme des manques (§ 12.10.8)", () => { + const { mesures, realises, aPriori } = chiffrer(PETITE, PLAN_DEUX_MANQUES); + assert.deepEqual(mesures.rencontres, [6, 7, 7, 7, 6, 7, 8, 8, 8, 8, 8, 8]); + assert.deepEqual(realises, HUIT); + // Manques 2, 1, 1, 1, 2, 1, puis six 0 : six personnes en manque, pour + // une somme de 8. + assert.deepEqual(troisChiffres(PETITE, mesures, aPriori, realises), { + manqueMax: { tous: 2, mobiles: 2, ancres: null }, + effectifManque: { tous: 6, mobiles: 6, ancres: null }, + ecartItineraireMax: { tous: 0, mobiles: 0, ancres: null }, + }); + }); + + test('le placement du § 12.10.4 : un manque de 13, porté par une seule personne', () => { + // Chacun atteint un plafond réalisé de 27, sauf une personne qui ne + // rencontre que 14 : le plus grand manque vaut 13, l'effectif 1. + // troisChiffres lit des listes, non un plan : celles de l'exemple, écrites + // à la main sur trois personnes, le plafond a priori inconnu. + const trois = normaliser({ + participants: sansAppartenance([1, 2, 3]), + tables: tablesDe([3]), + tours: 1, + reservations: [], + contraintes: SANS_CONTRAINTE, + }); + assert.deepEqual(troisChiffres(trois, { rencontres: [27, 27, 14] }, null, [27, 27, 27]), { + manqueMax: { tous: 13, mobiles: 13, ancres: null }, + effectifManque: { tous: 1, mobiles: 1, ancres: null }, + ecartItineraireMax: null, + }); + }); + + test('ancrés, partiellement fixé et réserve : chaque chiffre sur ses trois populations', () => { + const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES); + // Manque 1, 0, 1, 1, 1, 0 ; écart d'itinéraire 0, 0, 0, 1, 0, 3. Les + // ancrés sont 1 et 2 ; les mobiles, 3, partiellement fixé, puis 4, 5 et 6. + // 6, en réserve à trois tours sur quatre, a un manque nul : les deux + // premiers chiffres ne le voient pas, le troisième le montre (§ 12.10.4). + assert.deepEqual(troisChiffres(ANCRAGES, mesures, aPriori, realises), { + manqueMax: { tous: 1, mobiles: 1, ancres: 1 }, + effectifManque: { tous: 4, mobiles: 3, ancres: 1 }, + ecartItineraireMax: { tous: 3, mobiles: 3, ancres: 0 }, + }); + }); + + test("plafond a priori inconnu : l'écart d'itinéraire maximal vaut null, les deux autres se mesurent", () => { + const { mesures, realises } = chiffrer(ANCRAGES, PLAN_ANCRAGES); + assert.deepEqual(troisChiffres(ANCRAGES, mesures, null, realises), { + manqueMax: { tous: 1, mobiles: 1, ancres: 1 }, + effectifManque: { tous: 4, mobiles: 3, ancres: 1 }, + ecartItineraireMax: null, + }); + }); + + test("itinéraires bas : aucun manque, et l'écart d'itinéraire le dit (§ 5.5)", () => { + const { mesures, realises, aPriori } = chiffrer(BAS, PLAN_BAS); + assert.deepEqual(mesures.rencontres, [1, 1, 1, 1]); + assert.deepEqual(realises, [1, 1, 1, 1]); + assert.deepEqual(aPriori, [2, 2, 2, 2]); + assert.deepEqual(troisChiffres(BAS, mesures, aPriori, realises), { + manqueMax: { tous: 0, mobiles: 0, ancres: null }, + effectifManque: { tous: 0, mobiles: 0, ancres: null }, + ecartItineraireMax: { tous: 1, mobiles: 1, ancres: null }, + }); + }); + + test('population vide : « — » pour chacun des trois chiffres, jamais 0 (§ 5.4)', () => { + const { mesures, realises, aPriori } = chiffrer(VIDE, PLAN_VIDE); + assert.deepEqual(aPriori, []); + assert.deepEqual(troisChiffres(VIDE, mesures, aPriori, realises), { + manqueMax: AUCUN, + effectifManque: AUCUN, + ecartItineraireMax: AUCUN, + }); + }); + + test('une liste sans une case par participant lève RangeError, seule ou avec les deux autres', () => { + const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES); + const court = (liste) => liste.slice(1); + assert.throws(() => troisChiffres(ANCRAGES, mesures, aPriori, court(realises)), RangeError); + assert.throws(() => troisChiffres(ANCRAGES, mesures, court(aPriori), realises), RangeError); + assert.throws( + () => troisChiffres(ANCRAGES, { rencontres: court(mesures.rencontres) }, aPriori, realises), + RangeError, + ); + // Les trois listes ensemble, mesurées sur une autre population : accordées + // entre elles, elles passent les gardes de manques et d'ecartsItineraire, + // qui les comparent l'une à l'autre ; seules celles de troisChiffres les + // confrontent à l'instance. D'abord une population plus petite d'une + // personne, puis une proposition mesurée avant l'exclusion de 6 et relue + // sur l'instance qui l'exclut (§ 9, § 12.6) ; chaque fois l'a priori + // connu, puis inconnu. + const plusPetite = { rencontres: court(mesures.rencontres) }; + assert.throws(() => troisChiffres(ANCRAGES, plusPetite, court(aPriori), court(realises)), RangeError); + assert.throws(() => troisChiffres(ANCRAGES, plusPetite, null, court(realises)), RangeError); + const sansSix = normaliser({ + ...CONFIGURATION_ANCRAGES, + participants: CONFIGURATION_ANCRAGES.participants.map((p) => (p.id === 6 ? { ...p, exclu: true } : p)), + }); + assert.deepEqual(sansSix.ids, [1, 2, 3, 4, 5]); + assert.throws(() => troisChiffres(sansSix, mesures, aPriori, realises), RangeError); + assert.throws(() => troisChiffres(sansSix, mesures, null, realises), RangeError); + }); + + test("une valeur qui n'est pas une liste lève TypeError ; un a priori undefined aussi", () => { + const { mesures, realises, aPriori } = chiffrer(ANCRAGES, PLAN_ANCRAGES); + assert.throws(() => troisChiffres(ANCRAGES, mesures, aPriori, PAS_UNE_LISTE), TypeError); + assert.throws(() => troisChiffres(ANCRAGES, { rencontres: PAS_UNE_LISTE }, aPriori, realises), TypeError); + assert.throws(() => troisChiffres(ANCRAGES, mesures, undefined, realises), TypeError); + }); +}); + +describe('profilManque (§ 12.10.6)', () => { + test('manque décroissant, puis plafond réalisé croissant, puis identifiant croissant', () => { + // Identifiants 10 à 50, reçus dans le désordre ; les listes suivent + // l'ordre canonique, celui de instance.ids. + const instance = normaliser({ + participants: sansAppartenance([30, 10, 50, 20, 40]), + tables: tablesDe([5]), + tours: 1, + reservations: [], + contraintes: SANS_CONTRAINTE, + }); + assert.deepEqual(instance.ids, [10, 20, 30, 40, 50]); + // id 10 20 30 40 50 + // manque 1 2 1 1 0 + // plafond réalisé 5 3 4 5 2 + assert.deepEqual(profilManque(instance, figer([1, 2, 1, 1, 0]), figer([5, 3, 4, 5, 2])), [ + { id: 20, manque: 2, plafondRealise: 3 }, + { id: 30, manque: 1, plafondRealise: 4 }, + { id: 10, manque: 1, plafondRealise: 5 }, + { id: 40, manque: 1, plafondRealise: 5 }, + { id: 50, manque: 0, plafondRealise: 2 }, + ]); + }); + + test("plan dégradé : les quatre personnes en manque d'abord, chaque palier par identifiant", () => { + const ids = profilDe(PETITE, PLAN_DEGRADE).map(({ id }) => id); + assert.deepEqual(ids, [1, 3, 4, 5, 2, 6, 7, 8, 9, 10, 11, 12]); + }); + + test("une liste sans une case par participant lève RangeError", () => { + assert.throws(() => profilManque(PETITE, new Array(11).fill(0), HUIT), RangeError); + assert.throws(() => profilManque(PETITE, new Array(12).fill(0), HUIT.slice(1)), RangeError); + }); +}); + +// Les plans voisins du plan parfait de la petite démonstration : à un tour, +// deux personnes assises à deux tables différentes échangent leurs places. +// Quatre tours, six paires de tables, neuf échanges par paire : 216 plans. +function voisinsDuParfait() { + const { tours } = PLAN_PARFAIT_PETITE; + const voisins = []; + for (let r = 0; r < tours.length; r += 1) { + for (let i = 0; i < tours[r].length; i += 1) { + for (let j = i + 1; j < tours[r].length; j += 1) { + for (const x of tours[r][i]) { + for (const y of tours[r][j]) { + const tour = tours[r].map((liste, t) => { + if (t === i) return liste.map((id) => (id === x ? y : id)); + if (t === j) return liste.map((id) => (id === y ? x : id)); + return liste; + }); + voisins.push({ + nom: `${x} et ${y} échangés au tour ${r + 1}`, + plan: { ...PLAN_PARFAIT_PETITE, tours: tours.map((t, q) => (q === r ? tour : t)) }, + }); + } + } + } + } + } + return voisins; +} + +// Deux autres plans parfaits : les tours du plan parfait dans l'ordre +// inverse, et chaque tour décalé d'une table. Les rencontres restent les +// mêmes. +const AUTRES_PARFAITS = [ + { + nom: 'tours inversés', + plan: { ...PLAN_PARFAIT_PETITE, tours: [...PLAN_PARFAIT_PETITE.tours].reverse() }, + }, + { + nom: 'tables décalées', + plan: { ...PLAN_PARFAIT_PETITE, tours: PLAN_PARFAIT_PETITE.tours.map((t) => [...t.slice(1), t[0]]) }, + }, +]; + +// Vrai quand chacun des douze rencontre huit personnes distinctes, compté sur +// les listes du plan sans passer par le moteur. Sur un plan qui remplit les +// quatre tables à chaque tour, le plafond réalisé vaut 8 pour chacun : c'est +// exactement le cas d'un manque nul pour tous. +function chacunRencontreHuit(plan) { + const rencontres = new Map(); + for (const tour of plan.tours) { + for (const liste of tour) { + for (const a of liste) { + if (!rencontres.has(a)) rencontres.set(a, new Set()); + for (const b of liste) if (b !== a) rencontres.get(a).add(b); + } + } + } + return rencontres.size === 12 && [...rencontres.values()].every((vus) => vus.size === 8); +} + +describe('comparerProfils : dominance de profil (§ 12.10.7)', () => { + test("petite démonstration : le plan parfait domine tout plan qui n'est pas à manque nul, et rien ne le domine", () => { + const parfait = profilDe(PETITE, PLAN_PARFAIT_PETITE); + const concurrents = [...voisinsDuParfait(), ...AUTRES_PARFAITS]; + // Le test refuse de passer sans concurrent, et sans concurrent de chaque + // sorte (§ 14.2). Un échange retire à la personne déplacée deux voisins + // qu'elle ne retrouve à aucun autre tour, et l'assoit près d'une personne + // au moins qu'elle rencontre à un autre tour : elle perd deux rencontres + // et en gagne une au plus. Aucun voisin n'est donc à manque nul. + assert.equal(concurrents.length, 218); + assert.deepEqual( + concurrents.filter(({ plan }) => chacunRencontreHuit(plan)).map(({ nom }) => nom), + ['tours inversés', 'tables décalées'], + ); + const ecarts = concurrents.flatMap(({ nom, plan }) => { + const autre = profilDe(PETITE, plan); + const nul = chacunRencontreHuit(plan); + const lus = [ + ['parfait contre lui', comparerProfils(parfait, autre).domine, nul ? null : 'A'], + ['lui contre parfait', comparerProfils(autre, parfait).domine, nul ? null : 'B'], + ]; + return lus + .filter(([, obtenu, attendu]) => obtenu !== attendu) + .map(([sens, obtenu, attendu]) => `${nom}, ${sens} : ${obtenu} au lieu de ${attendu}`); + }); + assert.deepEqual(ecarts, []); + }); + + test('plan parfait contre plan dégradé : quatre rangs à A, huit égalités, aucune bascule', () => { + const parfait = profilDe(PETITE, PLAN_PARFAIT_PETITE); + const degrade = profilDe(PETITE, PLAN_DEGRADE); + assert.deepEqual(comparerProfils(figer(parfait), figer(degrade)), { + comparable: true, + rangsA: 4, + rangsB: 0, + egalite: 8, + rangBascule: null, + domine: 'A', + }); + }); + + test("deux profils tout à zéro ne se dominent pas", () => { + assert.deepEqual(comparerProfils(profil([0, 0, 0]), profil([0, 0, 0], [3, 2, 1])), { + comparable: true, + rangsA: 0, + rangsB: 0, + egalite: 3, + rangBascule: null, + domine: null, + }); + }); + + test("A domine B quand il sert au moins aussi bien à chaque rang, et mieux à l'un d'eux", () => { + // Manque de A − manque de B, rang par rang : 0, 0, −1. + assert.deepEqual(comparerProfils(profil([3, 2, 0]), profil([3, 2, 1], [2, 3, 1])), { + comparable: true, + rangsA: 1, + rangsB: 0, + egalite: 2, + rangBascule: null, + domine: 'A', + }); + assert.deepEqual(comparerProfils(profil([3, 2, 1], [2, 3, 1]), profil([3, 2, 0])), { + comparable: true, + rangsA: 0, + rangsB: 1, + egalite: 2, + rangBascule: null, + domine: 'B', + }); + }); +}); + +describe('comparerProfils : rangs et bascule (§ 12.10.6)', () => { + test("un seul changement de signe : le rang de bascule, premier rang où l'autre sert mieux", () => { + // −1, −1, +1, +1, 0 : A sert mieux aux rangs 1 et 2, B aux rangs 3 et 4. + assert.deepEqual(comparerProfils(profil([5, 3, 2, 1, 0]), profil([6, 4, 1, 0, 0], [5, 4, 3, 2, 1])), { + comparable: true, + rangsA: 2, + rangsB: 2, + egalite: 1, + rangBascule: 3, + domine: null, + }); + // +1, +1, −1 : B sert mieux d'abord, A à partir du rang 3. + assert.deepEqual(comparerProfils(profil([6, 4, 1]), profil([5, 3, 2])), { + comparable: true, + rangsA: 1, + rangsB: 2, + egalite: 0, + rangBascule: 3, + domine: null, + }); + }); + + test("une égalité au croisement n'est pas un changement de signe", () => { + // −1, 0, +1, +1 : une seule bascule, au rang 3, où B sert strictement mieux. + assert.deepEqual(comparerProfils(profil([5, 3, 2, 1]), profil([6, 3, 1, 0])), { + comparable: true, + rangsA: 1, + rangsB: 2, + egalite: 1, + rangBascule: 3, + domine: null, + }); + // 0, 0, −1, +1 : les égalités de tête ne fixent aucun signe ; bascule au rang 4. + assert.deepEqual(comparerProfils(profil([4, 3, 1, 1]), profil([4, 3, 2, 0])), { + comparable: true, + rangsA: 1, + rangsB: 1, + egalite: 2, + rangBascule: 4, + domine: null, + }); + }); + + test('deux changements de signe ou plus : aucun rang de bascule (§ 12.6)', () => { + // −1, +1, −1, 0. + assert.deepEqual(comparerProfils(profil([4, 3, 1, 0]), profil([5, 2, 2, 0])), { + comparable: true, + rangsA: 2, + rangsB: 1, + egalite: 1, + rangBascule: null, + domine: null, + }); + // −1, +1, −1, +1. + assert.deepEqual(comparerProfils(profil([4, 3, 1, 1]), profil([5, 2, 2, 0])), { + comparable: true, + rangsA: 2, + rangsB: 2, + egalite: 0, + rangBascule: null, + domine: null, + }); + }); +}); + +describe('comparerProfils : populations (§ 12.6)', () => { + test('populations différentes : { comparable: false }', () => { + assert.deepEqual(comparerProfils(profil([1, 0, 0]), profil([1, 0, 0], [1, 2, 4])), { + comparable: false, + }); + assert.deepEqual(comparerProfils(profil([1, 0, 0]), profil([1, 0])), { comparable: false }); + assert.deepEqual(comparerProfils(profil([1, 0]), profil([1, 0, 0])), { comparable: false }); + }); + + test("les mêmes personnes à d'autres rangs restent comparables, et deux profils vides aussi", () => { + assert.equal(comparerProfils(profil([1, 0, 0]), profil([1, 0, 0], [3, 1, 2])).comparable, true); + assert.deepEqual(comparerProfils([], []), { + comparable: true, + rangsA: 0, + rangsB: 0, + egalite: 0, + rangBascule: null, + domine: null, + }); + }); +}); + +describe('minimumAtteint : le certificat du § 5.5', () => { + test('vrai sur le plan parfait de la petite démonstration', () => { + const { mesures, aPriori } = chiffrer(PETITE, PLAN_PARFAIT_PETITE); + assert.deepEqual(aPriori, HUIT); + assert.equal(minimumAtteint(figer(mesures), figer(aPriori)), true); + }); + + test("faux après l'échange de deux personnes au tour 1, qui crée deux répétitions", () => { + const { mesures, aPriori } = chiffrer(PETITE, PLAN_DEGRADE); + assert.equal(mesures.rencontresRepetees.choisies, 2); + assert.equal(minimumAtteint(mesures, aPriori), false); + }); + + test('se lit contre le plafond a priori, jamais sur le seul manque', () => { + // Des itinéraires bas : chacun atteint son plafond réalisé, aucun son + // plafond a priori. + const { mesures, realises, aPriori } = chiffrer(BAS, PLAN_BAS); + assert.deepEqual(manques(realises, mesures.rencontres), [0, 0, 0, 0]); + assert.equal(minimumAtteint(mesures, aPriori), false); + }); + + test('égaler le plafond a priori, non le dépasser : un dépassement ne certifie rien', () => { + // 9 rencontres pour un plafond a priori de 8 rompent la ligne de + // décomposition (§ 12.10.5) : un calcul faux, que le certificat ne couvre + // pas. + assert.equal(minimumAtteint({ rencontres: [9, 8] }, [8, 8]), false); + }); + + test('null quand le plafond a priori est inconnu, et sur une population vide', () => { + assert.equal(minimumAtteint(chiffrer(PETITE, PLAN_PARFAIT_PETITE).mesures, null), null); + const { mesures, aPriori } = chiffrer(VIDE, PLAN_VIDE); + assert.equal(minimumAtteint(mesures, aPriori), null); + }); + + test("un a priori undefined lève TypeError ; d'une autre longueur, RangeError", () => { + const { mesures } = chiffrer(PETITE, PLAN_PARFAIT_PETITE); + assert.throws(() => minimumAtteint(mesures, undefined), TypeError); + assert.throws(() => minimumAtteint(mesures, HUIT.slice(1)), RangeError); + }); + + test('des rencontres qui ne sont pas une liste lèvent TypeError, le plafond a priori connu ou inconnu', () => { + assert.throws(() => minimumAtteint({ rencontres: PAS_UNE_LISTE }, HUIT), TypeError); + assert.throws(() => minimumAtteint({ rencontres: PAS_UNE_LISTE }, null), TypeError); + }); +});