gestion_table_tournante_libre/src/moteur/indicateurs.js
Mathieu Benoit 30045182e7 [ADD] engine: indicators measured by full recomputation
Full recomputation is the measure every displayed figure comes from
(§ 5.10). It keeps the three collision units apart — cumulative
occurrences, distinct pairs, their excess — splits table returns and
repeated meetings into those the engine chose and those the reservations
impose, and gives null, never zero, for an empty population.

Checked: the small demonstration's perfect plan gives 8 met, A = 2,
r = 6, d = 0.25 for all twelve; spread and concentrated plans give
excess 0 and 4.

--- FR ---

[ADD] moteur : indicateurs mesurés par recalcul complet

Le recalcul complet est la mesure dont sort tout chiffre affiché
(§ 5.10). Il tient séparées les trois unités de collision — occurrences
cumulées, paires distinctes, leur excédent —, partage les retours à une
table et les rencontres répétées entre ceux que le moteur a choisis et
ceux qu'imposent les réservations, et rend null, jamais zéro, pour une
population vide.

Vérifié : le plan parfait de la petite démonstration donne 8 rencontres,
A = 2, r = 6, d = 0,25 pour les douze ; plans réparti et concentré
donnent un excédent de 0 et de 4.

Assisted-by: Claude Opus 5.5
2026-10-05 17:27:34 -04:00

