[ADD] engine: lack, its three figures, dominance and the ranking

Raw meeting counts stop comparing proposals once a table is incomplete;
the lack, realised ceiling minus encounters, compares them. The module
gives the three figures of § 12.10.4, the sorted lack profile, rank
counts and profile dominance between two proposals, and the minimum
certificate read against the a priori ceiling. The ranking orders by gap,
then collision excess, cumulative collisions, repeats and redundancy.

Checked: the operator's three plans rank by excess 0, 2, 3, which either
count alone would invert; dominance and switch rank are pinned by hand.

--- FR ---

[ADD] moteur : manque, ses trois chiffres, dominance et classement

Les nombres bruts de rencontres cessent de comparer deux propositions dès
qu'une table est incomplète ; le manque, plafond réalisé moins
rencontres, les compare. Le module rend les trois chiffres du
§ 12.10.4, le profil trié du manque, le décompte des rangs et la
dominance de profil entre deux propositions, et le certificat de minimum
lu contre le plafond a priori. Le classement ordonne par écart, puis
excédent de collisions, collisions cumulées, répétitions et redondance.

Vérifié : les trois plans de l'opérateur se classent par excédent 0, 2,
3, ce que chaque compte seul inverserait ; dominance et rang de bascule
épinglés à la main.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-05 22:52:29 -04:00
parent f5d1bb04bd
commit f8b3e8b0a9
4 changed files with 1452 additions and 0 deletions

158
src/moteur/classement.js Normal file
View file

@ -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<number>|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,
};
}

View file

@ -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);
});
});

343
src/moteur/manque.js Normal file
View file

@ -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<number>} plafondsRealises
* @param {ArrayLike<number>} 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<number>|null} plafondsAPriori
* @param {ArrayLike<number>} 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<number>}} mesures seule la liste rencontres
* est lue
* @param {ArrayLike<number>|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<number>}} mesures seule la liste rencontres
* est lue
* @param {ArrayLike<number>|null} plafondsAPriori
* @param {ArrayLike<number>} 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<number>} manques ordre canonique
* @param {ArrayLike<number>} 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<number>}} mesures seule la liste rencontres
* est lue
* @param {ArrayLike<number>|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);
}

705
src/moteur/manque.test.js Normal file
View file

@ -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);
});
});