[ADD] engine: indicators measured by full recomputation

Full recomputation is the measure every displayed figure comes from
(§ 5.10). It keeps the three collision units apart — cumulative
occurrences, distinct pairs, their excess — splits table returns and
repeated meetings into those the engine chose and those the reservations
impose, and gives null, never zero, for an empty population.

Checked: the small demonstration's perfect plan gives 8 met, A = 2,
r = 6, d = 0.25 for all twelve; spread and concentrated plans give
excess 0 and 4.

--- FR ---

[ADD] moteur : indicateurs mesurés par recalcul complet

Le recalcul complet est la mesure dont sort tout chiffre affiché
(§ 5.10). Il tient séparées les trois unités de collision — occurrences
cumulées, paires distinctes, leur excédent —, partage les retours à une
table et les rencontres répétées entre ceux que le moteur a choisis et
ceux qu'imposent les réservations, et rend null, jamais zéro, pour une
population vide.

Vérifié : le plan parfait de la petite démonstration donne 8 rencontres,
A = 2, r = 6, d = 0,25 pour les douze ; plans réparti et concentré
donnent un excédent de 0 et de 4.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-05 17:27:34 -04:00
parent 4e31c8d3e5
commit 30045182e7
2 changed files with 885 additions and 0 deletions

323
src/moteur/indicateurs.js Normal file
View file

