diff --git a/src/moteur/diagnostic.js b/src/moteur/diagnostic.js new file mode 100644 index 0000000..26b590c --- /dev/null +++ b/src/moteur/diagnostic.js @@ -0,0 +1,288 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Diagnostic d'une configuration, avant toute recherche (§ 5.6) : ce que la +// configuration impose à tout plan, chiffré. Un plancher est une borne +// inférieure démontrée sur les plans que produit la recherche, ceux qui +// assoient chacun à chaque tour et honorent les réservations ; un plan édité +// à la main qui laisse quelqu'un en réserve (§ 12.6) peut mesurer moins. Un +// plancher s'écrit là où le diagnostic sait le prouver, et vaut null +// ailleurs, jamais 0 par défaut. +// +// Les quantités que le moteur calcule déjà viennent de leur unique +// implémentation (§ 13.2) : places manquantes de configuration.js, plafond a +// priori de plafond.js, retours imposés de la mesure d'indicateurs.js. Le +// diagnostic ne modifie pas la configuration et lève ce que normaliser lève. + +/** + * @typedef {Object} PlancherCollisions + * @property {string} groupe libellé de l'appartenance + * @property {number} effectif membres présents + * @property {number} plancherCumulees collisions cumulées, sur les R tours + * @property {number} plancherPairesDistinctes + * @property {number|null} plancherExcedent null quand il n'est pas + * strictement positif + * + * @typedef {Object} Diagnostic + * @property {number} placesManquantes max(0, N − Σ c_t), par tour + * @property {{min: number|null, max: number|null, parPersonne: number[]}} plafondAPriori + * parPersonne dans l'ordre de instance.ids ; min et max null pour + * une population vide + * @property {PlancherCollisions[]} collisions groupes dont le plancher de + * collisions cumulées est > 0, dans l'ordre de instance.groupes + * @property {number|null} redondanceMinimale null sans affilié + * @property {boolean} animateursMemeAppartenance + * @property {number} redondanceImposeeParAnimateurs min(R, T) − 1 ou 0 + * @property {Array<{table: number, ancres: number, capacite: number, gelee: boolean}>} ancrage + * chaque table, par identifiant, dans l'ordre de la configuration ; + * gelee quand ses ancrés occupent tous ses sièges + * @property {number} retoursImposes + * @property {{plancher: number, mobilesAuMoins: number}|null} ecartItineraire + */ +import { nombrePlacesManquantes, normaliser } from './configuration.js'; +import { mesurer } from './indicateurs.js'; +import { plafondsAPriori } from './plafond.js'; + +// Statuts d'un participant dans instance.statut (§ 4.2). +const MOBILE = 0; +const PARTIELLEMENT_FIXE = 1; +const ANCRE = 2; +// Case de instance.fixe sans table imposée. +const LIBRE = -1; +// instance.groupe d'un participant sans appartenance. +const SANS_GROUPE = -1; + +// Paires que forment n personnes : C(n, 2). +const paires = (n) => (n * (n - 1)) / 2; + +// Plus petit nombre de paires d'un groupe qu'un tour réunit. poses[t] compte +// les membres que les réservations assoient à la table t ce tour-là ; libres, +// les autres. Chaque libre va, l'un après l'autre, à la table qui compte le +// moins de membres, la première par index à égalité, et y forme une paire +// avec chacun. Le coût Σ C(x_t, 2) est séparable et convexe : ce remplissage +// glouton en donne le minimum sous la seule contrainte x_t ≥ poses[t]. Les +// capacités n'y entrent pas : relâchées, elles laissent un minimum qui minore +// celui de tout plan. Sans table, personne ne s'assied et aucune paire ne se +// forme. +function pairesMinimales(poses, libres) { + const T = poses.length; + if (T === 0) return 0; + const membres = Int32Array.from(poses); + for (let i = 0; i < libres; i += 1) { + let moins = 0; + for (let t = 1; t < T; t += 1) if (membres[t] < membres[moins]) moins = t; + membres[moins] += 1; + } + let total = 0; + for (let t = 0; t < T; t += 1) total += paires(membres[t]); + return total; +} + +// Plancher de collisions de chaque groupe (§ 5.6), dans l'ordre de +// instance.groupes ; un groupe n'y figure que si son plancher de collisions +// cumulées est positif. À chaque tour, les membres que les réservations fixent +// à ce tour, ancrés comme partiellement fixés, sont posés à leur table, et +// pairesMinimales place les autres. Les paires d'un même tour sont +// distinctes : le plus grand minimum d'un tour minore les paires distinctes, +// la somme des minimums les collisions cumulées. Un groupe de s membres +// n'offre que C(s, 2) paires distinctes : l'excédent, cumulées − distinctes, +// ne descend pas sous plancherCumulees − C(s, 2), écrit quand c'est positif. +function planchersCollisions({ N, T, R, groupe, groupes, fixe }) { + const membres = groupes.map(() => []); + for (let p = 0; p < N; p += 1) if (groupe[p] !== SANS_GROUPE) membres[groupe[p]].push(p); + const planchers = []; + for (let g = 0; g < groupes.length; g += 1) { + let cumulees = 0; + let distinctes = 0; + for (let r = 0; r < R; r += 1) { + const poses = new Int32Array(T); + let libres = 0; + for (const p of membres[g]) { + const t = fixe[p * R + r]; + if (t === LIBRE) libres += 1; + else poses[t] += 1; + } + const minimum = pairesMinimales(poses, libres); + cumulees += minimum; + distinctes = Math.max(distinctes, minimum); + } + if (cumulees === 0) continue; + const excedent = cumulees - paires(membres[g].length); + planchers.push({ + groupe: groupes[g], + effectif: membres[g].length, + plancherCumulees: cumulees, + plancherPairesDistinctes: distinctes, + plancherExcedent: excedent > 0 ? excedent : null, + }); + } + return planchers; +} + +// Redondance minimale (§ 5.6), r_min(p) = max(0, |F(p)| − G) : A(p) ne +// dépasse pas G, et r(p) = |F(p)| − A(p). Elle s'évalue au plus grand |F(p)| +// possible, le plus petit du plafond a priori de p et du nombre d'affiliés +// autres que lui : elle minore la redondance de qui rencontre autant +// d'affiliés, non celle d'un plan qui lui en fait rencontrer moins. Rend le +// plus grand r_min(p) sur tous les participants ; null quand aucun n'est +// affilié. +function redondanceMinimale({ N, groupe, groupes }, aPriori) { + const G = groupes.length; + if (G === 0) return null; + let affilies = 0; + for (let p = 0; p < N; p += 1) if (groupe[p] !== SANS_GROUPE) affilies += 1; + let plusGrande = 0; + for (let p = 0; p < N; p += 1) { + const autres = groupe[p] === SANS_GROUPE ? affilies : affilies - 1; + plusGrande = Math.max(plusGrande, Math.min(aPriori[p], autres) - G); + } + return plusGrande; +} + +// Vrai quand les ancrés, les animateurs, sont au moins deux et portent tous +// la même appartenance déclarée (§ 5.6). +function animateursMemeAppartenance({ N, statut, groupe }) { + let commun = SANS_GROUPE; + let animateurs = 0; + for (let p = 0; p < N; p += 1) { + if (statut[p] !== ANCRE) continue; + if (groupe[p] === SANS_GROUPE || (animateurs > 0 && groupe[p] !== commun)) return false; + commun = groupe[p]; + animateurs += 1; + } + return animateurs >= 2; +} + +// Ancrés et capacité de chaque table, dans l'ordre de l'instance, qui est +// celui de la configuration ; gelee quand les ancrés occupent tous les sièges +// (§ 5.9). +function ancrage({ T, idsTables, capacite, ancresParTable }) { + return Array.from({ length: T }, (_, t) => ({ + table: idsTables[t], + ancres: ancresParTable[t], + capacite: capacite[t], + gelee: ancresParTable[t] === capacite[t], + })); +} + +// Plafond du meilleur itinéraire d'un mobile p qui passe par la table t : le +// plafond a priori de p, la table t réservée au tour 1. La formule ne lit pas +// l'ordre des tours. Pour R ≥ 2, la réservation d'un seul tour rend p +// partiellement fixé (§ 4.2), ce qui ne change ni n ni les ancrés (§ 5.9) : +// seul l'itinéraire est contraint. Pour R = 1, elle couvre chacun des R tours +// et ancre p à la table t : a_t vu de p l'exclut, et n_p, le n de la sonde, +// vaut n − 1 comme pour un mobile ; le plafond est celui du même itinéraire. +// Sans partiellement fixé, seuls les ancrés occupent t au tour 1 ; une table +// où ils laissent un siège reçoit la réservation sans que normaliser lève +// SURRESERVATION. +function plafondPassantPar(configuration, instance, p, t) { + const sonde = normaliser({ + ...configuration, + reservations: [ + ...configuration.reservations, + { participant: instance.ids[p], table: instance.idsTables[t], portee: 'tour', tour: 1 }, + ], + }); + return plafondsAPriori(sonde)[p]; +} + +// Plancher de l'écart d'itinéraire maximal (§ 12.10.4), écrit pour une +// configuration tendue, Σ c_t = N, sans partiellement fixé ; null ailleurs. +// Là, tout plan qui assied chacun remplit chaque siège à chaque tour : +// l'occupation vaut la capacité, et le plafond réalisé d'un mobile vaut le +// plafond de son itinéraire. Les mobiles partagent alors un plafond a priori +// P*. Une table est inférieure quand le meilleur itinéraire qui passe par elle +// vaut P*_t < P*. Ses c_t − k_t sièges mobiles reçoivent un mobile à chacun +// des R tours : M_inf sièges-tours sur les tables inférieures, dont un mobile +// n'occupe que R au plus. Au moins ⌈M_inf / R⌉ mobiles passent donc par une +// table inférieure et finissent au moins P* − P*_t sous leur plafond a +// priori. Sans table inférieure, rien n'est prouvé : null. +// +// Deux tables de même capacité et de mêmes ancrés s'échangent sans changer +// aucun plafond : P*_t se calcule une fois par couple (c_t, k_t), que la Map +// retrouve. Le couple ne se réduit pas aux sièges libres c_t − k_t : à sièges +// libres égaux, les sièges qui tournent sont les mêmes, et les k_t ancrés +// s'ajoutent au plafond hors du terme que n_p borne. +function ecartItineraire(configuration, instance, aPriori) { + const { N, T, R, capacite, ancresParTable, statut } = instance; + let places = 0; + for (let t = 0; t < T; t += 1) places += capacite[t]; + if (places !== N || statut.includes(PARTIELLEMENT_FIXE)) return null; + const mobile = statut.indexOf(MOBILE); + if (mobile === -1) return null; + const plafondCommun = aPriori[mobile]; + const parGabarit = new Map(); + let siegesToursInferieurs = 0; + let plancher = null; + for (let t = 0; t < T; t += 1) { + const sieges = capacite[t] - ancresParTable[t]; + // Une table gelée n'a aucun siège mobile, aucun itinéraire n'y passe, et + // la sonde la surréserverait. + if (sieges === 0) continue; + const gabarit = `${capacite[t]}|${ancresParTable[t]}`; + let passant = parGabarit.get(gabarit); + if (passant === undefined) { + passant = plafondPassantPar(configuration, instance, mobile, t); + parGabarit.set(gabarit, passant); + } + if (passant < plafondCommun) { + siegesToursInferieurs += sieges * R; + const ecartPassant = plafondCommun - passant; + if (plancher === null || ecartPassant < plancher) plancher = ecartPassant; + } + } + if (siegesToursInferieurs === 0) return null; + return { plancher, mobilesAuMoins: Math.ceil(siegesToursInferieurs / R) }; +} + +// Plus petite et plus grande valeur, null pour une liste vide. +function etendue(valeurs) { + if (valeurs.length === 0) return { min: null, max: null }; + let min = valeurs[0]; + let max = valeurs[0]; + for (const valeur of valeurs) { + if (valeur < min) min = valeur; + if (valeur > max) max = valeur; + } + return { min, max }; +} + +/** + * Diagnostic d'une configuration, avant la recherche (§ 5.6). + * + * - placesManquantes : nombrePlacesManquantes ; la recherche refuse une + * configuration où elles sont positives, le diagnostic les chiffre (§ 5.9). + * - plafondAPriori : plafondsAPriori, et son étendue. + * - collisions : planchers de collisions par groupe, posés sur les tours + * fixés, capacités relâchées. + * - redondanceMinimale : max_p max(0, min(a priori(p), affiliés autres que + * p) − G). + * - animateursMemeAppartenance, et redondanceImposeeParAnimateurs : la forme + * close min(R, T) − 1 du § 5.6, qui compte un animateur à chaque table + * visitée ; 0 quand les animateurs ne partagent pas une appartenance. + * - ancrage : ancrés, capacité et gel de chaque table. + * - retoursImposes : la rangée de instance.fixe se lit comme le plan qui + * assied chacun aux seuls tours fixés, en réserve ailleurs ; sa mesure + * compte max(0, f − 1) retours pour f tours réservés à une même table, ce + * que mesure tout plan qui honore les réservations (§ 5.4). + * - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null. + * + * @param {import('./types.js').Configuration} configuration + * @returns {Diagnostic} + */ +export function diagnostiquer(configuration) { + const instance = normaliser(configuration); + const aPriori = plafondsAPriori(instance); + const memeAppartenance = animateursMemeAppartenance(instance); + return { + placesManquantes: nombrePlacesManquantes(instance), + plafondAPriori: { ...etendue(aPriori), parPersonne: aPriori }, + collisions: planchersCollisions(instance), + redondanceMinimale: redondanceMinimale(instance, aPriori), + animateursMemeAppartenance: memeAppartenance, + redondanceImposeeParAnimateurs: memeAppartenance ? Math.min(instance.R, instance.T) - 1 : 0, + ancrage: ancrage(instance), + retoursImposes: mesurer(instance, instance.fixe).totalRetoursImposes, + ecartItineraire: ecartItineraire(configuration, instance, aPriori), + }; +} diff --git a/src/moteur/diagnostic.test.js b/src/moteur/diagnostic.test.js new file mode 100644 index 0000000..25b36cf --- /dev/null +++ b/src/moteur/diagnostic.test.js @@ -0,0 +1,1047 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du diagnostic (§ 5.6) : les valeurs que le § 15 donne pour le +// catalogue, chaque règle sur une salle écrite à la main, et, sur des salles +// minuscules, l'énumération de tous les plans, qui arbitre les planchers sans +// rien lire du code éprouvé (§ 14.11) : le plus petit chiffre que mesure un +// plan ne descend sous aucun plancher, et l'atteint là où le commentaire du +// test montre le plan qui l'atteint. Les autres valeurs attendues se calculent +// à la main, dans le commentaire qui les précède. +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 { diagnostiquer } from './diagnostic.js'; +import { ErreurConfiguration } from './erreurs.js'; +import { mesurer } from './indicateurs.js'; +import { plafondItineraire, plafondsAPriori, plafondsRealises } from './plafond.js'; + +const SANS_CONTRAINTE = { + separerAppartenances: false, + nouveauxVoisins: false, + nouvelleTable: false, + varierAppartenances: false, +}; + +// instance.statut d'un mobile, d'un ancré. +const MOBILE = 0; +const ANCRE = 2; + +/** + * Configuration écrite à la main. tables porte une paire [id, capacité] par + * table, dans l'ordre de la configuration, le numéro suivant la position ; + * appartenances[j] est celle du participant d'id j + 1 ; exclus nomme des ids. + * Les identifiants de table diffèrent des index et des numéros : un index lu + * comme un identifiant désigne une table absente. + */ +function salle({ tables, appartenances, tours, reservations = [], exclus = [] }) { + return { + participants: appartenances.map((appartenance, j) => ({ + id: j + 1, + nom: `P${j + 1}`, + appartenance, + exclu: exclus.includes(j + 1), + })), + tables: tables.map(([id, capacite], i) => ({ id, numero: i + 1, capacite })), + tours, + reservations, + contraintes: SANS_CONTRAINTE, + }; +} + +const ancrer = (participant, table) => ({ participant, table, portee: 'tous' }); +const fixer = (participant, table, tour) => ({ participant, table, portee: 'tour', tour }); + +// n fois la même appartenance. +const fois = (n, appartenance) => Array(n).fill(appartenance); + +function construire(cle) { + const entree = CATALOGUE.find((e) => e.cle === cle); + assert.ok(entree, `démonstration ${cle} absente du catalogue`); + return entree.construire(); +} + +/** + * Appelle visiter(tableDe) pour chaque plan indexé qui assied chacun à chaque + * tour, honore chaque tour fixé et ne dépasse aucune capacité. Les cases + * [p × R + r] se remplissent dans l'ordre ; le même tableau sert à tous les + * plans. Rend le nombre de plans visités. + */ +function enumererPlans(instance, visiter) { + const { N, T, R, capacite, fixe } = instance; + const tableDe = new Int32Array(N * R); + const occupation = new Int32Array(T * R); + const toutes = Array.from({ length: T }, (_, t) => t); + let plans = 0; + const remplir = (i) => { + if (i === N * R) { + plans += 1; + visiter(tableDe); + return; + } + const r = i % R; + for (const t of fixe[i] === -1 ? toutes : [fixe[i]]) { + if (occupation[t * R + r] === capacite[t]) continue; + occupation[t * R + r] += 1; + tableDe[i] = t; + remplir(i + 1); + occupation[t * R + r] -= 1; + } + }; + remplir(0); + return plans; +} + +/** + * Sur tous les plans de la configuration, la plus petite mesure de chaque + * groupe : { cumulees, distinctes, excedent }, par libellé. Chaque minimum + * vient de son propre plan. + */ +function minimumsParGroupe(configuration) { + const instance = normaliser(configuration); + const minimums = new Map(); + for (const groupe of instance.groupes) { + minimums.set(groupe, { cumulees: Infinity, distinctes: Infinity, excedent: Infinity }); + } + const plans = enumererPlans(instance, (tableDe) => { + for (const mesure of mesurer(instance, tableDe).parGroupe) { + const minimum = minimums.get(mesure.groupe); + minimum.cumulees = Math.min(minimum.cumulees, mesure.collisionsCumulees); + minimum.distinctes = Math.min(minimum.distinctes, mesure.pairesDistinctes); + minimum.excedent = Math.min(minimum.excedent, mesure.excedent); + } + }); + assert.ok(plans > 0, 'aucun plan énuméré'); + return minimums; +} + +/** + * Sur tous les plans de la configuration : le plus petit écart d'itinéraire + * maximal, sur tous les participants, et le plus petit nombre de mobiles dont + * l'écart d'itinéraire atteint seuil. Chaque minimum vient de son propre plan. + */ +function ecartsItineraireSurTousLesPlans(configuration, seuil) { + const instance = normaliser(configuration); + const aPriori = plafondsAPriori(instance); + let plusPetitMaximum = Infinity; + let moinsDeMobiles = Infinity; + const plans = enumererPlans(instance, (tableDe) => { + const realises = plafondsRealises(instance, tableDe); + let maximum = 0; + let mobiles = 0; + for (let p = 0; p < instance.N; p += 1) { + const ecartItineraire = aPriori[p] - realises[p]; + maximum = Math.max(maximum, ecartItineraire); + if (instance.statut[p] !== ANCRE && ecartItineraire >= seuil) mobiles += 1; + } + plusPetitMaximum = Math.min(plusPetitMaximum, maximum); + moinsDeMobiles = Math.min(moinsDeMobiles, mobiles); + }); + assert.ok(plans > 0, 'aucun plan énuméré'); + return { plusPetitMaximum, moinsDeMobiles }; +} + +/** + * Pour le premier mobile, dans l'ordre de instance.ids : son plafond a + * priori, et, par index de table, le plafond du meilleur itinéraire qui + * visite la table, null pour une table qu'aucun itinéraire ne visite. Chaque + * itinéraire — à chaque tour, une table où les ancrés laissent un siège — est + * évalué par plafondItineraire, sans rien lire du diagnostic. + */ +function meilleursItineraires(configuration) { + const instance = normaliser(configuration); + const { T, R, capacite, ancresParTable } = instance; + const mobile = instance.statut.indexOf(MOBILE); + assert.notEqual(mobile, -1, 'aucun mobile'); + const admissibles = []; + for (let t = 0; t < T; t += 1) if (ancresParTable[t] < capacite[t]) admissibles.push(t); + const parTable = Array(T).fill(null); + let plafondAPriori = null; + let itineraires = 0; + const itineraire = new Int32Array(R); + const parcourir = (r) => { + if (r === R) { + itineraires += 1; + const plafond = plafondItineraire(instance, mobile, itineraire); + plafondAPriori = Math.max(plafondAPriori ?? plafond, plafond); + for (const t of itineraire) parTable[t] = Math.max(parTable[t] ?? plafond, plafond); + return; + } + for (const t of admissibles) { + itineraire[r] = t; + parcourir(r + 1); + } + }; + parcourir(0); + assert.ok(itineraires > 0, 'aucun itinéraire énuméré'); + return { plafondAPriori, parTable }; +} + +describe('diagnostic : le catalogue (§ 15)', () => { + // § 15.1 : 260 sièges pour 260 membres ; les animateurs des tables de 7 + // plafonnent à 24, tout autre à 28 ; 33 ancrages de 4 tours, 3 retours + // chacun. Les tables de 7 offrent 4 × 6 sièges mobiles à chaque tour, 96 + // sièges-tours qu'un mobile n'occupe que 4 fois au plus : 24 mobiles au + // moins passent par une table de 7, dont le meilleur itinéraire vaut 27 + // contre 28. Chaque appartenance compte au plus 12 membres pour 33 tables, + // et les 74 appartenances tirées dépassent tout plafond a priori. + test('grande : aucune place ne manque, a priori de 24 à 28, 99 retours imposés, écart d’itinéraire plancher à 1 pour 24 mobiles', () => { + const configuration = construire('grande'); + const diagnostic = diagnostiquer(configuration); + assert.equal(diagnostic.placesManquantes, 0); + assert.equal(diagnostic.plafondAPriori.min, 24); + assert.equal(diagnostic.plafondAPriori.max, 28); + // parPersonne suit l'ordre de instance.ids : 24 à l'index de chaque + // animateur d'une table de 7, lu dans les réservations, 28 à tout autre. + const tablesDeSept = new Set( + configuration.tables.filter(({ capacite }) => capacite === 7).map(({ id }) => id), + ); + const { indexDe } = normaliser(configuration); + const animateursDeSept = new Set( + configuration.reservations + .filter(({ table }) => tablesDeSept.has(table)) + .map(({ participant }) => indexDe.get(participant)), + ); + assert.equal(animateursDeSept.size, 4); + assert.deepEqual( + diagnostic.plafondAPriori.parPersonne, + Array.from({ length: 260 }, (_, p) => (animateursDeSept.has(p) ? 24 : 28)), + ); + assert.equal(diagnostic.retoursImposes, 99); + assert.deepEqual(diagnostic.ecartItineraire, { plancher: 1, mobilesAuMoins: 24 }); + assert.equal(diagnostic.animateursMemeAppartenance, false); + assert.equal(diagnostic.redondanceImposeeParAnimateurs, 0); + assert.deepEqual(diagnostic.collisions, []); + assert.equal(diagnostic.redondanceMinimale, 0); + assert.deepEqual( + diagnostic.ancrage, + Array.from({ length: 33 }, (_, t) => ({ + table: t + 1, + ancres: 1, + capacite: t < 29 ? 8 : 7, + gelee: false, + })), + ); + }); + + // 264 sièges pour 260 membres : la configuration n'est pas tendue. + test('grande sans exception : aucun plancher d’écart d’itinéraire', () => { + const diagnostic = diagnostiquer(construire('grande-sans-exception')); + assert.equal(diagnostic.ecartItineraire, null); + assert.equal(diagnostic.placesManquantes, 0); + }); + + // Quatre membres par appartenance, quatre tables : chacun la sienne. La + // salle est tendue, 12 sièges pour 12, mais ses tables sont identiques : + // aucune n'est inférieure, et aucun plancher ne s'écrit. + test('petite : aucun plancher de collisions, aucun plancher d’écart d’itinéraire', () => { + const diagnostic = diagnostiquer(construire('petite')); + assert.deepEqual(diagnostic.collisions, []); + assert.equal(diagnostic.ecartItineraire, null); + }); + + // Cinq membres de A pour quatre tables : deux partagent une table à chaque + // tour, 4 collisions cumulées, 1 paire distincte au moins ; 4 ≤ C(5, 2) : + // aucun excédent n'est forcé. + test('petite-conflit : un groupe de 5, 4 collisions cumulées, 1 paire distincte, aucun plancher d’excédent', () => { + const configuration = construire('petite-conflit'); + const diagnostic = diagnostiquer(configuration); + assert.deepEqual(diagnostic.collisions, [ + { + groupe: configuration.participants[0].appartenance, + effectif: 5, + plancherCumulees: 4, + plancherPairesDistinctes: 1, + plancherExcedent: null, + }, + ]); + }); +}); + +describe('diagnostic : places manquantes (§ 5.9)', () => { + // 15 personnes, 3 tables de 4 : 3 places manquent à chaque tour. Le + // diagnostic les chiffre et ne lève pas. + test('une salle trop petite se chiffre en places manquantes, sans refus', () => { + const configuration = salle({ + tables: [[10, 4], [20, 4], [30, 4]], + appartenances: fois(15, null), + tours: 3, + }); + assert.equal(diagnostiquer(configuration).placesManquantes, 3); + }); + + // Tables de 3 et de 2 pour 6 personnes : 1 place manque. La table de 2 y + // est inférieure, son meilleur itinéraire valant 3 contre P* = 4, comme + // dans la même salle tendue à 5 personnes, dont le plancher d'écart + // d'itinéraire vaut 1 pour 2 mobiles (§ 12.10.4). Trop petite, la salle + // n'en écrit aucun : la tension seule en décide. + test('une salle trop petite n’écrit aucun plancher d’écart d’itinéraire, même à tables inégales', () => { + const configuration = salle({ + tables: [[10, 3], [20, 2]], + appartenances: fois(6, null), + tours: 2, + }); + const diagnostic = diagnostiquer(configuration); + assert.equal(diagnostic.placesManquantes, 1); + assert.equal(diagnostic.ecartItineraire, null); + }); + + // Un ancré retire une personne des mobiles et un siège de la capacité + // libre : le nombre de places manquantes ne bouge pas. + test('le nombre de places manquantes ne dépend d’aucune réservation', () => { + const configuration = salle({ + tables: [[10, 4], [20, 4], [30, 4]], + appartenances: fois(15, null), + tours: 3, + reservations: [ancrer(1, 10), ancrer(2, 20), fixer(3, 30, 2)], + }); + assert.equal(diagnostiquer(configuration).placesManquantes, 3); + }); +}); + +describe('diagnostic : animateurs de même appartenance (§ 5.6)', () => { + // Trois ancrés de X aux tables 10, 20 et 30 ; quatre tables, quatre tours : + // min(R, T) − 1 = 3. + test('trois ancrés de même appartenance sur 4 tables, 4 tours : 3 redondances imposées', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3], [30, 3], [40, 3]], + appartenances: [...fois(3, 'X'), ...fois(9, null)], + tours: 4, + reservations: [ancrer(1, 10), ancrer(2, 20), ancrer(3, 30)], + }); + const diagnostic = diagnostiquer(configuration); + assert.equal(diagnostic.animateursMemeAppartenance, true); + assert.equal(diagnostic.redondanceImposeeParAnimateurs, 3); + }); + + test('la redondance imposée vaut min(R, T) − 1 : 2 tours sur 4 tables, 5 tours sur 3 tables', () => { + const quatreTables = salle({ + tables: [[10, 3], [20, 3], [30, 3], [40, 3]], + appartenances: [...fois(3, 'X'), ...fois(9, null)], + tours: 2, + reservations: [ancrer(1, 10), ancrer(2, 20), ancrer(3, 30)], + }); + assert.equal(diagnostiquer(quatreTables).redondanceImposeeParAnimateurs, 1); + const troisTables = salle({ + tables: [[10, 3], [20, 3], [30, 3]], + appartenances: [...fois(3, 'X'), ...fois(6, null)], + tours: 5, + reservations: [ancrer(1, 10), ancrer(2, 20), ancrer(3, 30)], + }); + assert.equal(diagnostiquer(troisTables).redondanceImposeeParAnimateurs, 2); + }); + + // Un seul ancré ; deux ancrés d'appartenances distinctes ; deux sans + // appartenance ; un de X et un sans appartenance ; deux de X et un sans + // appartenance. Un ancré sans appartenance rompt l'appartenance commune, + // même quand tous les autres la partagent. + test('un seul ancré, des appartenances distinctes ou absentes : rien n’est imposé', () => { + const cas = [ + { appartenances: ['X', null, null, null, null, null], ancres: [ancrer(1, 10)] }, + { appartenances: ['X', 'Y', null, null, null, null], ancres: [ancrer(1, 10), ancrer(2, 20)] }, + { appartenances: fois(6, null), ancres: [ancrer(1, 10), ancrer(2, 20)] }, + { appartenances: ['X', null, null, null, null, null], ancres: [ancrer(1, 10), ancrer(2, 20)] }, + { + appartenances: ['X', 'X', null, null, null, null], + ancres: [ancrer(1, 10), ancrer(2, 20), ancrer(3, 10)], + }, + ]; + for (const { appartenances, ancres } of cas) { + const diagnostic = diagnostiquer( + salle({ tables: [[10, 3], [20, 3]], appartenances, tours: 3, reservations: ancres }), + ); + assert.equal(diagnostic.animateursMemeAppartenance, false, JSON.stringify(appartenances)); + assert.equal(diagnostic.redondanceImposeeParAnimateurs, 0, JSON.stringify(appartenances)); + } + }); + + // L'ancré de Y est exclu : sa réservation est suspendue (§ 4.4), et les + // ancrés restants partagent X. + test('un ancré exclu ne compte pas parmi les animateurs', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3], [30, 3]], + appartenances: ['X', 'X', 'Y', ...fois(6, null)], + tours: 2, + reservations: [ancrer(1, 10), ancrer(2, 20), ancrer(3, 30)], + exclus: [3], + }); + const diagnostic = diagnostiquer(configuration); + assert.equal(diagnostic.animateursMemeAppartenance, true); + assert.equal(diagnostic.redondanceImposeeParAnimateurs, 1); + }); + + // 1 et 2, de X, ancrés aux tables 10 et 20 ; 3, de Y, réservé à la table + // 30 au seul tour 1 : partiellement fixé, il compte parmi les mobiles + // (§ 5.4), non parmi les animateurs. Les animateurs partagent X ; trois + // tables, deux tours : min(R, T) − 1 = 1. + test('un partiellement fixé ne compte pas parmi les animateurs', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3], [30, 3]], + appartenances: ['X', 'X', 'Y', ...fois(6, null)], + tours: 2, + reservations: [ancrer(1, 10), ancrer(2, 20), fixer(3, 30, 1)], + }); + const diagnostic = diagnostiquer(configuration); + assert.equal(diagnostic.animateursMemeAppartenance, true); + assert.equal(diagnostic.redondanceImposeeParAnimateurs, 1); + }); +}); + +describe('diagnostic : planchers de collisions (§ 5.6)', () => { + // Trois membres de X, deux tables de 2, cinq tours : à chaque tour, deux + // membres de X partagent une table, soit 5 collisions cumulées pour + // C(3, 2) = 3 paires possibles ; l'excédent ne descend pas sous 2. Le plan + // qui fait se retrouver chacune des trois paires l'atteint ; celui qui garde + // la même paire cinq fois atteint 1 paire distincte. + test('un groupe de 3 sur 2 tables pendant 5 tours : plancher d’excédent 2, atteint', () => { + const configuration = salle({ + tables: [[10, 2], [20, 2]], + appartenances: ['X', 'X', 'X', null], + tours: 5, + }); + assert.deepEqual(diagnostiquer(configuration).collisions, [ + { + groupe: 'X', + effectif: 3, + plancherCumulees: 5, + plancherPairesDistinctes: 1, + plancherExcedent: 2, + }, + ]); + assert.deepEqual(minimumsParGroupe(configuration).get('X'), { + cumulees: 5, + distinctes: 1, + excedent: 2, + }); + }); + + // Trois membres de X, deux tables de 2, quatre tours : une paire de X par + // tour, 4 collisions cumulées pour C(3, 2) = 3 paires possibles ; + // l'excédent ne descend pas sous 1, le plus petit plancher strictement + // positif. Le plan qui fait se retrouver chacune des trois paires + // l'atteint ; celui qui garde la même paire quatre fois atteint 1 paire + // distincte. + // Plus de deux membres d'un groupe à une même table : le remplissage glouton + // y compte C(m, 2) paires, et le plancher de paires distinctes y dépasse 1. + // L'exemple du § 5.6 — six membres sur quatre tables, quatre tours — est + // trop grand pour l'énumération ; les deux salles suivantes s'énumèrent, et + // leurs planchers égalent les minimums exacts. + test('l’exemple du § 5.6 : au moins 8 collisions cumulées sur 4 tours, portées par au moins 2 paires distinctes', () => { + const configuration = salle({ + tables: [[10, 4], [20, 4], [30, 4], [40, 4]], + appartenances: [...fois(6, 'X'), ...fois(10, null)], + tours: 4, + }); + assert.deepEqual(diagnostiquer(configuration).collisions, [ + { groupe: 'X', effectif: 6, plancherCumulees: 8, plancherPairesDistinctes: 2, plancherExcedent: null }, + ]); + }); + + test('six membres sur deux tables de 3, deux tours : trois par table, 6 paires par tour', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3]], + appartenances: fois(6, 'X'), + tours: 2, + }); + assert.deepEqual(diagnostiquer(configuration).collisions, [ + { groupe: 'X', effectif: 6, plancherCumulees: 12, plancherPairesDistinctes: 6, plancherExcedent: null }, + ]); + const exacts = minimumsParGroupe(configuration).get('X'); + assert.equal(exacts.cumulees, 12); + assert.equal(exacts.distinctes, 6); + }); + + test('cinq membres sur deux tables de 3, trois tours : 4 paires par tour, plancher d’excédent 2, atteint', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3]], + appartenances: [...fois(5, 'X'), null], + tours: 3, + }); + assert.deepEqual(diagnostiquer(configuration).collisions, [ + { groupe: 'X', effectif: 5, plancherCumulees: 12, plancherPairesDistinctes: 4, plancherExcedent: 2 }, + ]); + assert.deepEqual(minimumsParGroupe(configuration).get('X'), { cumulees: 12, distinctes: 4, excedent: 2 }); + }); + + test('un groupe de 3 sur 2 tables pendant 4 tours : plancher d’excédent 1, atteint', () => { + const configuration = salle({ + tables: [[10, 2], [20, 2]], + appartenances: ['X', 'X', 'X', null], + tours: 4, + }); + assert.deepEqual(diagnostiquer(configuration).collisions, [ + { + groupe: 'X', + effectif: 3, + plancherCumulees: 4, + plancherPairesDistinctes: 1, + plancherExcedent: 1, + }, + ]); + assert.deepEqual(minimumsParGroupe(configuration).get('X'), { + cumulees: 4, + distinctes: 1, + excedent: 1, + }); + }); + + // 1 et 2, de X, sont ancrés ensemble à la table 10 : une paire à chaque + // tour, 3 en tout, quand trois membres sur trois tables n'en forceraient + // aucune. 4 et 5, de Y, sont réservés ensemble à la table 20 au seul + // tour 1 : une paire ce tour-là, aucune aux deux autres. Chaque plan atteint + // les deux planchers, la table 10 étant pleine de ses ancrés ; celui qui + // sépare 4 et 5 aux tours 2 et 3 n'en compte qu'une rencontre. + test('un ancré pèse à chaque tour, un partiellement fixé à son seul tour réservé', () => { + const configuration = salle({ + tables: [[10, 2], [20, 2], [30, 2]], + appartenances: ['X', 'X', 'X', 'Y', 'Y', null], + tours: 3, + reservations: [ancrer(1, 10), ancrer(2, 10), fixer(4, 20, 1), fixer(5, 20, 1)], + }); + assert.deepEqual(diagnostiquer(configuration).collisions, [ + { + groupe: 'X', + effectif: 3, + plancherCumulees: 3, + plancherPairesDistinctes: 1, + plancherExcedent: null, + }, + { + groupe: 'Y', + effectif: 2, + plancherCumulees: 1, + plancherPairesDistinctes: 1, + plancherExcedent: null, + }, + ]); + const minimums = minimumsParGroupe(configuration); + assert.deepEqual(minimums.get('X'), { cumulees: 3, distinctes: 1, excedent: 2 }); + assert.deepEqual(minimums.get('Y'), { cumulees: 1, distinctes: 1, excedent: 0 }); + }); + + // Deux ancrés de X à la même table pendant 3 tours : la même paire 3 fois, + // pour C(2, 2) = 1 paire possible : l'excédent ne descend pas sous 2. + test('une paire d’ancrés réunie à chaque tour force un excédent', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3], [30, 3]], + appartenances: ['X', 'X', ...fois(7, null)], + tours: 3, + reservations: [ancrer(1, 10), ancrer(2, 10)], + }); + assert.deepEqual(diagnostiquer(configuration).collisions, [ + { + groupe: 'X', + effectif: 2, + plancherCumulees: 3, + plancherPairesDistinctes: 1, + plancherExcedent: 2, + }, + ]); + }); + + // X compte quatre membres dont un exclu : trois sur deux tables, une paire + // par tour, deux tours. + test('un membre exclu sort de l’effectif et du plancher', () => { + const configuration = salle({ + tables: [[10, 4], [20, 4]], + appartenances: ['X', 'X', 'X', 'X', null, null], + tours: 2, + exclus: [4], + }); + assert.deepEqual(diagnostiquer(configuration).collisions, [ + { + groupe: 'X', + effectif: 3, + plancherCumulees: 2, + plancherPairesDistinctes: 1, + plancherExcedent: null, + }, + ]); + }); + + test('les participants sans appartenance ne forment pas un groupe (§ 12.4)', () => { + const configuration = salle({ + tables: [[10, 5], [20, 5]], + appartenances: fois(10, null), + tours: 3, + }); + assert.deepEqual(diagnostiquer(configuration).collisions, []); + }); + + // Y paraît au participant 1, X au participant 2 : les groupes suivent + // l'ordre de première apparition par ids croissants. + test('les groupes se rangent dans l’ordre de instance.groupes', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3]], + appartenances: ['Y', 'X', 'X', 'X', 'Y', 'Y'], + tours: 1, + }); + assert.deepEqual( + diagnostiquer(configuration).collisions.map(({ groupe }) => groupe), + ['Y', 'X'], + ); + }); +}); + +describe('diagnostic : plancher de l’écart d’itinéraire (§ 12.10.4)', () => { + // Tables de 3 et de 2, cinq mobiles, deux tours : P* = 4 par deux tours à + // la table de 3 ; le meilleur itinéraire qui passe par la table de 2 vaut + // 2 + 1 = 3. Ses 2 sièges × 2 tours reçoivent 2 mobiles au moins, chacun à + // 1 au moins sous son plafond a priori. Le plan qui y met quatre personnes + // distinctes atteint l'écart d'itinéraire maximal 1 ; celui qui y garde les + // deux mêmes n'en met que 2 sous leur plafond. + test('une table inférieure dans une salle tendue : plancher 1, au moins 2 mobiles, atteints', () => { + const configuration = salle({ + tables: [[10, 3], [20, 2]], + appartenances: fois(5, null), + tours: 2, + }); + assert.deepEqual(diagnostiquer(configuration).ecartItineraire, { + plancher: 1, + mobilesAuMoins: 2, + }); + assert.deepEqual(ecartsItineraireSurTousLesPlans(configuration, 1), { + plusPetitMaximum: 1, + moinsDeMobiles: 2, + }); + }); + + // Tables de 3 et de 2, cinq mobiles, un seul tour : P* = 2, les deux + // voisins d'une table de 3 ; la table de 2 n'en offre qu'un : elle est + // inférieure. Ses 2 sièges reçoivent 2 mobiles, chacun à 1 sous P*, dans + // chacun des 10 plans. Le diagnostic éprouve une table en y réservant un + // tour au mobile ; à un seul tour, cette réservation les couvre tous et + // l'ancre (§ 4.2), sans changer le plafond du même itinéraire. + test('à un seul tour, une table inférieure fait aussi le plancher : plancher 1, au moins 2 mobiles, atteints', () => { + const configuration = salle({ + tables: [[10, 3], [20, 2]], + appartenances: fois(5, null), + tours: 1, + }); + assert.deepEqual(diagnostiquer(configuration).ecartItineraire, { + plancher: 1, + mobilesAuMoins: 2, + }); + assert.deepEqual(ecartsItineraireSurTousLesPlans(configuration, 1), { + plusPetitMaximum: 1, + moinsDeMobiles: 2, + }); + }); + + // Un ancré à la table de 3 : un mobile qui passe une fois par chaque table + // y rencontre l'ancré et un siège renouvelé à chacune, 1 + 1 + 1 = 3, son + // plafond a priori. Aucune table n'est inférieure, et le plan où chaque + // mobile visite les deux tables n'a aucun écart d'itinéraire. + test('une salle tendue sans table inférieure : aucun plancher, jamais 0', () => { + const configuration = salle({ + tables: [[10, 3], [20, 2]], + appartenances: fois(5, null), + tours: 2, + reservations: [ancrer(1, 10)], + }); + assert.equal(diagnostiquer(configuration).ecartItineraire, null); + assert.equal(ecartsItineraireSurTousLesPlans(configuration, 1).plusPetitMaximum, 0); + }); + + // Tables de 4, 4 et 2, dix mobiles, deux tours : P* = 3 + 3 = 6 ; par la + // table de 2, 3 + 1 = 4 au mieux. Ses 4 sièges-tours reçoivent 2 mobiles + // au moins, chacun à 2 au moins sous son plafond. Avec neuf mobiles, une + // place reste libre et la salle n'est plus tendue : rien ne s'écrit. + test('le plancher est la différence au meilleur itinéraire de la table inférieure, dans une salle tendue seulement', () => { + const tables = [[10, 4], [20, 4], [30, 2]]; + const tendue = salle({ tables, appartenances: fois(10, null), tours: 2 }); + assert.deepEqual(diagnostiquer(tendue).ecartItineraire, { + plancher: 2, + mobilesAuMoins: 2, + }); + const placeLibre = salle({ tables, appartenances: fois(9, null), tours: 2 }); + assert.equal(diagnostiquer(placeLibre).ecartItineraire, null); + }); + + // Tables de 4, 4, 3 et 2, treize mobiles, deux tours : P* = 6 ; par la + // table de 3, 5 ; par celle de 2, 4. Deux tables inférieures, (3 + 2) × 2 + // = 10 sièges-tours : 5 mobiles au moins ; l'écart d'itinéraire le plus + // petit, 1, fait le plancher. + test('plusieurs tables inférieures : leurs sièges s’additionnent, le plus petit écart d’itinéraire fait le plancher', () => { + const configuration = salle({ + tables: [[10, 4], [20, 4], [30, 3], [40, 2]], + appartenances: fois(13, null), + tours: 2, + }); + assert.deepEqual(diagnostiquer(configuration).ecartItineraire, { + plancher: 1, + mobilesAuMoins: 5, + }); + }); + + // Tables de 2, 3 et 2, sept personnes, deux tours ; 1 et 2 ancrés à la + // table 10, qu'ils gèlent (§ 5.9 l'accepte) : aucun itinéraire de mobile + // n'y passe. P* = 4 par deux tours à la table 20 ; par la table 30, + // 2 + 1 = 3. Ses 2 sièges × 2 tours reçoivent 2 mobiles au moins, chacun à + // 1 au moins sous P*. La table gelée est sautée : y réserver le siège d'un + // mobile la surréserverait. Le plan qui met quatre mobiles distincts à la + // table 30 atteint l'écart d'itinéraire maximal 1 ; celui qui y garde les + // deux mêmes n'en met que 2 sous leur plafond. + test('une table gelée dans une salle tendue est sautée, sans refus', () => { + const configuration = salle({ + tables: [[10, 2], [20, 3], [30, 2]], + appartenances: fois(7, null), + tours: 2, + reservations: [ancrer(1, 10), ancrer(2, 10)], + }); + const diagnostic = diagnostiquer(configuration); + assert.equal(diagnostic.ancrage[0].gelee, true); + assert.deepEqual(diagnostic.ecartItineraire, { plancher: 1, mobilesAuMoins: 2 }); + assert.deepEqual(ecartsItineraireSurTousLesPlans(configuration, 1), { + plusPetitMaximum: 1, + moinsDeMobiles: 2, + }); + }); + + // Tables de 2, 3 et 3, huit personnes, deux tours ; 1 ancré à la table 10, + // qui ne laisse qu'un siège mobile et aucun siège qui tourne : v_t = 0. + // P* = 4, par (20, 30) ; le meilleur itinéraire qui passe par la table 10 + // vaut 1 + 0 + 2 = 3, son ancré compris : elle est inférieure. Son siège + // × 2 tours reçoit 1 mobile au moins, à 1 au moins sous P*. Le plan qui y + // met deux mobiles distincts atteint l'écart d'itinéraire maximal 1 ; + // celui qui y garde le même n'en met qu'un sous son plafond. + test('une table qui ne laisse qu’un siège mobile peut être inférieure : plancher 1, au moins 1 mobile, atteints', () => { + const configuration = salle({ + tables: [[10, 2], [20, 3], [30, 3]], + appartenances: fois(8, null), + tours: 2, + reservations: [ancrer(1, 10)], + }); + assert.deepEqual(diagnostiquer(configuration).ecartItineraire, { + plancher: 1, + mobilesAuMoins: 1, + }); + assert.deepEqual(ecartsItineraireSurTousLesPlans(configuration, 1), { + plusPetitMaximum: 1, + moinsDeMobiles: 1, + }); + }); + + // Tables de 10, 3 et 3, seize personnes, trois tours ; 1 ancré à la table + // 20. Un mobile rencontre au plus n_p = 14 autres mobiles, et l'ancré de la + // table 20 s'y ajoute : P* = 1 + min(14, 1 + 9 + 9) = 15, par + // (20, 10, 10). La table 30, de même capacité que la 20 mais sans ancré, + // plafonne à min(14, 2 + 9 + 9) = 14 par (30, 10, 10) : elle est + // inférieure, la 20 ne l'est pas. Ses 3 sièges × 3 tours reçoivent + // 3 mobiles au moins, chacun à 1 au moins sous P*. Les deux tables ne + // diffèrent que par leurs ancrés : P*_t ne se reprend pas d'une table à + // l'autre sur la seule capacité. L'énumération des itinéraires arbitre P* + // et chaque P*_t. + test('deux tables de même capacité, l’une ancrée : le meilleur itinéraire de chacune compte ses ancrés', () => { + const configuration = salle({ + tables: [[10, 10], [20, 3], [30, 3]], + appartenances: fois(16, null), + tours: 3, + reservations: [ancrer(1, 20)], + }); + assert.deepEqual(meilleursItineraires(configuration), { + plafondAPriori: 15, + parTable: [15, 15, 14], + }); + assert.deepEqual(diagnostiquer(configuration).ecartItineraire, { + plancher: 1, + mobilesAuMoins: 3, + }); + }); + + // Tables de 4, 4 et 3, onze personnes, deux tours ; 1 ancré à la table 10. + // Pour un mobile, a_t vaut 1, 0, 0 et v_t vaut 2, 3, 2 ; n_p = 9 ne borne + // aucun itinéraire. P* = 6, par (10, 20) ou (20, 20). Le meilleur + // itinéraire qui passe par la table 30 vaut 5, par (20, 30) ou (10, 30) : + // elle est inférieure. Ses 3 sièges × 2 tours reçoivent 3 mobiles au + // moins, chacun à 1 au moins sous P*. Les tables 10 et 30 laissent les + // mêmes 3 sièges libres, d'où les mêmes 2 sièges qui tournent, mais l'ancré + // de la table 10 s'ajoute au plafond de qui y passe : le terme a_t du + // § 5.5 compte les ancrés d'une table, pas ses sièges libres, et P*_t ne se + // reprend pas d'une table à l'autre sur les sièges libres. L'énumération + // des itinéraires arbitre P* et chaque P*_t. + test('deux tables aux mêmes sièges libres, l’une ancrée : son ancré s’ajoute au meilleur itinéraire qui la visite', () => { + const configuration = salle({ + tables: [[10, 4], [20, 4], [30, 3]], + appartenances: fois(11, null), + tours: 2, + reservations: [ancrer(1, 10)], + }); + assert.deepEqual(meilleursItineraires(configuration), { + plafondAPriori: 6, + parTable: [6, 6, 5], + }); + assert.deepEqual(diagnostiquer(configuration).ecartItineraire, { + plancher: 1, + mobilesAuMoins: 3, + }); + }); + + // Tables de 10, 4 et 4, dix-huit personnes, trois tours ; 1 ancré à la + // table 20, 2 et 3 à la table 30. Pour un mobile, a_t vaut 0, 1, 2 et v_t + // vaut 9, 2, 1 ; n_p = 14 borne les sièges qui tournent, et les ancrés + // s'y ajoutent. P* = 2 + 14 = 16, par (10, 10, 30). Le meilleur itinéraire + // qui passe par la table 20 vaut 15, par (10, 10, 20) : 1 + 14, ou par + // (10, 20, 30) : 3 + 12. Elle est inférieure, la table 30 ne l'est pas. Ses + // 3 sièges × 3 tours reçoivent 3 mobiles au moins, chacun à 1 au moins + // sous P*. Les deux tables ont la même capacité et portent toutes deux des + // ancrés : leur nombre, a_t, fait le meilleur itinéraire, non leur seule + // présence. L'énumération des itinéraires arbitre P* et chaque P*_t. + test('deux tables de même capacité, ancrées l’une d’un, l’autre de deux : le nombre d’ancrés fait le meilleur itinéraire', () => { + const configuration = salle({ + tables: [[10, 10], [20, 4], [30, 4]], + appartenances: fois(18, null), + tours: 3, + reservations: [ancrer(1, 20), ancrer(2, 30), ancrer(3, 30)], + }); + assert.deepEqual(meilleursItineraires(configuration), { + plafondAPriori: 16, + parTable: [16, 15, 16], + }); + assert.deepEqual(diagnostiquer(configuration).ecartItineraire, { + plancher: 1, + mobilesAuMoins: 3, + }); + }); + + // Tables de 5, 7, 5, 3 et 6, vingt-six personnes, trois tours ; ancrés : 4 + // à la table 20, 2 à la 30, 1 à la 40, 2 à la 50. Pour un mobile, table par + // table, a_t vaut 0, 4, 2, 1, 2 et v_t vaut 4, 2, 2, 1, 3 ; n_p = 16 ne + // borne aucun itinéraire. P* = 15, par (20, 30, 50) : 8 + 7. Le meilleur + // itinéraire qui passe par la table 40 la complète par les tables ancrées + // 20 et 50 : 7 + 6 = 13 ; par chaque autre table passe un itinéraire à 15. + // Seule la table 40 est inférieure : ses 2 sièges mobiles × 3 tours reçoivent + // 2 mobiles au moins, chacun à 2 au moins sous P*. Lue sans ses ancrés, la + // salle offrirait par la table 40 l'itinéraire (40, 20, 20) à + // 2 + 6 + 6 = 14 : P*_t se calcule sur les réservations de la + // configuration. L'énumération des itinéraires arbitre P* et chaque P*_t. + test('le meilleur itinéraire qui visite une table inférieure compte les ancrés des tables où il passe', () => { + const configuration = salle({ + tables: [[10, 5], [20, 7], [30, 5], [40, 3], [50, 6]], + appartenances: fois(26, null), + tours: 3, + reservations: [ + ancrer(1, 20), + ancrer(2, 20), + ancrer(3, 20), + ancrer(4, 20), + ancrer(5, 30), + ancrer(6, 30), + ancrer(7, 40), + ancrer(8, 50), + ancrer(9, 50), + ], + }); + assert.deepEqual(meilleursItineraires(configuration), { + plafondAPriori: 15, + parTable: [15, 15, 15, 13, 15], + }); + assert.deepEqual(diagnostiquer(configuration).ecartItineraire, { + plancher: 2, + mobilesAuMoins: 2, + }); + }); + + test('un partiellement fixé, ou aucun mobile, et rien ne s’écrit', () => { + const avecPartiel = salle({ + tables: [[10, 3], [20, 2]], + appartenances: fois(5, null), + tours: 2, + reservations: [fixer(1, 10, 1)], + }); + assert.equal(diagnostiquer(avecPartiel).ecartItineraire, null); + const sansMobile = salle({ + tables: [[10, 2], [20, 2]], + appartenances: fois(4, null), + tours: 2, + reservations: [ancrer(1, 10), ancrer(2, 10), ancrer(3, 20), ancrer(4, 20)], + }); + assert.equal(diagnostiquer(sansMobile).ecartItineraire, null); + }); +}); + +describe('diagnostic : redondance minimale (§ 5.6)', () => { + // Neuf personnes, trois tables de 3, six appartenances. En 4 tours, le + // plafond a priori vaut 4 × 2 = 8, autant que les affiliés autres que soi : + // 8 − 6 = 2. En 3 tours, il vaut 6 : 6 − 6 = 0. + test('six appartenances : 2 redondances en 4 tours, aucune en 3', () => { + const appartenances = ['A', 'A', 'B', 'B', 'C', 'C', 'D', 'E', 'F']; + const tables = [[10, 3], [20, 3], [30, 3]]; + assert.equal(diagnostiquer(salle({ tables, appartenances, tours: 4 })).redondanceMinimale, 2); + assert.equal(diagnostiquer(salle({ tables, appartenances, tours: 3 })).redondanceMinimale, 0); + }); + + // Trois affiliés de A parmi neuf : un non-affilié en rencontre 3 au plus, + // 3 − 1 = 2 ; un affilié 2, 2 − 1 = 1. Le plafond a priori, 8, ne borne + // rien ici. + test('les affiliés autres que soi bornent la redondance, et le plus grand l’emporte', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3], [30, 3]], + appartenances: [...fois(3, 'A'), ...fois(6, null)], + tours: 4, + }); + assert.equal(diagnostiquer(configuration).redondanceMinimale, 2); + }); + + // 1 et 2, sans appartenance, ancrés à la table 10 de 3 sièges ; quatre + // mobiles de X, deux tours. Un mobile rencontre au plus 4 personnes, les + // deux ancrés au tour qu'il passe à la table 10, deux mobiles à l'autre, + // mais seulement 3 affiliés autres que lui : 3 − 1 = 2. Un ancré plafonne à + // 1 + 2 × 1 = 3 : 3 − 1 = 2. + test('un affilié ne se compte pas parmi les affiliés qu’il rencontre', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3]], + appartenances: [null, null, ...fois(4, 'X')], + tours: 2, + reservations: [ancrer(1, 10), ancrer(2, 10)], + }); + const diagnostic = diagnostiquer(configuration); + assert.deepEqual(diagnostic.plafondAPriori.parPersonne, [3, 3, 4, 4, 4, 4]); + assert.equal(diagnostic.redondanceMinimale, 2); + }); + + // Petite démonstration : 8 rencontres au plus, 11 affiliés autres que soi, + // 3 appartenances : qui rencontre 8 affiliés en recroise au moins + // 8 − 3 = 5. Le plan parfait (§ 15.3) fait rencontrer 8 affiliés à chacun, + // tous d'une autre appartenance que la sienne : A(p) = 2, et chacun mesure + // 6, au-dessus du plancher, qui suppose les trois appartenances croisées. + test('petite : 5 redondances au moins pour qui rencontre 8 affiliés ; le plan parfait en mesure 6', () => { + const configuration = construire('petite'); + assert.equal(diagnostiquer(configuration).redondanceMinimale, 5); + const instance = normaliser(configuration); + const mesures = mesurer(instance, indexerPlan(instance, PLAN_PARFAIT_PETITE)); + assert.deepEqual(mesures.affilies, fois(12, 8)); + assert.deepEqual(mesures.redondance, fois(12, 6)); + }); + + test('sans affilié, la redondance minimale est null', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3]], + appartenances: fois(6, null), + tours: 4, + }); + assert.equal(diagnostiquer(configuration).redondanceMinimale, null); + }); +}); + +describe('diagnostic : ancrage et retours imposés (§ 5.4, § 5.6)', () => { + // Table 30 : deux ancrés pour deux sièges, gelée ; table 10 : deux ancrés + // pour trois sièges, le troisième libre : elle n'est pas gelée ; table 20 : + // aucun. L'ordre est celui de la configuration. + test('chaque table, dans l’ordre de la configuration, avec ses ancrés et son gel', () => { + const configuration = salle({ + tables: [[30, 2], [10, 3], [20, 2]], + appartenances: fois(5, null), + tours: 2, + reservations: [ancrer(1, 30), ancrer(2, 30), ancrer(3, 10), ancrer(4, 10)], + }); + assert.deepEqual(diagnostiquer(configuration).ancrage, [ + { table: 30, ancres: 2, capacite: 2, gelee: true }, + { table: 10, ancres: 2, capacite: 3, gelee: false }, + { table: 20, ancres: 0, capacite: 2, gelee: false }, + ]); + }); + + // Quatre tours. 1, ancré : 3 retours. 2, réservé aux tours 1 et 3 à la + // table 20 : 1. 3, réservé à deux tables différentes : 0. 4, exclu : sa + // réservation est suspendue. 5, réservé aux quatre tours à la table 10, + // donc ancré (§ 4.2) : 3. En tout 7. + test('retours imposés : R − 1 par ancré, un par tour réservé de plus à une même table, aucun pour un exclu', () => { + const configuration = salle({ + tables: [[10, 4], [20, 4]], + appartenances: fois(6, null), + tours: 4, + reservations: [ + ancrer(1, 10), + fixer(2, 20, 1), + fixer(2, 20, 3), + fixer(3, 10, 1), + fixer(3, 20, 2), + ancrer(4, 20), + fixer(5, 10, 1), + fixer(5, 10, 2), + fixer(5, 10, 3), + fixer(5, 10, 4), + ], + exclus: [4], + }); + const diagnostic = diagnostiquer(configuration); + assert.equal(diagnostic.retoursImposes, 7); + assert.deepEqual( + diagnostic.ancrage.map(({ ancres }) => ancres), + [2, 0], + ); + }); + + // Ancrés 1 et 2 à la table 10 pendant 3 tours : 2 × 2 retours. Chaque plan + // qui honore les réservations mesure les mêmes 4. + test('les retours imposés sont ceux que mesure chaque plan qui honore les réservations', () => { + const configuration = salle({ + tables: [[10, 2], [20, 2], [30, 2]], + appartenances: ['X', 'X', 'X', 'Y', 'Y', null], + tours: 3, + reservations: [ancrer(1, 10), ancrer(2, 10), fixer(4, 20, 1), fixer(5, 20, 1)], + }); + const { retoursImposes } = diagnostiquer(configuration); + assert.equal(retoursImposes, 4); + const instance = normaliser(configuration); + const mesures = new Set(); + const plans = enumererPlans(instance, (tableDe) => { + mesures.add(mesurer(instance, tableDe).totalRetoursImposes); + }); + assert.ok(plans > 0, 'aucun plan énuméré'); + assert.deepEqual([...mesures], [retoursImposes]); + }); +}); + +describe('diagnostic : population vide, forme, refus', () => { + // Aucun participant : chaque agrégat d'une population vide est null + // (§ 5.4), chaque compte est un vrai 0. Les mêmes chiffres quand tous sont + // exclus : leurs réservations sont suspendues (§ 4.4). + test('une population vide rend null, jamais 0, là où rien ne se mesure', () => { + const attendu = { + placesManquantes: 0, + plafondAPriori: { min: null, max: null, parPersonne: [] }, + collisions: [], + redondanceMinimale: null, + animateursMemeAppartenance: false, + redondanceImposeeParAnimateurs: 0, + ancrage: [{ table: 10, ancres: 0, capacite: 4, gelee: false }], + retoursImposes: 0, + ecartItineraire: null, + }; + const vide = salle({ tables: [[10, 4]], appartenances: [], tours: 2 }); + assert.deepEqual(diagnostiquer(vide), attendu); + const tousExclus = salle({ + tables: [[10, 4]], + appartenances: ['X', 'X', 'X'], + tours: 2, + reservations: [ancrer(1, 10), ancrer(2, 10)], + exclus: [1, 2, 3], + }); + assert.deepEqual(diagnostiquer(tousExclus), attendu); + }); + + test('le diagnostic ne modifie pas la configuration', () => { + const configuration = construire('grande'); + const avant = structuredClone(configuration); + diagnostiquer(configuration); + assert.deepEqual(configuration, avant); + }); + + // Trois ancrés pour deux sièges : normaliser refuse, et le diagnostic + // laisse passer le refus. + test('une configuration que normaliser refuse lève le même refus', () => { + const configuration = salle({ + tables: [[10, 2], [20, 2]], + appartenances: fois(4, null), + tours: 2, + reservations: [ancrer(1, 10), ancrer(2, 10), ancrer(3, 10)], + }); + assert.throws( + () => diagnostiquer(configuration), + (erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'SURRESERVATION', + ); + }); +});