228 lines
9.4 KiB
JavaScript
228 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));
|
||
|
|
});
|
||
|
|
});
|