@ -0,0 +1,323 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Indicateurs d'un plan (§ 5.4), par recalcul complet : la mesure qui fait
// foi, dont sortent les chiffres affichés et ceux du classement (§ 5.10). Le
// plan se mesure entier, les N participants et les R tours ; les
// réservations ne retirent rien de la mesure, elles séparent seulement, dans
// les retours et les rencontres répétées, ce qu'elles imposent de ce que le
// moteur choisit. Tout tableau par personne suit l'ordre de instance.ids.
//
// Une rencontre est une paire de personnes assises à la même table au même
// tour : la réserve n'est pas une table, une table vide ou à un seul occupant
// ne forme aucune paire, et personne ne se rencontre soi-même. Deux personnes
// sans appartenance ne sont pas collègues. Un ensemble vide rend null, jamais
// 0 : le taux de diversité de qui ne rencontre aucun affilié, le minimum et la
// moyenne d'une population sans membre.
/**
* @typedef {Object} Agregat
* @property {number|null} min null pour une population vide
* @property {number|null} moyenne null pour une population vide
* @property {number} effectif
*
* @typedef {Object} TroisAgregats
* @property {Agregat} tous
* @property {Agregat} mobiles mobiles et partiellement fixés (§ 5.4)
* @property {Agregat} ancres
*
* @typedef {Object} Mesures
* @property {number[]} rencontres |met(p)|, ordre canonique
* @property {number[]} affilies |F(p)|
* @property {number[]} appartenancesVues A(p) — appartenances des affiliés
* rencontrés, la sienne comprise quand
* un collègue est rencontré
* @property {number[]} redondance r(p) = |F(p)| − A(p)
* @property {(number|null)[]} diversite d(p) = A(p) / |F(p)|, null si |F(p)| = 0
* @property {number[]} toursAssis
* @property {number[]} retoursChoisis par personne
* @property {number[]} retoursImposes par personne
* @property {TroisAgregats} aggRencontres
* @property {TroisAgregats} aggRedondance
* @property {number} collisionsCumulees Σ paires de même groupe × tours ensemble
* @property {number} pairesDistinctes paires de même groupe réunies ≥ 1 fois
* @property {number} excedentCollisions cumulées − distinctes
* @property {{choisies: number, imposees: number}} rencontresRepetees
* paires réunies ≥ 2 fois ; « imposée » quand chaque rencontre de la
* paire a lieu à une table où les deux sont réservés ce tour-là
* @property {number} maxRencontresPaire sur toutes les paires ; 0 si aucune
* @property {number} totalRetoursChoisis
* @property {number} totalRetoursImposes
* @property {Array<{groupe: string, effectif: number, collisionsCumulees: number,
* pairesDistinctes: number, excedent: number}>} parGroupe
* ordre de instance.groupes
*/
// Case d'un plan indexé : la réserve.
const RESERVE = -1;
// instance.groupe d'un participant sans appartenance.
const SANS_GROUPE = -1;
// instance.statut d'un ancré ; 0 marque un mobile, 1 un partiellement fixé.
const ANCRE = 2;
// Plus grand compte que tient un Uint16Array.
const UINT16_MAX = 0xffff;
// Valeur citée dans un message : une chaîne s'écrit entre guillemets, pour
// ne pas se lire comme le nombre qu'elle contient.
const decrire = (valeur) => (typeof valeur === 'string' ? JSON.stringify(valeur) : String(valeur));
// Lève RangeError quand tableDe n'a pas N × R cases, ou qu'une case n'est ni
// −1 ni un index de table entier ; le message nomme alors le participant et
// le tour. Sans cette garde, deux écarts passeraient sans bruit : une case de
// trop serait ignorée ; une chaîne numérique comme "0" assiérait la personne
// à la table qu'elle désigne, mais compterRetours, qui la compare strictement
// à instance.fixe, n'y reconnaîtrait pas sa réservation. Une case manquante
// ou toute autre valeur lèverait dans compterRencontres une TypeError qui ne
// nomme ni la personne ni le tour.
function exigerForme({ N, T, R, ids }, tableDe) {
if (tableDe.length !== N * R) {
throw new RangeError(`plan indexé de ${tableDe.length} cases, N × R = ${N * R} attendues`);
}
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t !== RESERVE && !(Number.isInteger(t) && t >= 0 && t < T)) {
throw new RangeError(
`plan indexé : participant ${ids[p]}, tour ${r + 1}, `
+ `index de table ${decrire(t)} hors de −1..${T - 1}`,
);
}
}
}
}
// Rang de la paire (a, b), a < b, dans le triangle supérieur d'une matrice
// N × N rangé ligne par ligne : la ligne a s'ouvre après les
// (N − 1) + (N − 2) + … + (N − a) paires des lignes qui la précèdent.
const rangPaire = (N, a, b) => (a * (2 * N - a - 1)) / 2 + (b - a - 1);
// Rencontres de chaque paire, rangées par rangPaire, en parcourant chaque
// table de chaque tour. choisie vaut 1 pour une paire dont une rencontre au
// moins a lieu hors d'une table où les deux sont réservés ce tour-là. Une
// paire se réunit au plus une fois par tour : R borne chaque compte, qu'un
// Uint16Array tient tant que R ≤ 65 535.
function compterRencontres({ N, T, R, fixe }, tableDe) {
const paires = (N * (N - 1)) / 2;
const compte = R <= UINT16_MAX ? new Uint16Array(paires) : new Uint32Array(paires);
const choisie = new Uint8Array(paires);
for (let r = 0; r < R; r += 1) {
// Occupants de chaque table, par index croissants : a < b pour toute
// paire (liste[i], liste[j]) avec i < j. La boucle intérieure part du
// voisin suivant : une liste vide ou d'un seul occupant ne forme aucune
// paire, et nul n'est apparié à lui-même.
const assis = Array.from({ length: T }, () => []);
for (let p = 0; p < N; p += 1) {
const t = tableDe[p * R + r];
if (t !== RESERVE) assis[t].push(p);
}
for (let t = 0; t < T; t += 1) {
const liste = assis[t];
for (let i = 0; i < liste.length; i += 1) {
const a = liste[i];
const aReserve = fixe[a * R + r] === t;
for (let j = i + 1; j < liste.length; j += 1) {
const b = liste[j];
const k = rangPaire(N, a, b);
compte[k] += 1;
if (!aReserve || fixe[b * R + r] !== t) choisie[k] = 1;
}
}
}
}
return { compte, choisie };
}
// Lecture du triangle des paires, dans l'ordre de rangPaire. Par personne :
// |met(p)|, |F(p)| et A(p), vu[p * G + g] notant que p a déjà croisé un
// affilié du groupe g. Par groupe : une paire de collègues réunie n fois
// porte n collisions cumulées et une paire distincte. Sur toutes les paires :
// les rencontres répétées, choisies ou imposées, et le plus grand compte.
function lirePaires({ N, groupe, groupes }, { compte, choisie }) {
const G = groupes.length;
const rencontres = new Int32Array(N);
const affilies = new Int32Array(N);
const vues = new Int32Array(N);
const vu = new Uint8Array(N * G);
const collisions = new Array(G).fill(0);
const distinctes = new Array(G).fill(0);
let choisies = 0;
let imposees = 0;
let maxRencontresPaire = 0;
// p rencontre q : q compte dans met(p) ; un affilié compte aussi dans F(p),
// et son groupe compte dans A(p) au premier membre de ce groupe que p
// rencontre.
const rencontrer = (p, q) => {
rencontres[p] += 1;
const g = groupe[q];
if (g === SANS_GROUPE) return;
affilies[p] += 1;
if (vu[p * G + g] === 0) {
vu[p * G + g] = 1;
vues[p] += 1;
}
};
let k = 0;
for (let a = 0; a < N; a += 1) {
for (let b = a + 1; b < N; b += 1, k += 1) {
const n = compte[k];
if (n === 0) continue;
rencontrer(a, b);
rencontrer(b, a);
if (n > maxRencontresPaire) maxRencontresPaire = n;
if (n >= 2) {
if (choisie[k] === 1) choisies += 1;
else imposees += 1;
}
const g = groupe[a];
if (g !== SANS_GROUPE && g === groupe[b]) {
collisions[g] += n;
distinctes[g] += 1;
}
}
}
return {
rencontres,
affilies,
vues,
collisions,
distinctes,
rencontresRepetees: { choisies, imposees },
maxRencontresPaire,
};
}
// Tours assis et retours de chaque personne (§ 5.4). Pour une table visitée
// m fois, dont f à un tour où la personne y est réservée : max(0, f − 1)
// retours imposés et (m − 1) − imposés retours choisis. visites et reservees
// comptent par table les tours de la personne en cours ; le second parcours
// de ses tours solde chaque table à sa première occurrence et remet ses
// compteurs à zéro, si bien que la personne suivante les trouve nuls.
function compterRetours({ N, T, R, fixe }, tableDe) {
const toursAssis = [];
const retoursChoisis = [];
const retoursImposes = [];
const visites = new Int32Array(T);
const reservees = new Int32Array(T);
for (let p = 0; p < N; p += 1) {
let assis = 0;
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t === RESERVE) continue;
assis += 1;
visites[t] += 1;
if (fixe[p * R + r] === t) reservees[t] += 1;
}
let choisis = 0;
let imposes = 0;
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t === RESERVE || visites[t] === 0) continue;
const imposesTable = Math.max(0, reservees[t] - 1);
imposes += imposesTable;
choisis += visites[t] - 1 - imposesTable;
visites[t] = 0;
reservees[t] = 0;
}
toursAssis.push(assis);
retoursChoisis.push(choisis);
retoursImposes.push(imposes);
}
return { toursAssis, retoursChoisis, retoursImposes };
}
// Ajoute une valeur à une population. min reste null tant que la population
// est vide : aucun Infinity ne sert de minimum initial.
function ajouter(population, valeur) {
if (population.min === null || valeur < population.min) population.min = valeur;
population.somme += valeur;
population.effectif += 1;
}
// Agrégat d'une population ; la moyenne d'une population vide vaut null.
const conclure = ({ min, somme, effectif }) => ({
min,
moyenne: effectif === 0 ? null : somme / effectif,
effectif,
});
// Minimum, moyenne et effectif de valeurs[p] sur les trois populations, en un
// passage : chacun compte dans « tous », puis parmi les ancrés ou parmi les
// mobiles, qui comprennent les partiellement fixés (§ 5.4).
function troisAgregats(valeurs, statut) {
const tous = { min: null, somme: 0, effectif: 0 };
const mobiles = { min: null, somme: 0, effectif: 0 };
const ancres = { min: null, somme: 0, effectif: 0 };
for (let p = 0; p < valeurs.length; p += 1) {
ajouter(tous, valeurs[p]);
ajouter(statut[p] === ANCRE ? ancres : mobiles, valeurs[p]);
}
return { tous: conclure(tous), mobiles: conclure(mobiles), ancres: conclure(ancres) };
}
const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
/**
* Mesure complète d'un plan indexé : la mesure qui fait foi (§ 5.10). Ne
* modifie ni l'instance ni le plan. Lève RangeError quand tableDe n'a pas
* N × R cases, ou qu'une case n'est ni −1 ni un index de table.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe tableDe[p * R + r] = index de table, −1 pour la réserve
* @returns {Mesures}
*/
export function mesurer(instance, tableDe) {
exigerForme(instance, tableDe);
const { N, groupe, groupes, statut } = instance;
const paires = lirePaires(instance, compterRencontres(instance, tableDe));
const { collisions, distinctes } = paires;
const rencontres = Array.from(paires.rencontres);
const affilies = Array.from(paires.affilies);
const appartenancesVues = Array.from(paires.vues);
const redondance = affilies.map((f, p) => f - appartenancesVues[p]);
const diversite = affilies.map((f, p) => (f === 0 ? null : appartenancesVues[p] / f));
const effectifs = new Array(groupes.length).fill(0);
for (let p = 0; p < N; p += 1) {
if (groupe[p] !== SANS_GROUPE) effectifs[groupe[p]] += 1;
}
const parGroupe = groupes.map((libelle, g) => ({
groupe: libelle,
effectif: effectifs[g],
collisionsCumulees: collisions[g],
pairesDistinctes: distinctes[g],
excedent: collisions[g] - distinctes[g],
}));
const collisionsCumulees = somme(collisions);
const pairesDistinctes = somme(distinctes);
const { toursAssis, retoursChoisis, retoursImposes } = compterRetours(instance, tableDe);
return {
rencontres,
affilies,
appartenancesVues,
redondance,
diversite,
toursAssis,
retoursChoisis,
retoursImposes,
aggRencontres: troisAgregats(rencontres, statut),
aggRedondance: troisAgregats(redondance, statut),
collisionsCumulees,
pairesDistinctes,
excedentCollisions: collisionsCumulees - pairesDistinctes,
rencontresRepetees: paires.rencontresRepetees,
maxRencontresPaire: paires.maxRencontresPaire,
totalRetoursChoisis: somme(retoursChoisis),
totalRetoursImposes: somme(retoursImposes),
parGroupe,
};
}

