From f5d1bb04bd4d39042a97b8521712e13ac8df9029 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 5 Oct 2026 22:52:29 -0400 Subject: [PATCH] [ADD] engine: diagnosis of what a configuration makes unavoidable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before any search, the diagnosis states what no plan can avoid: missing seats, the a priori ceiling, per-group collision floors in cumulative occurrences, distinct pairs and excess, imposed returns, anchoring per table, and the floor of the itinerary gap where the room is tight. A floor it cannot prove is null, never zero, so the operator is never sent to rerun towards a value the configuration forbids. Checked: the large demonstration gives a floor of 1 with at least 24 mobiles; collision floors equal exact minima found by enumeration, the § 5.6 example of 8 collisions on 2 pairs included. --- FR --- [ADD] moteur : diagnostic de ce qu'une configuration rend inévitable Avant toute recherche, le diagnostic énonce ce qu'aucun plan n'évite : places manquantes, plafond a priori, planchers de collisions par groupe en occurrences cumulées, paires distinctes et excédent, retours imposés, ancrage par table, et le plancher de l'écart d'itinéraire là où la salle est tendue. Un plancher qu'il ne sait pas prouver vaut null, jamais zéro : l'opérateur n'est jamais envoyé relancer vers une valeur que la configuration interdit. Vérifié : la grande démonstration donne un plancher de 1 pour 24 mobiles au moins ; les planchers de collisions égalent les minimums exacts trouvés par énumération, l'exemple du § 5.6 compris. Assisted-by: Claude Opus 5.5 --- src/moteur/diagnostic.js | 288 +++++++++ src/moteur/diagnostic.test.js | 1047 +++++++++++++++++++++++++++++++++ 2 files changed, 1335 insertions(+) create mode 100644 src/moteur/diagnostic.js create mode 100644 src/moteur/diagnostic.test.js 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', + ); + }); +});