[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
This commit is contained in:
Mathieu Benoit 2026-10-05 22:52:29 -04:00
parent a2a3a7eca8
commit 74c21ee031
3 changed files with 1604 additions and 0 deletions

690
src/moteur/recherche.js Normal file
View file

@ -0,0 +1,690 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Recherche reproductible (§ 5.7, § 5.10). Une génération rend plusieurs
// propositions ; chacune sort d'une descente par acceptation tardive (late
// acceptance hill climbing) sur le plan complet, les N participants et les R
// tours, ancrés compris : l'appartenance d'un ancré pèse sur ses voisins de
// table comme celle de tout autre (§ 5.2).
//
// Graines. creerPcg32(graine, FLUX.GRAINES) tire une graine dérivée par
// proposition, la i-ème au i-ème tirage. La proposition i porte l'identifiant
// i et tire tout le reste, placements et mouvements, de
// creerPcg32(graine dérivée, FLUX.RECHERCHE). Elle ne dépend donc que de la
// configuration, de sa graine dérivée, du compte d'arrêt et de la longueur de
// l'historique, trois réglages qu'elle porte : la proposition 2 d'une demande
// de 3 est celle d'une demande de 5, quel que soit l'ordre dans lequel les
// propositions s'achèvent.
//
// Sièges. Chaque tour offre S = Σ c_t sièges, rangés table par table dans
// l'ordre des tables. Les personnes que instance.fixe impose à un tour
// occupent les premiers sièges de leur table, par index croissant, et n'en
// bougent jamais. Les autres sièges sont mobiles : chacun porte une personne
// libre à ce tour, ou une place fantôme, siège vide. Le placement initial
// mélange les personnes libres de chaque tour par melanger, tour après tour,
// et les assied dans les sièges mobiles pris dans l'ordre ; les sièges qui
// restent sont des places fantômes. Sans place manquante, personne n'est en
// réserve.
//
// Mouvement. Un tour tiré par borne(R), puis deux sièges mobiles de ce tour,
// chacun par borne(m), m le nombre de ses sièges mobiles ; un tour de moins
// de deux sièges mobiles ne tire rien de plus. Deux sièges de la même table,
// ou deux places fantômes, font un mouvement évalué et rejeté, qui ne change
// rien. Sinon les deux occupants s'échangent : un échange avec une place
// fantôme déplace une personne vers le siège vide d'une autre table.
//
// Score. Un n-uplet de six entiers comparé dans l'ordre lexicographique, la
// forme exacte d'une pondération strictement hiérarchique, sans poids ni
// perte de précision :
// 0. le plus grand écart au plafond a priori, plafond a priori − rencontres ;
// 1. la somme des carrés de ces écarts ;
// 2. l'excédent de collisions, si separerAppartenances ;
// 3. les collisions cumulées, si separerAppartenances ;
// 4. Σ_paires max(0, M − 1), M les rencontres de la paire, si
// nouveauxVoisins, plus les retours choisis, si nouvelleTable ;
// 5. Σ_p r(p), la redondance d'appartenance, si varierAppartenances.
// Une composante désactivée vaut 0 ; aucune n'est négative, et le n-uplet
// nul ne s'améliore plus.
//
// Incrémental. L'état tient à jour ce dont le score dépend : la matrice des
// rencontres par paire ; rencontres(p) ; l'histogramme des écarts au plafond
// a priori, d'où leur maximum, et la somme de leurs carrés ; les collisions
// cumulées et les paires distinctes de même appartenance ; Σ_paires
// max(0, M − 1) ; les visites par (personne, table), d'où les retours
// choisis ; par personne, le nombre d'affiliés rencontrés de chaque
// appartenance, d'où |F(p)|, A(p) et r(p). Un échange ne touche que les
// paires des deux tables concernées, au tour concerné : O(capacité). Ce score
// sert la boucle de recherche et rien d'autre ; scoreComplet recompose le
// même n-uplet à partir de mesurer, la mesure qui fait foi (§ 5.10, § 13.2).
//
// Acceptation tardive. L'historique H, de longueur L, part du score initial.
// À l'itération i, le candidat est gardé s'il ne dépasse ni le score courant
// ni H[i mod L], défait sinon ; puis H[i mod L] reçoit le score courant. La
// meilleure affectation vue, dans l'ordre du score, est conservée. La
// recherche s'arrête après arret mouvements évalués, ou dès que le score
// courant est le n-uplet nul : sur un compte, jamais sur une durée (§ 5.10).
//
// Deux ordres d'acceptation. L'acceptation compare d'abord dans l'ordre du
// score, rencontres en tête. Quand la meilleure affectation porte un écart
// maximal nul, chacun atteint son plafond a priori : les deux premières
// composantes sont à leur minimum prouvé (§ 5.5), et une meilleure
// affectation ne peut plus différer que sur les suivantes. L'acceptation
// compare alors les composantes 2, 3 et 4, celles des contraintes, avant les
// deux premières ; l'historique garde son contenu. L'ordre des contraintes
// laisse perdre une rencontre le temps de défaire une collision. L'ordre du
// score le refuse dès que l'historique ne porte plus que des scores à écart
// nul : collisions, voisins et tables n'y progressent plus que par les rares
// échanges qui conservent toutes les rencontres, et s'arrêtent loin de leur
// minimum. La meilleure affectation, jugée dans l'ordre du score, ne retient
// que des affectations à écart nul. Une instance dont aucun plan n'annule
// tous les écarts garde l'ordre du score de bout en bout.
//
// Relance. Dans l'ordre des contraintes, un score courant inchangé pendant
// PATIENCE × L itérations fait relancer la descente : le reste du compte
// sert une nouvelle descente, depuis un nouveau placement tiré du même
// générateur, l'historique au score de ce placement ; la meilleure
// affectation, et l'ordre des contraintes qu'elle fixe, sont conservés. Une
// descente de cet ordre s'arrête parfois sur un minimum local des voisins et
// des tables, des rencontres en deçà de leur plafond ; une descente neuve en
// est une tentative indépendante. Dans l'ordre du score, la relance ne joue
// pas : les échanges de même score y sont acceptés, un score courant
// inchangé y marque aussi bien un plateau que la descente parcourt, et une
// relance couperait ce parcours, d'autant plus souvent que L est court.
import { FLUX, creerPcg32 } from '../demo/prng.js';
import { nombrePlacesManquantes, normaliser, planDepuisIndex } from './configuration.js';
import { ErreurAnnulee, ErreurConfiguration } from './erreurs.js';
import { mesurer } from './indicateurs.js';
import { plafondsAPriori } from './plafond.js';
/**
* @typedef {Object} ReglagesGeneration
* @property {number} graine entier 0 ≤ g < 2^32
* @property {number} arret mouvements évalués par proposition, ≥ 1
* @property {number} nombre propositions demandées, ≥ 1
* @property {number} [historique=1000] longueur de l'historique d'acceptation
*
* @typedef {Object} Proposition
* @property {number} id 1..nombre, place dans la suite des graines dérivées
* @property {number} graine graine dérivée de cette proposition
* @property {number} arret
* @property {number} historique longueur de l'historique d'acceptation
* @property {import('./types.js').Plan} plan
*
* @typedef {Object} EtatRecherche
* Rendu par creerEtat ; les champs qui suivent se lisent, aucun ne s'écrit
* hors de ce module.
* @property {Int32Array} tableDe plan indexé courant, N × R
* @property {Int32Array} rencontres |met(p)|, ordre canonique
* @property {Int32Array} affilies |F(p)|
* @property {Int32Array} appartenancesVues A(p)
* @property {number} collisionsCumulees
* @property {number} pairesDistinctes paires de même appartenance réunies
* @property {number} totalRetoursChoisis
*
* @typedef {{tour: number, a: number, b: number}} Mouvement
* l'échange des occupants des sièges a et b au tour d'index tour
*/
// Case de instance.fixe sans table imposée, et case d'un plan indexé en
// réserve.
const LIBRE = -1;
const RESERVE = -1;
// Siège sans occupant : la place fantôme.
const FANTOME = -1;
// instance.groupe d'un participant sans appartenance.
const SANS_GROUPE = -1;
const TAILLE_SCORE = 6;
// Ordres de comparaison du n-uplet, par rang de composante : celui du score,
// et celui des contraintes, qui passe les composantes 2, 3 et 4 devant les
// deux premières.
const ORDRE_SCORE = Object.freeze([0, 1, 2, 3, 4, 5]);
const ORDRE_CONTRAINTES = Object.freeze([2, 3, 4, 0, 1, 5]);
// Itérations entre deux appels de progression, et entre deux lectures du
// signal.
const CADENCE = 1_000;
const HISTORIQUE_PAR_DEFAUT = 1_000;
// Longueurs d'historique sans changement du score courant au-delà desquelles
// une descente se relance, dans l'ordre des contraintes seulement.
const PATIENCE = 10;
const MOT_MAX = 0xffffffff;
// Plus grand compte de rencontres d'une paire que tient un Uint16Array : une
// paire se réunit au plus une fois par tour.
const UINT16_MAX = 0xffff;
// Valeur citée dans un message : un nombre tel quel, toute autre valeur
// suivie de son type.
const decrire = (valeur) =>
typeof valeur === 'number' ? String(valeur) : `${String(valeur)} (${typeof valeur})`;
// Lève une RangeError qui nomme le réglage quand la valeur n'est pas un
// entier de min à max.
function exigerReglage(nom, valeur, min, max) {
if (!Number.isInteger(valeur) || valeur < min || valeur > max) {
throw new RangeError(`${nom} : entier de ${min} à ${max} attendu, reçu ${decrire(valeur)}`);
}
}
// Ce que toutes les propositions d'une instance partagent : la disposition
// des sièges, les sièges fixés et les sièges mobiles de chaque tour, les
// personnes libres de chaque tour par index croissant, le plafond a priori
// de chacun et, par (personne, table), le seuil de visites au-delà duquel
// une visite est un retour choisi. Lève PLACES_MANQUANTES quand les sièges
// d'un tour ne suffisent pas à asseoir tout le monde (§ 5.9).
function preparer(instance) {
const manque = nombrePlacesManquantes(instance);
if (manque > 0) throw new ErreurConfiguration('PLACES_MANQUANTES', { manque });
const { N, T, R, capacite, fixe } = instance;
const debut = new Int32Array(T + 1);
for (let t = 0; t < T; t += 1) debut[t + 1] = debut[t] + capacite[t];
const S = debut[T];
const tableDuSiege = new Int32Array(S);
for (let t = 0; t < T; t += 1) tableDuSiege.fill(t, debut[t], debut[t + 1]);
// siegesFixes[r × S + s] : la personne fixée au siège s du tour r,
// FANTOME pour un siège mobile. mobiles[r × S + i], i < nombreMobiles[r] :
// le i-ème siège mobile du tour r, dans l'ordre des sièges.
const siegesFixes = new Int32Array(R * S).fill(FANTOME);
const mobiles = new Int32Array(R * S);
const nombreMobiles = new Int32Array(R);
const libres = [];
const occupes = new Int32Array(T);
for (let r = 0; r < R; r += 1) {
occupes.fill(0);
const libresDuTour = [];
for (let p = 0; p < N; p += 1) {
const t = fixe[p * R + r];
if (t === LIBRE) {
libresDuTour.push(p);
} else {
siegesFixes[r * S + debut[t] + occupes[t]] = p;
occupes[t] += 1;
}
}
libres.push(libresDuTour);
let m = 0;
for (let s = 0; s < S; s += 1) {
if (siegesFixes[r * S + s] === FANTOME) {
mobiles[r * S + m] = s;
m += 1;
}
}
nombreMobiles[r] = m;
}
// Pour une table visitée m fois, dont f à un tour où la personne y est
// fixée, les retours choisis valent m − 1 − max(0, f − 1) (indicateurs.js),
// soit max(0, m − max(1, f)) : le seuil vaut max(1, f).
const seuilRetour = new Int32Array(N * T);
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
const t = fixe[p * R + r];
if (t !== LIBRE) seuilRetour[p * T + t] += 1;
}
}
for (let i = 0; i < seuilRetour.length; i += 1) {
if (seuilRetour[i] === 0) seuilRetour[i] = 1;
}
const aPriori = Int32Array.from(plafondsAPriori(instance));
let plusGrand = 0;
for (let p = 0; p < N; p += 1) plusGrand = Math.max(plusGrand, aPriori[p]);
return {
instance,
N,
T,
R,
S,
debut,
tableDuSiege,
siegesFixes,
mobiles,
nombreMobiles,
libres,
seuilRetour,
aPriori,
largeurHistogramme: plusGrand + 1,
};
}
// Les écarts au plafond a priori restent dans 0..aPriori[p] : sur un plan
// qui honore fixe et les capacités, rencontres ≤ plafond réalisé ≤ plafond a
// priori (plafond.js). Une hausse des rencontres ne fait baisser l'écart que
// d'un : quand le maximum perd son dernier porteur, e − 1 en a un.
function changerRencontres(etat, p, sens) {
const { rencontres, histogramme } = etat;
const avant = etat.aPriori[p] - rencontres[p];
const apres = avant - sens;
rencontres[p] += sens;
histogramme[avant] -= 1;
histogramme[apres] += 1;
etat.carresEcartsAuPlafondAPriori += apres * apres - avant * avant;
if (apres > etat.ecartAuPlafondAPrioriMax) {
etat.ecartAuPlafondAPrioriMax = apres;
} else if (avant === etat.ecartAuPlafondAPrioriMax && histogramme[avant] === 0) {
etat.ecartAuPlafondAPrioriMax = apres;
}
}
// p commence (sens = 1) ou cesse (sens = −1) de compter parmi ses
// rencontres un affilié du groupe g : |F(p)| suit, A(p) quand c'est le
// premier ou le dernier de ce groupe, et Σ r(p) = Σ (|F(p)| − A(p)) avec eux.
function changerAffilie(etat, p, g, sens) {
const i = p * etat.G + g;
const avant = etat.parGroupe[i];
etat.parGroupe[i] = avant + sens;
etat.affilies[p] += sens;
let vues = 0;
if (avant === (sens > 0 ? 0 : 1)) {
etat.appartenancesVues[p] += sens;
vues = sens;
}
etat.sommeRedondance += sens - vues;
}
// Ajoute sens, 1 ou −1, aux rencontres de la paire (a, b), a ≠ b, et
// propage : Σ max(0, M − 1) ; collisions cumulées pour deux personnes de la
// même appartenance ; et, quand la paire devient réunie ou cesse de l'être,
// les paires distinctes, les rencontres et les affiliés des deux.
function modifierPaire(etat, a, b, sens) {
const k = a < b ? a * etat.N + b : b * etat.N + a;
const avant = etat.paires[k];
const apres = avant + sens;
etat.paires[k] = apres;
if ((sens > 0 ? avant : apres) >= 1) etat.excedentPaires += sens;
const ga = etat.groupe[a];
const gb = etat.groupe[b];
const memeGroupe = ga !== SANS_GROUPE && ga === gb;
if (memeGroupe) etat.collisionsCumulees += sens;
if ((sens > 0 ? avant : apres) !== 0) return;
if (memeGroupe) etat.pairesDistinctes += sens;
changerRencontres(etat, a, sens);
changerRencontres(etat, b, sens);
if (gb !== SANS_GROUPE) changerAffilie(etat, a, gb, sens);
if (ga !== SANS_GROUPE) changerAffilie(etat, b, ga, sens);
}
// Ajoute sens aux visites de p à la table t, et ajuste les retours choisis.
function changerVisite(etat, p, t, sens) {
const i = p * etat.T + t;
const avant = etat.visites[i];
const apres = avant + sens;
etat.visites[i] = apres;
const seuil = etat.seuilRetour[i];
etat.totalRetoursChoisis += Math.max(0, apres - seuil) - Math.max(0, avant - seuil);
}
// Ajoute sens aux rencontres de p avec chaque occupant de la table t au tour
// dont les sièges commencent à base, le siège exclu mis à part.
function voisiner(etat, p, base, t, exclu, sens) {
const { siege, debut } = etat;
for (let s = debut[t], fin = debut[t + 1]; s < fin; s += 1) {
if (s === exclu) continue;
const q = siege[base + s];
if (q !== FANTOME) modifierPaire(etat, p, q, sens);
}
}
// Échange les occupants des sièges a et b, de deux tables différentes, au
// tour r. Chaque occupant quitte d'abord ses voisins, puis rejoint ceux de
// l'autre table : l'échange est sa propre réciproque, et le refaire rend
// l'état d'avant, compteurs compris.
function echanger(etat, r, a, b) {
const { siege, tableDe, tableDuSiege, R } = etat;
const base = r * etat.S;
const x = siege[base + a];
const y = siege[base + b];
const ta = tableDuSiege[a];
const tb = tableDuSiege[b];
if (x !== FANTOME) voisiner(etat, x, base, ta, a, -1);
if (y !== FANTOME) voisiner(etat, y, base, tb, b, -1);
siege[base + a] = y;
siege[base + b] = x;
if (x !== FANTOME) {
tableDe[x * R + r] = tb;
changerVisite(etat, x, ta, -1);
changerVisite(etat, x, tb, 1);
voisiner(etat, x, base, tb, b, 1);
}
if (y !== FANTOME) {
tableDe[y * R + r] = ta;
changerVisite(etat, y, tb, -1);
changerVisite(etat, y, ta, 1);
voisiner(etat, y, base, ta, a, 1);
}
}
// Placement initial d'une instance préparée : les personnes fixées à leurs
// sièges, les libres de chaque tour mélangées puis assises dans l'ordre des
// sièges mobiles. Les compteurs partent de zéro et suivent chaque paire et
// chaque visite par les mêmes fonctions que les mouvements ; les écarts
// partent du plafond a priori, aucune rencontre n'étant encore comptée.
function etatInitial(structure, rng) {
const { instance, N, T, R, S, mobiles, libres, aPriori, tableDuSiege, debut } = structure;
const G = instance.groupes.length;
const etat = {
instance,
contraintes: instance.contraintes,
groupe: instance.groupe,
N,
T,
R,
S,
G,
debut,
tableDuSiege,
mobiles,
nombreMobiles: structure.nombreMobiles,
seuilRetour: structure.seuilRetour,
aPriori,
siege: Int32Array.from(structure.siegesFixes),
tableDe: new Int32Array(N * R),
paires: R <= UINT16_MAX ? new Uint16Array(N * N) : new Uint32Array(N * N),
rencontres: new Int32Array(N),
affilies: new Int32Array(N),
appartenancesVues: new Int32Array(N),
parGroupe: new Int32Array(N * G),
visites: new Int32Array(N * T),
histogramme: new Int32Array(structure.largeurHistogramme),
ecartAuPlafondAPrioriMax: 0,
carresEcartsAuPlafondAPriori: 0,
collisionsCumulees: 0,
pairesDistinctes: 0,
excedentPaires: 0,
totalRetoursChoisis: 0,
sommeRedondance: 0,
};
for (let p = 0; p < N; p += 1) {
etat.histogramme[aPriori[p]] += 1;
etat.carresEcartsAuPlafondAPriori += aPriori[p] * aPriori[p];
etat.ecartAuPlafondAPrioriMax = Math.max(etat.ecartAuPlafondAPrioriMax, aPriori[p]);
}
const { siege, tableDe } = etat;
for (let r = 0; r < R; r += 1) {
const melanges = rng.melanger(Int32Array.from(libres[r]));
for (let i = 0; i < melanges.length; i += 1) siege[r * S + mobiles[r * S + i]] = melanges[i];
}
for (let r = 0; r < R; r += 1) {
const base = r * S;
for (let s = 0; s < S; s += 1) {
const p = siege[base + s];
if (p === FANTOME) continue;
const t = tableDuSiege[s];
tableDe[p * R + r] = t;
changerVisite(etat, p, t, 1);
for (let voisin = debut[t]; voisin < s; voisin += 1) {
const q = siege[base + voisin];
if (q !== FANTOME) modifierPaire(etat, p, q, 1);
}
}
}
return etat;
}
// Écrit dans sortie le n-uplet du score, à partir des grandeurs nommées de
// q, et le rend. Une seule composition sert le score tenu à jour et le score
// recalculé.
function composer(contraintes, q, sortie) {
const separer = contraintes.separerAppartenances;
sortie[0] = q.ecartAuPlafondAPrioriMax;
sortie[1] = q.carresEcartsAuPlafondAPriori;
sortie[2] = separer ? q.collisionsCumulees - q.pairesDistinctes : 0;
sortie[3] = separer ? q.collisionsCumulees : 0;
sortie[4] = (contraintes.nouveauxVoisins ? q.excedentPaires : 0)
+ (contraintes.nouvelleTable ? q.totalRetoursChoisis : 0);
sortie[5] = contraintes.varierAppartenances ? q.sommeRedondance : 0;
return sortie;
}
// Signe de la comparaison lexicographique des n-uplets a[ia …] et b[ib …],
// composantes prises dans l'ordre que donne la liste ordre.
function comparer(ordre, a, ia, b, ib) {
for (let j = 0; j < TAILLE_SCORE; j += 1) {
const k = ordre[j];
const difference = a[ia + k] - b[ib + k];
if (difference !== 0) return difference;
}
return 0;
}
// Ordre d'acceptation que fixe la meilleure affectation : celui des
// contraintes dès que son écart maximal est nul (en-tête du module). Cet
// écart ne fait que baisser d'une meilleure affectation à la suivante : une
// fois nul, il le reste.
const ordreAccepte = (meilleurScore) =>
meilleurScore[0] === 0 ? ORDRE_CONTRAINTES : ORDRE_SCORE;
function estNul(score) {
for (let k = 0; k < TAILLE_SCORE; k += 1) if (score[k] !== 0) return false;
return true;
}
// Lit signal.aborted une fois, et lève ErreurAnnulee s'il est vrai.
function lireSignal(signal) {
if (signal?.aborted) throw new ErreurAnnulee('recherche annulée');
}
// Descente par acceptation tardive depuis un placement initial tiré de rng ;
// rend la meilleure affectation vue, en plan indexé. L'ordre d'acceptation
// suit la meilleure affectation (ordreAccepte). Dans l'ordre des
// contraintes, un score courant inchangé pendant PATIENCE × historique
// itérations fait relancer la descente (en-tête du module). La bascule vers
// cet ordre suit une meilleure affectation, donc un changement du score
// courant, qui remet immobile à zéro. fait compte les mouvements évalués
// depuis le début de la génération, à partir de dejaFaits, ceux des
// propositions précédentes : à chaque multiple de CADENCE,
// progression(fait, total), puis une lecture du signal.
function descendre(structure, rng, { arret, historique, dejaFaits, total, signal, progression }) {
const { contraintes } = structure.instance;
let etat = etatInitial(structure, rng);
const courant = composer(contraintes, etat, new Float64Array(TAILLE_SCORE));
const candidat = new Float64Array(TAILLE_SCORE);
const meilleurScore = Float64Array.from(courant);
const meilleurPlan = Int32Array.from(etat.tableDe);
const memoire = new Float64Array(historique * TAILLE_SCORE);
const remplirMemoire = () => {
for (let v = 0; v < memoire.length; v += TAILLE_SCORE) memoire.set(courant, v);
};
// Garde l'état courant comme meilleure affectation s'il la dépasse dans
// l'ordre du score, et rend l'ordre d'acceptation qui en découle.
const retenir = (ordre) => {
if (comparer(ORDRE_SCORE, courant, 0, meilleurScore, 0) >= 0) return ordre;
meilleurScore.set(courant);
meilleurPlan.set(etat.tableDe);
return ordreAccepte(meilleurScore);
};
remplirMemoire();
let ordre = ordreAccepte(meilleurScore);
const patience = PATIENCE * historique;
let immobile = 0;
for (let i = 0; i < arret && !estNul(courant); i += 1) {
if (immobile >= patience && ordre === ORDRE_CONTRAINTES) {
etat = etatInitial(structure, rng);
composer(contraintes, etat, courant);
remplirMemoire();
ordre = retenir(ordre);
immobile = 0;
}
const mouvement = proposerEtAppliquer(etat, rng);
const v = (i % historique) * TAILLE_SCORE;
let change = false;
if (mouvement !== null) {
composer(contraintes, etat, candidat);
if (comparer(ordre, candidat, 0, courant, 0) <= 0
|| comparer(ordre, candidat, 0, memoire, v) <= 0) {
change = comparer(ORDRE_SCORE, candidat, 0, courant, 0) !== 0;
courant.set(candidat);
ordre = retenir(ordre);
} else {
defaire(etat, mouvement);
}
}
memoire.set(courant, v);
immobile = change ? 0 : immobile + 1;
const fait = dejaFaits + i + 1;
if (fait % CADENCE === 0) {
progression?.(fait, total);
lireSignal(signal);
}
}
return meilleurPlan;
}
/**
* Propositions de placement d'une configuration (§ 5.7) : nombre
* propositions, d'identifiants 1 à nombre, chacune la meilleure affectation
* vue en arret mouvements évalués, relances comprises (en-tête du module).
* Chacune porte sa graine dérivée, arret et historique, défaut résolu : les
* trois décident de son plan. À configuration et réglages égaux, le résultat
* est identique. Ne modifie pas la configuration.
*
* Toutes les 1 000 itérations, comptées sur la génération entière,
* progression(fait, total) reçoit le rang du mouvement en cours parmi
* total = nombre × arret ; une proposition arrêtée tôt sur un score nul
* laisse la suivante partir de son propre rang, (id − 1) × arret. Le signal
* se lit à chacun de ces instants et avant chaque proposition : vrai, il
* fait lever ErreurAnnulee, et aucune proposition n'est rendue (§ 5.10).
*
* Lève RangeError quand un réglage n'est pas un entier de son domaine —
* graine de 0 à 2^32 − 1, arret, nombre et historique ≥ 1 ;
* ErreurConfiguration de normaliser ; ErreurConfiguration
* ('PLACES_MANQUANTES', { manque }) quand les tables n'offrent pas assez de
* sièges, manque étant le nombre de places qui manquent à chaque tour.
*
* @param {import('./types.js').Configuration} configuration
* @param {ReglagesGeneration} reglages
* @param {{signal?: {aborted: boolean}, progression?: function(number, number): void}} [options]
* @returns {Proposition[]}
*/
export function rechercher(configuration, reglages, { signal, progression } = {}) {
const { graine, arret, nombre, historique = HISTORIQUE_PAR_DEFAUT } = reglages;
exigerReglage('graine', graine, 0, MOT_MAX);
exigerReglage('arret', arret, 1, Number.MAX_SAFE_INTEGER);
exigerReglage('nombre', nombre, 1, Number.MAX_SAFE_INTEGER);
exigerReglage('historique', historique, 1, Number.MAX_SAFE_INTEGER);
const instance = normaliser(configuration);
const structure = preparer(instance);
const graines = creerPcg32(graine, FLUX.GRAINES);
const total = nombre * arret;
const propositions = [];
for (let id = 1; id <= nombre; id += 1) {
lireSignal(signal);
const graineDerivee = graines.suivant();
const rng = creerPcg32(graineDerivee, FLUX.RECHERCHE);
const meilleur = descendre(structure, rng, {
arret,
historique,
dejaFaits: (id - 1) * arret,
total,
signal,
progression,
});
const plan = planDepuisIndex(instance, meilleur);
propositions.push({ id, graine: graineDerivee, arret, historique, plan });
}
return propositions;
}
/**
* État de recherche au placement initial (§ 5.10) : fixés à leurs tables,
* libres mélangés par rng.melanger, tour après tour, puis assis table après
* table dans l'ordre des tables. Lève ErreurConfiguration('PLACES_MANQUANTES',
* { manque }) comme rechercher.
*
* @param {import('./types.js').Instance} instance
* @param {ReturnType<typeof creerPcg32>} rng
* @returns {EtatRecherche}
*/
export function creerEtat(instance, rng) {
return etatInitial(preparer(instance), rng);
}
/**
* Le n-uplet du score, tel que l'état le tient à jour.
*
* @param {EtatRecherche} etat
* @returns {number[]}
*/
export function scoreIncremental(etat) {
return composer(etat.contraintes, etat, new Array(TAILLE_SCORE));
}
/**
* Le même n-uplet, recalculé de zéro sur etat.tableDe : plafondsAPriori et
* mesurer en donnent les grandeurs. Σ_paires max(0, M − 1) vaut Σ_paires M
* − Σ_paires min(M, 1) : le premier terme compte les paires réunies à chaque
* table de chaque tour, o(o − 1)/2 pour o occupants ; le second, les paires
* réunies au moins une fois, Σ_p rencontres(p) / 2.
*
* @param {EtatRecherche} etat
* @returns {number[]}
*/
export function scoreComplet(etat) {
const { instance, tableDe } = etat;
const { N, T, R } = instance;
const aPriori = plafondsAPriori(instance);
const mesures = mesurer(instance, tableDe);
let ecartAuPlafondAPrioriMax = 0;
let carresEcartsAuPlafondAPriori = 0;
let sommeRencontres = 0;
for (let p = 0; p < N; p += 1) {
const ecart = aPriori[p] - mesures.rencontres[p];
ecartAuPlafondAPrioriMax = Math.max(ecartAuPlafondAPrioriMax, ecart);
carresEcartsAuPlafondAPriori += ecart * ecart;
sommeRencontres += mesures.rencontres[p];
}
const occupation = new Int32Array(T * R);
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
if (t !== RESERVE) occupation[t * R + r] += 1;
}
}
let reunions = 0;
for (const o of occupation) reunions += (o * (o - 1)) / 2;
const grandeurs = {
ecartAuPlafondAPrioriMax,
carresEcartsAuPlafondAPriori,
collisionsCumulees: mesures.collisionsCumulees,
pairesDistinctes: mesures.pairesDistinctes,
excedentPaires: reunions - sommeRencontres / 2,
totalRetoursChoisis: mesures.totalRetoursChoisis,
sommeRedondance: mesures.redondance.reduce((somme, r) => somme + r, 0),
};
return composer(instance.contraintes, grandeurs, new Array(TAILLE_SCORE));
}
/**
* Un mouvement (en-tête du module), appliqué à l'état. Rend de quoi le
* défaire, ou null pour un mouvement évalué et rejeté, qui n'a rien changé.
*
* @param {EtatRecherche} etat
* @param {ReturnType<typeof creerPcg32>} rng
* @returns {Mouvement|null}
*/
export function proposerEtAppliquer(etat, rng) {
const { R, S, mobiles, nombreMobiles, tableDuSiege, siege } = etat;
const r = rng.borne(R);
const m = nombreMobiles[r];
if (m < 2) return null;
const base = r * S;
const a = mobiles[base + rng.borne(m)];
const b = mobiles[base + rng.borne(m)];
if (tableDuSiege[a] === tableDuSiege[b]) return null;
if (siege[base + a] === FANTOME && siege[base + b] === FANTOME) return null;
echanger(etat, r, a, b);
return { tour: r, a, b };
}
/**
* Défait mouvement, le dernier appliqué à l'état : l'état revient à
* l'identique, compteurs compris. Une suite de mouvements se défait à
* rebours, le dernier d'abord. Un mouvement null ne fait rien.
*
* @param {EtatRecherche} etat
* @param {Mouvement|null} mouvement
*/
export function defaire(etat, mouvement) {
if (mouvement !== null) echanger(etat, mouvement.tour, mouvement.a, mouvement.b);
}

