324 lines
13 KiB
JavaScript
324 lines
13 KiB
JavaScript
|
|
// © 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,
|
|||
|
|
};
|
|||
|
|
}
|