gestion_table_tournante_libre/src/moteur/recherche.test.js

762 lines
32 KiB
JavaScript
Raw Normal View History

[ADD] search: seeded late acceptance over a lexicographic score A proposition is the best plan a late-acceptance descent finds from its derived seed. It stops on a count of moves, never on a duration, so one seed gives one plan on any machine. The score is a six-integer tuple compared lexicographically: largest gap to the a priori ceiling, sum of squared gaps, collision excess, cumulative collisions, repeats with chosen returns, redundancy. An incremental state scores a swap in O(capacity); the full recomputation, from the measure, checks it. Checked: the small demonstration reaches 8 met for all twelve with no special case; incremental and full scores agree up to 10 000 moves. --- FR --- [ADD] recherche : acceptation tardive ensemencée, score lexicographique Une proposition est le meilleur plan que trouve une descente par acceptation tardive depuis sa graine dérivée. Elle s'arrête sur un compte de mouvements, jamais sur une durée : une graine donne un plan sur toute machine. Le score est un n-uplet de six entiers comparé dans l'ordre lexicographique : plus grand écart au plafond a priori, somme des carrés des écarts, excédent de collisions, collisions cumulées, répétitions et retours choisis, redondance. Un état incrémental évalue un échange en O(capacité) ; le recalcul complet, tiré de la mesure, le vérifie. Vérifié : la petite démonstration atteint 8 rencontres pour les douze sans cas particulier ; scores incrémental et complet s'accordent jusqu'à 10 000 mouvements. Assisted-by: Claude Opus 5.5
2026-10-05 22:52:29 -04:00
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la recherche (§ 5.2, § 5.7, § 5.9, § 5.10, § 14.10) : à graine
// et entrée égales, propositions identiques ; l'identifiant d'une proposition
// est son rang dans la suite des graines dérivées, et son plan sort de sa
// seule graine dérivée ; chaque plan rendu tient les invariants et les
// réservations, et l'appartenance d'un ancré y pèse comme une autre ; une
// salle trop petite est refusée avec le nombre de places manquantes ;
// l'avancement se rapporte toutes les 1 000 itérations, une descente
// s'arrête sur un score nul, une annulation ne rend rien ; le placement
// initial égale celui que décrit le paragraphe « Sièges » de l'en-tête de
// recherche.js, récrit ici à la lettre ; la descente égale l'acceptation
// tardive de son paragraphe « Acceptation tardive », récrite de même, là où
// ni bascule ni relance ne jouent, et sinon augmentée de la bascule et de la
// relance de son paragraphe « Deux ordres d'acceptation » ; le score tenu à jour et le score recalculé égalent un n-uplet
// recalculé ici par énumération des paires ; la petite démonstration atteint
// son plan parfait sans cas particulier. Un plan rendu se juge par
// verifierInvariants et se mesure par mesurer, deux modules distincts du
// module éprouvé.
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 { indexerPlan, normaliser, planDepuisIndex } from './configuration.js';
import { ErreurAnnulee } from './erreurs.js';
import { mesurer } from './indicateurs.js';
import { plafondsAPriori } from './plafond.js';
import {
creerEtat,
defaire,
proposerEtAppliquer,
rechercher,
scoreComplet,
scoreIncremental,
} from './recherche.js';
import { verifierInvariants } from './verification.js';
const SANS_CONTRAINTE = {
separerAppartenances: false,
nouveauxVoisins: false,
nouvelleTable: false,
varierAppartenances: false,
};
const TOUTES_CONTRAINTES = {
separerAppartenances: true,
nouveauxVoisins: true,
nouvelleTable: true,
varierAppartenances: true,
};
const NOMS_CONTRAINTES = [
'separerAppartenances',
'nouveauxVoisins',
'nouvelleTable',
'varierAppartenances',
];
// Longueur d'historique que rechercher prend quand les réglages n'en donnent
// pas (plan, ReglagesGeneration).
const HISTORIQUE_PAR_DEFAUT = 1_000;
// Configuration neuve d'une démonstration du catalogue.
const demo = (cle) => CATALOGUE.find((entree) => entree.cle === cle).construire();
const tous = (participant, table) => ({ participant, table, portee: 'tous' });
const auTour = (participant, table, tour) => ({ participant, table, portee: 'tour', tour });
// Configuration dont le participant d'id i + 1 porte appartenances[i] ; les
// tables reçoivent leurs numéros dans l'ordre de la liste ; un id cité dans
// exclus est exclu.
function configurationDe({
appartenances,
tables,
tours,
reservations = [],
contraintes = SANS_CONTRAINTE,
exclus = [],
}) {
return {
participants: appartenances.map((appartenance, i) => ({
id: i + 1,
nom: `P${i + 1}`,
appartenance,
exclu: exclus.includes(i + 1),
})),
tables: tables.map(({ id, capacite }, i) => ({ id, numero: i + 1, capacite })),
tours,
reservations,
contraintes: { ...contraintes },
};
}
// Trois tables, d'ids 10, 20 et 30 et de 4, 3 et 3 sièges, pour neuf
// participants sur trois tours : une place reste vide à chaque tour. Le
// participant 1 est ancré à la table 20 ; le 2 n'est réservé qu'au tour 2, à
// la table 10 ; le 3 l'est aux tours 1 et 3, à la table 30 ; le 4 au tour 1
// à la table 10 et au tour 3 à la table 20. Huit couples (réservation, tour).
const salleReservee = () =>
configurationDe({
appartenances: ['A', 'B', 'A', null, 'B', 'A', null, 'B', null],
tables: [
{ id: 10, capacite: 4 },
{ id: 20, capacite: 3 },
{ id: 30, capacite: 3 },
],
tours: 3,
reservations: [
tous(1, 20),
auTour(2, 10, 2),
auTour(3, 30, 1),
auTour(3, 30, 3),
auTour(4, 10, 1),
auTour(4, 20, 3),
],
contraintes: TOUTES_CONTRAINTES,
});
// Réservations qu'un plan n'honore pas, chacune « participant@table, tour
// n », et le nombre de couples (réservation, tour) examinés.
function reservationsRompues(configuration, plan) {
const rompues = [];
let examines = 0;
for (const { participant, table, portee, tour } of configuration.reservations) {
const numeros = portee === 'tous' ? plan.tours.map((_, r) => r + 1) : [tour];
for (const numero of numeros) {
examines += 1;
const liste = plan.tours[numero - 1][plan.tables.indexOf(table)];
if (!liste.includes(participant)) rompues.push(`${participant}@${table}, tour ${numero}`);
}
}
return { rompues, examines };
}
// Six participants sans appartenance, trois tables de 2, un tour, aucune
// contrainte : chacun rencontre son seul voisin, qui est son plafond a
// priori, quel que soit le placement. Le score est nul dès le placement
// initial, et chaque descente rend ce placement.
const troisTablesDeDeux = () =>
configurationDe({
appartenances: Array(6).fill(null),
tables: [
{ id: 1, capacite: 2 },
{ id: 2, capacite: 2 },
{ id: 3, capacite: 2 },
],
tours: 1,
});
// Placement initial du paragraphe « Sièges » de recherche.js, récrit à la
// lettre : tour après
// tour, les fixés à leur table ; les autres, par index croissant, mélangés
// par rng.melanger, puis assis table après table dans l'ordre des tables,
// aux places que les fixés laissent libres. Rend le plan indexé.
function placementLitteral(instance, rng) {
const { N, R, capacite, fixe } = instance;
const tableDe = new Int32Array(N * R);
for (let r = 0; r < R; r += 1) {
const restantes = Array.from(capacite);
const libres = [];
for (let p = 0; p < N; p += 1) {
const t = fixe[p * R + r];
if (t === -1) {
libres.push(p);
} else {
tableDe[p * R + r] = t;
restantes[t] -= 1;
}
}
let t = 0;
for (const p of rng.melanger(libres)) {
while (restantes[t] === 0) t += 1;
tableDe[p * R + r] = t;
restantes[t] -= 1;
}
}
return tableDe;
}
// Le n-uplet du paragraphe « Score » de recherche.js, recalculé par
// énumération directe des
// paires du plan indexé, sans mesurer ni le module éprouvé ; les plafonds a
// priori sont la seule grandeur reçue. Rend aussi les sept grandeurs brutes,
// contraintes ignorées : écart maximal au plafond a priori, somme des carrés
// des écarts, excédent de collisions, collisions cumulées, Σ_paires
// max(0, M − 1), retours choisis, Σ_p r(p).
function scoreOracle(instance, tableDe, aPriori) {
const { N, T, R, groupe, fixe, contraintes } = instance;
// M[a × N + b], a < b : le nombre de tours où a et b partagent une table.
const M = new Int32Array(N * N);
for (let r = 0; r < R; r += 1) {
for (let a = 0; a < N; a += 1) {
for (let b = a + 1; b < N; b += 1) {
const t = tableDe[a * R + r];
if (t !== -1 && t === tableDe[b * R + r]) M[a * N + b] += 1;
}
}
}
const rencontres = Array(N).fill(0);
const affilies = Array(N).fill(0);
const appartenances = Array.from({ length: N }, () => new Set());
let cumulees = 0;
let distinctes = 0;
let excedentPaires = 0;
for (let a = 0; a < N; a += 1) {
for (let b = a + 1; b < N; b += 1) {
const m = M[a * N + b];
if (m === 0) continue;
excedentPaires += m - 1;
for (const [p, q] of [[a, b], [b, a]]) {
rencontres[p] += 1;
if (groupe[q] !== -1) {
affilies[p] += 1;
appartenances[p].add(groupe[q]);
}
}
if (groupe[a] !== -1 && groupe[a] === groupe[b]) {
cumulees += m;
distinctes += 1;
}
}
}
// Une table visitée m fois, dont f à un tour où la personne y est
// réservée, compte m − 1 − max(0, f − 1) retours choisis (§ 5.4).
let retours = 0;
for (let p = 0; p < N; p += 1) {
for (let t = 0; t < T; t += 1) {
let visites = 0;
let reservees = 0;
for (let r = 0; r < R; r += 1) {
if (tableDe[p * R + r] !== t) continue;
visites += 1;
if (fixe[p * R + r] === t) reservees += 1;
}
if (visites > 0) retours += visites - 1 - Math.max(0, reservees - 1);
}
}
const ecarts = aPriori.map((plafond, p) => plafond - rencontres[p]);
const brutes = [
Math.max(...ecarts),
ecarts.reduce((somme, ecart) => somme + ecart * ecart, 0),
cumulees - distinctes,
cumulees,
excedentPaires,
retours,
affilies.reduce((somme, f, p) => somme + f - appartenances[p].size, 0),
];
const c = contraintes;
const nUplet = [
brutes[0],
brutes[1],
c.separerAppartenances ? brutes[2] : 0,
c.separerAppartenances ? brutes[3] : 0,
(c.nouveauxVoisins ? brutes[4] : 0) + (c.nouvelleTable ? brutes[5] : 0),
c.varierAppartenances ? brutes[6] : 0,
];
return { nUplet, brutes };
}
// Ordres de comparaison du n-uplet, par rang de composante : celui du score,
// et celui des contraintes, composantes 2, 3 et 4 devant les deux premières
// (en-tête de recherche.js).
const ORDRE_SCORE = [0, 1, 2, 3, 4, 5];
const ORDRE_CONTRAINTES = [2, 3, 4, 0, 1, 5];
// Longueurs d'historique sans changement du score courant au-delà desquelles
// une descente se relance (en-tête de recherche.js).
const PATIENCE = 10;
// Signe de la comparaison lexicographique de deux n-uplets, composantes
// prises dans l'ordre donné.
function comparerDans(ordre, a, b) {
for (const k of ordre) if (a[k] !== b[k]) return a[k] - b[k];
return 0;
}
// Une descente selon les paragraphes « Mouvement » à « Deux ordres
// d'acceptation » de recherche.js, écrite avec les primitives
// publiques : placement initial par creerEtat ; à l'itération i, un
// mouvement de proposerEtAppliquer, gardé si son score est ≤ courant ou
// ≤ H[i mod L] dans l'ordre d'acceptation, défait sinon ; puis H[i mod L]
// reçoit le score courant. La meilleure affectation change sur un score
// strictement meilleur dans l'ordre du score. Arrêt après arret mouvements
// évalués, ou sur le n-uplet nul.
//
// Sans bascule, l'ordre d'acceptation reste celui du score : c'est
// l'acceptation tardive seule, à la lettre (§ 5.10). Avec, il devient l'ordre des contraintes dès que la
// meilleure affectation porte un écart maximal nul ; dans cet ordre, un
// score courant inchangé pendant PATIENCE × L itérations fait repartir la
// descente d'un placement neuf, tiré du même générateur, l'historique à son
// score (en-tête de recherche.js). Rend le plan et trois comptes : bascules,
// relances, et meilleures affectations trouvées après une relance.
function descendreALaLettre(instance, graineDerivee, arret, L, { bascule }) {
const rng = creerPcg32(graineDerivee, FLUX.RECHERCHE);
let etat = creerEtat(instance, rng);
let courant = scoreIncremental(etat);
let meilleur = courant;
let meilleurPlan = Int32Array.from(etat.tableDe);
let H = Array(L).fill(courant);
let ordre = ORDRE_SCORE;
let immobile = 0;
const comptes = { bascules: 0, relances: 0, apresRelance: 0 };
const basculer = () => {
if (!bascule || ordre === ORDRE_CONTRAINTES || meilleur[0] !== 0) return;
ordre = ORDRE_CONTRAINTES;
comptes.bascules += 1;
};
const retenir = () => {
if (comparerDans(ORDRE_SCORE, courant, meilleur) >= 0) return;
meilleur = courant;
meilleurPlan = Int32Array.from(etat.tableDe);
if (comptes.relances > 0) comptes.apresRelance += 1;
basculer();
};
basculer();
for (let i = 0; i < arret && courant.some((composante) => composante !== 0); i += 1) {
if (immobile >= PATIENCE * L && ordre === ORDRE_CONTRAINTES) {
etat = creerEtat(instance, rng);
courant = scoreIncremental(etat);
H = Array(L).fill(courant);
comptes.relances += 1;
retenir();
immobile = 0;
}
const mouvement = proposerEtAppliquer(etat, rng);
let change = false;
if (mouvement !== null) {
const candidat = scoreIncremental(etat);
if (comparerDans(ordre, candidat, courant) <= 0 || comparerDans(ordre, candidat, H[i % L]) <= 0) {
change = comparerDans(ORDRE_SCORE, candidat, courant) !== 0;
courant = candidat;
retenir();
} else {
defaire(etat, mouvement);
}
}
H[i % L] = courant;
immobile = change ? 0 : immobile + 1;
}
return { plan: planDepuisIndex(instance, meilleurPlan), ...comptes };
}
// Les descentes à la lettre pour ces réglages, historique compris : la
// i-ème part de la i-ème graine que FLUX.GRAINES tire de graine (paragraphe
// « Graines » de recherche.js, § 5.7).
function descentesALaLettre(configuration, { graine, arret, nombre, historique }, options) {
const instance = normaliser(configuration);
const graines = creerPcg32(graine, FLUX.GRAINES);
return Array.from({ length: nombre }, () =>
descendreALaLettre(instance, graines.suivant(), arret, historique, options));
}
// Les plans de l'acceptation tardive seule, à la lettre : sans bascule ni
// relance.
const plansLitteraux = (configuration, reglages) =>
descentesALaLettre(configuration, reglages, { bascule: false }).map(({ plan }) => plan);
describe('rechercher : reproductibilité et identifiants (§ 5.7, § 19.4)', () => {
test('mêmes configuration et réglages : propositions identiques ; une autre graine change au moins un plan', () => {
const reglages = { graine: 11, arret: 2_000, nombre: 2 };
const premieres = rechercher(demo('grande'), reglages);
assert.equal(premieres.length, 2);
assert.deepStrictEqual(rechercher(demo('grande'), reglages), premieres);
const autres = rechercher(demo('grande'), { ...reglages, graine: 12 });
assert.notDeepStrictEqual(
autres.map((proposition) => proposition.plan),
premieres.map((proposition) => proposition.plan),
);
});
test("l'identifiant est le rang dans la suite des graines dérivées ; la proposition 2 de 3 égale celle de 5", () => {
const trois = rechercher(demo('petite'), { graine: 7, arret: 3_000, nombre: 3 });
const cinq = rechercher(demo('petite'), { graine: 7, arret: 3_000, nombre: 5 });
const graines = creerPcg32(7, FLUX.GRAINES);
const derivees = Array.from({ length: 5 }, () => graines.suivant());
assert.deepEqual(cinq.map((proposition) => proposition.id), [1, 2, 3, 4, 5]);
assert.deepEqual(cinq.map((proposition) => proposition.graine), derivees);
assert.deepEqual(cinq.map((proposition) => proposition.arret), [3_000, 3_000, 3_000, 3_000, 3_000]);
assert.deepStrictEqual(trois[1], cinq[1]);
assert.deepStrictEqual(trois, cinq.slice(0, 3));
});
test("chaque proposition porte la longueur d'historique qui l'a produite (§ 5.7, § 8.9)", () => {
// Graine, arret et historique décident ensemble du plan : une
// proposition qui ne porterait pas le troisième ne se régénérerait que
// sous la valeur par défaut.
const parDefaut = rechercher(demo('petite'), { graine: 7, arret: 500, nombre: 2 });
assert.deepEqual(parDefaut.map((proposition) => proposition.historique), [
HISTORIQUE_PAR_DEFAUT,
HISTORIQUE_PAR_DEFAUT,
]);
const reglee = rechercher(demo('petite'), { graine: 7, arret: 500, nombre: 2, historique: 37 });
assert.deepEqual(reglee.map((proposition) => proposition.historique), [37, 37]);
});
test('chaque proposition est le placement que sa graine dérivée tire par le flux de la recherche', () => {
// Score nul dès le placement : chaque descente rend son placement
// initial, que creerEtat redonne à partir de la seule graine de la
// proposition. Les quatre placements diffèrent : un générateur partagé
// entre les propositions, ou tiré d'une autre graine ou d'un autre flux,
// en donnerait d'autres.
const configuration = troisTablesDeDeux();
const instance = normaliser(configuration);
const propositions = rechercher(configuration, { graine: 21, arret: 1_000, nombre: 4 });
const attendus = propositions.map(({ graine }) =>
planDepuisIndex(instance, creerEtat(instance, creerPcg32(graine, FLUX.RECHERCHE)).tableDe));
assert.deepStrictEqual(propositions.map(({ plan }) => plan), attendus);
assert.equal(new Set(attendus.map((plan) => JSON.stringify(plan))).size, 4);
});
test('rechercher ne modifie pas la configuration reçue', () => {
const configuration = salleReservee();
rechercher(configuration, { graine: 1, arret: 500, nombre: 1 });
assert.deepStrictEqual(configuration, salleReservee());
});
test('réglage hors de son domaine : RangeError qui le nomme', () => {
const cas = [
[{ graine: -1, arret: 10, nombre: 1 }, 'graine'],
[{ graine: 2 ** 32, arret: 10, nombre: 1 }, 'graine'],
[{ graine: 1.5, arret: 10, nombre: 1 }, 'graine'],
[{ graine: 1, arret: 0, nombre: 1 }, 'arret'],
[{ graine: 1, arret: 2.5, nombre: 1 }, 'arret'],
[{ graine: 1, arret: '10', nombre: 1 }, 'arret'],
[{ graine: 1, arret: 10, nombre: 0 }, 'nombre'],
[{ graine: 1, arret: 10, nombre: 1, historique: 0 }, 'historique'],
];
for (const [reglages, nom] of cas) {
assert.throws(
() => rechercher(demo('petite'), reglages),
(erreur) => erreur instanceof RangeError && erreur.message.startsWith(`${nom} :`),
JSON.stringify(reglages),
);
}
});
});
describe('rechercher : plans valides (§ 5.2, § 5.9, § 14.12)', () => {
test('chaque proposition des quatre démonstrations tient tous les invariants, réserve interdite', () => {
let examinees = 0;
for (const { cle, construire } of CATALOGUE) {
const configuration = construire();
const instance = normaliser(configuration);
for (const { id, plan } of rechercher(configuration, { graine: 5, arret: 2_000, nombre: 2 })) {
assert.deepEqual(verifierInvariants(instance, plan), [], `${cle}, proposition ${id}`);
examinees += 1;
}
}
assert.equal(examinees, 8);
});
test('ancrés et partiellement fixés sont à leur table à chaque tour fixé', () => {
let examines = 0;
for (const configuration of [salleReservee(), demo('grande')]) {
for (const { id, plan } of rechercher(configuration, { graine: 9, arret: 3_000, nombre: 2 })) {
const releve = reservationsRompues(configuration, plan);
assert.deepEqual(releve.rompues, [], `proposition ${id}`);
examines += releve.examines;
}
}
// Deux propositions de chacune : huit couples dans la salle, 33 ancrés
// sur 4 tours dans la grande démonstration.
assert.equal(examines, 2 * 8 + 2 * 33 * 4);
});
test("l'appartenance d'un ancré compte : son collègue mobile n'est jamais assis à sa table", () => {
// Trois tables de 3, trois tours. 1 et 2 portent l'appartenance X, 1 est
// ancré à la table 1. Chacun peut rencontrer six personnes sans croiser
// personne deux fois, 2 compris, sans jamais s'asseoir à la table 1 :
// trois des quatre classes parallèles du plan affine d'ordre 3, celle où
// 1 et 2 se retrouvent écartée. Éviter la table 1 coûte à 2 au moins un
// retour, que la séparation fait accepter. Une recherche qui ignorerait
// l'appartenance de l'ancré l'y assiérait pour épargner ce retour.
const configuration = configurationDe({
appartenances: ['X', 'X', null, null, null, null, null, null, null],
tables: [
{ id: 1, capacite: 3 },
{ id: 2, capacite: 3 },
{ id: 3, capacite: 3 },
],
tours: 3,
reservations: [tous(1, 1)],
contraintes: { ...SANS_CONTRAINTE, separerAppartenances: true, nouvelleTable: true },
});
const instance = normaliser(configuration);
const propositions = rechercher(configuration, { graine: 4, arret: 20_000, nombre: 3 });
assert.equal(propositions.length, 3);
for (const { id, plan } of propositions) {
const mesures = mesurer(instance, indexerPlan(instance, plan));
assert.equal(mesures.collisionsCumulees, 0, `proposition ${id}`);
assert.deepEqual(
plan.tours.map((listes) => listes[plan.tables.indexOf(1)].includes(2)),
[false, false, false],
`proposition ${id}`,
);
assert.ok(mesures.retoursChoisis[instance.indexDe.get(2)] >= 1, `proposition ${id}`);
}
});
test('salle trop petite : PLACES_MANQUANTES et le nombre exact de places manquantes, aucun plan', () => {
// Douze inscrits dont un exclu, deux tables de 4 et une de 2 : onze
// présents pour dix places. L'exclu ne compte pas, une réservation ne
// change rien au compte (§ 5.9).
const tables = [
{ id: 1, capacite: 4 },
{ id: 2, capacite: 4 },
{ id: 3, capacite: 2 },
];
const appartenances = Array(12).fill(null);
const sansReservation = configurationDe({ appartenances, tables, tours: 2, exclus: [12] });
const avecReservations = configurationDe({
appartenances,
tables,
tours: 2,
exclus: [12],
reservations: [tous(1, 1), auTour(2, 3, 2)],
});
const reglages = { graine: 1, arret: 100, nombre: 2 };
for (const configuration of [sansReservation, avecReservations]) {
assert.throws(() => rechercher(configuration, reglages), {
name: 'ErreurConfiguration',
code: 'PLACES_MANQUANTES',
details: { manque: 1 },
});
}
const unDePlus = configurationDe({ appartenances: Array(13).fill(null), tables, tours: 2 });
assert.throws(() => rechercher(unDePlus, reglages), {
code: 'PLACES_MANQUANTES',
details: { manque: 3 },
});
});
});
describe('rechercher : avancement et annulation (§ 5.10)', () => {
test('progression toutes les 1 000 itérations, au rang du mouvement dans nombre × arret', () => {
const etapes = [];
const propositions = rechercher(
demo('petite'),
{ graine: 3, arret: 2_500, nombre: 2 },
{ progression: (fait, total) => etapes.push([fait, total]) },
);
assert.equal(propositions.length, 2);
assert.deepEqual(etapes, [
[1_000, 5_000],
[2_000, 5_000],
[3_000, 5_000],
[4_000, 5_000],
[5_000, 5_000],
]);
});
test("score nul dès le placement : chaque descente s'arrête sans évaluer de mouvement", () => {
// Le signal se lit une fois avant chaque proposition, et progression ne
// reçoit rien : aucun millième mouvement n'est atteint.
const configuration = troisTablesDeDeux();
let lectures = 0;
const signal = {
get aborted() {
lectures += 1;
return false;
},
};
const etapes = [];
const propositions = rechercher(
configuration,
{ graine: 8, arret: 50_000, nombre: 2 },
{ signal, progression: (fait, total) => etapes.push([fait, total]) },
);
assert.equal(propositions.length, 2);
assert.deepEqual(etapes, []);
assert.equal(lectures, 2);
});
test('un signal levé à sa deuxième lecture fait lever ErreurAnnulee, sans rien rendre', () => {
let lectures = 0;
const signal = {
get aborted() {
lectures += 1;
return lectures >= 2;
},
};
const etapes = [];
let rendu = 'rien';
assert.throws(() => {
rendu = rechercher(
demo('petite'),
{ graine: 3, arret: 5_000, nombre: 3 },
{ signal, progression: (fait, total) => etapes.push([fait, total]) },
);
}, ErreurAnnulee);
assert.equal(rendu, 'rien');
assert.equal(lectures, 2);
assert.deepEqual(etapes, [[1_000, 15_000]]);
});
});
describe('état de recherche : placement initial et score incrémental (§ 5.10)', () => {
test("creerEtat : fixés à leur table, les autres table après table dans l'ordre des tables", () => {
// 264 sièges pour 260 personnes : les 32 premières tables se remplissent,
// la 33e reçoit son animateur et les trois derniers mobiles, à chaque
// tour.
const instance = normaliser(demo('grande-sans-exception'));
const etat = creerEtat(instance, creerPcg32(1, FLUX.RECHERCHE));
const plan = planDepuisIndex(instance, etat.tableDe);
assert.equal(plan.tours.length, 4);
for (const listes of plan.tours) {
assert.deepEqual(listes.map((liste) => liste.length), [...Array(32).fill(8), 4]);
}
assert.deepEqual(verifierInvariants(instance, plan), []);
});
test("creerEtat : le placement initial décrit en tête de recherche.js, à la lettre", () => {
// La réplique tire du même flux, dans le même ordre : les deux plans
// coïncident, et les deux générateurs restent au même point.
for (const configuration of [salleReservee(), demo('petite'), demo('grande')]) {
const instance = normaliser(configuration);
const rng = creerPcg32(13, FLUX.RECHERCHE);
const temoin = creerPcg32(13, FLUX.RECHERCHE);
const etat = creerEtat(instance, rng);
assert.deepStrictEqual(etat.tableDe, placementLitteral(instance, temoin));
assert.equal(rng.suivant(), temoin.suivant());
}
});
test('scoreIncremental égale scoreComplet au départ, après 1 et 10 mouvements, et revient au départ une fois défaits', () => {
const instance = normaliser(demo('petite'));
const rng = creerPcg32(42, FLUX.RECHERCHE);
const etat = creerEtat(instance, rng);
const depart = scoreIncremental(etat);
const planDepart = Int32Array.from(etat.tableDe);
assert.equal(depart.length, 6);
assert.deepStrictEqual(depart, scoreComplet(etat));
const appliques = [];
for (let essai = 0; appliques.length < 10 && essai < 1_000; essai += 1) {
const mouvement = proposerEtAppliquer(etat, rng);
if (mouvement === null) continue;
appliques.push(mouvement);
if (appliques.length === 1 || appliques.length === 10) {
assert.deepStrictEqual(scoreIncremental(etat), scoreComplet(etat), `${appliques.length} mouvements`);
}
}
assert.equal(appliques.length, 10);
assert.notDeepStrictEqual(etat.tableDe, planDepart);
for (const mouvement of appliques.reverse()) defaire(etat, mouvement);
assert.deepStrictEqual(etat.tableDe, planDepart);
assert.deepStrictEqual(scoreIncremental(etat), depart);
});
});
describe('score : composition du n-uplet (en-tête de recherche.js)', () => {
test('scoreIncremental et scoreComplet égalent le n-uplet énuméré, pour aucune contrainte, chacune seule et toutes', () => {
// Score tenu à jour et score recalculé passent par la même composition :
// les comparer entre eux ne dit rien d'elle. L'oracle de ce fichier la
// refait par un autre chemin. Chaque grandeur brute est non nulle dans au
// moins un état de chaque jeu : une composante comptée sous une
// contrainte désactivée, ou omise sous une contrainte active, se voit.
const jeux = [
['aucune', SANS_CONTRAINTE],
...NOMS_CONTRAINTES.map((nom) => [nom, { ...SANS_CONTRAINTE, [nom]: true }]),
['toutes', TOUTES_CONTRAINTES],
];
const MOUVEMENTS = 40;
for (const [nom, contraintes] of jeux) {
const instance = normaliser({ ...salleReservee(), contraintes });
const aPriori = plafondsAPriori(instance);
const rng = creerPcg32(5, FLUX.RECHERCHE);
const etat = creerEtat(instance, rng);
const nonNulles = Array(7).fill(false);
const confronter = (etiquette) => {
const { nUplet, brutes } = scoreOracle(instance, etat.tableDe, aPriori);
assert.deepStrictEqual(scoreComplet(etat), nUplet, `${nom}, ${etiquette} : scoreComplet`);
assert.deepStrictEqual(scoreIncremental(etat), nUplet, `${nom}, ${etiquette} : scoreIncremental`);
for (let k = 0; k < brutes.length; k += 1) if (brutes[k] > 0) nonNulles[k] = true;
};
confronter('placement initial');
let appliques = 0;
for (let essai = 0; appliques < MOUVEMENTS && essai < 100 * MOUVEMENTS; essai += 1) {
if (proposerEtAppliquer(etat, rng) === null) continue;
appliques += 1;
confronter(`${appliques} mouvements`);
}
assert.equal(appliques, MOUVEMENTS, nom);
assert.deepEqual(nonNulles, Array(7).fill(true), `${nom} : grandeurs brutes non nulles`);
}
});
});
describe("rechercher : la descente par acceptation tardive, à la lettre (§ 5.10)", () => {
// Sur la grande démonstration, au moins 24 mobiles restent sous leur
// plafond a priori (§ 15.1) : l'écart maximal ne s'annule jamais, l'ordre
// d'acceptation ne bascule pas, et la relance, qui suit la bascule, ne joue
// pas non plus. rechercher y rend les plans de la réplique littérale.
test('grande démonstration, arret 5 000 : les plans de la réplique', () => {
const reglages = { graine: 17, arret: 5_000, nombre: 2 };
assert.deepStrictEqual(
rechercher(demo('grande'), reglages).map(({ plan }) => plan),
plansLitteraux(demo('grande'), { ...reglages, historique: HISTORIQUE_PAR_DEFAUT }),
);
});
test('grande démonstration sans contrainte, arret 20 000 : les plans de la réplique', () => {
const configuration = () => ({ ...demo('grande'), contraintes: { ...SANS_CONTRAINTE } });
const reglages = { graine: 17, arret: 20_000, nombre: 2 };
assert.deepStrictEqual(
rechercher(configuration(), reglages).map(({ plan }) => plan),
plansLitteraux(configuration(), { ...reglages, historique: HISTORIQUE_PAR_DEFAUT }),
);
});
test("historique d'une case : aucune relance hors de l'ordre des contraintes", () => {
// À historique 1, le score courant reste inchangé sur des plateaux que
// la descente parcourt sans être figée. Une relance dans l'ordre du
// score couperait ces parcours ; la réplique n'en fait aucune.
const reglages = { graine: 17, arret: 5_000, nombre: 2, historique: 1 };
assert.deepStrictEqual(
rechercher(demo('grande'), reglages).map(({ plan }) => plan),
plansLitteraux(demo('grande'), reglages),
);
});
});
describe('rechercher : bascule et relance, à la lettre (en-tête de recherche.js)', () => {
test('petite démonstration en conflit, historique 100, arret 20 000 : les plans de la réplique', () => {
// Chaque descente y atteint un écart maximal nul et bascule ; à
// historique 100, la patience vaut 1 000 itérations, et des relances
// suivent la bascule puis trouvent de meilleures affectations. Les
// comptes de la réplique l'exigent : sans eux, l'épreuve passerait aussi
// sur des descentes qui ne basculent ni ne relancent.
const reglages = { graine: 0, arret: 20_000, nombre: 3, historique: 100 };
const repliques = descentesALaLettre(demo('petite-conflit'), reglages, { bascule: true });
assert.deepStrictEqual(
rechercher(demo('petite-conflit'), reglages).map(({ plan }) => plan),
repliques.map(({ plan }) => plan),
);
assert.deepEqual(
repliques.map(({ bascules, relances, apresRelance }) => [
bascules,
relances > 0,
apresRelance > 0,
]),
[
[1, true, true],
[1, true, true],
[1, true, true],
],
);
});
});
describe('rechercher : la petite démonstration (§ 15.3)', () => {
test('arret 200 000, trois propositions : chacune rencontre 8 pour les douze, zéro collision, zéro répétition', () => {
const configuration = demo('petite');
const instance = normaliser(configuration);
const propositions = rechercher(configuration, { graine: 2_026, arret: 200_000, nombre: 3 });
assert.equal(propositions.length, 3);
for (const { id, plan } of propositions) {
const mesures = mesurer(instance, indexerPlan(instance, plan));
assert.deepEqual(mesures.rencontres, Array(12).fill(8), `proposition ${id}`);
assert.equal(mesures.collisionsCumulees, 0, `proposition ${id}`);
assert.deepEqual(mesures.rencontresRepetees, { choisies: 0, imposees: 0 }, `proposition ${id}`);
assert.equal(mesures.maxRencontresPaire, 1, `proposition ${id}`);
}
});
});