diff --git a/src/application/evaluation.js b/src/application/evaluation.js index 05c8d1b..efe086d 100644 --- a/src/application/evaluation.js +++ b/src/application/evaluation.js @@ -17,6 +17,10 @@ // seul appel à fautes qui l'arrête. resumeDuRetenu passe par derive et // planDepuisFichier, qui la refont et lèvent sur une faute. // +// La configuration de la charge est figée (figerConfiguration) : le +// diagnostic et la mesure en lisent la même instance, normalisée une fois. +// configurationDe la bâtit neuve, et figer ne touche pas la charge. +// // Les imposés viennent du diagnostic, les mêmes pour toute ligne ; les // rencontres répétées imposées dépendent du plan, et chaque ligne les lit // dans ses mesures, à part des choisies. @@ -35,7 +39,7 @@ // aussi l'état de chaque critère tel que normaliser le donne — coché, sans // objet, ses valeurs —, que l'écran lit au lieu de le recalculer. import { classer } from '../moteur/classement.js'; -import { indexerPlan, normaliser } from '../moteur/configuration.js'; +import { figerConfiguration, indexerPlan, normaliser } from '../moteur/configuration.js'; import { compilationDe } from '../moteur/criteres.js'; import { diagnostiquer } from '../moteur/diagnostic.js'; import { mesurer } from '../moteur/indicateurs.js'; @@ -132,7 +136,7 @@ export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) { if (plafondsAPriori !== 'calcul' && plafondsAPriori !== null) { throw new TypeError(`evaluer : plafondsAPriori 'calcul' ou null attendu, reçu ${JSON.stringify(plafondsAPriori)}`); } - const configuration = configurationDe(charge); + const configuration = figerConfiguration(configurationDe(charge)); const diagnostic = diagnostiquer(configuration); const instance = normaliser(configuration); const aPriori = plafondsAPriori === 'calcul' ? diagnostic.plafondAPriori.parPersonne : null; diff --git a/src/application/evaluation.test.js b/src/application/evaluation.test.js index 1f71554..9b8ba02 100644 --- a/src/application/evaluation.test.js +++ b/src/application/evaluation.test.js @@ -202,6 +202,19 @@ describe("évaluation : la mesure qui fait foi (§ 5.10, § 13.2)", () => { const charge = figer(generee(chargeDemo('petite'), { graine: 2, nombre: 2, arret: 300 })); assert.deepEqual(evaluer(charge), evaluer(charge)); }); + + test('evaluer fige sa configuration, jamais la charge : rien de ce que la charge atteint ne devient figé', () => { + const charge = generee(chargeDemo('grande'), { graine: 2, nombre: 1, arret: 300 }); + evaluer(charge); + const figes = []; + const parcourir = (valeur, chemin) => { + if (valeur === null || typeof valeur !== 'object') return; + if (Object.isFrozen(valeur)) figes.push(chemin); + for (const [cle, element] of Object.entries(valeur)) parcourir(element, `${chemin}.${cle}`); + }; + parcourir(charge, 'charge'); + assert.deepEqual(figes, []); + }); }); describe('évaluation : une réservation que le plan ne tient pas (§ 5.8, § 12.6)', () => { diff --git a/src/moteur/configuration.js b/src/moteur/configuration.js index 9a8e481..d100501 100644 --- a/src/moteur/configuration.js +++ b/src/moteur/configuration.js @@ -379,12 +379,23 @@ function numeroterObjectifs(objectifs, { T, R, idsTables, indexTableDe }) { // (§ 4.5) : (p, r) l'est quand la table fixée de p à ce tour lui correspond, // ou, p libre à ce tour, quand une table qui garde un siège libre lui // correspond. Les objectifs qu'offre une table à siège libre se marquent une -// fois par tour. +// fois par tour. Deux personnes aux mêmes étiquettes, dans le même ordre, ont +// la même rangée : correspondA ne se calcule qu'à la première, que les +// suivantes recopient ; la clé, la liste en JSON, ne confond pas deux listes +// différentes. function couplesPossibles(presents, distincts, { N, T, R, fixe, siegesLibres, objectifDe }) { const O = distincts.length; const correspond = new Uint8Array(N * O); - for (let p = 0; p < N; p += 1) { + const premiereAvec = new Map(); + for (let p = 0; O > 0 && p < N; p += 1) { const etiquettes = presents[p].etiquettes ?? []; + const cle = JSON.stringify(etiquettes); + const premiere = premiereAvec.get(cle); + if (premiere !== undefined) { + correspond.copyWithin(p * O, premiere * O, premiere * O + O); + continue; + } + premiereAvec.set(cle, p); for (let o = 0; o < O; o += 1) correspond[p * O + o] = correspondA(etiquettes, distincts[o]) ? 1 : 0; } const possible = new Uint8Array(N * R); @@ -481,28 +492,31 @@ function bornerAnimateurs({ N, T, R, fixe, siegesLibres }, { A, rangAnimateur, d } libresDuTour.push(rangs); } + // marque[f] vaut p quand p a déjà rejoint l'animateur de rang f. const marque = new Int32Array(A).fill(-1); for (let p = 0; p < N; p += 1) { const soi = rangAnimateur[p]; let rejoints = 0; let somme = 0; - const rejoindre = (f) => { - if (f === soi || marque[f] === p) return; - marque[f] = p; - rejoints += 1; - }; for (let r = 0; r < R; r += 1) { const t = fixe[p * R + r]; if (t === LIBRE) { - libresDuTour[r].forEach(rejoindre); + for (const f of libresDuTour[r]) { + if (f === soi || marque[f] === p) continue; + marque[f] = p; + rejoints += 1; + } somme += plusGrandLibre[r]; continue; } let ici = 0; for (let k = debutAnimes[t * R + r]; k < debutAnimes[t * R + r + 1]; k += 1) { - if (animes[k] === soi) continue; - rejoindre(animes[k]); + const f = animes[k]; + if (f === soi) continue; ici += 1; + if (marque[f] === p) continue; + marque[f] = p; + rejoints += 1; } somme += ici; } @@ -584,10 +598,78 @@ function etatDesCriteres(contraintes, mode, possibles, borne) { * deux cases facultatives restent absentes de instance.contraintes, où * estCoche (criteres.js) leur donne leur valeur par défaut. * + * Une configuration passée par figerConfiguration ne se normalise qu'une + * fois : les appels suivants rendent la même instance, que l'appelant lit + * sans la modifier, comme tout le moteur. + * * @param {import('./types.js').Configuration} configuration * @returns {import('./types.js').Instance} */ export function normaliser(configuration) { + const connue = figees.get(configuration); + if (connue) return connue; + const instance = instanceDe(configuration); + if (connue === null) figees.set(configuration, instance); + return instance; +} + +// Les configurations que figerConfiguration a figées, chacune avec son +// instance, null tant qu'aucune normalisation n'a réussi : une +// configuration refusée lève à chaque appel. La clé est l'objet même ; ce +// que normaliser en lit, figé, ne change plus sous la même identité, et +// l'instance tirée au premier appel vaut pour les suivants. Une +// configuration que plus rien ne retient quitte la table avec son instance. +const figees = new WeakMap(); + +// Fige liste, quand c'en est une, après chacun de ses éléments par +// figerElement. +function figerListe(liste, figerElement) { + if (!Array.isArray(liste)) return; + for (const element of liste) figerElement(element); + Object.freeze(liste); +} + +// Fige valeur quand c'est un objet ; une autre valeur reste telle quelle. +function figerObjet(valeur) { + if (valeur !== null && typeof valeur === 'object') Object.freeze(valeur); +} + +/** + * Fige ce que normaliser lit de la configuration et la rend : l'objet, ses + * participants avec leurs étiquettes et leurs tours animés, ses tables, ses + * réservations, ses contraintes, ses objectifs avec leurs étiquettes, ses + * priorités, palier par palier. normaliser ne la calcule alors plus qu'une + * fois, et chaque appel suivant rend la même instance : un appelant qui la + * passe à plusieurs fonctions du moteur — diagnostiquer, puis normaliser + * pour mesurer — ne paie qu'une normalisation. Ce qu'elle atteint devient + * immuable : elle ne doit rien partager avec une valeur que son + * propriétaire modifie encore. Les champs sont nommés, et non parcourus : + * l'instance ne dépend que d'eux. + * + * @param {import('./types.js').Configuration} configuration + * @returns {import('./types.js').Configuration} la même, figée + */ +export function figerConfiguration(configuration) { + figerListe(configuration.participants, (participant) => { + figerObjet(participant); + figerListe(participant?.etiquettes, figerObjet); + figerListe(participant?.anime, figerObjet); + }); + figerListe(configuration.tables, figerObjet); + figerListe(configuration.reservations, figerObjet); + figerObjet(configuration.contraintes); + figerListe(configuration.objectifs, (objectif) => { + figerObjet(objectif); + figerListe(objectif?.etiquettes, figerObjet); + }); + figerListe(configuration.priorites, (palier) => figerListe(palier, figerObjet)); + Object.freeze(configuration); + if (!figees.has(configuration)) figees.set(configuration, null); + return configuration; +} + +// L'instance d'une configuration, calculée à neuf (normaliser). +function instanceDe(configuration) { const { participants, tables, tours: R, reservations } = configuration; exigerListe(participants, 'participants'); exigerListe(tables, 'tables'); @@ -861,9 +943,10 @@ export function nombrePlacesManquantes(instance) { * range une personne dans sa population : les agrégats des indicateurs et * les chiffres du manque le lisent tous deux. * - * replier(cumul, valeur) rend le cumul suivant sans modifier celui qu'il - * reçoit : les trois populations partent du même initial, et une population - * sans membre le garde. + * replier(cumul, valeur) rend le cumul suivant sans jamais modifier + * initial : les trois populations partent de lui, et une population sans + * membre le garde. Un cumul que replier a lui-même rendu n'appartient qu'à + * sa population, et replier peut le compléter en place. * * @template C * @param {ArrayLike<*>} valeurs une par personne, dans l'ordre de instance.ids diff --git a/src/moteur/configuration.test.js b/src/moteur/configuration.test.js index 73bafb9..10028ae 100644 --- a/src/moteur/configuration.test.js +++ b/src/moteur/configuration.test.js @@ -20,6 +20,7 @@ import { SANS_OBJECTIF, STATUT, exigerPlanIndexe, + figerConfiguration, indexerPlan, nombrePlaces, nombrePlacesManquantes, @@ -851,6 +852,48 @@ describe('plans : forme par identifiants et forme indexée (§ 8.9)', () => { }); }); +describe('figerConfiguration : une normalisation par configuration figée', () => { + // 2 anime aux deux tours, réservé à la table 10 ; 4 porte « finance », que + // vise la table 30 pour la soirée. + const recue = () => ({ + ...configuration({ + participants: [ + ...personnes([9]), + { id: 2, nom: 'P2', appartenance: null, anime: [1, 2] }, + { id: 4, nom: 'P4', appartenance: 'X', etiquettes: ['finance'] }, + ], + tables: tablesDe([30, 10], 4), + tours: 2, + reservations: [tous(9, 30), tous(2, 10)], + }), + objectifs: [{ table: 30, tour: null, regle: 'ou', etiquettes: ['finance'] }], + priorites: PRIORITES_PAR_DEFAUT.map((palier) => palier.map((entree) => ({ ...entree }))), + }); + + test('fige ce que normaliser lit et rend la configuration ; normaliser rend alors la même instance à chaque appel, celle d’une copie non figée', () => { + const figee = recue(); + assert.equal(figerConfiguration(figee), figee); + const lues = [ + figee, figee.participants, figee.participants[0], figee.participants[1].anime, figee.participants[2].etiquettes, + figee.tables, figee.tables[1], figee.reservations, figee.reservations[1], figee.contraintes, + figee.objectifs, figee.objectifs[0], figee.objectifs[0].etiquettes, figee.priorites, figee.priorites[3], figee.priorites[3][0], + ]; + assert.deepEqual(lues.filter((valeur) => !Object.isFrozen(valeur)), []); + const premiere = normaliser(figee); + assert.equal(normaliser(figee), premiere); + assert.deepEqual(premiere, normaliser(recue())); + const libre = recue(); + assert.notEqual(normaliser(libre), normaliser(libre)); + }); + + test('une configuration figée que normaliser refuse lève à chaque appel', () => { + const figee = figerConfiguration({ ...recue(), tours: 0 }); + for (let appel = 0; appel < 2; appel += 1) { + assert.throws(() => normaliser(figee), (erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'TOURS'); + } + }); +}); + describe('replierParPopulation (§ 5.4)', () => { test('chacun compte dans tous, puis parmi les ancrés ou parmi les mobiles, partiellement fixés compris, dans l’ordre canonique', () => { const statut = Uint8Array.from([ANCRE, MOBILE, PARTIELLEMENT_FIXE, ANCRE, MOBILE, PARTIELLEMENT_FIXE]); diff --git a/src/moteur/indicateurs.js b/src/moteur/indicateurs.js index f347674..2168619 100644 --- a/src/moteur/indicateurs.js +++ b/src/moteur/indicateurs.js @@ -129,7 +129,10 @@ const rangPaire = (N, a, b) => (a * (2 * N - a - 1)) / 2 + (b - a - 1); * à 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 + * Uint16Array tient tant que R ≤ 65 535. reunies : chaque paire réunie au + * moins une fois, une seule fois, a puis b à plat, dans l'ordre de sa + * première rencontre ; lirePaires n'en parcourt pas d'autre, au lieu du + * triangle entier. toursAssis, et retours — visites * d'une table déjà visitée, imposées comprises —, par personne. servis, * s(p) : les tours où p est assis, à une table qu'il peut occuper, sous un * objectif en vigueur qui lui correspond. rencontresAnimateurs, m(p) : les @@ -141,7 +144,7 @@ const rangPaire = (N, a, b) => (a * (2 * N - a - 1)) / 2 + (b - a - 1); * @param {import('./types.js').Instance} instance * @param {ArrayLike} tableDe * @returns {{parTour: Array>, - * parPaire: Uint16Array|Uint32Array, toursAssis: number[], retours: number[], + * parPaire: Uint16Array|Uint32Array, reunies: number[], toursAssis: number[], retours: number[], * servis: number[], rencontresAnimateurs: number[]}} */ export function parcourirTours(instance, tableDe) { @@ -169,17 +172,19 @@ export function parcourirTours(instance, tableDe) { const reserveeAvant = new Uint8Array(T); const rencontre = new Int32Array(A).fill(-1); for (let p = 0; p < N; p += 1) { + const soi = rangAnimateur[p]; for (let r = 0; r < R; r += 1) { const t = tableDe[p * R + r]; if (t === RESERVE) continue; toursAssis[p] += 1; const imposee = fixe[p * R + r]; - if (imposee === LIBRE ? siegesLibres[t * R + r] > 0 : imposee === t) { - const o = objectifDe[t * R + r]; + const ligne = t * R + r; + if (imposee === LIBRE ? siegesLibres[ligne] > 0 : imposee === t) { + const o = objectifDe[ligne]; if (o !== SANS_OBJECTIF && correspond[p * O + o] === 1) servis[p] += 1; - for (let k = debutAnimes[t * R + r]; k < debutAnimes[t * R + r + 1]; k += 1) { + for (let k = debutAnimes[ligne], fin = debutAnimes[ligne + 1]; k < fin; k += 1) { const f = animes[k]; - if (f === rangAnimateur[p] || rencontre[f] === p || tableDe[animateurs[f] * R + r] !== t) continue; + if (f === soi || rencontre[f] === p || tableDe[animateurs[f] * R + r] !== t) continue; rencontre[f] = p; rencontresAnimateurs[p] += 1; } @@ -209,6 +214,7 @@ export function parcourirTours(instance, tableDe) { const nombrePaires = (N * (N - 1)) / 2; const parPaire = R <= UINT16_MAX ? new Uint16Array(nombrePaires) : new Uint32Array(nombrePaires); const libre = new Uint8Array(nombrePaires); + const reunies = []; const parTour = []; for (let r = 0; r < R; r += 1) { const assis = Array.from({ length: T }, () => []); @@ -231,6 +237,7 @@ export function parcourirTours(instance, tableDe) { const b = liste[j]; const k = rangPaire(N, a, b); parPaire[k] += 1; + if (parPaire[k] === 1) reunies.push(a, b); 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]); @@ -240,14 +247,16 @@ export function parcourirTours(instance, tableDe) { } parTour.push(tables); } - return { parTour, parPaire, toursAssis, retours, servis, rencontresAnimateurs }; + return { parTour, parPaire, reunies, toursAssis, retours, servis, rencontresAnimateurs }; } -// 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. Sur toutes les paires : les paires réunies deux fois -// au moins, et le plus grand compte. -function lirePaires({ N, groupe, groupes }, parPaire) { +// Lecture des paires réunies (reunies de parcourirTours), leurs comptes dans +// parPaire : une paire jamais réunie ne change aucun chiffre, et l'ordre de +// lecture n'en change aucun non plus. 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. Sur +// toutes les paires : les paires réunies deux fois au moins, et le plus +// grand compte. O(paires réunies), et non O(N²). +function lirePaires({ N, groupe, groupes }, parPaire, reunies) { const G = groupes.length; const rencontres = new Int32Array(N); const affilies = new Int32Array(N); @@ -270,16 +279,14 @@ function lirePaires({ N, groupe, groupes }, parPaire) { } }; - let k = 0; - for (let a = 0; a < N; a += 1) { - for (let b = a + 1; b < N; b += 1, k += 1) { - const n = parPaire[k]; - if (n === 0) continue; - rencontrer(a, b); - rencontrer(b, a); - if (n > maxRencontresPaire) maxRencontresPaire = n; - if (n >= 2) repetees += 1; - } + for (let i = 0; i < reunies.length; i += 2) { + const a = reunies[i]; + const b = reunies[i + 1]; + const n = parPaire[rangPaire(N, a, b)]; + rencontrer(a, b); + rencontrer(b, a); + if (n > maxRencontresPaire) maxRencontresPaire = n; + if (n >= 2) repetees += 1; } return { rencontres, affilies, vues, repetees, maxRencontresPaire }; } @@ -325,12 +332,16 @@ function lireTours({ N, groupe, groupes }, parTour) { // aucun Infinity ne sert de minimum initial. const VIDE = Object.freeze({ min: null, somme: 0, effectif: 0 }); -// La population, une valeur de plus ; celle qu'elle reçoit reste intacte. -const ajouter = ({ min, somme, effectif }, valeur) => ({ - min: min === null || valeur < min ? valeur : min, - somme: somme + valeur, - effectif: effectif + 1, -}); +// La population, une valeur de plus. VIDE, que les trois populations +// partagent au départ, reste intact : la première valeur ouvre un cumul +// neuf, que les suivantes complètent en place, sans un objet par personne. +function ajouter(cumul, valeur) { + if (cumul === VIDE) return { min: valeur, somme: valeur, effectif: 1 }; + if (valeur < cumul.min) cumul.min = valeur; + cumul.somme += valeur; + cumul.effectif += 1; + return cumul; +} // Agrégat d'une population ; la moyenne d'une population vide vaut null. const conclure = ({ min, somme, effectif }) => ({ @@ -355,55 +366,71 @@ const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0); // Les objectifs des tables mesurés (A9), sur les personnes concernées, celles // dont P(p) > 0 : null sans aucune. Pour chacune, P(p) − s(p) entre dans H ; // s(p) nul, elle est jamais servie ; E et le nombre de personnes à E se -// tiennent en un passage, ce nombre ramené à 0 quand E l'est. -function lireObjectifs({ N, possibles, statut }, servis) { - const tours = [...servis]; - const parPersonne = Array.from(possibles); +// tiennent en un passage, ce nombre ramené à 0 quand E l'est. servis est le +// tableau neuf de parcourirTours, que la mesure rend tel quel sous tours. +function lireObjectifs({ N, possibles: P, statut }, servis) { let concernes = 0; + for (let p = 0; p < N; p += 1) if (P[p] > 0) concernes += 1; + if (concernes === 0) return null; + const possibles = new Array(N); + // s(p) des concernés, null pour les autres : l'agrégat n'en compte aucun. + const selonObjectif = new Array(N); let horsObjectif = 0; let jamaisServies = 0; let horsObjectifMax = 0; let auMax = 0; for (let p = 0; p < N; p += 1) { - if (parPersonne[p] === 0) continue; - concernes += 1; - const hors = parPersonne[p] - tours[p]; + possibles[p] = P[p]; + if (P[p] === 0) { + selonObjectif[p] = null; + continue; + } + selonObjectif[p] = servis[p]; + const hors = P[p] - servis[p]; horsObjectif += hors; - if (tours[p] === 0) jamaisServies += 1; + if (servis[p] === 0) jamaisServies += 1; if (hors > horsObjectifMax) { horsObjectifMax = hors; auMax = 0; } if (hors === horsObjectifMax) auMax += 1; } - if (concernes === 0) return null; return { - tours, - possibles: parPersonne, + tours: servis, + possibles, horsObjectif, jamaisServies, horsObjectifMax, personnesAuMax: horsObjectifMax === 0 ? 0 : auMax, concernes, - agregat: troisAgregats(tours.map((s, p) => (parPersonne[p] > 0 ? s : null)), statut, ajouterSiDefinie), + agregat: troisAgregats(selonObjectif, statut, ajouterSiDefinie), }; } // Les animateurs rencontrés mesurés (A9), sur les personnes concernées, // celles dont B(p) > 0 : null sans aucune. Les animateurs non rencontrés // somment B(p) − m(p) sur toutes les personnes, une non concernée y valant -// zéro. -function lireAnimateurs({ borneAnimateurs, statut }, rencontresAnimateurs) { - const rencontres = [...rencontresAnimateurs]; - const bornes = Array.from(borneAnimateurs); - const concernes = bornes.filter((borne) => borne > 0).length; +// zéro. rencontres est le tableau neuf de parcourirTours, rendu tel quel. +function lireAnimateurs({ N, borneAnimateurs, statut }, rencontres) { + let concernes = 0; + for (let p = 0; p < N; p += 1) if (borneAnimateurs[p] > 0) concernes += 1; if (concernes === 0) return null; + const bornes = new Array(N); + // m(p) des concernés, null pour les autres : l'agrégat n'en compte aucun. + const selonBorne = new Array(N); + let nonRencontres = 0; + for (let p = 0; p < N; p += 1) { + const borne = borneAnimateurs[p]; + bornes[p] = borne; + nonRencontres += borne - rencontres[p]; + selonBorne[p] = borne > 0 ? rencontres[p] : null; + } return { rencontres, bornes, - nonRencontres: somme(bornes) - somme(rencontres), + nonRencontres, concernes, - agregat: troisAgregats(rencontres.map((m, p) => (bornes[p] > 0 ? m : null)), statut, ajouterSiDefinie), + agregat: troisAgregats(selonBorne, statut, ajouterSiDefinie), }; } @@ -420,8 +447,8 @@ function lireAnimateurs({ borneAnimateurs, statut }, rencontresAnimateurs) { */ export function mesurer(instance, tableDe) { const { N, groupe, groupes, statut } = instance; - const { parTour, parPaire, toursAssis, retours, servis, rencontresAnimateurs } = parcourirTours(instance, tableDe); - const paires = lirePaires(instance, parPaire); + const { parTour, parPaire, reunies, toursAssis, retours, servis, rencontresAnimateurs } = parcourirTours(instance, tableDe); + const paires = lirePaires(instance, parPaire, reunies); const { collisions, distinctes, choisies, retoursChoisis } = lireTours(instance, parTour); const rencontres = Array.from(paires.rencontres); diff --git a/src/moteur/indicateurs.test.js b/src/moteur/indicateurs.test.js index 63cea22..5f15ede 100644 --- a/src/moteur/indicateurs.test.js +++ b/src/moteur/indicateurs.test.js @@ -714,7 +714,8 @@ describe('parcourirTours', () => { // (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. + // découvre la table d'index 1. Réunies une première fois : (0, 1), + // (0, 2) et (1, 2) au tour 1, (2, 3) au tour 2. const { instance, tableDe } = preparer(PARCOURS); const vide = { collegues: [], revues: [], revenus: [] }; assert.deepEqual(parcourirTours(instance, tableDe), { @@ -727,6 +728,7 @@ describe('parcourirTours', () => { [{ collegues: [], revues: [[0, 2]], revenus: [0, 2] }, vide], ], parPaire: Uint16Array.of(2, 2, 0, 1, 0, 1), + reunies: [0, 1, 0, 2, 1, 2, 2, 3], toursAssis: [3, 3, 3, 2], retours: [2, 1, 1, 1], servis: [0, 0, 0, 0], @@ -763,6 +765,7 @@ describe('parcourirTours', () => { assert.deepEqual(parcourirTours(instance, new Int32Array(0)), { parTour: [[vide], [vide]], parPaire: new Uint16Array(0), + reunies: [], toursAssis: [], retours: [], servis: [], diff --git a/src/moteur/recherche.js b/src/moteur/recherche.js index 3bbe7bd..080302b 100644 --- a/src/moteur/recherche.js +++ b/src/moteur/recherche.js @@ -79,7 +79,10 @@ // rencontre apparaît ou disparaît, et m(p), avec les animateurs non // rencontrés Σ (B(p) − m(p)), suit. Partager la table d'un animateur à un // tour où il n'anime pas n'y compte pas : modifierPaire n'y touche pas. -// O(animateurs des deux tables), au plus O(capacité). +// O(animateurs des deux tables), au plus O(capacité). L'état ne les tient +// que lorsque le n-uplet les compose, le critère coché et non sans objet : +// sinon aucune composante ne les lit, et la recherche ne paie rien pour +// eux, même quand des personnes animent. // // Ce score sert la boucle de recherche et rien d'autre ; scoreComplet // recompose le même n-uplet à partir de la mesure qui fait foi, dont il lit @@ -169,11 +172,15 @@ import { plafondsAPriori } from './plafond.js'; * @property {number} jamaisServies J * @property {number} horsObjectifMax E * @property {number} personnesAuMax concernés à E ; 0 quand E = 0 - * @property {Int32Array} rencontresAnimateurs m(p) - * @property {number} animateursNonRencontres Σ (B(p) − m(p)) + * @property {boolean} suitAnimateurs vrai quand le n-uplet compose les + * animateurs non rencontrés : l'état ne les tient qu'alors + * @property {Int32Array|null} rencontresAnimateurs m(p) ; null quand + * le n-uplet ne compose pas les animateurs non rencontrés + * @property {number|null} animateursNonRencontres Σ (B(p) − m(p)) ; null + * de même * @property {Object} composition la forme du n-uplet de l'instance : - * taille, termes de chaque composante, ordres de comparaison et - * rang du plus grand écart + * taille, termes de chaque composante, ordres de comparaison, + * rang du plus grand écart et suitAnimateurs * * @typedef {{tour: number, a: number, b: number}} Mouvement * l'échange des occupants des sièges a et b au tour d'index tour @@ -253,7 +260,9 @@ function lire(q, code) { // terme par composante qu'il descend ; un palier partagé, un terme par // critère dans sa composante. Les rencontres répétées posent un terme par // case cochée de « nouveaux voisins » et « nouvelle table », au même poids. -// Lève RangeError pour une grandeur que la composition ne sait pas lire. +// suitAnimateurs dit qu'un terme lit les animateurs non rencontrés : l'état +// ne les tient qu'alors. Lève RangeError pour une grandeur que la +// composition ne sait pas lire. function composition(instance) { const { paliers, ordreScore, ordreContraintes, rangEcart, taille } = compilationDe(instance); const { contraintes, modeObjectifs } = instance; @@ -287,6 +296,7 @@ function composition(instance) { ordreScore: Int32Array.from(ordreScore), ordreContraintes: Int32Array.from(ordreContraintes), rangEcart, + suitAnimateurs: termes.some(([code]) => code === ANIMATEURS), }; } @@ -517,13 +527,14 @@ function changerAnimateurs(etat, p, t, r, sens) { } // p, libre au tour r, passe de la table depart à la table arrivee : ses tours -// servis et ses rencontres d'animateurs suivent (en-tête du module). +// servis et, quand l'état les tient, ses rencontres d'animateurs suivent +// (en-tête du module). function changerTable(etat, p, depart, arrivee, r) { if (etat.O > 0) { const ecart = sert(etat, p, arrivee, r) - sert(etat, p, depart, r); if (ecart !== 0) changerServis(etat, p, ecart); } - if (etat.A > 0) { + if (etat.suitAnimateurs) { changerAnimateurs(etat, p, depart, r, -1); changerAnimateurs(etat, p, arrivee, r, 1); } @@ -586,7 +597,8 @@ function echanger(etat, r, a, b) { // sièges mobiles. Les compteurs partent de zéro et suivent chaque paire, // chaque visite et chaque rencontre d'animateur par les mêmes fonctions que // les mouvements ; les écarts partent du plafond a priori, aucune rencontre -// n'étant encore comptée, et les animateurs non rencontrés de Σ B(p). Les +// n'étant encore comptée, et les animateurs non rencontrés de Σ B(p), +// quand l'état les tient (suitAnimateurs de la composition). Les // tours servis se comptent siège par siège, puis l'histogramme des // P(p) − s(p) se forme sur les personnes concernées. Chaque personne assise // l'est à une table qu'elle peut occuper — sa table fixée, ou un siège @@ -595,6 +607,7 @@ function echanger(etat, r, a, b) { function etatInitial(structure, rng) { const { instance, N, T, R, S, mobiles, libres, aPriori, tableDuSiege, debut } = structure; const G = instance.groupes.length; + const { suitAnimateurs } = structure.composition; const etat = { instance, contraintes: instance.contraintes, @@ -646,20 +659,23 @@ function etatInitial(structure, rng) { personnesAuMax: 0, // Les animateurs : qui anime à chaque table et chaque tour, de // l'instance ; le compte de chaque couple (personne, animateur), m(p) et - // Σ (B(p) − m(p)), de cet état. + // Σ (B(p) − m(p)), de cet état, null quand il ne les tient pas. + suitAnimateurs, A: instance.A, rangAnimateur: instance.rangAnimateur, debutAnimes: instance.debutAnimes, animes: instance.animes, - rencontresAnimees: R <= UINT8_MAX ? new Uint8Array(N * instance.A) : new Uint32Array(N * instance.A), - rencontresAnimateurs: new Int32Array(N), - animateursNonRencontres: 0, + rencontresAnimees: !suitAnimateurs + ? null + : R <= UINT8_MAX ? new Uint8Array(N * instance.A) : new Uint32Array(N * instance.A), + rencontresAnimateurs: suitAnimateurs ? new Int32Array(N) : null, + animateursNonRencontres: suitAnimateurs ? 0 : null, }; for (let p = 0; p < N; p += 1) { etat.histogramme[aPriori[p]] += 1; etat.carresEcartsAuPlafondAPriori += aPriori[p] * aPriori[p]; etat.ecartAuPlafondAPrioriMax = Math.max(etat.ecartAuPlafondAPrioriMax, aPriori[p]); - etat.animateursNonRencontres += instance.borneAnimateurs[p]; + if (suitAnimateurs) etat.animateursNonRencontres += instance.borneAnimateurs[p]; } const { siege, tableDe } = etat; for (let r = 0; r < R; r += 1) { @@ -679,7 +695,7 @@ function etatInitial(structure, rng) { if (q !== FANTOME) modifierPaire(etat, p, q, 1); } if (etat.O > 0) etat.servis[p] += sert(etat, p, t, r); - if (etat.A > 0) changerAnimateurs(etat, p, t, r, 1); + if (suitAnimateurs) changerAnimateurs(etat, p, t, r, 1); } } for (let p = 0; p < N; p += 1) { diff --git a/src/moteur/recherche.test.js b/src/moteur/recherche.test.js index 283ff0a..3a12f03 100644 --- a/src/moteur/recherche.test.js +++ b/src/moteur/recherche.test.js @@ -1347,6 +1347,24 @@ describe('score : objectifs et animateurs, sous chaque mode et sous un ordre dé const directs = animateursRencontres(configuration, instance, etat.tableDe); assert.deepEqual(Array.from(etat.rencontresAnimateurs), directs); }); + + test("case des animateurs décochée : l'état ne tient pas leurs rencontres, le score reste celui de la mesure, et la recherche rend les plans d'une soirée sans animateur", () => { + const cochee = soireeAObjectifs(); + const decochee = { ...cochee, contraintes: { ...cochee.contraintes, rencontrerAnimateurs: false } }; + const sansAnimateur = { ...decochee, participants: decochee.participants.map(({ anime, ...reste }) => reste) }; + assert.ok(normaliser(decochee).borneAnimateurs.some((B) => B > 0)); + assert.equal(creerEtat(normaliser(cochee), creerPcg32(5, FLUX.RECHERCHE)).suitAnimateurs, true); + const rng = creerPcg32(5, FLUX.RECHERCHE); + const etat = creerEtat(normaliser(decochee), rng); + assert.deepEqual( + [etat.suitAnimateurs, etat.rencontresAnimees, etat.rencontresAnimateurs, etat.animateursNonRencontres], + [false, null, null, null], + ); + for (let essai = 0; essai < 300; essai += 1) proposerEtAppliquer(etat, rng); + assert.deepStrictEqual(scoreIncremental(etat), scoreComplet(etat)); + const reglages = { graine: 9, arret: 3_000, nombre: 2 }; + assert.deepEqual(rechercher(decochee, reglages), rechercher(sansAnimateur, reglages)); + }); }); describe("recherche : le mode et l'ordre réglé ne touchent pas au hasard (A13, § 15.5)", () => {