View file

@ -0,0 +1,562 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des indicateurs (§ 5.4, § 12.6, § 14.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, une liste par table, et passe par normaliser et
// indexerPlan comme un plan enregistré.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { indexerPlan, normaliser } from './configuration.js';
import { mesurer } from './indicateurs.js';
const SANS_CONTRAINTE = {
separerAppartenances: false,
nouveauxVoisins: false,
nouvelleTable: false,
varierAppartenances: false,
};
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
// Agrégat d'une population vide.
const AUCUN = { min: null, moyenne: null, effectif: 0 };
// Appartenances des participants 1..N : le libellé du groupe qui contient
// l'identifiant, null hors de tout groupe. groupes est une liste de couples
// [libellé, identifiants].
function appartenances(N, groupes) {
return Array.from({ length: N }, (_, i) => {
const groupe = groupes.find(([, membres]) => membres.includes(i + 1));
return groupe === undefined ? null : groupe[0];
});
}
// Instance et plan indexé d'un plan écrit par identifiants. Le participant
// i + 1 porte appartenance[i] ; les tables portent les identifiants 1..T,
// toutes de la capacité donnée ; tours[r][i] liste les participants assis à
// la table i + 1 au tour r + 1, et reserves[r] ceux qui n'y sont assis nulle
// part.
function preparer({
appartenance,
capacite,
tours,
reserves = tours.map(() => []),
reservations = [],
}) {
const participants = appartenance.map((libelle, i) => ({
id: i + 1,
nom: `P${i + 1}`,
appartenance: libelle,
}));
const tables = tours[0].map((_, i) => ({ id: i + 1, numero: i + 1, capacite }));
const instance = normaliser({
participants,
tables,
tours: tours.length,
reservations,
contraintes: SANS_CONTRAINTE,
});
const plan = { tables: tables.map(({ id }) => id), tours, reserves };
return { instance, tableDe: indexerPlan(instance, plan) };
}
function mesurerPlan(description) {
const { instance, tableDe } = preparer(description);
return mesurer(instance, tableDe);
}
// Plan parfait de la petite démonstration (§ 15.3) : quatre tables de trois
// sièges, quatre tours, chaque table réunit à chaque tour un membre de A, de
// B et de C.
const PLAN_PARFAIT = [
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
[[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]],
[[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]],
[[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]],
];
const PETITE = appartenances(12, [
['A', [1, 5, 8, 12]],
['B', [2, 4, 9, 11]],
['C', [3, 6, 7, 10]],
]);
// Variante « conflit inévitable » : 10 passe de C à A.
const VARIANTE = appartenances(12, [
['A', [1, 5, 8, 10, 12]],
['B', [2, 4, 9, 11]],
['C', [3, 6, 7]],
]);
// Trois tables de trois sièges, quatre tours. 1 est ancré à la table 1 ; 2
// est mobile ; 3, réservé à la table 2 au tour 3, et 4, réservé à la table 3
// aux tours 1 et 3, sont partiellement fixés. Chaque réservation est tenue.
const PLAN_RESERVATIONS = {
appartenance: [null, null, null, null],
capacite: 3,
tours: [
[[1], [2, 3], [4]],
[[1, 3], [], [2, 4]],
[[1, 2], [3], [4]],
[[1, 4], [2], [3]],
],
reservations: [tous(1, 1), auTour(3, 2, 3), auTour(4, 3, 1), auTour(4, 3, 3)],
};
// Deux ancrés à la table 1, seule table, quatre tours.
const DEUX_ANCRES = {
appartenance: [null, null],
capacite: 2,
tours: [[[1, 2]], [[1, 2]], [[1, 2]], [[1, 2]]],
reservations: [tous(1, 1), tous(2, 1)],
};
describe('mesurer', () => {
test('plan parfait de la petite démonstration : huit rencontres chacun, ni collision ni répétition', () => {
// Aucun retour : chacun visite les quatre tables. Sans réservation, la
// population des ancrés est vide.
assert.deepEqual(mesurerPlan({ appartenance: PETITE, capacite: 3, tours: PLAN_PARFAIT }), {
rencontres: new Array(12).fill(8),
affilies: new Array(12).fill(8),
appartenancesVues: new Array(12).fill(2),
redondance: new Array(12).fill(6),
diversite: new Array(12).fill(0.25),
toursAssis: new Array(12).fill(4),
retoursChoisis: new Array(12).fill(0),
retoursImposes: new Array(12).fill(0),
aggRencontres: {
tous: { min: 8, moyenne: 8, effectif: 12 },
mobiles: { min: 8, moyenne: 8, effectif: 12 },
ancres: AUCUN,
},
aggRedondance: {
tous: { min: 6, moyenne: 6, effectif: 12 },
mobiles: { min: 6, moyenne: 6, effectif: 12 },
ancres: AUCUN,
},
collisionsCumulees: 0,
pairesDistinctes: 0,
excedentCollisions: 0,
rencontresRepetees: { choisies: 0, imposees: 0 },
maxRencontresPaire: 1,
totalRetoursChoisis: 0,
totalRetoursImposes: 0,
parGroupe: [
{ groupe: 'A', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
{ groupe: 'B', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
{ groupe: 'C', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
],
});
});
test('variante 5, 4, 3 : quatre collisions cumulées sur quatre paires distinctes, excédent nul', () => {
const mesures = mesurerPlan({ appartenance: VARIANTE, capacite: 3, tours: PLAN_PARFAIT });
const { collisionsCumulees, pairesDistinctes, excedentCollisions } = mesures;
assert.deepEqual(
{ collisionsCumulees, pairesDistinctes, excedentCollisions },
{ collisionsCumulees: 4, pairesDistinctes: 4, excedentCollisions: 0 },
);
assert.deepEqual(mesures.parGroupe, [
{ groupe: 'A', effectif: 5, collisionsCumulees: 4, pairesDistinctes: 4, excedent: 0 },
{ groupe: 'B', effectif: 4, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
{ groupe: 'C', effectif: 3, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
]);
// 1, 5, 8 et 12 croisent chacun 10, leur collègue : A(p) compte leur
// propre appartenance avec les deux autres. 10 croise A et B, jamais C.
assert.deepEqual(mesures.appartenancesVues, [3, 2, 2, 2, 3, 2, 2, 3, 2, 2, 2, 3]);
});
test("l'excédent compte les retrouvailles de collègues au-delà de la première", () => {
// Deux tables de quatre, trois tours ; X = {1, 2, 3, 4}, les autres sans
// appartenance.
const appartenance = appartenances(8, [['X', [1, 2, 3, 4]]]);
const collisions = ({ collisionsCumulees, pairesDistinctes, excedentCollisions, parGroupe }) => ({
collisionsCumulees,
pairesDistinctes,
excedentCollisions,
parGroupe,
});
const reparti = mesurerPlan({
appartenance,
capacite: 4,
tours: [
[[1, 2, 5, 6], [3, 4, 7, 8]],
[[1, 3, 5, 7], [2, 4, 6, 8]],
[[1, 4, 5, 8], [2, 3, 6, 7]],
],
});
assert.deepEqual(collisions(reparti), {
collisionsCumulees: 6,
pairesDistinctes: 6,
excedentCollisions: 0,
parGroupe: [{ groupe: 'X', effectif: 4, collisionsCumulees: 6, pairesDistinctes: 6, excedent: 0 }],
});
// 5 et 6, puis 7 et 8, se retrouvent aussi à chaque tour : deux personnes
// sans appartenance ne sont pas des collègues.
const concentre = mesurerPlan({
appartenance,
capacite: 4,
tours: [
[[1, 2, 5, 6], [3, 4, 7, 8]],
[[1, 2, 5, 6], [3, 4, 7, 8]],
[[1, 2, 5, 6], [3, 4, 7, 8]],
],
});
assert.deepEqual(collisions(concentre), {
collisionsCumulees: 6,
pairesDistinctes: 2,
excedentCollisions: 4,
parGroupe: [{ groupe: 'X', effectif: 4, collisionsCumulees: 6, pairesDistinctes: 2, excedent: 4 }],
});
});
test("une table vide et une table à un seul occupant n'ajoutent aucune paire ni aucune rencontre de soi", () => {
// Au tour 1, la table 3 est vide, 4 et 5 sont seuls à leur table. Au
// tour 2, 5 l'est encore, et la table 1 est vide.
const mesures = mesurerPlan({
appartenance: ['X', 'X', 'Y', 'X', null],
capacite: 3,
tours: [
[[1, 2, 3], [4], [], [5]],
[[], [1, 4], [2, 3], [5]],
],
});
assert.deepEqual(mesures.rencontres, [3, 2, 2, 1, 0]);
assert.deepEqual(mesures.affilies, [3, 2, 2, 1, 0]);
assert.deepEqual(mesures.appartenancesVues, [2, 2, 1, 1, 0]);
assert.deepEqual(mesures.redondance, [1, 0, 1, 0, 0]);
// 5 ne rencontre personne : son taux de diversité n'a pas de diviseur.
assert.deepEqual(mesures.diversite, [2 / 3, 1, 1 / 2, 1, null]);
// (1, 2) au tour 1 et (1, 4) au tour 2 ; 4, seul de X à sa table au tour
// 1, ne s'y croise pas lui-même.
assert.equal(mesures.collisionsCumulees, 2);
// (2, 3), réunis aux deux tours, est la seule paire répétée.
assert.equal(mesures.maxRencontresPaire, 2);
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 0 });
});
test("F(p) ne retient que les affiliés : qui ne croise aucun affilié n'a pas de taux de diversité", () => {
// 3, seul affilié de la table, rencontre deux personnes sans
// appartenance ; chacune d'elles rencontre un seul affilié, 3.
const mesures = mesurerPlan({ appartenance: [null, null, 'X'], capacite: 3, tours: [[[1, 2, 3]]] });
assert.deepEqual(mesures.rencontres, [2, 2, 2]);
assert.deepEqual(mesures.affilies, [1, 1, 0]);
assert.deepEqual(mesures.appartenancesVues, [1, 1, 0]);
assert.deepEqual(mesures.diversite, [1, 1, null]);
// La redondance se lit sur les affiliés rencontrés, |F(p)| − A(p), et non
// sur toutes les rencontres : personne ne recroise une appartenance.
assert.deepEqual(mesures.redondance, [0, 0, 0]);
assert.deepEqual(mesures.aggRedondance, {
tous: { min: 0, moyenne: 0, effectif: 3 },
mobiles: { min: 0, moyenne: 0, effectif: 3 },
ancres: AUCUN,
});
});
test("la réserve n'est pas une table : seuls les tours assis comptent", () => {
// 6 est en réserve aux tours 1 et 2, 5 au tour 2. Ils partagent
// l'appartenance X et ne sont jamais assis ensemble.
const mesures = mesurerPlan({
appartenance: [null, null, null, null, 'X', 'X'],
capacite: 3,
tours: [
[[1, 2, 5], [3, 4]],
[[1, 3], [2, 4]],
[[1, 4, 6], [2, 3, 5]],
],
reserves: [[6], [5, 6], []],
});
assert.deepEqual(mesures.toursAssis, [3, 3, 3, 3, 2, 1]);
assert.deepEqual(mesures.rencontres, [5, 4, 4, 4, 3, 2]);
assert.equal(mesures.collisionsCumulees, 0);
// Deux passages en réserve ne font pas un retour.
assert.deepEqual(mesures.retoursChoisis, [2, 1, 1, 1, 0, 0]);
// Une personne en réserve à un tour reste dans les agrégats.
assert.deepEqual(mesures.aggRencontres.tous, { min: 2, moyenne: 22 / 6, effectif: 6 });
});
test('les retours imposés par les réservations se comptent à part des retours choisis', () => {
// 1 visite quatre fois la table 1, réservée à chaque tour : trois retours
// imposés. 2 visite deux fois la table 2, sans réservation : un retour
// choisi. 3 est placé à la table 2 au tour 1, puis y revient au tour 3 où
// il y est réservé : un retour choisi. 4 visite trois fois la table 3,
// dont deux réservées : un retour imposé et un choisi.
const mesures = mesurerPlan(PLAN_RESERVATIONS);
assert.deepEqual(mesures.retoursImposes, [3, 0, 0, 1]);
assert.deepEqual(mesures.retoursChoisis, [0, 1, 1, 1]);
assert.equal(mesures.totalRetoursImposes, 4);
assert.equal(mesures.totalRetoursChoisis, 3);
});
test('les agrégats portent sur tous et rangent les partiellement fixés parmi les mobiles', () => {
// 1 est ancré, 2 mobile, 3 et 4 partiellement fixés.
const mesures = mesurerPlan(PLAN_RESERVATIONS);
assert.deepEqual(mesures.rencontres, [3, 3, 2, 2]);
assert.deepEqual(mesures.aggRencontres, {
tous: { min: 2, moyenne: 10 / 4, effectif: 4 },
mobiles: { min: 2, moyenne: 7 / 3, effectif: 3 },
ancres: { min: 3, moyenne: 3, effectif: 1 },
});
});
test('une population vide rend null, jamais 0 ni Infinity', () => {
const sansAncre = mesurerPlan({ appartenance: PETITE, capacite: 3, tours: PLAN_PARFAIT });
assert.deepEqual(sansAncre.aggRencontres.ancres, AUCUN);
assert.deepEqual(sansAncre.aggRedondance.ancres, AUCUN);
const sansMobile = mesurerPlan(DEUX_ANCRES);
assert.deepEqual(sansMobile.aggRencontres.mobiles, AUCUN);
assert.deepEqual(sansMobile.aggRedondance.mobiles, AUCUN);
});
test('deux ancrés à la même table : une rencontre répétée, imposée et non choisie', () => {
const mesures = mesurerPlan(DEUX_ANCRES);
assert.deepEqual(mesures.rencontresRepetees, { choisies: 0, imposees: 1 });
assert.equal(mesures.maxRencontresPaire, 4);
assert.deepEqual(mesures.retoursImposes, [3, 3]);
assert.deepEqual(mesures.retoursChoisis, [0, 0]);
});
test('une seule rencontre hors réservation rend la répétition choisie', () => {
// 1 et 2 sont ancrés à la table 1 ; 3 y est réservé au seul tour 1 et y
// reste au tour 2 ; 4 est mobile. (1, 2) se réunit quatre fois, chaque
// fois réservés tous deux ; (1, 3) et (2, 3) au tour 1 réservés, au tour
// 2 non ; (3, 4) aux tours 3 et 4, sans réservation.
const mesures = mesurerPlan({
appartenance: [null, null, null, null],
capacite: 3,
tours: [
[[1, 2, 3], [4]],
[[1, 2, 3], [4]],
[[1, 2], [3, 4]],
[[1, 2], [3, 4]],
],
reservations: [tous(1, 1), tous(2, 1), auTour(3, 1, 1)],
});
assert.deepEqual(mesures.rencontresRepetees, { choisies: 3, imposees: 1 });
});
describe('une rencontre hors réservation rend la répétition choisie, quels que soient son rang et le membre non réservé', () => {
// 1 et 2 sont assis ensemble à la table 1 aux trois tours. L'un y est
// ancré ; l'autre n'y est réservé qu'aux deux tours autres que « libre ».
// La rencontre de ce tour-là n'est réservée que pour l'ancré : la
// répétition est choisie, que cette rencontre soit la première, celle du
// milieu ou la dernière, et que le membre non réservé soit le premier de
// la paire ou le second. Le partiellement fixé visite trois fois la
// table, dont deux réservées : un retour imposé, un choisi. L'ancré,
// réservé aux trois visites, compte deux retours imposés. Chaque cas est
// une épreuve : un écart nomme chacun des cas qu'il touche.
const cas = [
{ libre: 1, partiel: 1, ancre: 2, retoursImposes: [1, 2], retoursChoisis: [1, 0] },
{ libre: 2, partiel: 1, ancre: 2, retoursImposes: [1, 2], retoursChoisis: [1, 0] },
{ libre: 3, partiel: 1, ancre: 2, retoursImposes: [1, 2], retoursChoisis: [1, 0] },
{ libre: 1, partiel: 2, ancre: 1, retoursImposes: [2, 1], retoursChoisis: [0, 1] },
{ libre: 2, partiel: 2, ancre: 1, retoursImposes: [2, 1], retoursChoisis: [0, 1] },
{ libre: 3, partiel: 2, ancre: 1, retoursImposes: [2, 1], retoursChoisis: [0, 1] },
];
for (const { libre, partiel, ancre, retoursImposes, retoursChoisis } of cas) {
test(`rencontre du tour ${libre} réservée pour ${ancre} seul`, () => {
const mesures = mesurerPlan({
appartenance: [null, null],
capacite: 2,
tours: [[[1, 2]], [[1, 2]], [[1, 2]]],
reservations: [
tous(ancre, 1),
...[1, 2, 3].filter((tour) => tour !== libre).map((tour) => auTour(partiel, 1, tour)),
],
});
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 0 });
assert.deepEqual(mesures.retoursImposes, retoursImposes);
assert.deepEqual(mesures.retoursChoisis, retoursChoisis);
});
}
});
test('une répétition imposée se lit sur les réservations du tour des deux membres, ancrés ou non', () => {
// 2 est ancré à la table 1 ; 3 et 4, réservés à la table 2 aux tours 2 et
// 3, sont partiellement fixés ; 1 est mobile. (1, 2) se réunit aux trois
// tours : 1, le premier membre de la paire, n'est jamais réservé, la
// répétition est choisie. (3, 4) se réunit aux tours 2 et 3, chaque fois
// à la table où tous deux sont réservés ce tour-là : la répétition est
// imposée, bien qu'aucun des deux ne soit ancré et que 3 ne soit réservé
// nulle part au tour 1.
const mesures = mesurerPlan({
appartenance: [null, null, null, null],
capacite: 3,
tours: [
[[1, 2, 4], [3]],
[[1, 2], [3, 4]],
[[1, 2], [3, 4]],
],
reservations: [tous(2, 1), auTour(3, 2, 2), auTour(3, 2, 3), auTour(4, 2, 2), auTour(4, 2, 3)],
});
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 1 });
});
test("une visite n'est réservée qu'à la table de la réservation : un plan qui ne la tient pas n'impose rien", () => {
// 1 et 2 sont ancrés à la table 1 ; le plan les assoit ensemble à la
// table 2 aux deux tours. Un tel plan, en dérive, se conserve et se
// mesure (§ 9) : la réservation qu'il ne tient pas n'impose ni leurs
// retours ni leur répétition, qui sont choisis.
const mesures = mesurerPlan({
appartenance: [null, null],
capacite: 2,
tours: [
[[], [1, 2]],
[[], [1, 2]],
],
reservations: [tous(1, 1), tous(2, 1)],
});
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 0 });
assert.deepEqual(mesures.retoursImposes, [0, 0]);
assert.deepEqual(mesures.retoursChoisis, [1, 1]);
});
test("un seul membre réservé ailleurs suffit à rendre la répétition choisie : réservé ce tour-là ne suffit pas, il faut l'être à cette table", () => {
// Deux plans en dérive, la paire assise à la table 2 aux deux tours. Dans
// le premier, le premier membre est réservé à la table 1 et le second à la
// table 2 ; dans le second, l'inverse. Chacun des deux membres est donc
// tour à tour celui qui n'est pas réservé à la table de la rencontre.
for (const reservations of [[tous(1, 1), tous(2, 2)], [tous(1, 2), tous(2, 1)]]) {
const mesures = mesurerPlan({
appartenance: [null, null],
capacite: 2,
tours: [
[[], [1, 2]],
[[], [1, 2]],
],
reservations,
});
assert.deepEqual(
mesures.rencontresRepetees,
{ choisies: 1, imposees: 0 },
JSON.stringify(reservations),
);
}
});
test("le compte d'une paire tient 65 536 tours", () => {
const R = 65_536;
const instance = normaliser({
participants: [
{ id: 1, nom: 'P1', appartenance: null },
{ id: 2, nom: 'P2', appartenance: null },
],
tables: [{ id: 1, numero: 1, capacite: 2 }],
tours: R,
reservations: [],
contraintes: SANS_CONTRAINTE,
});
// Toutes les cases valent 0 : les deux participants partagent la table
// d'index 0 à chaque tour.
const mesures = mesurer(instance, new Int32Array(2 * R));
assert.equal(mesures.maxRencontresPaire, R);
assert.deepEqual(mesures.rencontres, [1, 1]);
assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 0 });
});
test('personne à la même table : aucune rencontre, maximum par paire à 0', () => {
const mesures = mesurerPlan({
appartenance: ['X', 'X'],
capacite: 2,
tours: [
[[1], [2]],
[[2], [1]],
],
});
assert.deepEqual(mesures.rencontres, [0, 0]);
assert.deepEqual(mesures.diversite, [null, null]);
assert.equal(mesures.maxRencontresPaire, 0);
assert.equal(mesures.collisionsCumulees, 0);
// L'effectif d'un groupe compte tous ses membres, qu'ils rencontrent
// quelqu'un ou non.
assert.deepEqual(mesures.parGroupe, [
{ groupe: 'X', effectif: 2, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
]);
});
test("parGroupe suit l'ordre de instance.groupes, non l'ordre alphabétique", () => {
// instance.groupes range les appartenances par première apparition, en
// parcourant les identifiants croissants : Y, portée par 1, puis X, par
// 2. 1 et 3, tous deux de Y, se rencontrent une fois.
const mesures = mesurerPlan({ appartenance: ['Y', 'X', 'Y'], capacite: 3, tours: [[[1, 2, 3]]] });
assert.deepEqual(mesures.parGroupe, [
{ groupe: 'Y', effectif: 2, collisionsCumulees: 1, pairesDistinctes: 1, excedent: 0 },
{ groupe: 'X', effectif: 1, collisionsCumulees: 0, pairesDistinctes: 0, excedent: 0 },
]);
});
test('sans participant présent : tableaux vides, agrégats à null, compteurs à 0', () => {
const instance = normaliser({
participants: [{ id: 1, nom: 'P1', appartenance: 'X', exclu: true }],
tables: [{ id: 1, numero: 1, capacite: 2 }],
tours: 2,
reservations: [],
contraintes: SANS_CONTRAINTE,
});
assert.deepEqual(mesurer(instance, new Int32Array(0)), {
rencontres: [],
affilies: [],
appartenancesVues: [],
redondance: [],
diversite: [],
toursAssis: [],
retoursChoisis: [],
retoursImposes: [],
aggRencontres: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
aggRedondance: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN },
collisionsCumulees: 0,
pairesDistinctes: 0,
excedentCollisions: 0,
rencontresRepetees: { choisies: 0, imposees: 0 },
maxRencontresPaire: 0,
totalRetoursChoisis: 0,
totalRetoursImposes: 0,
parGroupe: [],
});
});
test("un plan indexé qui n'a pas la forme de l'instance lève RangeError, qui nomme le participant et le tour", () => {
const { instance, tableDe } = preparer({ appartenance: PETITE, capacite: 3, tours: PLAN_PARFAIT });
// Douze participants, quatre tours : 48 cases, ni une de moins ni une de
// trop.
assert.throws(() => mesurer(instance, tableDe.subarray(1)), RangeError);
assert.throws(() => mesurer(instance, Int32Array.of(...tableDe, 0)), RangeError);
// Quatre tables : une case vaut −1 ou un index entier de 0 à 3. Les
// chaînes "0" et "-1" ne sont ni l'un ni l'autre : une comparaison qui
// convertit lirait l'une comme la table d'index 0, l'autre comme la
// réserve. Chaque intrus se pose à la première case, à une case du milieu
// et à la dernière.
for (const intrus of [4, -2, 1.5, '0', '-1']) {
for (const k of [0, 5, 47]) {
const fautif = Array.from(tableDe);
fautif[k] = intrus;
assert.throws(() => mesurer(instance, fautif), RangeError, `case ${k} à ${JSON.stringify(intrus)}`);
}
}
// La case p × R + r est celle du participant d'index p au tour r + 1 : la
// dernière est celle de 12 au tour 4. Le message cite une chaîne entre
// guillemets, pour qu'elle ne se lise pas comme l'index qu'elle contient.
const fautif = Array.from(tableDe);
fautif[47] = '0';
assert.throws(() => mesurer(instance, fautif), {
name: 'RangeError',
message: /participant 12, tour 4, index de table "0" hors de −1\.\.3/,
});
});
test("mesurer ne modifie ni l'instance ni le plan", () => {
const { instance, tableDe } = preparer(PLAN_RESERVATIONS);
const avant = {
tableDe: tableDe.slice(),
fixe: instance.fixe.slice(),
statut: instance.statut.slice(),
groupe: instance.groupe.slice(),
};
mesurer(instance, tableDe);
assert.deepEqual(
{ tableDe, fixe: instance.fixe, statut: instance.statut, groupe: instance.groupe },
avant,
);
});
});