gestion_table_tournante_libre/src/moteur/conflits.long.test.js

228 lines
9.4 KiB
JavaScript
Raw Normal View History

// © 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));
});
});