diff --git a/src/moteur/conflits.js b/src/moteur/conflits.js new file mode 100644 index 0000000..6399a7f --- /dev/null +++ b/src/moteur/conflits.js @@ -0,0 +1,66 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Conflits par table (§ 7.5) : les violations des règles actives, table par +// table et tour par tour, que le plan lit sans rien recalculer. Elles se +// lisent dans parcourirTours (indicateurs.js), le passage dont mesurer tire +// ses chiffres : la somme des paires séparer d'un plan égale ses collisions +// cumulées, celle des personnes nouvelle table ses retours choisis, et les +// paires distinctes nouveaux voisins ses rencontres répétées choisies +// (§ 13.2). La pastille d'une table compte des occurrences de violation — +// une paire en collision, une paire revue, une personne revenue —, et chaque +// règle porte les siennes dans son unité (§ 5.4). +// +// « Varier les appartenances » ne se localise pas à une table : aucune +// violation n'en sort. +import { parcourirTours } from './indicateurs.js'; + +/** + * @typedef {Object} ConflitsTable + * @property {number} table identifiant + * @property {number} nombre violations des règles actives à ce tour, toutes règles + * @property {Array<{regle: 'separerAppartenances'|'nouveauxVoisins', paires: number[][]} + * |{regle: 'nouvelleTable', personnes: number[]}>} regles + * identifiants croissants, règles dans cet ordre ; une règle inactive + * ou sans violation est omise + */ + +/** + * Violations des règles actives (§ 7.5), lues dans parcourirTours et rendues + * en identifiants : séparer les appartenances — les collègues réunis ; + * nouveaux voisins — les paires revues ; nouvelle table — les personnes + * revenues. Une paire revue se signale à chaque rencontre répétée, sauf quand + * toutes ses rencontres jusque-là ont eu lieu à une table où les deux étaient + * réservés ; une personne revenue, à chaque visite répétée d'une table, sauf + * quand elle y est réservée à ce tour et l'a été à une visite antérieure. + * + * Les index de personnes croissent avec les identifiants : les listes de + * parcourirTours, traduites terme à terme, restent croissantes. + * + * Ne modifie ni l'instance ni le plan ; lève ce que lève exigerPlanIndexe. + * + * @param {import('./types.js').Instance} instance + * @param {ArrayLike} tableDe + * @returns {ConflitsTable[][]} [tour][index de table], tables dans l'ordre de l'instance + */ +export function conflitsParTable(instance, tableDe) { + const { ids, idsTables, contraintes } = instance; + const enIds = (paires) => paires.map(([a, b]) => [ids[a], ids[b]]); + return parcourirTours(instance, tableDe).parTour.map((tables) => + tables.map(({ collegues, revues, revenus }, t) => { + const regles = []; + if (contraintes.separerAppartenances && collegues.length > 0) { + regles.push({ regle: 'separerAppartenances', paires: enIds(collegues) }); + } + if (contraintes.nouveauxVoisins && revues.length > 0) { + regles.push({ regle: 'nouveauxVoisins', paires: enIds(revues) }); + } + if (contraintes.nouvelleTable && revenus.length > 0) { + regles.push({ regle: 'nouvelleTable', personnes: revenus.map((p) => ids[p]) }); + } + let nombre = 0; + for (const regle of regles) nombre += (regle.paires ?? regle.personnes).length; + return { table: idsTables[t], nombre, regles }; + }), + ); +} diff --git a/src/moteur/conflits.long.test.js b/src/moteur/conflits.long.test.js new file mode 100644 index 0000000..0e48025 --- /dev/null +++ b/src/moteur/conflits.long.test.js @@ -0,0 +1,227 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves lourdes des conflits par table (§ 7.5, § 13.2) : les violations +// par table, sommées sur un plan entier, rendent les chiffres de la mesure. +// Σ des paires séparer = collisionsCumulees ; Σ des personnes nouvelle table +// = totalRetoursChoisis ; paires distinctes nouveaux voisins = +// rencontresRepetees.choisies. D'abord les propositions d'une génération sur +// la grande démonstration, puis cent plans tirés sur de petites +// configurations, chacun de sa propre graine. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { CATALOGUE } from '../demo/catalogue.js'; +import { FLUX, creerPcg32 } from '../demo/prng.js'; +import { conflitsParTable } from './conflits.js'; +import { LIBRE, indexerPlan, normaliser, planDepuisIndex } from './configuration.js'; +import { mesurer } from './indicateurs.js'; +import { rechercher } from './recherche.js'; +import { verifierInvariants } from './verification.js'; + +// Une génération courte : la qualité des propositions n'importe pas ici, et +// des propositions qui portent encore des conflits éprouvent mieux l'accord. +const GENERATION = Object.freeze({ graine: 7, arret: 20_000, nombre: 2 }); + +// Les cent plans tirés : la graine du plan i est GRAINE + i. +const PLANS = 100; +const GRAINE = 2_718; +const PLANS_ACCEPTES_MIN = 90; + +// Planchers des comptes de la boucle, posés au-dessous de ce que donnent les +// graines écrites ci-dessus : 100 plans acceptés ; 69 dont les trois totaux +// sont non nuls ; 32 où une paire réunie librement se retrouve à une table où +// les deux sont réservés ; 41 où une personne assise librement à une table y +// revient réservée. +const TOUS_NON_NULS_MIN = 55; +const PAIRE_PUIS_RESERVEE_MIN = 25; +const PERSONNE_PUIS_RESERVEE_MIN = 32; + +const LIBELLES = Object.freeze(['Grisaille', 'Club des Merles', 'Ondée']); + +// Les trois sommes d'un plan, lues dans conflitsParTable : paires séparer, +// personnes nouvelle table, et paires distinctes nouveaux voisins, clé +// « a,b » en identifiants. +function sommer(conflits) { + let separer = 0; + let nouvelleTable = 0; + const revues = new Set(); + for (const tables of conflits) { + for (const { regles } of tables) { + for (const { regle, paires, personnes } of regles) { + if (regle === 'separerAppartenances') separer += paires.length; + else if (regle === 'nouvelleTable') nouvelleTable += personnes.length; + else for (const [a, b] of paires) revues.add(`${a},${b}`); + } + } + } + return { separer, nouvelleTable, nouveauxVoisins: revues.size }; +} + +// Les écarts entre les sommes par table et la mesure du même plan. +function ecarts(nom, instance, tableDe) { + const sommes = sommer(conflitsParTable(instance, tableDe)); + const mesures = mesurer(instance, tableDe); + const attendu = { + separer: mesures.collisionsCumulees, + nouvelleTable: mesures.totalRetoursChoisis, + nouveauxVoisins: mesures.rencontresRepetees.choisies, + }; + const fautes = []; + for (const cle of ['separer', 'nouvelleTable', 'nouveauxVoisins']) { + if (sommes[cle] !== attendu[cle]) { + fautes.push(`${nom} : ${cle} ${sommes[cle]} par table, ${attendu[cle]} mesurés`); + } + } + return { fautes, sommes }; +} + +// Une petite configuration tirée de rng : de 2 à 6 tables de 2 à 5 sièges, +// de 1 à 5 tours, de la moitié des sièges à tous occupés ; une appartenance +// parmi trois, ou aucune, pour chacun. Un participant sur cinq est ancré, un +// sur trois réservé à une partie des tours quand R ≥ 2, chacun à une table +// où il reste un siège à chacun de ses tours : normaliser l'accepte toujours. +// Séparer, nouveaux voisins et nouvelle table sont actifs. +function tirerConfiguration(rng) { + const T = 2 + rng.borne(5); + const capacites = Array.from({ length: T }, () => 2 + rng.borne(4)); + const R = 1 + rng.borne(5); + const S = capacites.reduce((somme, c) => somme + c, 0); + const N = S - rng.borne(Math.floor(S / 2) + 1); + const fixees = new Int32Array(T * R); + const siegeLibre = (t, r) => fixees[t * R + r] < capacites[t]; + const participants = []; + const reservations = []; + for (let id = 1; id <= N; id += 1) { + const g = rng.borne(LIBELLES.length + 1); + participants.push({ id, nom: `P${id}`, appartenance: g < LIBELLES.length ? LIBELLES[g] : null }); + const role = rng.borne(15); + if (role < 3) { + const t = rng.borne(T); + if (Array.from({ length: R }, (_, r) => r).every((r) => siegeLibre(t, r))) { + for (let r = 0; r < R; r += 1) fixees[t * R + r] += 1; + reservations.push({ participant: id, table: t + 1, portee: 'tous' }); + } + } else if (role < 8 && R >= 2) { + for (let r = 0; r < R; r += 1) { + if (rng.borne(2) === 0) continue; + const t = rng.borne(T); + if (!siegeLibre(t, r)) continue; + fixees[t * R + r] += 1; + reservations.push({ participant: id, table: t + 1, portee: 'tour', tour: r + 1 }); + } + } + } + return { + participants, + tables: capacites.map((capacite, t) => ({ id: t + 1, numero: t + 1, capacite })), + tours: R, + reservations, + contraintes: { + separerAppartenances: true, + nouveauxVoisins: true, + nouvelleTable: true, + varierAppartenances: rng.borne(2) === 0, + }, + }; +} + +// Un plan qui assied chacun à chaque tour : les réservés à leur table, puis +// chaque autre, par index croissants, à une table tirée parmi celles qui lui +// laissent un siège. +function tirerPlan(rng, instance) { + const { N, T, R, capacite, fixe } = instance; + const tableDe = new Int32Array(N * R); + for (let r = 0; r < R; r += 1) { + const occupation = new Int32Array(T); + for (let p = 0; p < N; p += 1) { + const t = fixe[p * R + r]; + if (t === LIBRE) continue; + tableDe[p * R + r] = t; + occupation[t] += 1; + } + for (let p = 0; p < N; p += 1) { + if (fixe[p * R + r] !== LIBRE) continue; + const ouvertes = []; + for (let t = 0; t < T; t += 1) if (occupation[t] < capacite[t]) ouvertes.push(t); + const t = ouvertes[rng.borne(ouvertes.length)]; + tableDe[p * R + r] = t; + occupation[t] += 1; + } + } + return tableDe; +} + +// Vrai quand une paire réunie à une table où les deux ne sont pas réservés +// se retrouve, à un tour suivant, à une table où les deux le sont. +function pairePuisReservee({ N, R, fixe }, tableDe) { + for (let a = 0; a < N; a += 1) { + for (let b = a + 1; b < N; b += 1) { + let libre = false; + for (let r = 0; r < R; r += 1) { + const t = tableDe[a * R + r]; + if (t !== tableDe[b * R + r]) continue; + const reservee = fixe[a * R + r] === t && fixe[b * R + r] === t; + if (reservee && libre) return true; + if (!reservee) libre = true; + } + } + } + return false; +} + +// Vrai quand une personne assise sans réservation à une table y revient, à +// un tour suivant, réservée. +function personnePuisReservee({ N, T, R, fixe }, tableDe) { + for (let p = 0; p < N; p += 1) { + const libreA = new Uint8Array(T); + for (let r = 0; r < R; r += 1) { + const t = tableDe[p * R + r]; + if (fixe[p * R + r] !== t) libreA[t] = 1; + else if (libreA[t] === 1) return true; + } + } + return false; +} + +describe('conflits par table : leurs sommes rendent la mesure (§ 7.5, § 13.2)', () => { + test('grande démonstration, deux propositions de 20 000 mouvements', () => { + const configuration = CATALOGUE.find(({ cle }) => cle === 'grande').construire(); + const instance = normaliser(configuration); + const propositions = rechercher(configuration, GENERATION); + assert.equal(propositions.length, GENERATION.nombre); + const fautes = []; + for (const { id, plan } of propositions) { + assert.deepEqual(verifierInvariants(instance, plan), [], `proposition ${id}`); + fautes.push(...ecarts(`proposition ${id}`, instance, indexerPlan(instance, plan)).fautes); + } + assert.deepEqual(fautes, []); + }); + + test('cent plans tirés sur de petites configurations, ancrés et partiellement fixés', () => { + const fautes = []; + const comptes = { acceptes: 0, tousNonNuls: 0, pairePuisReservee: 0, personnePuisReservee: 0 }; + for (let i = 1; i <= PLANS; i += 1) { + const rng = creerPcg32(GRAINE + i, FLUX.DEMO); + const instance = normaliser(tirerConfiguration(rng)); + const tableDe = tirerPlan(rng, instance); + const violations = verifierInvariants(instance, planDepuisIndex(instance, tableDe)); + if (violations.length > 0) { + fautes.push(`graine ${GRAINE + i} : plan refusé, ${JSON.stringify(violations)}`); + continue; + } + comptes.acceptes += 1; + const { fautes: fautesPlan, sommes } = ecarts(`graine ${GRAINE + i}`, instance, tableDe); + fautes.push(...fautesPlan); + if (sommes.separer > 0 && sommes.nouvelleTable > 0 && sommes.nouveauxVoisins > 0) { + comptes.tousNonNuls += 1; + } + if (pairePuisReservee(instance, tableDe)) comptes.pairePuisReservee += 1; + if (personnePuisReservee(instance, tableDe)) comptes.personnePuisReservee += 1; + } + assert.deepEqual(fautes, []); + assert.ok(comptes.acceptes >= PLANS_ACCEPTES_MIN, JSON.stringify(comptes)); + assert.ok(comptes.tousNonNuls >= TOUS_NON_NULS_MIN, JSON.stringify(comptes)); + assert.ok(comptes.pairePuisReservee >= PAIRE_PUIS_RESERVEE_MIN, JSON.stringify(comptes)); + assert.ok(comptes.personnePuisReservee >= PERSONNE_PUIS_RESERVEE_MIN, JSON.stringify(comptes)); + }); +}); diff --git a/src/moteur/conflits.test.js b/src/moteur/conflits.test.js new file mode 100644 index 0000000..358378e --- /dev/null +++ b/src/moteur/conflits.test.js @@ -0,0 +1,297 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves des conflits par table (§ 7.5, § 5.4). Chaque plan s'écrit par +// identifiants, une liste par table, et passe par normaliser et indexerPlan +// comme un plan enregistré ; chaque valeur attendue se compte à la main dans +// le commentaire qui la précède. Les mesures citées viennent de mesurer : +// conflitsParTable et elle lisent le même parcours, et leurs chiffres +// s'accordent par construction. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js'; +import { conflitsParTable } from './conflits.js'; +import { indexerPlan, normaliser } from './configuration.js'; +import { mesurer } from './indicateurs.js'; + +const TOUTES = Object.freeze({ + separerAppartenances: true, + nouveauxVoisins: true, + nouvelleTable: true, + varierAppartenances: true, +}); + +const MERLES = 'Club des Merles'; + +const tous = (participant, table) => ({ participant, table, portee: 'tous' }); +const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour }); + +// Une table sans violation. +const sansConflit = (table) => ({ table, nombre: 0, regles: [] }); + +/** + * Instance et plan indexé d'un plan écrit par identifiants. ids[i] porte + * appartenance[i] (par défaut, l'identifiant i + 1) ; idsTables[i] est + * l'identifiant de la table d'index i (par défaut i + 1), de la capacité + * donnée ; tours[r][i] liste les participants assis à la table d'index i au + * tour r + 1. + */ +function preparer({ + appartenance, + ids = appartenance.map((_, i) => i + 1), + capacite, + idsTables, + tours, + reservations = [], + contraintes = TOUTES, +}) { + const participants = appartenance.map((libelle, i) => ({ + id: ids[i], + nom: `P${ids[i]}`, + appartenance: libelle, + })); + const tables = (idsTables ?? tours[0].map((_, i) => i + 1)).map((id, i) => ({ + id, + numero: i + 1, + capacite, + })); + const instance = normaliser({ + participants, + tables, + tours: tours.length, + reservations, + contraintes: { ...contraintes }, + }); + const plan = { tables: tables.map(({ id }) => id), tours, reserves: tours.map(() => []) }; + return { instance, tableDe: indexerPlan(instance, plan) }; +} + +function conflits(description) { + const { instance, tableDe } = preparer(description); + return conflitsParTable(instance, tableDe); +} + +function mesures(description) { + const { instance, tableDe } = preparer(description); + return mesurer(instance, tableDe); +} + +// Six personnes, 1 et 2 du Club des Merles, trois tables de 2, deux tours. +const SIX = [MERLES, MERLES, null, null, null, null]; +const TOUR_1 = [[3, 4], [1, 2], [5, 6]]; + +describe('conflitsParTable', () => { + test('séparer : la table 2 réunit 1 et 2 au tour 1, une violation ; aucune autre table, à aucun tour', () => { + // Au tour 2, (2, 6), (3, 5) et (1, 4) se rencontrent pour la première + // fois, et chacun change de table. + const description = { appartenance: SIX, capacite: 2, tours: [TOUR_1, [[2, 6], [3, 5], [1, 4]]] }; + assert.deepEqual(conflits(description), [ + [ + sansConflit(1), + { table: 2, nombre: 1, regles: [{ regle: 'separerAppartenances', paires: [[1, 2]] }] }, + sansConflit(3), + ], + [sansConflit(1), sansConflit(2), sansConflit(3)], + ]); + const sansSeparer = conflits({ + ...description, + contraintes: { ...TOUTES, separerAppartenances: false }, + }); + assert.deepEqual(sansSeparer, [ + [sansConflit(1), sansConflit(2), sansConflit(3)], + [sansConflit(1), sansConflit(2), sansConflit(3)], + ]); + }); + + test('nouvelle table : 3 revient à la table 1, 2 à la table 2 ; une visite réservée deux fois ne se signale pas', () => { + // Tour 2 : [3, 6], [2, 5], [1, 4]. 3 était à la table 1 au tour 1, 2 à + // la table 2 ; 6, 5, 1 et 4 découvrent la leur. + const description = { appartenance: SIX, capacite: 2, tours: [TOUR_1, [[3, 6], [2, 5], [1, 4]]] }; + const auTour2 = (resultat) => resultat[1]; + assert.deepEqual(auTour2(conflits(description)), [ + { table: 1, nombre: 1, regles: [{ regle: 'nouvelleTable', personnes: [3] }] }, + { table: 2, nombre: 1, regles: [{ regle: 'nouvelleTable', personnes: [2] }] }, + sansConflit(3), + ]); + // 3 ancré à la table 1 : réservé au tour 2 et à sa visite du tour 1, son + // retour est imposé. + const ancre = { ...description, reservations: [tous(3, 1)] }; + assert.deepEqual(auTour2(conflits(ancre)), [ + sansConflit(1), + { table: 2, nombre: 1, regles: [{ regle: 'nouvelleTable', personnes: [2] }] }, + sansConflit(3), + ]); + assert.deepEqual(mesures(ancre).retoursImposes, [0, 0, 1, 0, 0, 0]); + // 3 réservé à la table 1 au seul tour 2 : sa visite du tour 1 était + // libre, le retour est choisi, et la mesure le compte choisi. + const tour2 = { ...description, reservations: [auTour(3, 1, 2)] }; + assert.deepEqual(auTour2(conflits(tour2))[0], { + table: 1, + nombre: 1, + regles: [{ regle: 'nouvelleTable', personnes: [3] }], + }); + assert.deepEqual(mesures(tour2).retoursChoisis, [0, 1, 1, 0, 0, 0]); + assert.deepEqual(mesures(tour2).retoursImposes, [0, 0, 0, 0, 0, 0]); + }); + + test('nouveaux voisins : une paire réunie librement puis réservée ensemble reste signalée ; réservée aux deux rencontres, elle ne l’est plus', () => { + // Quatre personnes sans appartenance, deux tables de 2. Tour 1 : [1, 2], + // [3, 4] ; tour 2 : [3, 4], [1, 2]. Personne ne revient à sa table. + const description = { + appartenance: [null, null, null, null], + capacite: 2, + tours: [ + [[1, 2], [3, 4]], + [[3, 4], [1, 2]], + ], + reservations: [auTour(3, 1, 2), auTour(4, 1, 2)], + }; + assert.deepEqual(conflits(description)[1], [ + { table: 1, nombre: 1, regles: [{ regle: 'nouveauxVoisins', paires: [[3, 4]] }] }, + { table: 2, nombre: 1, regles: [{ regle: 'nouveauxVoisins', paires: [[1, 2]] }] }, + ]); + assert.deepEqual(mesures(description).rencontresRepetees, { choisies: 2, imposees: 0 }); + // 3 et 4 réservés aussi à la table 2 au tour 1 : chacune de leurs + // rencontres est réservée pour les deux. + const imposee = { + ...description, + reservations: [...description.reservations, auTour(3, 2, 1), auTour(4, 2, 1)], + }; + assert.deepEqual(conflits(imposee)[1], [ + sansConflit(1), + { table: 2, nombre: 1, regles: [{ regle: 'nouveauxVoisins', paires: [[1, 2]] }] }, + ]); + assert.deepEqual(mesures(imposee).rencontresRepetees, { choisies: 1, imposees: 1 }); + }); + + test('les trois règles à une même table, dans leur ordre, comptées ensemble, en identifiants', () => { + // Participants 5, 9, 14 et 20 ; 5 et 9 de Grisaille. Tables d'identifiants + // 40 puis 10, dans cet ordre de configuration. Les deux mêmes paires aux + // deux tours : au tour 2, la table 40 porte (5, 9) collègues et revus, 5 + // et 9 revenus, quatre violations ; la table 10, (14, 20) revus, 14 et + // 20 revenus, trois. + const description = { + appartenance: ['Grisaille', 'Grisaille', null, null], + ids: [5, 9, 14, 20], + idsTables: [40, 10], + capacite: 2, + tours: [ + [[5, 9], [14, 20]], + [[5, 9], [14, 20]], + ], + }; + assert.deepEqual(conflits(description), [ + [ + { table: 40, nombre: 1, regles: [{ regle: 'separerAppartenances', paires: [[5, 9]] }] }, + sansConflit(10), + ], + [ + { + table: 40, + nombre: 4, + regles: [ + { regle: 'separerAppartenances', paires: [[5, 9]] }, + { regle: 'nouveauxVoisins', paires: [[5, 9]] }, + { regle: 'nouvelleTable', personnes: [5, 9] }, + ], + }, + { + table: 10, + nombre: 3, + regles: [ + { regle: 'nouveauxVoisins', paires: [[14, 20]] }, + { regle: 'nouvelleTable', personnes: [14, 20] }, + ], + }, + ], + ]); + // Une règle inactive est omise, les autres gardent leur ordre. + const sansVoisins = conflits({ ...description, contraintes: { ...TOUTES, nouveauxVoisins: false } }); + assert.deepEqual(sansVoisins[1][0], { + table: 40, + nombre: 3, + regles: [ + { regle: 'separerAppartenances', paires: [[5, 9]] }, + { regle: 'nouvelleTable', personnes: [5, 9] }, + ], + }); + const sansTable = conflits({ ...description, contraintes: { ...TOUTES, nouvelleTable: false } }); + assert.deepEqual(sansTable[1][1], { + table: 10, + nombre: 1, + regles: [{ regle: 'nouveauxVoisins', paires: [[14, 20]] }], + }); + }); + + test('une table de plusieurs collègues : chaque paire, identifiants croissants, paires dans l’ordre lexicographique', () => { + // 2, 4, 6 et 8 du Club des Merles, assis tous les quatre à une table de 5 + // avec 3, donné avant eux dans la liste : C(4, 2) = 6 paires. + const resultat = conflits({ + appartenance: [null, MERLES, null, MERLES, MERLES, MERLES], + ids: [1, 2, 3, 4, 6, 8], + capacite: 5, + tours: [[[8, 6, 3, 4, 2], [1]]], + }); + assert.deepEqual(resultat[0][0], { + table: 1, + nombre: 6, + regles: [ + { + regle: 'separerAppartenances', + paires: [[2, 4], [2, 6], [2, 8], [4, 6], [4, 8], [6, 8]], + }, + ], + }); + }); + + test('une seule table, un seul tour : aucune violation, des répétitions à zéro, sans lever (§ 15.4)', () => { + // Trois personnes, 3 seule du Club des Merles. 1 et 2 rencontrent un + // affilié d'une appartenance : A = 1, d = 1 ; 3 n'en rencontre aucun : + // A = 0, et il sort de l'effectif de la diversité. + const description = { + appartenance: [null, null, MERLES], + capacite: 3, + tours: [[[1, 2, 3]]], + }; + assert.deepEqual(conflits(description), [[sansConflit(1)]]); + const { rencontresRepetees, aggAppartenancesVues, aggDiversite } = mesures(description); + assert.deepEqual(rencontresRepetees, { choisies: 0, imposees: 0 }); + assert.deepEqual(aggAppartenancesVues.tous, { min: 0, moyenne: 2 / 3, effectif: 3 }); + assert.deepEqual(aggDiversite.tous, { min: 1, moyenne: 1, effectif: 2 }); + }); + + test('aucun présent : une table, deux tours, chacun à zéro, sans lever', () => { + const instance = normaliser({ + participants: [{ id: 1, nom: 'P1', appartenance: MERLES, exclu: true }], + tables: [{ id: 1, numero: 1, capacite: 2 }], + tours: 2, + reservations: [], + contraintes: { ...TOUTES }, + }); + assert.deepEqual(conflitsParTable(instance, new Int32Array(0)), [[sansConflit(1)], [sansConflit(1)]]); + }); + + test('plan parfait de la petite démonstration : aucune violation, à chaque table de chaque tour', () => { + const configuration = CATALOGUE.find(({ cle }) => cle === 'petite').construire(); + const instance = normaliser({ ...configuration, contraintes: { ...TOUTES } }); + const resultat = conflitsParTable(instance, indexerPlan(instance, PLAN_PARFAIT_PETITE)); + const cases = resultat.flat(); + assert.equal(cases.length, 16); + for (const [k, { nombre, regles }] of cases.entries()) { + assert.equal(nombre, 0, `case ${k}`); + assert.deepEqual(regles, [], `case ${k}`); + } + }); + + test("lève ce que lève exigerPlanIndexe, et ne modifie ni l'instance ni le plan", () => { + const { instance, tableDe } = preparer({ + appartenance: SIX, + capacite: 2, + tours: [TOUR_1, [[3, 6], [2, 5], [1, 4]]], + }); + assert.throws(() => conflitsParTable(instance, tableDe.subarray(1)), RangeError); + const avant = { tableDe: tableDe.slice(), fixe: instance.fixe.slice(), groupe: instance.groupe.slice() }; + conflitsParTable(instance, tableDe); + assert.deepEqual({ tableDe, fixe: instance.fixe, groupe: instance.groupe }, avant); + }); +}); diff --git a/src/moteur/diagnostic.js b/src/moteur/diagnostic.js index acaa739..3d13e83 100644 --- a/src/moteur/diagnostic.js +++ b/src/moteur/diagnostic.js @@ -14,6 +14,11 @@ // 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. +// +// Un remède se chiffre en sollicitant la même arithmétique sur une +// configuration modifiée — plus de tables, moins de tours —, jamais par une +// forme close de plus (§ 13.2) : la sonde passe par normaliser, puis par la +// fonction qui chiffre la quantité sur la configuration elle-même. /** * @typedef {Object} PlancherCollisions @@ -23,21 +28,39 @@ * @property {number} plancherPairesDistinctes * @property {number|null} plancherExcedent null quand il n'est pas * strictement positif + * @property {number|null} tablesManquantes plus petit nombre de tables, de + * la plus grande capacité présente, dont l'ajout ramène ce plancher + * à 0 ; null quand l'effectif du groupe n'y suffit pas : les + * réservations assoient alors deux membres à une même table d'un tour * * @typedef {Object} Diagnostic * @property {number} placesManquantes max(0, N − Σ c_t), par tour - * @property {{min: number|null, max: number|null, parPersonne: number[]}} plafondAPriori + * @property {{min: number|null, max: number|null, parPersonne: number[], + * repartition: Array<{valeur: number, ancres: number, mobiles: number}>}} plafondAPriori * parPersonne dans l'ordre de instance.ids ; min et max null pour - * une population vide + * une population vide ; repartition, une ligne par valeur + * présente, valeurs croissantes, les partiellement fixés parmi les + * mobiles * @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 {{toursSansRedondance: number|null}} remedes + * toursSansRedondance : plus grand R′ < R où redondanceMinimale vaut + * 0 ; null quand elle vaut déjà 0 ou null, ou qu'aucun R′ ne l'annule * @property {boolean} animateursMemeAppartenance * @property {number} redondanceImposeeParAnimateurs min(R, T) − 1 ou 0 - * @property {Array<{table: number, ancres: number, capacite: number, gelee: boolean}>} ancrage + * @property {boolean} uneSeuleAppartenance séparation active, une seule + * appartenance, portée par chaque présent (§ 15.4) + * @property {boolean} collisionPartout tout plan qui assied chacun + * porte une collision à chaque table de chaque tour (§ 7.5) + * @property {Array<{table: number, ancres: number, capacite: number, gelee: boolean, + * moitie: boolean}>} ancrage * chaque table, par identifiant, dans l'ordre de la configuration ; - * gelee quand ses ancrés occupent tous ses sièges + * gelee quand ses ancrés occupent tous ses sièges ; moitie quand ils + * en occupent la moitié au moins, sans la geler * @property {number} retoursImposes + * @property {number} ancragesImposantRetours personnes dont les réservations + * imposent un retour * @property {{plancher: number, mobilesAuMoins: number}|null} ecartItineraire */ import { @@ -154,14 +177,110 @@ function animateursMemeAppartenance({ N, statut, groupe }) { // 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). +// (§ 5.9) ; moitie quand ils en occupent la moitié au moins, 2 × a_t ≥ c_t, +// sur une table qui tourne encore. 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], - })); + return Array.from({ length: T }, (_, t) => { + const gelee = ancresParTable[t] === capacite[t]; + return { + table: idsTables[t], + ancres: ancresParTable[t], + capacite: capacite[t], + gelee, + moitie: !gelee && 2 * ancresParTable[t] >= capacite[t], + }; + }); +} + +// Plafonds a priori regroupés par valeur, valeurs croissantes : à chacune, +// les ancrés et les mobiles, partiellement fixés compris (§ 5.4), qui la +// portent. Les personnes se parcourent par plafond, puis par index. +function repartition(aPriori, statut) { + const ordre = Array.from({ length: aPriori.length }, (_, p) => p); + ordre.sort((p, q) => aPriori[p] - aPriori[q] || p - q); + const lignes = []; + for (const p of ordre) { + let ligne = lignes[lignes.length - 1]; + if (ligne === undefined || ligne.valeur !== aPriori[p]) { + ligne = { valeur: aPriori[p], ancres: 0, mobiles: 0 }; + lignes.push(ligne); + } + if (statut[p] === STATUT.ANCRE) ligne.ancres += 1; + else ligne.mobiles += 1; + } + return lignes; +} + +// Chaque plancher de collisions, complété de tablesManquantes. La sonde à m +// tables ajoute m tables de la plus grande capacité présente, d'identifiants +// au-delà du plus grand, et rechiffre les planchers par planchersCollisions : +// le premier m où le groupe n'y figure plus est son remède. Un groupe de s +// membres se sépare dès que s tables de plus s'offrent à ses membres libres, +// sauf quand des réservations en assoient deux à une même table d'un tour : +// au-delà de s, il garde null. Les capacités, relâchées dans le plancher, ne +// changent pas le compte ; elles disent quelles tables ajouter. Un plancher +// suppose une table au moins. +function avecTablesManquantes(configuration, instance, planchers) { + const lignes = planchers.map((plancher) => ({ ...plancher, tablesManquantes: null })); + if (lignes.length === 0) return lignes; + const enAttente = (m) => lignes.some((l) => l.tablesManquantes === null && m <= l.effectif); + const capacite = Math.max(...instance.capacite); + const premierId = Math.max(...instance.idsTables) + 1; + const ajoutees = []; + for (let m = 1; enAttente(m); m += 1) { + ajoutees.push({ id: premierId + m - 1, numero: configuration.tables.length + m, capacite }); + const sonde = normaliser({ ...configuration, tables: [...configuration.tables, ...ajoutees] }); + const restants = new Set(planchersCollisions(sonde).map(({ groupe }) => groupe)); + for (const ligne of lignes) { + if (ligne.tablesManquantes === null && m <= ligne.effectif && !restants.has(ligne.groupe)) { + ligne.tablesManquantes = m; + } + } + } + return lignes; +} + +// Plus grand R′ < R où la redondance minimale s'annule, null quand elle vaut +// déjà 0 ou null, ou qu'aucun R′ ne l'annule. La sonde à R′ tours garde les +// réservations de portée « tous », qui couvrent alors ses R′ tours, et retire +// celles d'un tour au-delà de R′, exclus compris : normaliser les refuserait +// (RESERVATION_TOUR). Les R′ se sondent du plus grand au plus petit. +function toursSansRedondance(configuration, R, minimale) { + if (minimale === null || minimale === 0) return null; + for (let tours = R - 1; tours >= 1; tours -= 1) { + const reservations = configuration.reservations.filter( + ({ portee, tour }) => portee !== 'tour' || tour <= tours, + ); + const sonde = normaliser({ ...configuration, tours, reservations }); + if (redondanceMinimale(sonde, plafondsAPriori(sonde)) === 0) return tours; + } + return null; +} + +// Vrai quand la séparation est active et que les présents portent tous une +// même appartenance, la seule (§ 15.4) ; faux dès qu'un présent est sans +// appartenance : la séparation y garde un sens. +function uneSeuleAppartenance({ groupe, groupes, contraintes }) { + return contraintes.separerAppartenances && groupes.length === 1 && !groupe.includes(SANS_GROUPE); +} + +// Vrai quand tout plan qui assied chacun porte une collision à chaque table +// de chaque tour (§ 7.5). Les autres tables tiennent au plus Σ c − c_t +// personnes : la table t en reçoit au moins N − (Σ c − c_t). Sans collision, +// elle n'assied qu'un membre de chacune des G appartenances et les U présents +// sans appartenance : au-delà de G + U, la collision est forcée. Faux quand +// la séparation est inactive, qu'une place manque — aucun plan n'assied +// chacun —, ou sans table : rien ne le prouve. +function collisionPartout(instance, placesManquantes) { + const { N, T, capacite, groupe, groupes, contraintes } = instance; + if (!contraintes.separerAppartenances || placesManquantes > 0 || T === 0) return false; + let sansAppartenance = 0; + for (let p = 0; p < N; p += 1) if (groupe[p] === SANS_GROUPE) sansAppartenance += 1; + const places = nombrePlaces(instance); + for (let t = 0; t < T; t += 1) { + if (N - (places - capacite[t]) <= groupes.length + sansAppartenance) return false; + } + return true; } // Plafond du meilleur itinéraire d'un mobile p qui passe par la table t : le @@ -249,19 +368,23 @@ function etendue(valeurs) { * * - placesManquantes : nombrePlacesManquantes ; la recherche refuse une * configuration où elles sont positives, le diagnostic les chiffre (§ 5.9). - * - plafondAPriori : plafondsAPriori, et son étendue. + * - plafondAPriori : plafondsAPriori, son étendue et sa répartition par + * valeur. * - collisions : planchers de collisions par groupe, posés sur les tours - * fixés, capacités relâchées. + * fixés, capacités relâchées ; chacun avec les tables qui l'annulent. * - redondanceMinimale : max_p max(0, min(a priori(p), affiliés autres que - * p) − G). + * p) − G) ; remedes.toursSansRedondance, le nombre de tours qui l'annule. * - 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. + * - uneSeuleAppartenance et collisionPartout : une séparation vide de sens, + * une collision forcée à chaque table. + * - ancrage : ancrés, capacité, gel et demi-ancrage 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). + * que mesure tout plan qui honore les réservations (§ 5.4) ; + * ancragesImposantRetours, les personnes qui en portent un au moins. * - ecartItineraire : plancher de l'écart d'itinéraire maximal, ou null. * * @param {import('./types.js').Configuration} configuration @@ -271,15 +394,26 @@ export function diagnostiquer(configuration) { const instance = normaliser(configuration); const aPriori = plafondsAPriori(instance); const memeAppartenance = animateursMemeAppartenance(instance); + const placesManquantes = nombrePlacesManquantes(instance); + const minimale = redondanceMinimale(instance, aPriori); + const imposes = mesurer(instance, instance.fixe); return { - placesManquantes: nombrePlacesManquantes(instance), - plafondAPriori: { ...etendue(aPriori), parPersonne: aPriori }, - collisions: planchersCollisions(instance), - redondanceMinimale: redondanceMinimale(instance, aPriori), + placesManquantes, + plafondAPriori: { + ...etendue(aPriori), + parPersonne: aPriori, + repartition: repartition(aPriori, instance.statut), + }, + collisions: avecTablesManquantes(configuration, instance, planchersCollisions(instance)), + redondanceMinimale: minimale, + remedes: { toursSansRedondance: toursSansRedondance(configuration, instance.R, minimale) }, animateursMemeAppartenance: memeAppartenance, redondanceImposeeParAnimateurs: memeAppartenance ? Math.min(instance.R, instance.T) - 1 : 0, + uneSeuleAppartenance: uneSeuleAppartenance(instance), + collisionPartout: collisionPartout(instance, placesManquantes), ancrage: ancrage(instance), - retoursImposes: mesurer(instance, instance.fixe).totalRetoursImposes, + retoursImposes: imposes.totalRetoursImposes, + ancragesImposantRetours: imposes.retoursImposes.filter((retours) => retours > 0).length, ecartItineraire: ecartItineraire(configuration, instance, aPriori), }; } diff --git a/src/moteur/diagnostic.test.js b/src/moteur/diagnostic.test.js index a2bd4b2..c46a768 100644 --- a/src/moteur/diagnostic.test.js +++ b/src/moteur/diagnostic.test.js @@ -11,6 +11,7 @@ import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js'; +import { conflitsParTable } from './conflits.js'; import { LIBRE, STATUT, indexerPlan, normaliser } from './configuration.js'; import { diagnostiquer } from './diagnostic.js'; import { ErreurConfiguration } from './erreurs.js'; @@ -23,15 +24,31 @@ const SANS_CONTRAINTE = { nouvelleTable: false, varierAppartenances: false, }; +const TOUTES = { + separerAppartenances: true, + nouveauxVoisins: true, + nouvelleTable: true, + varierAppartenances: true, +}; +const SEPARER = { ...SANS_CONTRAINTE, separerAppartenances: true }; +const TOUTES_SAUF_SEPARER = { ...TOUTES, separerAppartenances: false }; /** * 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. + * appartenances[j] est celle du participant d'id j + 1 ; exclus nomme des ids ; + * contraintes, aucune par défaut. * 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 = [] }) { +function salle({ + tables, + appartenances, + tours, + reservations = [], + exclus = [], + contraintes = SANS_CONTRAINTE, +}) { return { participants: appartenances.map((appartenance, j) => ({ id: j + 1, @@ -42,7 +59,7 @@ function salle({ tables, appartenances, tours, reservations = [], exclus = [] }) tables: tables.map(([id, capacite], i) => ({ id, numero: i + 1, capacite })), tours, reservations, - contraintes: SANS_CONTRAINTE, + contraintes: { ...contraintes }, }; } @@ -217,8 +234,20 @@ describe('diagnostic : le catalogue (§ 15)', () => { ancres: 1, capacite: t < 29 ? 8 : 7, gelee: false, + moitie: false, })), ); + // 24 aux quatre animateurs des tables de 7, 28 à chacun des autres ; les + // 227 mobiles sont tous à 28. + assert.deepEqual(diagnostic.plafondAPriori.repartition, [ + { valeur: 24, ancres: 4, mobiles: 0 }, + { valeur: 28, ancres: 29, mobiles: 227 }, + ]); + // Chacun des 33 animateurs revient trois fois à sa table. + assert.equal(diagnostic.ancragesImposantRetours, 33); + assert.equal(diagnostic.uneSeuleAppartenance, false); + assert.equal(diagnostic.collisionPartout, false); + assert.deepEqual(diagnostic.remedes, { toursSansRedondance: null }); }); // 264 sièges pour 260 membres : la configuration n'est pas tendue. @@ -250,6 +279,7 @@ describe('diagnostic : le catalogue (§ 15)', () => { plancherCumulees: 4, plancherPairesDistinctes: 1, plancherExcedent: null, + tablesManquantes: 1, }, ]); }); @@ -403,6 +433,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => { plancherCumulees: 5, plancherPairesDistinctes: 1, plancherExcedent: 2, + tablesManquantes: 1, }, ]); assert.deepEqual(minimumsParGroupe(configuration).get('X'), { @@ -430,7 +461,14 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => { tours: 4, }); assert.deepEqual(diagnostiquer(configuration).collisions, [ - { groupe: 'X', effectif: 6, plancherCumulees: 8, plancherPairesDistinctes: 2, plancherExcedent: null }, + { + groupe: 'X', + effectif: 6, + plancherCumulees: 8, + plancherPairesDistinctes: 2, + plancherExcedent: null, + tablesManquantes: 2, + }, ]); }); @@ -441,7 +479,14 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => { tours: 2, }); assert.deepEqual(diagnostiquer(configuration).collisions, [ - { groupe: 'X', effectif: 6, plancherCumulees: 12, plancherPairesDistinctes: 6, plancherExcedent: null }, + { + groupe: 'X', + effectif: 6, + plancherCumulees: 12, + plancherPairesDistinctes: 6, + plancherExcedent: null, + tablesManquantes: 4, + }, ]); const exacts = minimumsParGroupe(configuration).get('X'); assert.equal(exacts.cumulees, 12); @@ -455,7 +500,14 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => { tours: 3, }); assert.deepEqual(diagnostiquer(configuration).collisions, [ - { groupe: 'X', effectif: 5, plancherCumulees: 12, plancherPairesDistinctes: 4, plancherExcedent: 2 }, + { + groupe: 'X', + effectif: 5, + plancherCumulees: 12, + plancherPairesDistinctes: 4, + plancherExcedent: 2, + tablesManquantes: 3, + }, ]); assert.deepEqual(minimumsParGroupe(configuration).get('X'), { cumulees: 12, distinctes: 4, excedent: 2 }); }); @@ -473,6 +525,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => { plancherCumulees: 4, plancherPairesDistinctes: 1, plancherExcedent: 1, + tablesManquantes: 1, }, ]); assert.deepEqual(minimumsParGroupe(configuration).get('X'), { @@ -502,6 +555,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => { plancherCumulees: 3, plancherPairesDistinctes: 1, plancherExcedent: null, + tablesManquantes: null, }, { groupe: 'Y', @@ -509,6 +563,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => { plancherCumulees: 1, plancherPairesDistinctes: 1, plancherExcedent: null, + tablesManquantes: null, }, ]); const minimums = minimumsParGroupe(configuration); @@ -532,6 +587,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => { plancherCumulees: 3, plancherPairesDistinctes: 1, plancherExcedent: 2, + tablesManquantes: null, }, ]); }); @@ -552,6 +608,7 @@ describe('diagnostic : planchers de collisions (§ 5.6)', () => { plancherCumulees: 2, plancherPairesDistinctes: 1, plancherExcedent: null, + tablesManquantes: 1, }, ]); }); @@ -933,9 +990,9 @@ describe('diagnostic : ancrage et retours imposés (§ 5.4, § 5.6)', () => { 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 }, + { table: 30, ancres: 2, capacite: 2, gelee: true, moitie: false }, + { table: 10, ancres: 2, capacite: 3, gelee: false, moitie: true }, + { table: 20, ancres: 0, capacite: 2, gelee: false, moitie: false }, ]); }); @@ -964,6 +1021,8 @@ describe('diagnostic : ancrage et retours imposés (§ 5.4, § 5.6)', () => { }); const diagnostic = diagnostiquer(configuration); assert.equal(diagnostic.retoursImposes, 7); + // 1, 2 et 5 portent des retours imposés ; 3 n'en porte aucun. + assert.equal(diagnostic.ancragesImposantRetours, 3); assert.deepEqual( diagnostic.ancrage.map(({ ancres }) => ancres), [2, 0], @@ -998,25 +1057,34 @@ describe('diagnostic : population vide, forme, refus', () => { test('une population vide rend null, jamais 0, là où rien ne se mesure', () => { const attendu = { placesManquantes: 0, - plafondAPriori: { min: null, max: null, parPersonne: [] }, + plafondAPriori: { min: null, max: null, parPersonne: [], repartition: [] }, collisions: [], redondanceMinimale: null, + remedes: { toursSansRedondance: null }, animateursMemeAppartenance: false, redondanceImposeeParAnimateurs: 0, - ancrage: [{ table: 10, ancres: 0, capacite: 4, gelee: false }], + uneSeuleAppartenance: false, + collisionPartout: false, + ancrage: [{ table: 10, ancres: 0, capacite: 4, gelee: false, moitie: false }], retoursImposes: 0, + ancragesImposantRetours: 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); + // Les mêmes chiffres, séparation active ou non : sans présent, aucune + // appartenance n'est portée par chacun, et aucune table n'est forcée. + for (const contraintes of [SANS_CONTRAINTE, TOUTES]) { + const vide = salle({ tables: [[10, 4]], appartenances: [], tours: 2, contraintes }); + 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], + contraintes, + }); + assert.deepEqual(diagnostiquer(tousExclus), attendu); + } }); test('le diagnostic ne modifie pas la configuration', () => { @@ -1041,3 +1109,310 @@ describe('diagnostic : population vide, forme, refus', () => { ); }); }); + +describe('diagnostic : répartition du plafond a priori (§ 5.6)', () => { + // 1 et 2, sans appartenance, ancrés à la table 10 de 3 sièges ; quatre + // mobiles de X, deux tours : les ancrés plafonnent à 3, les mobiles à 4 + // (voir « un affilié ne se compte pas parmi les affiliés qu'il + // rencontre »). + test('une ligne par valeur, croissante, ancrés et mobiles comptés à part', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3]], + appartenances: [null, null, ...fois(4, 'X')], + tours: 2, + reservations: [ancrer(1, 10), ancrer(2, 10)], + }); + assert.deepEqual(diagnostiquer(configuration).plafondAPriori.repartition, [ + { valeur: 3, ancres: 2, mobiles: 0 }, + { valeur: 4, ancres: 0, mobiles: 4 }, + ]); + }); + + // Un partiellement fixé compte parmi les mobiles (§ 5.4) : 1, réservé à la + // table 10 au seul tour 1, plafonne comme les autres, la table 10 restant + // libre à l'autre tour. Six personnes sur deux tables de 3, deux tours : + // chacun rencontre 2 voisins par tour, 4 en tout. + test('un partiellement fixé se compte parmi les mobiles', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3]], + appartenances: fois(6, null), + tours: 2, + reservations: [fixer(1, 10, 1)], + }); + assert.deepEqual(diagnostiquer(configuration).plafondAPriori.repartition, [ + { valeur: 4, ancres: 0, mobiles: 6 }, + ]); + }); +}); + +describe('diagnostic : tables manquantes (§ 5.6, § 15.3)', () => { + // Le remède compte des tables de la plus grande capacité présente ; le + // plancher, capacités relâchées, n'en dépend pas. Tables de 2 et de 6 : + // quatre membres de X pour deux tables, deux de plus les séparent. + test('des tables de capacités inégales : le compte ne lit que le plancher', () => { + const configuration = salle({ + tables: [[10, 2], [20, 6]], + appartenances: [...fois(4, 'X'), ...fois(4, null)], + tours: 2, + }); + assert.deepEqual( + diagnostiquer(configuration).collisions.map(({ tablesManquantes }) => tablesManquantes), + [2], + ); + }); + + // Deux groupes : Y, réservé ensemble à la table 10 au tour 2, n'a pas de + // remède ; X, cinq membres libres sur trois tables, en a un, 2 tables. + test('un groupe sans remède n’empêche pas le remède d’un autre', () => { + const configuration = salle({ + tables: [[10, 4], [20, 4], [30, 4]], + appartenances: [...fois(5, 'X'), 'Y', 'Y', null], + tours: 2, + reservations: [fixer(6, 10, 2), fixer(7, 10, 2)], + }); + assert.deepEqual( + diagnostiquer(configuration).collisions.map(({ groupe, tablesManquantes }) => [groupe, tablesManquantes]), + [ + ['X', 2], + ['Y', null], + ], + ); + }); +}); + +describe('diagnostic : remède en tours (§ 5.6)', () => { + // 28 personnes, dix appartenances de 2 et huit de 1, G = 18 ; quatre + // tables de 7. Un participant rencontre au plus 6 personnes par tour. En + // 4 tours : min(24, 27 affiliés autres que soi) − 18 = 6. En 3 tours : + // 18 − 18 = 0. + const DIX_PAIRES_HUIT_SEULS = [ + ...Array.from({ length: 10 }, (_, g) => fois(2, `Grisaille ${g + 1}`)).flat(), + ...Array.from({ length: 8 }, (_, g) => `Club des Merles ${g + 1}`), + ]; + const QUATRE_TABLES_DE_SEPT = [[10, 7], [20, 7], [30, 7], [40, 7]]; + + test('28 personnes en 18 appartenances, quatre tours : 6 redondances, aucune en 3 tours', () => { + assert.equal(DIX_PAIRES_HUIT_SEULS.length, 28); + const configuration = salle({ + tables: QUATRE_TABLES_DE_SEPT, + appartenances: DIX_PAIRES_HUIT_SEULS, + tours: 4, + }); + const diagnostic = diagnostiquer(configuration); + assert.equal(diagnostic.redondanceMinimale, 6); + assert.deepEqual(diagnostic.remedes, { toursSansRedondance: 3 }); + }); + + // La réservation du tour 4 ne tient pas dans une configuration de 3 + // tours : la sonde la retire, et celle d'un exclu aussi, au lieu de lever + // RESERVATION_TOUR. La réservation de portée « tous » couvre les trois. + test('une réservation d’un tour au-delà de R′ se retire de la sonde, sans lever', () => { + const configuration = salle({ + tables: QUATRE_TABLES_DE_SEPT, + appartenances: [...DIX_PAIRES_HUIT_SEULS, 'Club des Merles 1'], + tours: 4, + reservations: [fixer(1, 10, 4), ancrer(2, 20), fixer(29, 30, 4)], + exclus: [29], + }); + assert.deepEqual(diagnostiquer(configuration).remedes, { toursSansRedondance: 3 }); + }); + + // Trois tables de 2, P1 sans appartenance, P2 et P3 de la même. En 3 + // tours, P1, fixé à la table 3 à chaque tour, y retrouve P3 au tour 2 et + // P2 au tour 3 : 2 − 1 = 1. En 2 tours, P2 et P3 occupent la table 2 au + // tour 1 et P1 n'y retrouve que P3 au tour 2 : 1 − 1 = 0. Le remède, 2, + // tient aux réservations des tours 1 et 2, que la sonde garde. + test('les réservations d’un tour jusqu’à R′ restent dans la sonde', () => { + const configuration = salle({ + tables: [[1, 2], [2, 2], [3, 2]], + appartenances: [null, 'Grisaille', 'Grisaille'], + tours: 3, + reservations: [ + fixer(1, 3, 1), fixer(1, 3, 2), fixer(1, 3, 3), + fixer(2, 2, 1), fixer(2, 2, 2), fixer(2, 3, 3), + fixer(3, 2, 1), fixer(3, 3, 2), fixer(3, 2, 3), + ], + contraintes: TOUTES, + }); + const diagnostic = diagnostiquer(configuration); + assert.equal(diagnostic.redondanceMinimale, 1); + assert.deepEqual(diagnostic.remedes, { toursSansRedondance: 2 }); + }); + + // Deux tables de 3, P1 sans appartenance ancré à la table 10, P2 et P3 de + // la même appartenance, fixés aux tables 10 et 20 aux tours 1 et 2. En 3 + // tours, le tour 3 libre réunit P1 et P3 : 2 − 1 = 1. En 2 tours, P1 ne + // rencontre que P2 : 0. Le remède, 2, tient à l'ancrage, qui couvre les + // tours de la sonde, et aux réservations du tour 2, R′ lui-même. + test('l’ancrage et les réservations du tour R′ lui-même restent dans la sonde', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3]], + appartenances: [null, 'Grisaille', 'Grisaille'], + tours: 3, + reservations: [ancrer(1, 10), fixer(2, 10, 1), fixer(3, 20, 1), fixer(2, 10, 2), fixer(3, 20, 2)], + }); + const diagnostic = diagnostiquer(configuration); + assert.equal(diagnostic.redondanceMinimale, 1); + assert.deepEqual(diagnostic.remedes, { toursSansRedondance: 2 }); + }); + + // Deux appartenances de 14 : en un seul tour, 6 − 2 = 4 ; aucun R′ ne + // l'annule. + test('deux appartenances de 14 : aucun nombre de tours n’annule la redondance', () => { + const configuration = salle({ + tables: QUATRE_TABLES_DE_SEPT, + appartenances: [...fois(14, 'Grisaille'), ...fois(14, 'Club des Merles')], + tours: 4, + }); + const diagnostic = diagnostiquer(configuration); + assert.equal(diagnostic.redondanceMinimale, 22); + assert.deepEqual(diagnostic.remedes, { toursSansRedondance: null }); + }); + + test('sans affilié, ou à redondance déjà nulle : aucun remède', () => { + const sansAffilie = salle({ tables: QUATRE_TABLES_DE_SEPT, appartenances: fois(28, null), tours: 4 }); + assert.deepEqual(diagnostiquer(sansAffilie).remedes, { toursSansRedondance: null }); + const dejaNulle = salle({ tables: QUATRE_TABLES_DE_SEPT, appartenances: DIX_PAIRES_HUIT_SEULS, tours: 3 }); + assert.equal(diagnostiquer(dejaNulle).redondanceMinimale, 0); + assert.deepEqual(diagnostiquer(dejaNulle).remedes, { toursSansRedondance: null }); + }); +}); + +describe('diagnostic : une seule appartenance (§ 15.4)', () => { + const tables = [[10, 3], [20, 3], [30, 3], [40, 3]]; + + test('douze personnes d’une appartenance, séparation active : annoncée ; désactivée : non', () => { + const active = salle({ tables, appartenances: fois(12, 'Grisaille'), tours: 4, contraintes: SEPARER }); + assert.equal(diagnostiquer(active).uneSeuleAppartenance, true); + const inactive = salle({ tables, appartenances: fois(12, 'Grisaille'), tours: 4, contraintes: TOUTES_SAUF_SEPARER }); + assert.equal(diagnostiquer(inactive).uneSeuleAppartenance, false); + }); + + // Les présents sans appartenance se séparent des trois membres : la + // séparation y garde un sens. + test('la même appartenance portée par 3 des 12 : faux', () => { + const configuration = salle({ + tables, + appartenances: [...fois(3, 'Grisaille'), ...fois(9, null)], + tours: 4, + contraintes: SEPARER, + }); + assert.equal(diagnostiquer(configuration).uneSeuleAppartenance, false); + }); + + // L'appartenance du seul membre exclu ne compte pas : les présents n'en + // portent qu'une. + test('un exclu d’une autre appartenance ne compte pas', () => { + const configuration = salle({ + tables, + appartenances: [...fois(11, 'Grisaille'), 'Club des Merles'], + tours: 4, + contraintes: SEPARER, + exclus: [12], + }); + assert.equal(diagnostiquer(configuration).uneSeuleAppartenance, true); + }); +}); + +describe('diagnostic : collision à chaque table (§ 7.5)', () => { + // Douze personnes en deux appartenances de six, quatre tables de 3 : une + // table reçoit au moins 12 − 9 = 3 personnes, plus que G + U = 2. + const DEUX_FOIS_SIX = [...fois(6, 'Grisaille'), ...fois(6, 'Club des Merles')]; + const QUATRE_DE_TROIS = [[10, 3], [20, 3], [30, 3], [40, 3]]; + + test('deux appartenances de six sur quatre tables de 3 : vrai ; désactivée, faux ; une cinquième table, faux', () => { + const active = salle({ tables: QUATRE_DE_TROIS, appartenances: DEUX_FOIS_SIX, tours: 4, contraintes: SEPARER }); + assert.equal(diagnostiquer(active).collisionPartout, true); + const inactive = salle({ + tables: QUATRE_DE_TROIS, + appartenances: DEUX_FOIS_SIX, + tours: 4, + contraintes: TOUTES_SAUF_SEPARER, + }); + assert.equal(diagnostiquer(inactive).collisionPartout, false); + // Une table de 3 de plus : chaque table peut rester vide, 12 − 12 = 0. + const cinqTables = salle({ + tables: [...QUATRE_DE_TROIS, [50, 3]], + appartenances: DEUX_FOIS_SIX, + tours: 4, + contraintes: SEPARER, + }); + assert.equal(diagnostiquer(cinqTables).collisionPartout, false); + }); + + // Trois membres de chacune des trois appartenances par table de 3 : un + // plan sans collision existe, le plan parfait. + test('la petite : faux', () => { + assert.equal(diagnostiquer(construire('petite')).collisionPartout, false); + }); + + // Un présent sans appartenance : 6 − 3 = 3 places au moins par table, et + // G + U = 3 personnes s'y assoient sans collision. + test('un présent sans appartenance peut rompre la preuve : faux', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3]], + appartenances: ['Grisaille', 'Grisaille', 'Grisaille', 'Club des Merles', 'Club des Merles', null], + tours: 2, + contraintes: SEPARER, + }); + assert.equal(diagnostiquer(configuration).collisionPartout, false); + }); + + // Sept personnes pour six sièges : aucun plan n'assied chacun, rien ne se + // prouve. Sans table, rien non plus. + test('des places manquantes, ou aucune table : faux', () => { + const tropPetite = salle({ + tables: [[10, 3], [20, 3]], + appartenances: [...fois(4, 'Grisaille'), ...fois(3, 'Club des Merles')], + tours: 2, + contraintes: SEPARER, + }); + assert.equal(diagnostiquer(tropPetite).placesManquantes, 1); + assert.equal(diagnostiquer(tropPetite).collisionPartout, false); + const sansTable = salle({ tables: [], appartenances: [], tours: 2, contraintes: SEPARER }); + assert.equal(diagnostiquer(sansTable).collisionPartout, false); + }); + + // Deux appartenances de trois, deux tables de 3, deux tours : 6 − 3 = 3 + // > 2. L'énumération le confirme sans lire le diagnostic : chacun des + // C(6, 3)² = 400 plans porte une violation de séparation à chaque table de + // chaque tour. + test('deux appartenances de trois sur deux tables de 3 : vrai, et chacun des 400 plans le confirme', () => { + const configuration = salle({ + tables: [[10, 3], [20, 3]], + appartenances: [...fois(3, 'Grisaille'), ...fois(3, 'Club des Merles')], + tours: 2, + contraintes: SEPARER, + }); + assert.equal(diagnostiquer(configuration).collisionPartout, true); + const instance = normaliser(configuration); + const fautes = []; + const plans = enumererPlans(instance, (tableDe) => { + const cases = conflitsParTable(instance, tableDe).flat(); + if (cases.length !== 4) fautes.push(`${cases.length} cases`); + for (const { table, regles } of cases) { + if (regles.every(({ regle }) => regle !== 'separerAppartenances')) { + fautes.push(`table ${table} sans collision : ${Array.from(tableDe)}`); + } + } + }); + assert.equal(plans, 400); + assert.deepEqual(fautes, []); + }); +}); + +describe('diagnostic : ancrage par table (§ 5.6)', () => { + test('trois ancrés pour trois sièges : gelée, non à moitié ; deux pour quatre : à moitié', () => { + const configuration = salle({ + tables: [[10, 3], [20, 4], [30, 4]], + appartenances: fois(9, null), + tours: 2, + reservations: [ancrer(1, 10), ancrer(2, 10), ancrer(3, 10), ancrer(4, 20), ancrer(5, 20), ancrer(6, 30)], + }); + assert.deepEqual(diagnostiquer(configuration).ancrage, [ + { table: 10, ancres: 3, capacite: 3, gelee: true, moitie: false }, + { table: 20, ancres: 2, capacite: 4, gelee: false, moitie: true }, + { table: 30, ancres: 1, capacite: 4, gelee: false, moitie: false }, + ]); + }); +}); diff --git a/src/moteur/indicateurs.js b/src/moteur/indicateurs.js index 4536bfc..e01eb89 100644 --- a/src/moteur/indicateurs.js +++ b/src/moteur/indicateurs.js @@ -14,6 +14,12 @@ // sans appartenance ne sont pas collègues. Un ensemble vide rend null, jamais // 0 : le taux de diversité de qui ne rencontre aucun affilié, le minimum et la // moyenne d'une population sans membre. +// +// Une seule lecture du plan décide qu'une paire est réunie, collègue ou revue, +// et qu'une personne revient : parcourirTours. mesurer en tire chacun de ses +// chiffres, et conflitsParTable (conflits.js) le détail par table, si bien +// que la somme des violations par table égale le compte de la mesure +// (§ 13.2). import { RESERVE, SANS_GROUPE, @@ -41,10 +47,15 @@ import { * @property {number[]} redondance r(p) = |F(p)| − A(p) * @property {(number|null)[]} diversite d(p) = A(p) / |F(p)|, null si |F(p)| = 0 * @property {number[]} toursAssis - * @property {number[]} retoursChoisis par personne - * @property {number[]} retoursImposes par personne + * @property {number[]} retoursChoisis par personne : ses occurrences + * parmi les revenus de parcourirTours + * @property {number[]} retoursImposes par personne : ses retours moins + * ses retours choisis * @property {TroisAgregats} aggRencontres * @property {TroisAgregats} aggRedondance + * @property {TroisAgregats} aggAppartenancesVues A(p) + * @property {TroisAgregats} aggDiversite d(p), sur les seules personnes qui + * rencontrent un affilié * @property {number} totalRedondance Σ_p r(p), le seul total de la * redondance : la recherche et le * classement le lisent @@ -53,7 +64,8 @@ import { * @property {number} excedentCollisions cumulées − distinctes * @property {{choisies: number, imposees: number}} rencontresRepetees * paires réunies ≥ 2 fois ; « imposée » quand chaque rencontre de la - * paire a lieu à une table où les deux sont réservés ce tour-là + * paire a lieu à une table où les deux sont réservés ce tour-là ; + * choisies, les paires distinctes des revues de parcourirTours * @property {number} maxRencontresPaire sur toutes les paires ; 0 si aucune * @property {number} totalRetoursChoisis * @property {number} totalRetoursImposes @@ -70,57 +82,122 @@ const UINT16_MAX = 0xffff; // (N − 1) + (N − 2) + … + (N − a) paires des lignes qui la précèdent. const rangPaire = (N, a, b) => (a * (2 * N - a - 1)) / 2 + (b - a - 1); -// Rencontres de chaque paire, rangées par rangPaire, en parcourant chaque -// table de chaque tour. choisie vaut 1 pour une paire dont une rencontre au -// moins a lieu hors d'une table où les deux sont réservés ce tour-là. Une -// paire se réunit au plus une fois par tour : R borne chaque compte, qu'un -// Uint16Array tient tant que R ≤ 65 535. -function compterRencontres({ N, T, R, fixe }, tableDe) { - const paires = (N * (N - 1)) / 2; - const compte = R <= UINT16_MAX ? new Uint16Array(paires) : new Uint32Array(paires); - const choisie = new Uint8Array(paires); +/** + * Le plan lu tour par tour, puis table par table, par index croissants : le + * seul code qui décide qu'une paire est réunie, de même appartenance ou + * revue, et qu'une personne revient. mesurer en tire chacun de ses chiffres, + * conflitsParTable (conflits.js) le détail par table. + * + * parTour[r][t], en index de personnes, chaque paire [a, b] avec a < b, + * chaque liste croissante — paires dans l'ordre de a, puis de b : + * - collegues : paires de même appartenance assises à t au tour r ; + * - revues : paires assises à t au tour r et réunies à un tour antérieur, + * sauf quand chacune de leurs rencontres jusqu'à r a lieu à une table où + * les deux sont réservés ce tour-là ; + * - revenus : personnes assises à t au tour r et à un tour antérieur, sauf + * quand elles y sont réservées au tour r et lors d'une visite antérieure + * à t. + * parPaire : les rencontres de chaque paire, rangées par rangPaire ; une + * paire se réunit au plus une fois par tour, R borne chaque compte, qu'un + * Uint16Array tient tant que R ≤ 65 535. toursAssis, et retours — visites + * d'une table déjà visitée, imposées comprises —, par personne. + * + * Ne modifie ni l'instance ni le plan ; lève ce que lève exigerPlanIndexe. + * + * @param {import('./types.js').Instance} instance + * @param {ArrayLike} tableDe + * @returns {{parTour: Array>, + * parPaire: Uint16Array|Uint32Array, toursAssis: number[], retours: number[]}} + */ +export function parcourirTours(instance, tableDe) { + exigerPlanIndexe(instance, tableDe); + const { N, T, R, fixe, groupe } = instance; + + // Personne par personne, ses tours dans l'ordre : visites[t] compte ses + // passages à la table t, reserveeAvant[t] note qu'un passage antérieur y + // était réservé. revenu[p × R + r] marque la visite répétée que la + // personne choisit. Le second parcours de ses tours remet à zéro les + // tables qu'elle a visitées : la personne suivante les trouve nulles. + const toursAssis = new Array(N).fill(0); + const retours = new Array(N).fill(0); + const revenu = new Uint8Array(N * R); + const visites = new Int32Array(T); + const reserveeAvant = new Uint8Array(T); + 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) continue; + toursAssis[p] += 1; + const reservee = fixe[p * R + r] === t; + if (visites[t] > 0) { + retours[p] += 1; + if (!reservee || reserveeAvant[t] === 0) revenu[p * R + r] = 1; + } + visites[t] += 1; + if (reservee) reserveeAvant[t] = 1; + } + for (let r = 0; r < R; r += 1) { + const t = tableDe[p * R + r]; + if (t === RESERVE) continue; + visites[t] = 0; + reserveeAvant[t] = 0; + } + } + + // Tour par tour : occupants de chaque table, par index croissants, a < b + // pour toute paire (liste[i], liste[j]) avec i < j. La boucle intérieure + // part du voisin suivant : une liste vide ou d'un seul occupant ne forme + // aucune paire, et nul n'est apparié à lui-même. libre[k] note qu'une + // rencontre de la paire, jusqu'au tour courant compris, a eu lieu hors + // d'une table où les deux sont réservés. + const nombrePaires = (N * (N - 1)) / 2; + const parPaire = R <= UINT16_MAX ? new Uint16Array(nombrePaires) : new Uint32Array(nombrePaires); + const libre = new Uint8Array(nombrePaires); + const parTour = []; for (let r = 0; r < R; r += 1) { - // Occupants de chaque table, par index croissants : a < b pour toute - // paire (liste[i], liste[j]) avec i < j. La boucle intérieure part du - // voisin suivant : une liste vide ou d'un seul occupant ne forme aucune - // paire, et nul n'est apparié à lui-même. const assis = Array.from({ length: T }, () => []); for (let p = 0; p < N; p += 1) { const t = tableDe[p * R + r]; if (t !== RESERVE) assis[t].push(p); } + const tables = []; for (let t = 0; t < T; t += 1) { const liste = assis[t]; + const collegues = []; + const revues = []; + const revenus = []; for (let i = 0; i < liste.length; i += 1) { const a = liste[i]; + if (revenu[a * R + r] === 1) revenus.push(a); const aReserve = fixe[a * R + r] === t; + const g = groupe[a]; for (let j = i + 1; j < liste.length; j += 1) { const b = liste[j]; const k = rangPaire(N, a, b); - compte[k] += 1; - if (!aReserve || fixe[b * R + r] !== t) choisie[k] = 1; + parPaire[k] += 1; + if (!aReserve || fixe[b * R + r] !== t) libre[k] = 1; + if (g !== SANS_GROUPE && g === groupe[b]) collegues.push([a, b]); + if (parPaire[k] >= 2 && libre[k] === 1) revues.push([a, b]); } } + tables.push({ collegues, revues, revenus }); } + parTour.push(tables); } - return { compte, choisie }; + return { parTour, parPaire, toursAssis, retours }; } // Lecture du triangle des paires, dans l'ordre de rangPaire. Par personne : // |met(p)|, |F(p)| et A(p), vu[p * G + g] notant que p a déjà croisé un -// affilié du groupe g. Par groupe : une paire de collègues réunie n fois -// porte n collisions cumulées et une paire distincte. Sur toutes les paires : -// les rencontres répétées, choisies ou imposées, et le plus grand compte. -function lirePaires({ N, groupe, groupes }, { compte, choisie }) { +// affilié du groupe g. Sur toutes les paires : les paires réunies deux fois +// au moins, et le plus grand compte. +function lirePaires({ N, groupe, groupes }, parPaire) { const G = groupes.length; const rencontres = new Int32Array(N); const affilies = new Int32Array(N); const vues = new Int32Array(N); const vu = new Uint8Array(N * G); - const collisions = new Array(G).fill(0); - const distinctes = new Array(G).fill(0); - let choisies = 0; - let imposees = 0; + let repetees = 0; let maxRencontresPaire = 0; // p rencontre q : q compte dans met(p) ; un affilié compte aussi dans F(p), @@ -140,70 +217,52 @@ function lirePaires({ N, groupe, groupes }, { compte, choisie }) { let k = 0; for (let a = 0; a < N; a += 1) { for (let b = a + 1; b < N; b += 1, k += 1) { - const n = compte[k]; + const n = parPaire[k]; if (n === 0) continue; rencontrer(a, b); rencontrer(b, a); if (n > maxRencontresPaire) maxRencontresPaire = n; - if (n >= 2) { - if (choisie[k] === 1) choisies += 1; - else imposees += 1; - } - const g = groupe[a]; - if (g !== SANS_GROUPE && g === groupe[b]) { - collisions[g] += n; - distinctes[g] += 1; - } + if (n >= 2) repetees += 1; } } - return { - rencontres, - affilies, - vues, - collisions, - distinctes, - rencontresRepetees: { choisies, imposees }, - maxRencontresPaire, - }; + return { rencontres, affilies, vues, repetees, maxRencontresPaire }; } -// Tours assis et retours de chaque personne (§ 5.4). Pour une table visitée -// m fois, dont f à un tour où la personne y est réservée : max(0, f − 1) -// retours imposés et (m − 1) − imposés retours choisis. visites et reservees -// comptent par table les tours de la personne en cours ; le second parcours -// de ses tours solde chaque table à sa première occurrence et remet ses -// compteurs à zéro, si bien que la personne suivante les trouve nuls. -function compterRetours({ N, T, R, fixe }, tableDe) { - const toursAssis = []; - const retoursChoisis = []; - const retoursImposes = []; - const visites = new Int32Array(T); - const reservees = new Int32Array(T); - for (let p = 0; p < N; p += 1) { - let assis = 0; - for (let r = 0; r < R; r += 1) { - const t = tableDe[p * R + r]; - if (t === RESERVE) continue; - assis += 1; - visites[t] += 1; - if (fixe[p * R + r] === t) reservees[t] += 1; +// Lecture des listes de parcourirTours. Une paire de collègues réunie n fois +// porte n collisions cumulées et compte une fois parmi les paires +// distinctes de son groupe ; une paire revue compte une fois parmi les +// rencontres répétées choisies, à quelque tour qu'elle le soit ; chaque +// occurrence d'une personne revenue est un retour choisi. +function lireTours({ N, groupe, groupes }, parTour) { + const nombrePaires = (N * (N - 1)) / 2; + const collisions = new Array(groupes.length).fill(0); + const distinctes = new Array(groupes.length).fill(0); + const collegueVu = new Uint8Array(nombrePaires); + const revueVue = new Uint8Array(nombrePaires); + const retoursChoisis = new Array(N).fill(0); + let choisies = 0; + for (const tables of parTour) { + for (const { collegues, revues, revenus } of tables) { + for (const [a, b] of collegues) { + const g = groupe[a]; + const k = rangPaire(N, a, b); + collisions[g] += 1; + if (collegueVu[k] === 0) { + collegueVu[k] = 1; + distinctes[g] += 1; + } + } + for (const [a, b] of revues) { + const k = rangPaire(N, a, b); + if (revueVue[k] === 0) { + revueVue[k] = 1; + choisies += 1; + } + } + for (const p of revenus) retoursChoisis[p] += 1; } - let choisis = 0; - let imposes = 0; - for (let r = 0; r < R; r += 1) { - const t = tableDe[p * R + r]; - if (t === RESERVE || visites[t] === 0) continue; - const imposesTable = Math.max(0, reservees[t] - 1); - imposes += imposesTable; - choisis += visites[t] - 1 - imposesTable; - visites[t] = 0; - reservees[t] = 0; - } - toursAssis.push(assis); - retoursChoisis.push(choisis); - retoursImposes.push(imposes); } - return { toursAssis, retoursChoisis, retoursImposes }; + return { collisions, distinctes, choisies, retoursChoisis }; } // Population sans membre. min reste null tant qu'aucune valeur n'y entre : @@ -224,10 +283,14 @@ const conclure = ({ min, somme, effectif }) => ({ effectif, }); +// La population, une valeur de plus quand elle en est une : null n'y entre +// pas, et la personne qui le porte sort de l'effectif. +const ajouterSiDefinie = (cumul, valeur) => (valeur === null ? cumul : ajouter(cumul, valeur)); + // Minimum, moyenne et effectif de valeurs[p] sur les trois populations du -// § 5.4, que replierParPopulation forme. -function troisAgregats(valeurs, statut) { - const { tous, mobiles, ancres } = replierParPopulation(valeurs, statut, VIDE, ajouter); +// § 5.4, que replierParPopulation forme ; replier y range chaque valeur. +function troisAgregats(valeurs, statut, replier = ajouter) { + const { tous, mobiles, ancres } = replierParPopulation(valeurs, statut, VIDE, replier); return { tous: conclure(tous), mobiles: conclure(mobiles), ancres: conclure(ancres) }; } @@ -245,16 +308,17 @@ const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0); * @returns {Mesures} */ export function mesurer(instance, tableDe) { - exigerPlanIndexe(instance, tableDe); const { N, groupe, groupes, statut } = instance; - const paires = lirePaires(instance, compterRencontres(instance, tableDe)); - const { collisions, distinctes } = paires; + const { parTour, parPaire, toursAssis, retours } = parcourirTours(instance, tableDe); + const paires = lirePaires(instance, parPaire); + const { collisions, distinctes, choisies, retoursChoisis } = lireTours(instance, parTour); const rencontres = Array.from(paires.rencontres); const affilies = Array.from(paires.affilies); const appartenancesVues = Array.from(paires.vues); const redondance = affilies.map((f, p) => f - appartenancesVues[p]); const diversite = affilies.map((f, p) => (f === 0 ? null : appartenancesVues[p] / f)); + const retoursImposes = retours.map((total, p) => total - retoursChoisis[p]); const effectifs = new Array(groupes.length).fill(0); for (let p = 0; p < N; p += 1) { @@ -270,8 +334,6 @@ export function mesurer(instance, tableDe) { const collisionsCumulees = somme(collisions); const pairesDistinctes = somme(distinctes); - const { toursAssis, retoursChoisis, retoursImposes } = compterRetours(instance, tableDe); - return { rencontres, affilies, @@ -283,11 +345,13 @@ export function mesurer(instance, tableDe) { retoursImposes, aggRencontres: troisAgregats(rencontres, statut), aggRedondance: troisAgregats(redondance, statut), + aggAppartenancesVues: troisAgregats(appartenancesVues, statut), + aggDiversite: troisAgregats(diversite, statut, ajouterSiDefinie), totalRedondance: somme(redondance), collisionsCumulees, pairesDistinctes, excedentCollisions: collisionsCumulees - pairesDistinctes, - rencontresRepetees: paires.rencontresRepetees, + rencontresRepetees: { choisies, imposees: paires.repetees - choisies }, maxRencontresPaire: paires.maxRencontresPaire, totalRetoursChoisis: somme(retoursChoisis), totalRetoursImposes: somme(retoursImposes), diff --git a/src/moteur/indicateurs.test.js b/src/moteur/indicateurs.test.js index 0767d0d..c19191d 100644 --- a/src/moteur/indicateurs.test.js +++ b/src/moteur/indicateurs.test.js @@ -9,7 +9,7 @@ import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { indexerPlan, normaliser } from './configuration.js'; -import { mesurer } from './indicateurs.js'; +import { mesurer, parcourirTours } from './indicateurs.js'; const SANS_CONTRAINTE = { separerAppartenances: false, @@ -135,6 +135,18 @@ describe('mesurer', () => { mobiles: { min: 6, moyenne: 6, effectif: 12 }, ancres: AUCUN, }, + // Chacun croise deux appartenances, la sienne exclue : A(p) = 2 pour + // huit affiliés rencontrés, d(p) = 2 / 8. + aggAppartenancesVues: { + tous: { min: 2, moyenne: 2, effectif: 12 }, + mobiles: { min: 2, moyenne: 2, effectif: 12 }, + ancres: AUCUN, + }, + aggDiversite: { + tous: { min: 0.25, moyenne: 0.25, effectif: 12 }, + mobiles: { min: 0.25, moyenne: 0.25, effectif: 12 }, + ancres: AUCUN, + }, totalRedondance: 72, collisionsCumulees: 0, pairesDistinctes: 0, @@ -268,6 +280,46 @@ describe('mesurer', () => { mobiles: { min: 0, moyenne: 0, effectif: 3 }, ancres: AUCUN, }); + // A(p) se replie sur chacun ; d(p) sur les seuls qui rencontrent un + // affilié : 3 sort de l'effectif, et son null n'entre ni dans le minimum + // ni dans la moyenne. + assert.deepEqual(mesures.aggAppartenancesVues, { + tous: { min: 0, moyenne: 2 / 3, effectif: 3 }, + mobiles: { min: 0, moyenne: 2 / 3, effectif: 3 }, + ancres: AUCUN, + }); + assert.deepEqual(mesures.aggDiversite, { + tous: { min: 1, moyenne: 1, effectif: 2 }, + mobiles: { min: 1, moyenne: 1, effectif: 2 }, + ancres: AUCUN, + }); + }); + + test("le taux de diversité se replie par population : des mobiles qui ne croisent aucun affilié laissent leur ligne vide", () => { + // 1 et 2, sans appartenance, sont ancrés à la table 1 ; 3 et 4, de X et + // de Y, mobiles, la visitent tour à tour. 1 et 2 rencontrent chacun 3 et + // 4 : d = 2 / 2. 3 rencontre 1 et 2, sans appartenance, au tour 1, puis + // personne : il sort de l'effectif ; de même 4. + const mesures = mesurerPlan({ + appartenance: [null, null, 'X', 'Y'], + capacite: 3, + tours: [ + [[1, 2, 3], [4]], + [[1, 2, 4], [3]], + ], + reservations: [tous(1, 1), tous(2, 1)], + }); + assert.deepEqual(mesures.diversite, [1, 1, null, null]); + assert.deepEqual(mesures.aggDiversite, { + tous: { min: 1, moyenne: 1, effectif: 2 }, + mobiles: AUCUN, + ancres: { min: 1, moyenne: 1, effectif: 2 }, + }); + assert.deepEqual(mesures.aggAppartenancesVues, { + tous: { min: 0, moyenne: 1, effectif: 4 }, + mobiles: { min: 0, moyenne: 0, effectif: 2 }, + ancres: { min: 2, moyenne: 2, effectif: 2 }, + }); }); test("la réserve n'est pas une table : seuls les tours assis comptent", () => { @@ -521,6 +573,8 @@ describe('mesurer', () => { retoursImposes: [], aggRencontres: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN }, aggRedondance: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN }, + aggAppartenancesVues: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN }, + aggDiversite: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN }, totalRedondance: 0, collisionsCumulees: 0, pairesDistinctes: 0, @@ -577,3 +631,84 @@ describe('mesurer', () => { ); }); }); + +// Quatre personnes, 1 et 2 de X, deux tables de 3, trois tours ; 4 attend en +// réserve au tour 3. Les paires se rangent par rangPaire : (0, 1), (0, 2), +// (0, 3), (1, 2), (1, 3), (2, 3), en index de personnes. +const PARCOURS = { + appartenance: ['X', 'X', null, null], + capacite: 3, + tours: [ + [[1, 2, 3], [4]], + [[1, 2], [3, 4]], + [[1, 3], [2]], + ], + reserves: [[], [], [4]], +}; + +describe('parcourirTours', () => { + test('chaque table de chaque tour : collègues, paires revues, personnes revenues, en index croissants', () => { + // Tour 1 : (0, 1) collègues, rien de revu, personne ne revient. Tour 2 : + // (0, 1) collègues et revus ; 0 et 1 reviennent à la table d'index 0, 3 + // à celle d'index 1, que 2 découvre. Tour 3 : (0, 2) revus, réunis + // librement au tour 1 ; 0 revient une deuxième fois, 2 revient ; 1 + // découvre la table d'index 1. + const { instance, tableDe } = preparer(PARCOURS); + const vide = { collegues: [], revues: [], revenus: [] }; + assert.deepEqual(parcourirTours(instance, tableDe), { + parTour: [ + [{ collegues: [[0, 1]], revues: [], revenus: [] }, vide], + [ + { collegues: [[0, 1]], revues: [[0, 1]], revenus: [0, 1] }, + { collegues: [], revues: [], revenus: [3] }, + ], + [{ collegues: [], revues: [[0, 2]], revenus: [0, 2] }, vide], + ], + parPaire: Uint16Array.of(2, 2, 0, 1, 0, 1), + toursAssis: [3, 3, 3, 2], + retours: [2, 1, 1, 1], + }); + }); + + test('une rencontre et une visite que les réservations imposent ne se signalent pas ; elles restent comptées', () => { + // 1 et 2 sont réservés à la table 1 aux tours 1 et 2 : leur rencontre du + // tour 2 ne répète qu'une rencontre réservée, et chacun y revient + // réservé, comme à sa visite du tour 1. Au tour 3, 1 y revient sans + // réservation : il est revenu. parPaire et retours comptent tout, + // imposé compris. + const { instance, tableDe } = preparer({ + ...PARCOURS, + reservations: [auTour(1, 1, 1), auTour(2, 1, 1), auTour(1, 1, 2), auTour(2, 1, 2)], + }); + const { parTour, parPaire, retours } = parcourirTours(instance, tableDe); + assert.deepEqual(parTour[1][0], { collegues: [[0, 1]], revues: [], revenus: [] }); + assert.deepEqual(parTour[2][0], { collegues: [], revues: [[0, 2]], revenus: [0, 2] }); + assert.deepEqual(Array.from(parPaire), [2, 2, 0, 1, 0, 1]); + assert.deepEqual(retours, [2, 1, 1, 1]); + }); + + test('une table, ses tours, et aucune personne présente : chaque case est vide', () => { + const instance = normaliser({ + participants: [{ id: 1, nom: 'P1', appartenance: 'X', exclu: true }], + tables: [{ id: 1, numero: 1, capacite: 2 }], + tours: 2, + reservations: [], + contraintes: SANS_CONTRAINTE, + }); + const vide = { collegues: [], revues: [], revenus: [] }; + assert.deepEqual(parcourirTours(instance, new Int32Array(0)), { + parTour: [[vide], [vide]], + parPaire: new Uint16Array(0), + toursAssis: [], + retours: [], + }); + }); + + test("lève ce que lève exigerPlanIndexe, et ne modifie ni l'instance ni le plan", () => { + const { instance, tableDe } = preparer(PARCOURS); + assert.throws(() => parcourirTours(instance, tableDe.subarray(1)), RangeError); + const avant = { tableDe: tableDe.slice(), fixe: instance.fixe.slice(), groupe: instance.groupe.slice() }; + parcourirTours(instance, tableDe); + assert.deepEqual({ tableDe, fixe: instance.fixe, groupe: instance.groupe }, avant); + }); +});