The plan view must say at which table and round a conflict sits, and the quality page must aggregate diversity. indicateurs.js now exports the single pass that reads a plan round by round and table by table, from which mesurer draws every figure; conflits.js gives its detail per table, so the totals and the table view share one arithmetic (§ 13.2). The diagnosis gains its repartition, remedies, single-affiliation flag and the anchors that force a return. Checked: 1582 node and 60 node-long tests; indicateurs.js at 100 %. --- FR --- [ADD] moteur : conflits par table, diversité agrégée, diagnostic enrichi La vue du plan doit dire à quelle table et à quel tour se trouve un conflit, et la page de qualité agréger la diversité. indicateurs.js exporte le seul passage qui lit un plan tour par tour et table par table, d'où mesurer tire chaque chiffre ; conflits.js en rend le détail par table : les totaux et la vue par table partagent une arithmétique (§ 13.2). Le diagnostic gagne sa répartition, ses remèdes, le drapeau d'appartenance unique et les ancrages qui imposent un retour. Vérifié : 1582 node et 60 node-long ; indicateurs.js à 100 %. Assisted-by: Claude Opus 5.5
227 lines
9.4 KiB
JavaScript
227 lines
9.4 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|
|
|
// Épreuves lourdes des conflits par table (§ 7.5, § 13.2) : les violations
|
|
// par table, sommées sur un plan entier, rendent les chiffres de la mesure.
|
|
// Σ des paires séparer = collisionsCumulees ; Σ des personnes nouvelle table
|
|
// = totalRetoursChoisis ; paires distinctes nouveaux voisins =
|
|
// rencontresRepetees.choisies. D'abord les propositions d'une génération sur
|
|
// la grande démonstration, puis cent plans tirés sur de petites
|
|
// configurations, chacun de sa propre graine.
|
|
import assert from 'node:assert/strict';
|
|
import { describe, test } from '../../test/lanceur.js';
|
|
import { CATALOGUE } from '../demo/catalogue.js';
|
|
import { FLUX, creerPcg32 } from '../demo/prng.js';
|
|
import { conflitsParTable } from './conflits.js';
|
|
import { LIBRE, indexerPlan, normaliser, planDepuisIndex } from './configuration.js';
|
|
import { mesurer } from './indicateurs.js';
|
|
import { rechercher } from './recherche.js';
|
|
import { verifierInvariants } from './verification.js';
|
|
|
|
// Une génération courte : la qualité des propositions n'importe pas ici, et
|
|
// des propositions qui portent encore des conflits éprouvent mieux l'accord.
|
|
const GENERATION = Object.freeze({ graine: 7, arret: 20_000, nombre: 2 });
|
|
|
|
// Les cent plans tirés : la graine du plan i est GRAINE + i.
|
|
const PLANS = 100;
|
|
const GRAINE = 2_718;
|
|
const PLANS_ACCEPTES_MIN = 90;
|
|
|
|
// Planchers des comptes de la boucle, posés au-dessous de ce que donnent les
|
|
// graines écrites ci-dessus : 100 plans acceptés ; 69 dont les trois totaux
|
|
// sont non nuls ; 32 où une paire réunie librement se retrouve à une table où
|
|
// les deux sont réservés ; 41 où une personne assise librement à une table y
|
|
// revient réservée.
|
|
const TOUS_NON_NULS_MIN = 55;
|
|
const PAIRE_PUIS_RESERVEE_MIN = 25;
|
|
const PERSONNE_PUIS_RESERVEE_MIN = 32;
|
|
|
|
const LIBELLES = Object.freeze(['Grisaille', 'Club des Merles', 'Ondée']);
|
|
|
|
// Les trois sommes d'un plan, lues dans conflitsParTable : paires séparer,
|
|
// personnes nouvelle table, et paires distinctes nouveaux voisins, clé
|
|
// « a,b » en identifiants.
|
|
function sommer(conflits) {
|
|
let separer = 0;
|
|
let nouvelleTable = 0;
|
|
const revues = new Set();
|
|
for (const tables of conflits) {
|
|
for (const { regles } of tables) {
|
|
for (const { regle, paires, personnes } of regles) {
|
|
if (regle === 'separerAppartenances') separer += paires.length;
|
|
else if (regle === 'nouvelleTable') nouvelleTable += personnes.length;
|
|
else for (const [a, b] of paires) revues.add(`${a},${b}`);
|
|
}
|
|
}
|
|
}
|
|
return { separer, nouvelleTable, nouveauxVoisins: revues.size };
|
|
}
|
|
|
|
// Les écarts entre les sommes par table et la mesure du même plan.
|
|
function ecarts(nom, instance, tableDe) {
|
|
const sommes = sommer(conflitsParTable(instance, tableDe));
|
|
const mesures = mesurer(instance, tableDe);
|
|
const attendu = {
|
|
separer: mesures.collisionsCumulees,
|
|
nouvelleTable: mesures.totalRetoursChoisis,
|
|
nouveauxVoisins: mesures.rencontresRepetees.choisies,
|
|
};
|
|
const fautes = [];
|
|
for (const cle of ['separer', 'nouvelleTable', 'nouveauxVoisins']) {
|
|
if (sommes[cle] !== attendu[cle]) {
|
|
fautes.push(`${nom} : ${cle} ${sommes[cle]} par table, ${attendu[cle]} mesurés`);
|
|
}
|
|
}
|
|
return { fautes, sommes };
|
|
}
|
|
|
|
// Une petite configuration tirée de rng : de 2 à 6 tables de 2 à 5 sièges,
|
|
// de 1 à 5 tours, de la moitié des sièges à tous occupés ; une appartenance
|
|
// parmi trois, ou aucune, pour chacun. Un participant sur cinq est ancré, un
|
|
// sur trois réservé à une partie des tours quand R ≥ 2, chacun à une table
|
|
// où il reste un siège à chacun de ses tours : normaliser l'accepte toujours.
|
|
// Séparer, nouveaux voisins et nouvelle table sont actifs.
|
|
function tirerConfiguration(rng) {
|
|
const T = 2 + rng.borne(5);
|
|
const capacites = Array.from({ length: T }, () => 2 + rng.borne(4));
|
|
const R = 1 + rng.borne(5);
|
|
const S = capacites.reduce((somme, c) => somme + c, 0);
|
|
const N = S - rng.borne(Math.floor(S / 2) + 1);
|
|
const fixees = new Int32Array(T * R);
|
|
const siegeLibre = (t, r) => fixees[t * R + r] < capacites[t];
|
|
const participants = [];
|
|
const reservations = [];
|
|
for (let id = 1; id <= N; id += 1) {
|
|
const g = rng.borne(LIBELLES.length + 1);
|
|
participants.push({ id, nom: `P${id}`, appartenance: g < LIBELLES.length ? LIBELLES[g] : null });
|
|
const role = rng.borne(15);
|
|
if (role < 3) {
|
|
const t = rng.borne(T);
|
|
if (Array.from({ length: R }, (_, r) => r).every((r) => siegeLibre(t, r))) {
|
|
for (let r = 0; r < R; r += 1) fixees[t * R + r] += 1;
|
|
reservations.push({ participant: id, table: t + 1, portee: 'tous' });
|
|
}
|
|
} else if (role < 8 && R >= 2) {
|
|
for (let r = 0; r < R; r += 1) {
|
|
if (rng.borne(2) === 0) continue;
|
|
const t = rng.borne(T);
|
|
if (!siegeLibre(t, r)) continue;
|
|
fixees[t * R + r] += 1;
|
|
reservations.push({ participant: id, table: t + 1, portee: 'tour', tour: r + 1 });
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
participants,
|
|
tables: capacites.map((capacite, t) => ({ id: t + 1, numero: t + 1, capacite })),
|
|
tours: R,
|
|
reservations,
|
|
contraintes: {
|
|
separerAppartenances: true,
|
|
nouveauxVoisins: true,
|
|
nouvelleTable: true,
|
|
varierAppartenances: rng.borne(2) === 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Un plan qui assied chacun à chaque tour : les réservés à leur table, puis
|
|
// chaque autre, par index croissants, à une table tirée parmi celles qui lui
|
|
// laissent un siège.
|
|
function tirerPlan(rng, instance) {
|
|
const { N, T, R, capacite, fixe } = instance;
|
|
const tableDe = new Int32Array(N * R);
|
|
for (let r = 0; r < R; r += 1) {
|
|
const occupation = new Int32Array(T);
|
|
for (let p = 0; p < N; p += 1) {
|
|
const t = fixe[p * R + r];
|
|
if (t === LIBRE) continue;
|
|
tableDe[p * R + r] = t;
|
|
occupation[t] += 1;
|
|
}
|
|
for (let p = 0; p < N; p += 1) {
|
|
if (fixe[p * R + r] !== LIBRE) continue;
|
|
const ouvertes = [];
|
|
for (let t = 0; t < T; t += 1) if (occupation[t] < capacite[t]) ouvertes.push(t);
|
|
const t = ouvertes[rng.borne(ouvertes.length)];
|
|
tableDe[p * R + r] = t;
|
|
occupation[t] += 1;
|
|
}
|
|
}
|
|
return tableDe;
|
|
}
|
|
|
|
// Vrai quand une paire réunie à une table où les deux ne sont pas réservés
|
|
// se retrouve, à un tour suivant, à une table où les deux le sont.
|
|
function pairePuisReservee({ N, R, fixe }, tableDe) {
|
|
for (let a = 0; a < N; a += 1) {
|
|
for (let b = a + 1; b < N; b += 1) {
|
|
let libre = false;
|
|
for (let r = 0; r < R; r += 1) {
|
|
const t = tableDe[a * R + r];
|
|
if (t !== tableDe[b * R + r]) continue;
|
|
const reservee = fixe[a * R + r] === t && fixe[b * R + r] === t;
|
|
if (reservee && libre) return true;
|
|
if (!reservee) libre = true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Vrai quand une personne assise sans réservation à une table y revient, à
|
|
// un tour suivant, réservée.
|
|
function personnePuisReservee({ N, T, R, fixe }, tableDe) {
|
|
for (let p = 0; p < N; p += 1) {
|
|
const libreA = new Uint8Array(T);
|
|
for (let r = 0; r < R; r += 1) {
|
|
const t = tableDe[p * R + r];
|
|
if (fixe[p * R + r] !== t) libreA[t] = 1;
|
|
else if (libreA[t] === 1) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
describe('conflits par table : leurs sommes rendent la mesure (§ 7.5, § 13.2)', () => {
|
|
test('grande démonstration, deux propositions de 20 000 mouvements', () => {
|
|
const configuration = CATALOGUE.find(({ cle }) => cle === 'grande').construire();
|
|
const instance = normaliser(configuration);
|
|
const propositions = rechercher(configuration, GENERATION);
|
|
assert.equal(propositions.length, GENERATION.nombre);
|
|
const fautes = [];
|
|
for (const { id, plan } of propositions) {
|
|
assert.deepEqual(verifierInvariants(instance, plan), [], `proposition ${id}`);
|
|
fautes.push(...ecarts(`proposition ${id}`, instance, indexerPlan(instance, plan)).fautes);
|
|
}
|
|
assert.deepEqual(fautes, []);
|
|
});
|
|
|
|
test('cent plans tirés sur de petites configurations, ancrés et partiellement fixés', () => {
|
|
const fautes = [];
|
|
const comptes = { acceptes: 0, tousNonNuls: 0, pairePuisReservee: 0, personnePuisReservee: 0 };
|
|
for (let i = 1; i <= PLANS; i += 1) {
|
|
const rng = creerPcg32(GRAINE + i, FLUX.DEMO);
|
|
const instance = normaliser(tirerConfiguration(rng));
|
|
const tableDe = tirerPlan(rng, instance);
|
|
const violations = verifierInvariants(instance, planDepuisIndex(instance, tableDe));
|
|
if (violations.length > 0) {
|
|
fautes.push(`graine ${GRAINE + i} : plan refusé, ${JSON.stringify(violations)}`);
|
|
continue;
|
|
}
|
|
comptes.acceptes += 1;
|
|
const { fautes: fautesPlan, sommes } = ecarts(`graine ${GRAINE + i}`, instance, tableDe);
|
|
fautes.push(...fautesPlan);
|
|
if (sommes.separer > 0 && sommes.nouvelleTable > 0 && sommes.nouveauxVoisins > 0) {
|
|
comptes.tousNonNuls += 1;
|
|
}
|
|
if (pairePuisReservee(instance, tableDe)) comptes.pairePuisReservee += 1;
|
|
if (personnePuisReservee(instance, tableDe)) comptes.personnePuisReservee += 1;
|
|
}
|
|
assert.deepEqual(fautes, []);
|
|
assert.ok(comptes.acceptes >= PLANS_ACCEPTES_MIN, JSON.stringify(comptes));
|
|
assert.ok(comptes.tousNonNuls >= TOUS_NON_NULS_MIN, JSON.stringify(comptes));
|
|
assert.ok(comptes.pairePuisReservee >= PAIRE_PUIS_RESERVEE_MIN, JSON.stringify(comptes));
|
|
assert.ok(comptes.personnePuisReservee >= PERSONNE_PUIS_RESERVEE_MIN, JSON.stringify(comptes));
|
|
});
|
|
});
|