gestion_table_tournante_libre/src/moteur/indicateurs.js
Mathieu Benoit 6c67cba949 [IMP] engine: single owners, regeneration, property grid
The search recomputed the collision excess and the gaps that the measure
already gives — two arithmetics for one quantity. It now reads them from
the measure, and the redundancy total has one owner. Contract values, the
indexed-plan guard and the population split are defined once. regenerer
rebuilds a proposition from its derived seed, stop count and history
length, which the spec now lists (§ 8.9). A property grid runs seeded
random rooms through the whole chain.

Checked: 435 node and 20 long tests; redefining the excess or the gaps in
the measure now fails the search tests too.

--- FR ---

[IMP] moteur : propriétaires uniques, régénération, grille de propriétés

La recherche recalculait l'excédent de collisions et les écarts que la
mesure donne déjà — deux arithmétiques pour une grandeur. Elle les lit
désormais dans la mesure, et le total de redondance a un seul
propriétaire. Valeurs du contrat, garde du plan indexé et partition des
populations sont définies une fois. regenerer reconstruit une proposition
depuis sa graine dérivée, son arrêt et la longueur de son historique,
que le spec énumère désormais (§ 8.9). Une grille de propriétés fait
traverser toute la chaîne à des salles tirées d'une graine.

Vérifié : 435 épreuves node et 20 longues ; redéfinir l'excédent ou les
écarts dans la mesure fait désormais tomber aussi la recherche.

Assisted-by: Claude Opus 5.5
2026-10-06 04:23:02 -04:00

296 lines
12 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.
import {
RESERVE,
SANS_GROUPE,
exigerPlanIndexe,
replierParPopulation,
} from './configuration.js';
/**
* @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} totalRedondance Σ_p r(p), le seul total de la
* redondance : la recherche et le
* classement le lisent
* @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
*/
// Plus grand compte que tient un Uint16Array.
const UINT16_MAX = 0xffff;
// 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 };
}
// Population sans membre. min reste null tant qu'aucune valeur n'y entre :
// 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,
});
// 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 du
// § 5.4, que replierParPopulation forme.
function troisAgregats(valeurs, statut) {
const { tous, mobiles, ancres } = replierParPopulation(valeurs, statut, VIDE, ajouter);
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 ce que lève exigerPlanIndexe
* (configuration.js) avant de lire une case : une case manquante ou d'une
* autre forme lèverait plus loin une erreur qui ne nommerait ni la personne
* ni le tour.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe tableDe[p * R + r] = index de table, RESERVE pour la réserve
* @returns {Mesures}
*/
export function mesurer(instance, tableDe) {
exigerPlanIndexe(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),
totalRedondance: somme(redondance),
collisionsCumulees,
pairesDistinctes,
excedentCollisions: collisionsCumulees - pairesDistinctes,
rencontresRepetees: paires.rencontresRepetees,
maxRencontresPaire: paires.maxRencontresPaire,
totalRetoursChoisis: somme(retoursChoisis),
totalRetoursImposes: somme(retoursImposes),
parGroupe,
};
}