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
323 lines
13 KiB
JavaScript
323 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,
|
||
};
|
||
}
|