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`); + }); +});