View file

@ -0,0 +1,153 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves lourdes de la recherche (§ 5.10, § 14.10, § 15.3). Sur chacune
// des quatre démonstrations, une suite déterministe de mouvements, acceptés
// ou défaits, confronte l'état tenu à jour au recalcul complet à 1, 10, 100,
// 1 000 et 10 000 mouvements appliqués, et non au seul terme : une dérive qui
// se compense passe inaperçue à la fin, et le palier où un désaccord affleure
// est le chiffre que l'épreuve rend. Le relevé liste chaque désaccord, palier
// et grandeur, au lieu de s'arrêter au premier. La petite démonstration et sa
// variante conflit atteignent ensuite leur optimum sur vingt graines, trois
// propositions chacune ; une recherche longue sur la grande démonstration
// rend des plans que jugent verifierInvariants et verifierIndicateurs.
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 } from './configuration.js';
import { mesurer } from './indicateurs.js';
import { plafondsAPriori, plafondsRealises } from './plafond.js';
import {
creerEtat,
defaire,
proposerEtAppliquer,
rechercher,
scoreComplet,
scoreIncremental,
} from './recherche.js';
import { verifierIndicateurs, verifierInvariants } from './verification.js';
const PALIERS = [1, 10, 100, 1_000, 10_000];
// Graine des suites de mouvements, écrite ici.
const GRAINE = 271_828;
// Un mouvement appliqué sur trois est défait : la suite passe par les deux
// chemins, l'acceptation et le retour en arrière.
const DEFAIT_TOUS_LES = 3;
// Propositions de mouvement au-delà desquelles une suite renonce : une
// instance où aucun mouvement ne s'applique n'atteindrait jamais le dernier
// palier.
const ESSAIS_MAX = 100 * PALIERS.at(-1);
// Vrai quand deux listes de nombres ont la même longueur et les mêmes
// valeurs, rang par rang.
const egales = (a, b) => a.length === b.length && a.every((valeur, i) => valeur === b[i]);
// Ajoute à releve un libellé « palier : grandeur » pour chaque grandeur où
// l'état tenu à jour diffère du recalcul complet.
function relever(etat, instance, palier, releve) {
const mesures = mesurer(instance, etat.tableDe);
const confronter = (grandeur, tenue, recalculee) => {
if (!egales(tenue, recalculee)) releve.push(`${palier} : ${grandeur}`);
};
confronter('score', scoreIncremental(etat), scoreComplet(etat));
confronter('rencontres', Array.from(etat.rencontres), mesures.rencontres);
confronter('affilies', Array.from(etat.affilies), mesures.affilies);
confronter('appartenancesVues', Array.from(etat.appartenancesVues), mesures.appartenancesVues);
confronter('collisionsCumulees', [etat.collisionsCumulees], [mesures.collisionsCumulees]);
confronter('pairesDistinctes', [etat.pairesDistinctes], [mesures.pairesDistinctes]);
confronter('totalRetoursChoisis', [etat.totalRetoursChoisis], [mesures.totalRetoursChoisis]);
}
describe('recherche : incrémental contre recalcul complet (§ 5.10, § 14.10)', () => {
test('le catalogue fournit les quatre démonstrations éprouvées ci-dessous', () => {
assert.equal(CATALOGUE.length, 4);
});
for (const { cle, construire } of CATALOGUE) {
test(`${cle} : accord à 1, 10, 100, 1 000 et 10 000 mouvements`, () => {
const instance = normaliser(construire());
const rng = creerPcg32(GRAINE, FLUX.RECHERCHE);
const etat = creerEtat(instance, rng);
const releve = [];
relever(etat, instance, 0, releve);
let appliques = 0;
let defaits = 0;
let palier = 0;
for (let essai = 0; palier < PALIERS.length && essai < ESSAIS_MAX; essai += 1) {
const mouvement = proposerEtAppliquer(etat, rng);
if (mouvement === null) continue;
appliques += 1;
if (appliques % DEFAIT_TOUS_LES === 0) {
defaire(etat, mouvement);
defaits += 1;
}
if (appliques === PALIERS[palier]) {
relever(etat, instance, appliques, releve);
palier += 1;
}
}
assert.equal(appliques, PALIERS.at(-1), `${cle} : mouvements appliqués`);
assert.equal(defaits, Math.floor(PALIERS.at(-1) / DEFAIT_TOUS_LES));
assert.deepEqual(releve, [], cle);
});
}
});
// Graines 0 à GRAINES − 1, trois propositions chacune : l'optimum se juge sur
// une suite de graines, et non sur une seule, qu'un réglage de l'algorithme
// pourrait servir par hasard.
const GRAINES = 20;
describe('recherche : la petite démonstration et sa variante, sur vingt graines (§ 15.3)', () => {
// Plancher de collisions cumulées : aucune dans la petite démonstration ;
// une par tour dans la variante, dont le groupe de cinq ne se répartit pas
// sur quatre tables, chacune d'une paire différente au mieux.
for (const [cle, plancher] of [['petite', 0], ['petite-conflit', 4]]) {
test(`${cle} : chaque proposition rencontre 8 pour les douze, sans répétition, ${plancher} collisions sur ${plancher} paires`, () => {
const configuration = CATALOGUE.find((entree) => entree.cle === cle).construire();
const instance = normaliser(configuration);
const manquees = [];
let examinees = 0;
for (let graine = 0; graine < GRAINES; graine += 1) {
for (const { id, plan } of rechercher(configuration, { graine, arret: 200_000, nombre: 3 })) {
examinees += 1;
const m = mesurer(instance, indexerPlan(instance, plan));
const optimale = m.rencontres.every((rencontres) => rencontres === 8)
&& m.maxRencontresPaire === 1
&& m.collisionsCumulees === plancher
&& m.pairesDistinctes === plancher;
if (!optimale) {
manquees.push(
`graine ${graine}, proposition ${id} : minimum ${m.aggRencontres.tous.min}, `
+ `collisions ${m.collisionsCumulees} sur ${m.pairesDistinctes} paires, `
+ `paire la plus revue ${m.maxRencontresPaire} fois`,
);
}
}
}
assert.equal(examinees, 3 * GRAINES);
assert.deepEqual(manquees, []);
});
}
});
describe('recherche : recherche longue sur la grande démonstration (§ 14.12)', () => {
test('arret 200 000 : plans valides, et rencontres ≤ plafond réalisé ≤ plafond a priori ≤ N − 1', () => {
const configuration = CATALOGUE.find((entree) => entree.cle === 'grande').construire();
const instance = normaliser(configuration);
const aPriori = plafondsAPriori(instance);
const propositions = rechercher(configuration, { graine: 31, arret: 200_000, nombre: 2 });
assert.equal(propositions.length, 2);
for (const { id, plan } of propositions) {
assert.deepEqual(verifierInvariants(instance, plan), [], `proposition ${id}`);
const tableDe = indexerPlan(instance, plan);
const mesures = mesurer(instance, tableDe);
assert.deepEqual(
verifierIndicateurs(mesures, aPriori, plafondsRealises(instance, tableDe)),
[],
`proposition ${id}`,
);
}
});
});

View file

@ -0,0 +1,761 @@
// © 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}`);
}
});
});