From 9efcfc997330f18f031fdf1f9774612d651d3f62 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 5 Oct 2026 17:27:34 -0400 Subject: [PATCH] [ADD] engine: ceilings a priori and realised, exact dynamic programme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The a priori ceiling is maximised over admissible itineraries by a dynamic programme over tables, free rounds and capped seats, memoised by signature, with an exhaustive enumeration as its oracle. The realised ceiling counts the occupancy of each table at each round, not its capacity, so a lack of zero means nothing left to take from that plan. Checked: anchored 16 and mobile 19 on four tables of five; 28, 27, 28 and 24 on the large shape; the programme equals the enumeration on random instances; five mutants of the formula and the programme each fail. --- FR --- [ADD] moteur : plafonds a priori et réalisé, programmation dynamique Le plafond a priori se maximise sur les itinéraires admissibles par une programmation dynamique sur les tables, les tours libres et les sièges plafonnés, mémoïsée par signature, avec une énumération exhaustive pour oracle. Le plafond réalisé compte l'occupation de chaque table à chaque tour, non sa capacité : un manque nul veut dire que ce plan n'a plus rien à reprendre. Vérifié : ancré 16 et mobile 19 sur quatre tables de cinq ; 28, 27, 28 et 24 sur la forme de la grande ; la programmation égale l'énumération sur des instances tirées ; cinq mutants de la formule et du calcul échouent. Assisted-by: Claude Opus 5.5 --- src/moteur/plafond.js | 364 +++++++++++++++++++++++ src/moteur/plafond.long.test.js | 195 +++++++++++++ src/moteur/plafond.test.js | 502 ++++++++++++++++++++++++++++++++ 3 files changed, 1061 insertions(+) create mode 100644 src/moteur/plafond.js create mode 100644 src/moteur/plafond.long.test.js create mode 100644 src/moteur/plafond.test.js diff --git a/src/moteur/plafond.js b/src/moteur/plafond.js new file mode 100644 index 0000000..578bcd4 --- /dev/null +++ b/src/moteur/plafond.js @@ -0,0 +1,364 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Plafonds de rencontres (§ 5.5). Le plafond d'une personne p sur un +// itinéraire — la table qu'elle occupe à chaque tour — vaut +// +// min(N − 1, Σ_{t∈D} a_t + min(n_p, S)) +// +// D est l'ensemble des tables distinctes visitées ; a_t, les ancrés de la +// table t, p non compté ; n_p = n − 1 pour un mobile ou un partiellement +// fixé, n pour un ancré ; S, les sièges qui changent d'occupant, sommés sur +// les tours assis. Les ancrés d'une table visitée comptent une fois, quel +// que soit le nombre de tours passés là ; les sièges se renouvellent à chaque +// tour, sans livrer plus de personnes qu'il n'existe de mobiles. Un tour en +// réserve n'entre ni dans D ni dans S. +// +// La formule s'écrit une fois, dans borne, et se calcule sur deux bases : +// - les capacités, S = Σ_{t∈D} m_t · (c_t − 1 − a_t), m_t les tours passés à +// t : plafondItineraire, et son maximum sur les itinéraires admissibles, le +// plafond a priori ; +// - l'occupation, S = Σ_{(t,r)∈I} (o_{t,r} − 1 − a_t), I les couples (table, +// tour) où p est assis : le plafond réalisé. Sur un plan qui honore les +// réservations, les a_t ancrés et p occupent la table à chaque tour, et +// chaque terme est positif ou nul. +// +// Un itinéraire admissible impose les tours fixés par les réservations et +// place chaque tour libre à une table non entièrement ancrée : les ancrés +// d'une telle table occupent chacun de ses sièges à chaque tour. Quand +// aucune table ne l'est, les tours libres restent en réserve. +// +// Le plafond a priori majore le plafond réalisé de tout plan valide. Dans un +// tel plan, l'occupation ne dépasse pas la capacité : le plafond réalisé de p +// ne dépasse pas le plafond de son itinéraire. Cet itinéraire tient ses tours +// fixés et place ses tours libres à des tables admissibles, ou en réserve ; +// asseoir p à une table admissible n'ôte rien au plafond, puisque +// a_t ≥ 0 et c_t − 1 − a_t ≥ 0. +// +// Une personne se désigne par son index dans l'instance, une table par son +// index ; un tableau rendu suit l'ordre de instance.ids. Aucune fonction ne +// modifie l'instance. +import { ErreurConfiguration } from './erreurs.js'; + +// Statut d'un ancré dans instance.statut (§ 4.2). +const ANCRE = 2; +// Case de instance.fixe sans table imposée, et case d'un itinéraire ou d'un +// plan indexé en réserve : la même valeur, si bien qu'une rangée de fixe se +// lit comme l'itinéraire qui laisse en réserve chaque tour libre. +const LIBRE = -1; +const RESERVE = -1; +// Nombre de multiensembles au-delà duquel l'énumération refuse une +// signature. +const LIMITE_ENUMERATION = 300_000; + +// La formule du § 5.5. ancres : Σ a_t sur les tables distinctes ; sieges : +// les sièges qui changent d'occupant ; nP : n_p. +function borne(instance, nP, ancres, sieges) { + return Math.min(instance.N - 1, ancres + Math.min(nP, sieges)); +} + +// n_p : les mobiles — partiellement fixés compris — que p peut rencontrer, +// p excepté quand il en est un. +const mobilesRencontrables = (instance, p) => + instance.statut[p] === ANCRE ? instance.n : instance.n - 1; + +// Table d'ancrage de p, −1 s'il n'est pas ancré. +const ancrageDe = (instance, p) => + instance.statut[p] === ANCRE ? instance.fixe[p * instance.R] : -1; + +// a_t vu de p : les ancrés de la table t, p non compté. +const ancresVus = (instance, t, ancrage) => instance.ancresParTable[t] - (t === ancrage ? 1 : 0); + +// Tables qu'un tour libre peut recevoir, par index croissant : celles où les +// ancrés laissent au moins un siège. +function tablesAdmissibles({ T, capacite, ancresParTable }) { + const admissibles = []; + for (let t = 0; t < T; t += 1) if (ancresParTable[t] < capacite[t]) admissibles.push(t); + return admissibles; +} + +// Vrai quand la table de la case debut + r figure déjà à l'un des tours +// précédents de la rangée. +function dejaVisitee(cases, debut, r) { + for (let q = 0; q < r; q += 1) if (cases[debut + q] === cases[debut + r]) return true; + return false; +} + +// Plafond de p sur la rangée cases[debut … debut + R − 1], une table par +// tour. occupation porte o_{t,r} en [t × R + r] et fait la base du plafond +// réalisé ; null fait celle des capacités. +function plafondSurRangee(instance, p, cases, debut, occupation) { + const { R, capacite } = instance; + const ancrage = ancrageDe(instance, p); + let ancres = 0; + let sieges = 0; + for (let r = 0; r < R; r += 1) { + const t = cases[debut + r]; + if (t === RESERVE) continue; + const a = ancresVus(instance, t, ancrage); + const occupants = occupation === null ? capacite[t] : occupation[t * R + r]; + sieges += occupants - 1 - a; + if (!dejaVisitee(cases, debut, r)) ancres += a; + } + return borne(instance, mobilesRencontrables(instance, p), ancres, sieges); +} + +// Lève RangeError quand une case de cases n'est ni −1 ni un index de table. +function exigerCases(cases, T, quoi) { + for (let i = 0; i < cases.length; i += 1) { + const t = cases[i]; + if (!Number.isInteger(t) || t < RESERVE || t >= T) { + throw new RangeError(`${quoi}, case ${i} : index de table ${String(t)} hors de −1..${T - 1}`); + } + } +} + +/** + * Plafond d'une personne sur un itinéraire (index de table par tour, −1 en + * réserve), calculé sur les capacités : + * min(N − 1, Σ_{t∈D} a_t + min(n_p, Σ_{t∈D} m_t·v_t)), v_t = c_t − 1 − a_t, + * a_t excluant p ; n_p = n − 1 pour un mobile ou un partiel, n pour un ancré. + * + * L'itinéraire n'est pas confronté aux réservations : la formule vaut pour + * tout itinéraire, et le plafond a priori en prend le maximum sur les + * itinéraires admissibles. Lève RangeError quand p n'est pas un index de + * personne, ou que l'itinéraire n'a pas R cases, chacune −1 ou un index de + * table. + * + * @param {import('./types.js').Instance} instance + * @param {number} p index de la personne + * @param {ArrayLike} itineraire + * @returns {number} + */ +export function plafondItineraire(instance, p, itineraire) { + const { N, T, R } = instance; + if (!Number.isInteger(p) || p < 0 || p >= N) { + throw new RangeError(`personne d'index ${String(p)} hors de 0..${N - 1}`); + } + if (itineraire.length !== R) { + throw new RangeError(`itinéraire de ${itineraire.length} tours, ${R} attendus`); + } + exigerCases(itineraire, T, 'itinéraire'); + return plafondSurRangee(instance, p, itineraire, 0, null); +} + +/** + * Plafond réalisé de chacun, sur l'occupation : v_{t,r} = o_{t,r} − 1 − a_t, + * somme sur les tours assis ; Σ a_t reste sur les tables distinctes. Un tour + * en réserve n'entre pas dans le plafond de la personne, ni dans + * l'occupation d'aucune table. + * + * Lève RangeError quand tableDe n'a pas N × R cases, ou qu'une case n'est ni + * −1 ni un index de table. + * + * @param {import('./types.js').Instance} instance + * @param {ArrayLike} tableDe plan indexé, [p × R + r] + * @returns {number[]} + */ +export function plafondsRealises(instance, tableDe) { + const { N, T, R } = instance; + if (tableDe.length !== N * R) { + throw new RangeError(`plan indexé de ${tableDe.length} cases, N × R = ${N * R} attendues`); + } + exigerCases(tableDe, T, 'plan indexé'); + const occupation = new Int32Array(T * R); + for (let p = 0; p < N; p += 1) { + for (let r = 0; r < R; r += 1) { + const t = tableDe[p * R + r]; + if (t !== RESERVE) occupation[t * R + r] += 1; + } + } + const plafonds = []; + for (let p = 0; p < N; p += 1) { + plafonds.push(plafondSurRangee(instance, p, tableDe, p * R, occupation)); + } + return plafonds; +} + +// Clé de signature de p : statut, table d'ancrage, multiensemble trié des +// tables fixées. Le plafond a priori ne dépend que d'elle : la formule ne lit +// ni l'ordre des tours ni l'identité de p, et les tables admissibles sont les +// mêmes pour tous. +function signature(instance, p) { + const { R, fixe, statut } = instance; + const fixees = []; + for (let r = 0; r < R; r += 1) if (fixe[p * R + r] !== LIBRE) fixees.push(fixe[p * R + r]); + fixees.sort((x, y) => x - y); + return `${statut[p]}|${ancrageDe(instance, p)}|${fixees.join(',')}`; +} + +// Plafond a priori de p, par programmation dynamique. Les tours fixés donnent +// F, ses tables distinctes D₀, ancres0 = Σ_{t∈D₀} a_t et sieges0 = +// Σ_{t∈F} v_t ; restent f tours libres. Un état (s, w) — s tours libres +// attribués, w = min(cap, sièges ajoutés), cap = max(0, n_p − sieges0) — +// porte la plus grande somme d'ancrés ajoutée qui l'atteint, −1 s'il est +// inatteignable. Chaque table admissible, prise une fois, reçoit une +// multiplicité m de 0 à f − s : ses ancrés s'ajoutent si m > 0 et qu'elle +// n'est pas dans D₀, ses sièges m fois. Seuls les états à s = f concluent. +// Plafonner w à cap ne perd rien, la formule bornant les sièges à n_p ; et +// la plus grande somme d'ancrés suffit par état, la formule croissant avec +// elle. +function maximiser(instance, p, admissibles) { + const { T, R, fixe, capacite } = instance; + const ancrage = ancrageDe(instance, p); + const nP = mobilesRencontrables(instance, p); + const sieges = (t) => capacite[t] - 1 - ancresVus(instance, t, ancrage); + const dansD0 = new Uint8Array(T); + let ancres0 = 0; + let sieges0 = 0; + let f = 0; + for (let r = 0; r < R; r += 1) { + const t = fixe[p * R + r]; + if (t === LIBRE) { + f += 1; + continue; + } + sieges0 += sieges(t); + if (dansD0[t] === 0) { + dansD0[t] = 1; + ancres0 += ancresVus(instance, t, ancrage); + } + } + // Sans tour libre, l'itinéraire est imposé ; sans table admissible, les + // tours libres restent en réserve. + if (f === 0 || admissibles.length === 0) return borne(instance, nP, ancres0, sieges0); + + // Les sièges ajoutés ne dépassent jamais f fois le plus grand v_t : au-delà + // de cette valeur, w n'a pas de case à remplir. + let vMax = 0; + for (const t of admissibles) vMax = Math.max(vMax, sieges(t)); + const W = Math.min(Math.max(0, nP - sieges0), f * vMax); + const largeur = W + 1; + let meilleur = new Int32Array((f + 1) * largeur).fill(-1); + meilleur[0] = 0; + for (const t of admissibles) { + const gain = dansD0[t] === 1 ? 0 : ancresVus(instance, t, ancrage); + const v = sieges(t); + const suivant = meilleur.slice(); + for (let s = 0; s < f; s += 1) { + for (let w = 0; w <= W; w += 1) { + const atteint = meilleur[s * largeur + w]; + if (atteint < 0) continue; + for (let m = 1; s + m <= f; m += 1) { + const cible = (s + m) * largeur + Math.min(W, w + m * v); + if (atteint + gain > suivant[cible]) suivant[cible] = atteint + gain; + } + } + } + meilleur = suivant; + } + let plafond = -1; + for (let w = 0; w <= W; w += 1) { + const ajout = meilleur[f * largeur + w]; + if (ajout >= 0) plafond = Math.max(plafond, borne(instance, nP, ancres0 + ajout, sieges0 + w)); + } + return plafond; +} + +/** + * Plafond a priori de chacun, maximisé sur les itinéraires admissibles : + * tours fixés imposés, tours libres sur toute table non entièrement ancrée. + * Exact, par programmation dynamique ; mémoïsé par signature. + * + * @param {import('./types.js').Instance} instance + * @returns {number[]} + */ +export function plafondsAPriori(instance) { + const admissibles = tablesAdmissibles(instance); + const parSignature = new Map(); + const plafonds = []; + for (let p = 0; p < instance.N; p += 1) { + const cle = signature(instance, p); + let plafond = parSignature.get(cle); + if (plafond === undefined) { + plafond = maximiser(instance, p, admissibles); + parSignature.set(cle, plafond); + } + plafonds.push(plafond); + } + return plafonds; +} + +// Vrai quand C(A + f − 1, f), le nombre de multiensembles de f tables parmi +// A, dépasse limite. Le produit avance par C(A − 1 + i, i), entier et +// croissant en i : il s'arrête dès qu'il dépasse, avant d'excéder les +// entiers qu'un nombre représente exactement. +function multiensemblesAuDela(A, f, limite) { + let compte = 1; + for (let i = 1; i <= f; i += 1) { + compte = (compte * (A - 1 + i)) / i; + if (compte > limite) return true; + } + return false; +} + +// Plafond a priori de p par énumération : chaque multiensemble de tables +// admissibles, de la taille du nombre de tours libres, se pose sur ces tours +// dans l'ordre, et plafondItineraire en donne la valeur. La formule ne lisant +// pas l'ordre des tours, un multiensemble suffit pour chaque ensemble +// d'itinéraires qui ne diffèrent que par cet ordre. +function enumerer(instance, p, admissibles) { + const { R, fixe, ids } = instance; + const itineraire = Int32Array.from(fixe.subarray(p * R, (p + 1) * R)); + const libres = []; + for (let r = 0; r < R; r += 1) if (itineraire[r] === LIBRE) libres.push(r); + const f = libres.length; + const A = admissibles.length; + // Sans table admissible, les tours libres restent à −1, en réserve. + if (f === 0 || A === 0) return plafondItineraire(instance, p, itineraire); + if (multiensemblesAuDela(A, f, LIMITE_ENUMERATION)) { + throw new ErreurConfiguration('ENUMERATION_TROP_GRANDE', { + participant: ids[p], + tablesAdmissibles: A, + toursLibres: f, + limite: LIMITE_ENUMERATION, + }); + } + // choix[i] : rang, dans admissibles, de la table du i-ème tour libre. Les + // suites non décroissantes se suivent dans l'ordre lexicographique, de + // (0, …, 0) à (A − 1, …, A − 1). + const choix = new Int32Array(f); + let plafond = -1; + for (;;) { + for (let i = 0; i < f; i += 1) itineraire[libres[i]] = admissibles[choix[i]]; + plafond = Math.max(plafond, plafondItineraire(instance, p, itineraire)); + let i = f - 1; + while (i >= 0 && choix[i] === A - 1) i -= 1; + if (i < 0) return plafond; + choix[i] += 1; + for (let j = i + 1; j < f; j += 1) choix[j] = choix[i]; + } +} + +/** + * Oracle : même résultat par énumération des multiensembles de tables des + * tours libres. Lève ErreurConfiguration('ENUMERATION_TROP_GRANDE') au-delà de + * 300 000 multiensembles pour une signature, avec pour détails le + * participant, par identifiant, le premier dans l'ordre des index dont + * l'énumération dépasse, le nombre de tables admissibles, celui de ses tours + * libres, et la limite. + * + * L'énumération ne partage avec plafondsAPriori que la formule et la règle + * d'admissibilité. Elle regroupe les personnes par rangée de tours fixés, + * plus fine que la signature : si la signature confondait deux personnes de + * plafonds distincts, les deux fonctions divergeraient sur l'une d'elles. + * + * @param {import('./types.js').Instance} instance + * @returns {number[]} + */ +export function plafondsAPrioriParEnumeration(instance) { + const { N, R, fixe } = instance; + const admissibles = tablesAdmissibles(instance); + const parRangee = new Map(); + const plafonds = []; + for (let p = 0; p < N; p += 1) { + const cle = fixe.subarray(p * R, (p + 1) * R).join(','); + let plafond = parRangee.get(cle); + if (plafond === undefined) { + plafond = enumerer(instance, p, admissibles); + parRangee.set(cle, plafond); + } + plafonds.push(plafond); + } + return plafonds; +} diff --git a/src/moteur/plafond.long.test.js b/src/moteur/plafond.long.test.js new file mode 100644 index 0000000..a26a746 --- /dev/null +++ b/src/moteur/plafond.long.test.js @@ -0,0 +1,195 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves lourdes des plafonds (§ 14.4, § 14.12) : l'accord de la +// programmation dynamique et de l'énumération sur une grille plus large que +// celle de la série node, jusqu'à 8 tables et 5 tours ; la forme de la grande +// démonstration et sa variante, arbitrées par l'énumération ; la limite de +// l'énumération à sa frontière exacte. +import assert from 'node:assert/strict'; +import fc from 'fast-check'; +import { describe, test } from '../../test/lanceur.js'; +import { normaliser } from './configuration.js'; +import { ErreurConfiguration } from './erreurs.js'; +import { plafondsAPriori, plafondsAPrioriParEnumeration } from './plafond.js'; + +const SANS_CONTRAINTE = { + separerAppartenances: false, + nouveauxVoisins: false, + nouvelleTable: false, + varierAppartenances: false, +}; + +// Graine de la grille, écrite ici (§ 14.12) ; chaque case de la grille en +// dérive la sienne. +const GRAINE = 314_159; + +/** + * Instance d'une salle : capacites[t] et ancres[t] décrivent la table + * d'index t ; partiels[j][r] est la table imposée au j-ème partiellement + * fixé au tour r, −1 pour un tour libre. Identifiants 1 à N : les ancrés, + * table par table, puis les partiels, puis les mobiles ; la table d'index t + * a pour identifiant 100 + t. Lève ce que normaliser lève. + */ +function instanceDe({ capacites, ancres, mobiles, tours, partiels = [] }) { + const participants = []; + const reservations = []; + const nouveau = () => { + const id = participants.length + 1; + participants.push({ id, nom: `P${id}`, appartenance: null }); + return id; + }; + ancres.forEach((a, t) => { + for (let i = 0; i < a; i += 1) { + reservations.push({ participant: nouveau(), table: 100 + t, portee: 'tous' }); + } + }); + for (const tables of partiels) { + const id = nouveau(); + tables.forEach((t, r) => { + if (t < 0) return; + reservations.push({ participant: id, table: 100 + t, portee: 'tour', tour: r + 1 }); + }); + } + for (let i = 0; i < mobiles; i += 1) nouveau(); + return normaliser({ + participants, + tables: capacites.map((capacite, t) => ({ id: 100 + t, numero: t + 1, capacite })), + tours, + reservations, + contraintes: SANS_CONTRAINTE, + }); +} + +// Une case de la grille : T tables et R tours fixés, capacités de 2 à 6. +// Une table est entièrement ancrée une fois sur quatre, et garde sinon de 1 à +// c sièges libres. Chaque tour d'un partiel est libre une fois sur deux, ou +// désigne par son rang l'une des tables qui gardent un siège, rang pris +// modulo leur nombre : seul l'empilement de partiels à une même table, au +// même tour, fait encore refuser une instance. +function arbitraireCase(T, R) { + const table = fc + .integer({ min: 2, max: 6 }) + .chain((c) => + fc.tuple( + fc.constant(c), + fc.oneof( + { arbitrary: fc.integer({ min: 0, max: c - 1 }), weight: 3 }, + { arbitrary: fc.constant(c), weight: 1 }, + ), + ), + ); + const tour = fc.oneof(fc.constant(-1), fc.integer({ min: 0, max: T - 1 })); + return fc + .record({ + salle: fc.array(table, { minLength: T, maxLength: T }), + mobiles: fc.integer({ min: 0, max: 10 }), + rangs: fc.array(fc.array(tour, { minLength: R, maxLength: R }), { maxLength: 4 }), + }) + .map(({ salle, mobiles, rangs }) => { + const ouvertes = []; + salle.forEach(([c, a], t) => { + if (a < c) ouvertes.push(t); + }); + const partiels = rangs.map((tours) => + tours.map((rang) => (rang < 0 || ouvertes.length === 0 ? -1 : ouvertes[rang % ouvertes.length])), + ); + return { + capacites: salle.map(([c]) => c), + ancres: salle.map(([, a]) => a), + mobiles, + tours: R, + partiels, + }; + }); +} + +describe("plafond a priori : la programmation dynamique contre l'énumération, grille large", () => { + test('de 2 à 8 tables et de 2 à 5 tours, chaque case de la grille : le même plafond pour chacun', () => { + const parCase = []; + const parStatut = [0, 0, 0]; + for (let T = 2; T <= 8; T += 1) { + for (let R = 2; R <= 5; R += 1) { + let acceptees = 0; + fc.assert( + fc.property(arbitraireCase(T, R), (description) => { + let instance; + try { + instance = instanceDe(description); + } catch (erreur) { + if (erreur instanceof ErreurConfiguration && erreur.code === 'SURRESERVATION') return; + throw erreur; + } + if (instance.N === 0) return; + acceptees += 1; + for (let p = 0; p < instance.N; p += 1) parStatut[instance.statut[p]] += 1; + assert.deepEqual(plafondsAPriori(instance), plafondsAPrioriParEnumeration(instance)); + }), + { seed: GRAINE + 10 * T + R, numRuns: 300 }, + ); + parCase.push([T, R, acceptees]); + } + } + // Chaque case compte ses instances acceptées : une case que le + // générateur viderait n'éprouverait rien. + assert.equal(parCase.length, 28); + const maigres = parCase + .filter(([, , acceptees]) => acceptees < 200) + .map(([T, R, n]) => `${T} tables, ${R} tours : ${n} acceptées sur 300, 200 au moins`); + assert.deepEqual(maigres, []); + assert.ok(parStatut[1] >= 5000, `${parStatut[1]} partiellement fixés comparés, 5000 au moins`); + assert.ok(parStatut[2] >= 5000, `${parStatut[2]} ancrés comparés, 5000 au moins`); + }); + + test("forme de la grande démonstration et sa variante : la programmation dynamique égale l'énumération", () => { + // Principale : 29 tables de 8 puis 4 de 7, un ancré par table. Variante : + // 33 tables de 8, 4 places libres. 227 mobiles, 4 tours ; un mobile + // énumère C(36, 4) = 58 905 multiensembles. + const principale = instanceDe({ + capacites: [...Array(29).fill(8), ...Array(4).fill(7)], + ancres: Array(33).fill(1), + mobiles: 227, + tours: 4, + }); + // Les ancrés portent les identifiants 1 à 33, table par table. + const attendu = new Array(260).fill(28); + for (let t = 29; t < 33; t += 1) attendu[t] = 24; + assert.deepEqual(plafondsAPriori(principale), attendu); + assert.deepEqual(plafondsAPrioriParEnumeration(principale), attendu); + + const variante = instanceDe({ + capacites: Array(33).fill(8), + ancres: Array(33).fill(1), + mobiles: 227, + tours: 4, + }); + const vingtHuit = new Array(260).fill(28); + assert.deepEqual(plafondsAPriori(variante), vingtHuit); + assert.deepEqual(plafondsAPrioriParEnumeration(variante), vingtHuit); + }); + + test("la limite de l'énumération : 300 000 multiensembles passent, un de plus est refusé", () => { + // Un tour libre : le compte est celui des tables admissibles. + const unTour = (tables) => + instanceDe({ capacites: Array(tables).fill(2), ancres: [], mobiles: 1, tours: 1 }); + assert.deepEqual(plafondsAPrioriParEnumeration(unTour(300_000)), [0]); + assert.throws( + () => plafondsAPrioriParEnumeration(unTour(300_001)), + (erreur) => + erreur instanceof ErreurConfiguration + && erreur.code === 'ENUMERATION_TROP_GRANDE' + && erreur.details.tablesAdmissibles === 300_001 + && erreur.details.toursLibres === 1, + ); + // Deux tours libres : C(775, 2) = 299 925 passent, là où 774² le + // refuserait ; C(776, 2) = 300 700 est refusé. + const deuxTours = (tables) => + instanceDe({ capacites: Array(tables).fill(2), ancres: [], mobiles: 2, tours: 2 }); + const passe = deuxTours(774); + assert.deepEqual(plafondsAPrioriParEnumeration(passe), plafondsAPriori(passe)); + assert.throws( + () => plafondsAPrioriParEnumeration(deuxTours(775)), + (erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'ENUMERATION_TROP_GRANDE', + ); + }); +}); diff --git a/src/moteur/plafond.test.js b/src/moteur/plafond.test.js new file mode 100644 index 0000000..88c348c --- /dev/null +++ b/src/moteur/plafond.test.js @@ -0,0 +1,502 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves des plafonds (§ 5.5) : la formule sur un itinéraire, le plafond +// réalisé sur l'occupation, le plafond a priori maximisé sur les itinéraires +// admissibles, et l'accord de la programmation dynamique avec l'énumération +// exhaustive qui l'arbitre (§ 14.11, § 14.12). Les valeurs attendues des +// formes nommées sont celles du § 5.5, du § 14.10 et du § 15 ; les autres se +// calculent à la main, table par table, dans le commentaire qui les précède. +import assert from 'node:assert/strict'; +import fc from 'fast-check'; +import { describe, test } from '../../test/lanceur.js'; +import { indexerPlan, normaliser } from './configuration.js'; +import { ErreurConfiguration } from './erreurs.js'; +import { + plafondItineraire, + plafondsAPriori, + plafondsAPrioriParEnumeration, + plafondsRealises, +} from './plafond.js'; + +const SANS_CONTRAINTE = { + separerAppartenances: false, + nouveauxVoisins: false, + nouvelleTable: false, + varierAppartenances: false, +}; + +// Graine de la propriété, écrite ici pour que chaque exécution tire les +// mêmes instances (§ 14.12). +const GRAINE = 271_828; + +// Entiers de debut à fin, inclus. +const suite = (debut, fin) => Array.from({ length: fin - debut + 1 }, (_, i) => debut + i); + +/** + * Instance d'une forme de salle. capacites[t] et ancres[t] décrivent la + * table d'index t ; autres compte les participants non ancrés, dont les + * premiers sont partiellement fixés selon partiels : partiels[j][r] est + * l'index de table imposé au tour r, −1 pour un tour libre. Les ancrés + * prennent les identifiants pairs tant qu'il reste des non-ancrés, et les + * identifiants de table décroissent quand l'index croît : un index lu comme + * un identifiant désigne une autre personne ou une autre table. Rend + * l'instance et les index de personne de chaque rôle ; lève ce que + * normaliser lève. + */ +function forme({ capacites, ancres, autres, tours, partiels = [] }) { + const T = capacites.length; + const idTable = (t) => 10 * (T - t); + const K = ancres.reduce((somme, a) => somme + a, 0); + const idsAncres = []; + const idsAutres = []; + for (const id of suite(1, K + autres)) { + const resteAutres = autres - idsAutres.length; + if (idsAncres.length < K && (id % 2 === 0 || resteAutres === 0)) idsAncres.push(id); + else idsAutres.push(id); + } + const reservations = []; + const ancresParTable = []; + let premier = 0; + for (let t = 0; t < T; t += 1) { + const ids = idsAncres.slice(premier, premier + ancres[t]); + premier += ancres[t]; + ancresParTable.push(ids); + for (const id of ids) reservations.push({ participant: id, table: idTable(t), portee: 'tous' }); + } + partiels.forEach((tables, j) => { + tables.forEach((t, r) => { + if (t < 0) return; + reservations.push({ + participant: idsAutres[j], + table: idTable(t), + portee: 'tour', + tour: r + 1, + }); + }); + }); + const instance = normaliser({ + participants: suite(1, K + autres).map((id) => ({ id, nom: `P${id}`, appartenance: null })), + tables: capacites.map((capacite, t) => ({ id: idTable(t), numero: t + 1, capacite })), + tours, + reservations, + contraintes: SANS_CONTRAINTE, + }); + const index = (id) => instance.indexDe.get(id); + return { + instance, + ancres: ancresParTable.map((ids) => ids.map(index)), + partiels: idsAutres.slice(0, partiels.length).map(index), + mobiles: idsAutres.slice(partiels.length).map(index), + }; +} + +// Forme de la grande démonstration (§ 15.1) : 29 tables de 8 puis 4 de 7, +// un ancré par table, 227 non-ancrés, 4 tours. Les tables de 7 portent les +// index 29 à 32. +const grandeForme = (partiels = []) => + forme({ + capacites: [...Array(29).fill(8), ...Array(4).fill(7)], + ancres: Array(33).fill(1), + autres: 227, + tours: 4, + partiels, + }); + +// Plan indexé d'un plan écrit table par table : listes[r][t] porte les +// identifiants assis à la table d'index t au tour r, reserves[r] ceux du +// tour r en réserve. +const planIndexe = (instance, listes, reserves = listes.map(() => [])) => + indexerPlan(instance, { tables: [...instance.idsTables], tours: listes, reserves }); + +// Plan valide quelconque, que la graine brasse : à chaque tour, chaque +// personne fixée s'assied à sa table ; les autres, dans un ordre brassé, +// prennent tour à tour un siège à la table suivante d'un cycle brassé des +// tables qui en ont encore ; sans siège, une personne va en réserve. +function planBrasse(instance, graine) { + const { N, T, R, fixe, capacite } = instance; + const tableDe = new Int32Array(N * R).fill(-1); + const brassage = (i, r) => ((i + 1) * 2_654_435_761 + (r + 1) * 40_503 + graine * 97) % 1_000_003; + for (let r = 0; r < R; r += 1) { + const libres = Int32Array.from(capacite); + const aPlacer = []; + for (let p = 0; p < N; p += 1) { + const t = fixe[p * R + r]; + if (t >= 0) { + tableDe[p * R + r] = t; + libres[t] -= 1; + } else { + aPlacer.push(p); + } + } + aPlacer.sort((x, y) => brassage(x, r) - brassage(y, r) || x - y); + const cycle = suite(0, T - 1).sort((x, y) => brassage(N + x, r) - brassage(N + y, r) || x - y); + let rang = 0; + for (const p of aPlacer) { + let essais = 0; + while (essais < T && libres[cycle[rang % T]] === 0) { + rang += 1; + essais += 1; + } + if (essais === T) break; + const t = cycle[rang % T]; + tableDe[p * R + r] = t; + libres[t] -= 1; + rang += 1; + } + } + return tableDe; +} + +// Ancrés d'une table de capacité c : de 0 à c − 1, ou c une fois sur +// quatre, pour une table entièrement ancrée. +const ancresDe = (c) => + fc.oneof( + { arbitrary: fc.integer({ min: 0, max: c - 1 }), weight: 3 }, + { arbitrary: fc.constant(c), weight: 1 }, + ); + +// Un tour d'un non-ancré : libre (−1) une fois sur deux, fixé sinon à l'une +// des T tables. +const tourDe = (T) => fc.oneof(fc.constant(-1), fc.integer({ min: 0, max: T - 1 })); + +// Description d'une instance tirée dans les bornes données : chaque table +// tire sa capacité puis ses ancrés, chaque partiel un tour par tour. Un +// partiel dont aucun tour n'est fixé est un mobile ; un partiel fixé à la +// même table à chaque tour, un ancré (§ 4.2). +function arbitraireForme({ tables, tours, capacites, mobiles, partiels }) { + const table = fc + .integer({ min: capacites[0], max: capacites[1] }) + .chain((c) => fc.tuple(fc.constant(c), ancresDe(c))); + return fc + .tuple( + fc.integer({ min: tables[0], max: tables[1] }), + fc.integer({ min: tours[0], max: tours[1] }), + ) + .chain(([T, R]) => + fc.record({ + salle: fc.array(table, { minLength: T, maxLength: T }), + tours: fc.constant(R), + mobiles: fc.integer({ min: 0, max: mobiles }), + partiels: fc.array(fc.array(tourDe(T), { minLength: R, maxLength: R }), { + maxLength: partiels, + }), + }), + ); +} + +// Compare les deux calculs du plafond a priori sur chaque instance tirée. +// Une instance que normaliser refuse — une table qui reçoit plus de +// personnes fixées que de sièges — ou sans participant n'est pas comptée ; +// une autre l'est, avec ses personnes par statut et ses tables pleines +// d'ancrés. Rend ces comptes. +function eprouverAccord(arbitraire, parametres) { + const bilan = { acceptees: 0, parStatut: [0, 0, 0], tablesPleines: 0 }; + fc.assert( + fc.property(arbitraire, ({ salle, tours, mobiles, partiels }) => { + let instance; + try { + ({ instance } = forme({ + capacites: salle.map(([c]) => c), + ancres: salle.map(([, a]) => a), + autres: partiels.length + mobiles, + tours, + partiels, + })); + } catch (erreur) { + if (erreur instanceof ErreurConfiguration && erreur.code === 'SURRESERVATION') return; + throw erreur; + } + if (instance.N === 0) return; + bilan.acceptees += 1; + for (let p = 0; p < instance.N; p += 1) bilan.parStatut[instance.statut[p]] += 1; + if (salle.some(([c, a]) => a === c)) bilan.tablesPleines += 1; + assert.deepEqual(plafondsAPriori(instance), plafondsAPrioriParEnumeration(instance)); + }), + parametres, + ); + return bilan; +} + +describe('plafonds : valeurs exactes (§ 5.5, § 15)', () => { + test('4 tables de 5, un ancré chacune, 5 tours : un ancré 16, un mobile 19', () => { + const { instance, ancres, mobiles } = forme({ + capacites: [5, 5, 5, 5], + ancres: [1, 1, 1, 1], + autres: 16, + tours: 5, + }); + assert.equal(instance.N, 20); + assert.equal(instance.k, 4); + assert.equal(instance.n, 16); + // L'écart entre les deux valeurs est l'objet du test. Un ancré ne + // rencontre jamais les ancrés des autres tables : ses 5 tours × 4 + // sièges ne livrent que les 16 mobiles. Sans le terme min(n_p, …), il + // monterait à 19 avec le mobile. + const attendu = new Array(20).fill(19); + for (const [p] of ancres) attendu[p] = 16; + assert.deepEqual(plafondsAPriori(instance), attendu); + assert.deepEqual(plafondsAPrioriParEnumeration(instance), attendu); + assert.equal(plafondItineraire(instance, ancres[0][0], [0, 0, 0, 0, 0]), 16); + assert.equal(plafondItineraire(instance, mobiles[0], [0, 1, 2, 3, 0]), 19); + }); + + test('forme de la grande démonstration : mobile 28, animateur 28 à une table de 8, 24 à une table de 7', () => { + const { instance, ancres, mobiles } = grandeForme(); + assert.equal(instance.N, 260); + assert.equal(instance.k, 33); + assert.equal(instance.n, 227); + const attendu = new Array(260).fill(28); + for (let t = 29; t < 33; t += 1) attendu[ancres[t][0]] = 24; + assert.deepEqual(plafondsAPriori(instance), attendu); + // Un mobile qui passe une fois par une table de 7 : 4 ancrés, 3 × 6 + 5 + // sièges, 27. Par quatre tables de 8 : 28. + assert.equal(plafondItineraire(instance, mobiles[0], [0, 1, 2, 29]), 27); + assert.equal(plafondItineraire(instance, mobiles[0], [0, 1, 2, 3]), 28); + }); + + test('petite démonstration : 8 pour chacun, que le plan parfait atteint (§ 15.3)', () => { + const { instance } = forme({ + capacites: [3, 3, 3, 3], + ancres: [0, 0, 0, 0], + autres: 12, + tours: 4, + }); + const huit = new Array(12).fill(8); + assert.deepEqual(plafondsAPriori(instance), huit); + assert.deepEqual(plafondsAPrioriParEnumeration(instance), huit); + // Le plan parfait du § 15.3, la table k du tableau à l'index k − 1. Les + // tables y sont pleines à chaque tour : le réalisé égale l'a priori. + const parfait = planIndexe(instance, [ + [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], + [[6, 9, 12], [3, 8, 11], [2, 5, 10], [1, 4, 7]], + [[4, 8, 10], [2, 7, 12], [1, 6, 11], [3, 5, 9]], + [[5, 7, 11], [1, 9, 10], [3, 4, 12], [2, 6, 8]], + ]); + assert.deepEqual(plafondsRealises(instance, parfait), huit); + }); + + test("plafond réalisé sur l'occupation : 3 tables de 4, un ancré chacune, N = 10, 2 tours", () => { + const { instance } = forme({ capacites: [4, 4, 4], ancres: [1, 1, 1], autres: 7, tours: 2 }); + assert.equal(instance.n, 7); + // Ancrés 2, 4 et 6 aux tables d'index 0, 1 et 2 ; les mobiles 1 et 3 + // passent les deux tours à la table d'index 0, occupée par 3. + const tableDe = planIndexe(instance, [ + [[1, 2, 3], [4, 5, 7, 8], [6, 9, 10]], + [[1, 2, 3], [4, 9, 10], [5, 6, 7, 8]], + ]); + // Par identifiant, l'index valant id − 1 ; N − 1 = 9, n_p = 6 pour un + // mobile et 7 pour un ancré. Un tour à une table d'occupation o apporte + // o − 1 − a_t sièges, a_t = 1 pour un mobile et 0 pour l'ancré de la + // table : + // 1, 3 table 0 deux fois, o = 3 : 1 ancré + 1 + 1 = 3 + // 5, 7, 8 tables 1 puis 2, o = 4 : 2 ancrés + 2 + 2 = 6 + // 9, 10 tables 2 puis 1, o = 3 : 2 ancrés + 1 + 1 = 4 + // 2 sa table, o = 3 puis 3 : 2 + 2 = 4 + // 4 sa table, o = 4 puis 3 : 3 + 2 = 5 + // 6 sa table, o = 3 puis 4 : 2 + 3 = 5 + const realises = plafondsRealises(instance, tableDe); + assert.deepEqual(realises, [3, 4, 3, 5, 6, 5, 6, 6, 4, 4]); + // A priori, sur les capacités : un mobile visite deux tables, 2 + 2 × 2 ; + // un ancré tient sa table, 2 × 3. + const aPriori = plafondsAPriori(instance); + assert.deepEqual(aPriori, new Array(10).fill(6)); + const p = instance.indexDe.get(1); + assert.equal(realises[p], 3); + assert.equal(aPriori[p], 6); + // Écart d'itinéraire du mobile 1 : 3. + assert.equal(aPriori[p] - realises[p], 3); + }); + + test('partiellement fixé : sa table fixée entre dans F, seuls ses tours libres varient', () => { + // Un non-ancré de la grande forme, fixé au tour 1 à une table de 7. + const { instance, partiels, mobiles, ancres } = grandeForme([[29, -1, -1, -1]]); + const [p] = partiels; + assert.equal(instance.statut[p], 1); + assert.equal(instance.k, 33); + const aPriori = plafondsAPriori(instance); + // La table de 7 entre dans F : 1 ancré et 5 sièges ; ses trois tours + // libres prennent trois tables de 8 : 3 ancrés et 18 sièges. 27. + assert.equal(aPriori[p], 27); + assert.equal(aPriori[mobiles[0]], 28); + assert.equal(aPriori[ancres[29][0]], 24); + // Libéré de son tour fixé, l'itinéraire atteindrait 28 : le maximum ne + // déplace pas ce tour. + assert.equal(plafondItineraire(instance, p, [0, 1, 2, 3]), 28); + assert.equal(plafondItineraire(instance, p, [29, 0, 1, 2]), 27); + assert.equal(plafondsAPrioriParEnumeration(instance)[p], 27); + }); + + test("une table pleine d'ancrés n'est pas admissible ; un partiellement fixé compte parmi les mobiles", () => { + // Table d'index 0 : 5 sièges, aucun ancré. Table d'index 1 : 2 sièges, + // 2 ancrés, pleine. Le partiellement fixé est fixé au tour 1 à la table + // 0 ; N = 5, k = 2, n = 3, 2 tours. + const { instance, ancres, partiels, mobiles } = forme({ + capacites: [5, 2], + ancres: [0, 2], + autres: 3, + tours: 2, + partiels: [[0, -1]], + }); + assert.equal(instance.n, 3); + // Partiel et mobiles : seule la table 0 est admissible, 0 ancré et + // 4 + 4 sièges, bornés par n_p = n − 1 = 2. La table pleine, admise, leur + // donnerait 4 : 2 ancrés, et v = 2 − 1 − 2 = −1 ; n_p = n en donnerait 3. + // Ancrés : 1 ancré voisin, 0 siège qui change d'occupant. 1. + const attendu = new Array(5).fill(-1); + for (const p of [...partiels, ...mobiles]) attendu[p] = 2; + for (const p of ancres[1]) attendu[p] = 1; + assert.deepEqual(plafondsAPriori(instance), attendu); + assert.deepEqual(plafondsAPrioriParEnumeration(instance), attendu); + }); + + test('réserve : une personne en réserve à un tour a un plafond réalisé sur ses seuls tours assis', () => { + // Deux tables de 3, le participant 2 ancré à la table d'index 0 ; + // 4 mobiles, 2 tours. Le mobile 3 est en réserve au tour 2. + const { instance } = forme({ capacites: [3, 3], ancres: [1, 0], autres: 4, tours: 2 }); + const tableDe = planIndexe( + instance, + [ + [[1, 2, 3], [4, 5]], + [[2, 4], [1, 5]], + ], + [[], [3]], + ); + // N − 1 = 4, n_p = 3 pour un mobile et 4 pour l'ancré : + // 1 table 0 (3 occupants, 1 ancré) puis 1 (2) : 1 ancré + 1 + 1 = 3 + // 2 sa table, occupée par 3 puis 2 : 2 + 1 = 3 + // 3 table 0 au seul tour 1 : 1 ancré + 1 = 2 + // 4 table 1 (2) puis 0 (2, 1 ancré) : 1 ancré + 1 + 0 = 2 + // 5 table 1, occupée par 2 aux deux tours : 1 + 1 = 2 + assert.deepEqual(plafondsRealises(instance, tableDe), [3, 3, 2, 2, 2]); + const p = instance.indexDe.get(3); + assert.equal(plafondItineraire(instance, p, [0, -1]), 2); + // L'écart d'itinéraire porte ce que le manque ne porte pas : 4 − 2. + assert.deepEqual(plafondsAPriori(instance), [4, 4, 4, 4, 4]); + }); + + test('sans table admissible, les tours libres restent en réserve', () => { + // Deux tables entièrement ancrées et un mobile, qui ne peut s'asseoir + // nulle part. Ancrés de la table de 2 : 1 voisin, 0 siège. Ancrés de la + // table de 3 : 2 voisins, 0 siège. Le mobile : 0. + const { instance, ancres, mobiles } = forme({ + capacites: [2, 3], + ancres: [2, 3], + autres: 1, + tours: 2, + }); + const attendu = new Array(6).fill(-1); + for (const p of ancres[0]) attendu[p] = 1; + for (const p of ancres[1]) attendu[p] = 2; + attendu[mobiles[0]] = 0; + assert.deepEqual(plafondsAPriori(instance), attendu); + assert.deepEqual(plafondsAPrioriParEnumeration(instance), attendu); + assert.equal(plafondItineraire(instance, mobiles[0], [-1, -1]), 0); + }); + + test('plafond a priori ≥ plafond réalisé pour chacun, sur trois plans quelconques de chaque forme', () => { + const formes = [ + forme({ capacites: [5, 5, 5, 5], ancres: [1, 1, 1, 1], autres: 16, tours: 5 }), + grandeForme(), + grandeForme([[29, -1, -1, -1], [-1, 30, 30, -1]]), + forme({ capacites: [3, 3, 3, 3], ancres: [0, 0, 0, 0], autres: 12, tours: 4 }), + forme({ capacites: [4, 4, 4], ancres: [1, 1, 1], autres: 7, tours: 2 }), + forme({ capacites: [5, 2], ancres: [0, 2], autres: 3, tours: 2, partiels: [[0, -1]] }), + // Une place de moins que de participants : chaque plan met quelqu'un + // en réserve. + forme({ capacites: [3, 3], ancres: [1, 0], autres: 6, tours: 3, partiels: [[-1, 1, -1]] }), + ]; + const ecarts = []; + let comparaisons = 0; + for (let rang = 0; rang < formes.length; rang += 1) { + const { instance } = formes[rang]; + const aPriori = plafondsAPriori(instance); + for (const graine of [1, 2, 3]) { + const realises = plafondsRealises(instance, planBrasse(instance, graine)); + for (let p = 0; p < instance.N; p += 1) { + comparaisons += 1; + if (realises[p] > aPriori[p]) { + ecarts.push( + `forme ${rang}, graine ${graine}, index ${p} : ` + + `réalisé ${realises[p]} > a priori ${aPriori[p]}`, + ); + } + } + } + } + assert.equal(comparaisons, 3 * formes.reduce((somme, { instance }) => somme + instance.N, 0)); + assert.ok(comparaisons > 0, 'aucune comparaison'); + assert.deepEqual(ecarts, []); + }); + + test("un itinéraire ou un plan indexé hors de la forme de l'instance lève RangeError", () => { + const { instance } = forme({ capacites: [3, 3], ancres: [0, 0], autres: 4, tours: 2 }); + const cas = [ + ['itinéraire de 1 tour sur 2', () => plafondItineraire(instance, 0, [0])], + ["table d'index 2 sur 2 tables", () => plafondItineraire(instance, 0, [0, 2])], + ["table d'index −2", () => plafondItineraire(instance, 0, [-2, 0])], + ["table d'index non entier", () => plafondItineraire(instance, 0, [0, 0.5])], + ["personne d'index 4 sur 4", () => plafondItineraire(instance, 4, [0, 0])], + ["personne d'index −1", () => plafondItineraire(instance, -1, [0, 0])], + ['plan de 7 cases sur 8', () => plafondsRealises(instance, new Int32Array(7))], + ['plan à une case −2', () => plafondsRealises(instance, Int32Array.of(0, 0, 0, 0, 1, 1, 1, -2))], + ['plan à une case 2', () => plafondsRealises(instance, Int32Array.of(0, 0, 0, 0, 1, 1, 1, 2))], + ]; + const ecarts = cas + .map(([libelle, fonction]) => { + try { + fonction(); + } catch (erreur) { + if (erreur instanceof RangeError) return null; + return `${libelle} : ${erreur?.name} au lieu de RangeError`; + } + return `${libelle} : aucun refus`; + }) + .filter((ecart) => ecart !== null); + assert.deepEqual(ecarts, []); + }); +}); + +describe("plafond a priori : la programmation dynamique contre l'énumération (§ 14.11, § 14.12)", () => { + test("au-delà de 300 000 multiensembles, l'énumération refuse ; la programmation dynamique répond", () => { + // 775 tables admissibles et 2 tours libres : C(776, 2) = 300 700 + // multiensembles pour la signature des mobiles. Le premier d'entre eux + // dans l'ordre des index est nommé. + const { instance, mobiles } = forme({ + capacites: Array(775).fill(2), + ancres: Array(775).fill(0), + autres: 2, + tours: 2, + }); + assert.throws( + () => plafondsAPrioriParEnumeration(instance), + (erreur) => { + assert.ok(erreur instanceof ErreurConfiguration, `${erreur?.name} : ${erreur?.message}`); + assert.equal(erreur.code, 'ENUMERATION_TROP_GRANDE'); + assert.deepEqual(erreur.details, { + participant: instance.ids[mobiles[0]], + tablesAdmissibles: 775, + toursLibres: 2, + limite: 300_000, + }); + return true; + }, + ); + // N − 1 = 1 borne les deux mobiles. + assert.deepEqual(plafondsAPriori(instance), [1, 1]); + }); + + test('2 à 5 tables, 2 à 4 tours, capacités 2 à 5, ancrés et partiels : le même plafond pour chacun', () => { + const bilan = eprouverAccord( + arbitraireForme({ tables: [2, 5], tours: [2, 4], capacites: [2, 5], mobiles: 6, partiels: 3 }), + { seed: GRAINE, numRuns: 500 }, + ); + // Une instance que normaliser refuse n'éprouve rien : la boucle compte + // les acceptées, et les personnes comparées de chaque statut. + const { acceptees, parStatut, tablesPleines } = bilan; + assert.ok(acceptees >= 200, `${acceptees} instances acceptées, 200 au moins`); + assert.ok(parStatut[1] >= 100, `${parStatut[1]} partiellement fixés comparés, 100 au moins`); + assert.ok(parStatut[2] >= 100, `${parStatut[2]} ancrés comparés, 100 au moins`); + assert.ok(tablesPleines >= 50, `${tablesPleines} instances à table pleine d'ancrés, 50 au moins`); + }); +});