diff --git a/src/moteur/recherche.js b/src/moteur/recherche.js new file mode 100644 index 0000000..c8e66da --- /dev/null +++ b/src/moteur/recherche.js @@ -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} 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} 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); +} diff --git a/src/moteur/recherche.long.test.js b/src/moteur/recherche.long.test.js new file mode 100644 index 0000000..6d84e8d --- /dev/null +++ b/src/moteur/recherche.long.test.js @@ -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}`, + ); + } + }); +}); diff --git a/src/moteur/recherche.test.js b/src/moteur/recherche.test.js new file mode 100644 index 0000000..4a7e1ad --- /dev/null +++ b/src/moteur/recherche.test.js @@ -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}`); + } + }); +});