[IMP] engine perf: facilitator tracking on demand, one normalisation

The default search ran 4.4 % slower than before tags and goals, and a
cold evaluation of 200 proposals with goals up to 18.7 % slower. The
search now tracks facilitator meetings only when the compiled order
counts that criterion; an evaluation normalises its configuration once
for both the table and the diagnostic, through a cache keyed on the
frozen copy it builds; met pairs are read from the pairs that met.
Results are byte-identical. Measured against the previous revision,
alternating runs: search +1.3 %, evaluation with goals +4.3 to +9.1 %.
Checked: node 4594, navigateur 963, node-long 124, fingerprints intact.

--- FR ---

[IMP] perf du moteur : animateurs suivis à la demande, une normalisation

La recherche par défaut tournait 4,4 % plus lentement qu'avant étiquettes
et objectifs, et une évaluation à froid de 200 propositions à objectifs
jusqu'à 18,7 % plus lentement. La recherche ne suit les rencontres
d'animateurs que si l'ordre compilé compte ce critère ; une évaluation
normalise sa configuration une fois pour la table et le diagnostic, par un
cache sur la copie figée qu'elle bâtit ; les paires se lisent parmi celles
réunies. Résultats identiques à l'octet. Mesuré contre la révision
précédente, en alternance : recherche +1,3 %, évaluation +4,3 à +9,1 %.
Vérifié : node 4594, navigateur 963, longues 124, empreintes intactes.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-11 21:57:38 -04:00
parent d17e2931a7
commit 0f8f4663b8
8 changed files with 287 additions and 80 deletions

View file

@ -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;

View file

@ -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)', () => {

View file

@ -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

View file

@ -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]);

View file

@ -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<number>} tableDe
* @returns {{parTour: Array<Array<{collegues: number[][], revues: number[][], revenus: number[]}>>,
* 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);

View file

@ -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: [],

View file

@ -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) {

View file

@ -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)", () => {