323 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Indicateurs d'un plan (§ 5.4), par recalcul complet : la mesure qui fait
// foi, dont sortent les chiffres affichés et ceux du classement (§ 5.10). Le
// plan se mesure entier, les N participants et les R tours ; les
// réservations ne retirent rien de la mesure, elles séparent seulement, dans
// les retours et les rencontres répétées, ce qu'elles imposent de ce que le
// moteur choisit. Tout tableau par personne suit l'ordre de instance.ids.
//
// Une rencontre est une paire de personnes assises à la même table au même
// tour : la réserve n'est pas une table, une table vide ou à un seul occupant
// ne forme aucune paire, et personne ne se rencontre soi-même. Deux personnes
// 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.
/**
* @typedef {Object} Agregat
* @property {number|null} min null pour une population vide
* @property {number|null} moyenne null pour une population vide
* @property {number} effectif
*
* @typedef {Object} TroisAgregats
* @property {Agregat} tous
* @property {Agregat} mobiles mobiles et partiellement fixés (§ 5.4)
* @property {Agregat} ancres
*
* @typedef {Object} Mesures
* @property {number[]} rencontres |met(p)|, ordre canonique
* @property {number[]} affilies |F(p)|
* @property {number[]} appartenancesVues A(p) — appartenances des affiliés
* rencontrés, la sienne comprise quand
* un collègue est rencontré
* @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 {TroisAgregats} aggRencontres
* @property {TroisAgregats} aggRedondance
* @property {number} collisionsCumulees Σ paires de même groupe × tours ensemble
* @property {number} pairesDistinctes paires de même groupe réunies ≥ 1 fois
* @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à
* @property {number} maxRencontresPaire sur toutes les paires ; 0 si aucune
* @property {number} totalRetoursChoisis
* @property {number} totalRetoursImposes
* @property {Array<{groupe: string, effectif: number, collisionsCumulees: number,
* pairesDistinctes: number, excedent: number}>} parGroupe
* ordre de instance.groupes
*/
// Case d'un plan indexé : la réserve.
const RESERVE = -1;
// instance.groupe d'un participant sans appartenance.
const SANS_GROUPE = -1;
// instance.statut d'un ancré ; 0 marque un mobile, 1 un partiellement fixé.
const ANCRE = 2;
// Plus grand compte que tient un Uint16Array.
const UINT16_MAX = 0xffff;
// Valeur citée dans un message : une chaîne s'écrit entre guillemets, pour
// ne pas se lire comme le nombre qu'elle contient.
const decrire = (valeur) => (typeof valeur === 'string' ? JSON.stringify(valeur) : String(valeur));
// Lève RangeError quand tableDe n'a pas N × R cases, ou qu'une case n'est ni
// −1 ni un index de table entier ; le message nomme alors le participant et
// le tour. Sans cette garde, deux écarts passeraient sans bruit : une case de
// trop serait ignorée ; une chaîne numérique comme "0" assiérait la personne
// à la table qu'elle désigne, mais compterRetours, qui la compare strictement
// à instance.fixe, n'y reconnaîtrait pas sa réservation. Une case manquante
// ou toute autre valeur lèverait dans compterRencontres une TypeError qui ne
// nomme ni la personne ni le tour.
function exigerForme({ N, T, R, ids }, tableDe) {
if (tableDe.length !== N * R) {
throw new RangeError(`plan indexé de ${tableDe.length} cases, N × R = ${N * R} attendues`);
}
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 && !(Number.isInteger(t) && t >= 0 && t < T)) {
throw new RangeError(
`plan indexé : participant ${ids[p]}, tour ${r + 1}, `
+ `index de table ${decrire(t)} hors de −1..${T - 1}`,
);
}
}
}
}
// Rang de la paire (a, b), a < b, dans le triangle supérieur d'une matrice
// N × N rangé ligne par ligne : la ligne a s'ouvre après les
// (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);
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);
}
for (let t = 0; t < T; t += 1) {
const liste = assis[t];
for (let i = 0; i < liste.length; i += 1) {
const a = liste[i];
const aReserve = fixe[a * R + r] === t;
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;
}
}
}
}
return { compte, choisie };
}
// 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 }) {
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 maxRencontresPaire = 0;
// p rencontre q : q compte dans met(p) ; un affilié compte aussi dans F(p),
// et son groupe compte dans A(p) au premier membre de ce groupe que p
// rencontre.
const rencontrer = (p, q) => {
rencontres[p] += 1;
const g = groupe[q];
if (g === SANS_GROUPE) return;
affilies[p] += 1;
if (vu[p * G + g] === 0) {
vu[p * G + g] = 1;
vues[p] += 1;
}
};
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];
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;
}
}
}
return {
rencontres,
affilies,
vues,
collisions,
distinctes,
rencontresRepetees: { choisies, imposees },
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;
}
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 };
}
// Ajoute une valeur à une population. min reste null tant que la population
// est vide : aucun Infinity ne sert de minimum initial.
function ajouter(population, valeur) {
if (population.min === null || valeur < population.min) population.min = valeur;
population.somme += valeur;
population.effectif += 1;
}
// Agrégat d'une population ; la moyenne d'une population vide vaut null.
const conclure = ({ min, somme, effectif }) => ({
min,
moyenne: effectif === 0 ? null : somme / effectif,
effectif,
});
// Minimum, moyenne et effectif de valeurs[p] sur les trois populations, en un
// passage : chacun compte dans « tous », puis parmi les ancrés ou parmi les
// mobiles, qui comprennent les partiellement fixés (§ 5.4).
function troisAgregats(valeurs, statut) {
const tous = { min: null, somme: 0, effectif: 0 };
const mobiles = { min: null, somme: 0, effectif: 0 };
const ancres = { min: null, somme: 0, effectif: 0 };
for (let p = 0; p < valeurs.length; p += 1) {
ajouter(tous, valeurs[p]);
ajouter(statut[p] === ANCRE ? ancres : mobiles, valeurs[p]);
}
return { tous: conclure(tous), mobiles: conclure(mobiles), ancres: conclure(ancres) };
}
const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
/**
* Mesure complète d'un plan indexé : la mesure qui fait foi (§ 5.10). Ne
* modifie ni l'instance ni le plan. Lève RangeError quand tableDe n'a pas
* N × R cases, ou qu'une case n'est ni −1 ni un index de table.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe tableDe[p * R + r] = index de table, −1 pour la réserve
* @returns {Mesures}
*/
export function mesurer(instance, tableDe) {
exigerForme(instance, tableDe);
const { N, groupe, groupes, statut } = instance;
const paires = lirePaires(instance, compterRencontres(instance, tableDe));
const { collisions, distinctes } = paires;
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 effectifs = new Array(groupes.length).fill(0);
for (let p = 0; p < N; p += 1) {
if (groupe[p] !== SANS_GROUPE) effectifs[groupe[p]] += 1;
}
const parGroupe = groupes.map((libelle, g) => ({
groupe: libelle,
effectif: effectifs[g],
collisionsCumulees: collisions[g],
pairesDistinctes: distinctes[g],
excedent: collisions[g] - distinctes[g],
}));
const collisionsCumulees = somme(collisions);
const pairesDistinctes = somme(distinctes);
const { toursAssis, retoursChoisis, retoursImposes } = compterRetours(instance, tableDe);
return {
rencontres,
affilies,
appartenancesVues,
redondance,
diversite,
toursAssis,
retoursChoisis,
retoursImposes,
aggRencontres: troisAgregats(rencontres, statut),
aggRedondance: troisAgregats(redondance, statut),
collisionsCumulees,
pairesDistinctes,
excedentCollisions: collisionsCumulees - pairesDistinctes,
rencontresRepetees: paires.rencontresRepetees,
maxRencontresPaire: paires.maxRencontresPaire,
totalRetoursChoisis: somme(retoursChoisis),
totalRetoursImposes: somme(retoursImposes),
parGroupe,
};
}