diff --git a/spec.md b/spec.md index 7c21d26..71637ff 100644 --- a/spec.md +++ b/spec.md @@ -157,8 +157,9 @@ quand la configuration en impose un. Sans ce dernier, le troisième chiffre de l page de qualité envoie relancer vers un zéro que la configuration interdit. **9. Générer.** *Nécessaire pour obtenir un placement, reprenable sans perte.* -La commande prend une **graine** et un **compte d'arrêt**, que chaque -proposition conserve (§ 5.7, § 8.9). Le logiciel produit plusieurs propositions +La commande prend une **graine** et un **compte d'arrêt** ; chaque proposition +conserve sa graine dérivée, ce compte et la longueur d'historique qui l'ont +produite (§ 5.7, § 8.9). Le logiciel produit plusieurs propositions comparables ; relancer **ajoute** des candidats, un classement unique les ordonne tous. Les réservations des étapes 6 et 7 sont honorées, le moteur place les autres autour (§ 5.2). Le logiciel nomme automatiquement l'instant qui @@ -611,10 +612,11 @@ réglages ajoute des candidats plutôt que d'effacer les précédents, et un classement unique les ordonne tous. Une commande efface la liste en épargnant celui qui est retenu. -**La commande prend une graine entière et un compte d'arrêt**, que chaque -proposition conserve (§ 8.9). C'est le support de « à graine et entrée égales, -placement identique » ; sans lui, une proposition retenue cesse d'être -régénérable. +**La commande prend une graine entière et un compte d'arrêt**, et une longueur +d'historique d'acceptation dont l'algorithme fixe la valeur par défaut. Chaque +proposition conserve sa graine dérivée, ce compte et cette longueur (§ 8.9). +C'est le support de « à graine et entrée égales, placement identique » ; sans +eux, une proposition retenue cesse d'être régénérable. **L'identifiant entier d'une proposition vient de sa place dans la suite de graines dérivées, jamais de son ordre d'achèvement.** Quand plusieurs @@ -1505,11 +1507,22 @@ Chaque proposition déclare, **une fois** : - la **liste des capacités** correspondantes ; - le **nombre de tours** ; - l'**ensemble des identifiants de participants** qu'elle place ; -- sa **graine** entière et le **compte d'arrêt** de la recherche qui l'a produite. +- sa **graine dérivée** (§ 5.7), le **compte d'arrêt** et la **longueur de + l'historique** d'acceptation de la recherche qui l'a produite : les trois + réglages qui la déterminent. -Les deux derniers champs sont le support de « à graine et entrée égales, placement -identique » (§ 5.10) ; sans eux, cette phrase n'existe que dans un scénario de -pilotage, et une proposition retenue cesse d'être régénérable. +Ces trois champs sont le support de « à graine et entrée égales, placement +identique » (§ 5.10) : à configuration égale et pour une même version du moteur +(`produit_version`, § 8.8), la régénération d'une proposition à partir d'eux +seuls rend son plan, quel que soit son rang dans sa génération. Sans eux, cette +phrase n'existe que dans un scénario de pilotage, et une proposition retenue +cesse d'être régénérable. La longueur de l'historique a une valeur par défaut, +mais la proposition porte celle qui l'a produite : régénérer sous une valeur +supposée rendrait une autre proposition, sans le dire. Les trois champs ne +déterminent le plan que pour l'algorithme de recherche qui l'a produit, dont +l'ordre des tirages et les règles d'acceptation font partie du résultat : une +proposition produite par une autre version du moteur n'est pas garantie +régénérable. Puis, **par tour** : la liste des tables, chacune étant la liste des **identifiants entiers** de ses occupants **dans l'ordre des sièges** ; et la @@ -1525,7 +1538,7 @@ différents, et le § 8.8 est violé par la seule forme du stockage. # illustration, annotée ; le fichier livré est du JSON strict propositions: [ { id: 3, - graine: 48271, arret: 20000, + graine: 48271, arret: 20000, historique: 1000, tables: [7, 12, 2, ...], identifiants de table (§ 4) capacites: [8, 8, 7, ...], tours: 4, @@ -3655,9 +3668,16 @@ certitude fausse. gros groupe tient dans le nombre de tables » est nécessaire mais **ne suffit pas** : trois tables de 10, 1 et 1 ne peuvent séparer quatre groupes de 3, parce que la table de 10 exigerait 10 appartenances distinctes. Le logiciel vérifie -**deux** inégalités — plus gros groupe ≤ nombre de tables, plus grande capacité -≤ nombre de groupes — puis **construit un tour témoin**. Le générateur **refuse -une graine dont aucun tour témoin ne se construit**. +**deux** inégalités — plus gros groupe ≤ nombre de tables, plus grande +capacité − places vides ≤ nombre de groupes — puis **construit un tour +témoin**. Les places vides sont les sièges qui restent une fois chaque membre +assis, zéro quand la salle n'en laisse pas : une table reçoit au moins sa +capacité moins ces places, chacun de ses occupants d'une appartenance +différente. Dans une salle exactement pleine, la seconde inégalité se lit +« plus grande capacité ≤ nombre de groupes » ; ailleurs, cette lecture +refuserait à tort deux tables de 3 pour deux membres d'appartenances +différentes, qu'un tour sépare. Le générateur **refuse une graine dont aucun +tour témoin ne se construit**. ### 15.3 La petite démonstration — 12 membres diff --git a/src/demo/appartenances.js b/src/demo/appartenances.js index a8aa069..2585f0b 100644 --- a/src/demo/appartenances.js +++ b/src/demo/appartenances.js @@ -66,11 +66,12 @@ export function tirerTailles(rng, total) { * (§ 15.2), contrôlées dans cet ordre ; la première rompue est nommée. * - PLUS_GROS_GROUPE : le plus gros groupe dépasse le nombre de tables, alors * que chacun de ses membres occupe une table différente. - * - PLUS_GRANDE_CAPACITE : la plus grande capacité, diminuée du surplus de - * sièges sur les membres quand il est positif, dépasse le nombre de groupes. - * Une table reçoit au moins sa capacité moins ce surplus, chacun de ses - * membres d'un groupe différent. Dans une salle exactement pleine, c'est - * « plus grande capacité ≤ nombre de groupes ». + * - PLUS_GRANDE_CAPACITE : la plus grande capacité, diminuée des places + * vides, dépasse le nombre de groupes. Les places vides sont les sièges qui + * restent une fois chaque membre assis, zéro quand la salle n'en laisse + * pas. Une table reçoit au moins sa capacité moins ces places, chacun de + * ses membres d'un groupe différent. Dans une salle exactement pleine, + * c'est « plus grande capacité ≤ nombre de groupes ». * Nécessaires, elles ne suffisent pas : seul tourTemoin conclut. * * @param {number[]} tailles taille de chaque groupe @@ -79,8 +80,8 @@ export function tirerTailles(rng, total) { */ export function faisabilite(tailles, capacites) { if (maximum(tailles) > capacites.length) return { ok: false, raison: 'PLUS_GROS_GROUPE' }; - const surplus = Math.max(0, somme(capacites) - somme(tailles)); - if (maximum(capacites) - surplus > tailles.length) { + const placesVides = Math.max(0, somme(capacites) - somme(tailles)); + if (maximum(capacites) - placesVides > tailles.length) { return { ok: false, raison: 'PLUS_GRANDE_CAPACITE' }; } return { ok: true }; diff --git a/src/demo/appartenances.test.js b/src/demo/appartenances.test.js index 3f03b57..4b7e6f4 100644 --- a/src/demo/appartenances.test.js +++ b/src/demo/appartenances.test.js @@ -158,19 +158,19 @@ describe('appartenances : les deux inégalités', () => { [[2, 2], [3, 1], { ok: false, raison: 'PLUS_GRANDE_CAPACITE' }, 'salle pleine : une table de 3 pour 2 groupes'], [[2, 2, 2], [5, 2], { ok: false, raison: 'PLUS_GRANDE_CAPACITE' }, - 'un siège en surplus : la table de 5 reçoit au moins 4 membres, pour 3 groupes'], + 'une place vide : la table de 5 reçoit au moins 4 membres, pour 3 groupes'], [[4, 2], [3, 3, 3], { ok: false, raison: 'PLUS_GROS_GROUPE' }, 'un groupe de 4 pour 3 tables'], [[3, 2, 1], [3, 3], { ok: false, raison: 'PLUS_GROS_GROUPE' }, 'un groupe de 3 pour 2 tables'], [[3, 3, 3, 3], [10, 1], { ok: false, raison: 'PLUS_GROS_GROUPE' }, 'les deux rompues : la première est nommée'], [[2, 2, 1], [3, 2], { ok: true }, 'les deux inégalités à égalité'], [[2, 2, 2], [4, 3], { ok: true }, - 'un siège en surplus : la table de 4 reçoit au moins 3 membres, pour 3 groupes'], - [[1, 1, 1], [8, 2], { ok: true }, 'sept sièges en surplus'], + 'une place vide : la table de 4 reçoit au moins 3 membres, pour 3 groupes'], + [[1, 1, 1], [8, 2], { ok: true }, 'sept places vides'], [[2, 2], [2, 1], { ok: true }, - 'salle trop petite : un surplus négatif compte pour zéro, la table de 2 tient dans 2 groupes'], + 'salle trop petite : aucune place vide, la table de 2 tient dans 2 groupes'], [[2, 2, 2], [4, 1], { ok: false, raison: 'PLUS_GRANDE_CAPACITE' }, - 'salle trop petite : un surplus négatif compte pour zéro, la table de 4 dépasse 3 groupes'], + 'salle trop petite : aucune place vide, la table de 4 dépasse 3 groupes'], [[3, 3, 3, 1, 1, 1], [6, 4, 2], { ok: true }, 'inégalités tenues, sans tour pourtant'], [[], [8], { ok: true }, 'aucun membre'], ]; @@ -235,7 +235,7 @@ describe('appartenances : le tour témoin', () => { test('petites instances : un tour quand, et seulement quand, la recherche exhaustive en trouve un', () => { // Domaine : 1 à 4 groupes de 1 à 4 membres, 1 à 4 tables de 1 à 4 sièges, // soit 340 × 340 instances. Il contient des instances sans tour où les - // deux inégalités tiennent, et des instances à sièges en surplus dont la + // deux inégalités tiennent, et des instances à places vides dont la // plus grande table dépasse le nombre de groupes et qui ont un tour. const ecarts = []; let avecTour = 0; @@ -264,6 +264,6 @@ describe('appartenances : le tour témoin', () => { assert.deepEqual(ecarts.slice(0, 12), []); assert.ok(avecTour > 0, 'aucune instance avec tour'); assert.ok(sansTourInegalitesTenues > 0, 'aucune instance où seul le tour témoin conclut'); - assert.ok(avecTourTableAuDelaDesGroupes > 0, 'aucune instance où le surplus de sièges compte'); + assert.ok(avecTourTableAuDelaDesGroupes > 0, 'aucune instance où les places vides comptent'); }); }); diff --git a/src/moteur/classement.js b/src/moteur/classement.js index 7e50d65..c943a7a 100644 --- a/src/moteur/classement.js +++ b/src/moteur/classement.js @@ -15,17 +15,18 @@ import { ecartsAuPlafondAPriori } from './manque.js'; /** * Les critères du classement, dans leur ordre d'application : - * - ecartAPrioriMax : le plus grand écart au plafond a priori, plafond a - * priori − rencontres, sur toutes les personnes ; + * - ecartAuPlafondAPrioriMax : le plus grand écart au plafond a priori, + * plafond a priori − rencontres, sur toutes les personnes, sous le nom + * que lui donne le glossaire du § 5.5 ; * - excedentCollisions et collisionsCumulees, tels que mesurer les rend ; * - rencontresRepetees : les rencontres répétées que le moteur a choisies ; * celles qu'imposent les réservations sont les mêmes pour toute * proposition, et ne départagent rien (§ 5.4) ; * - redondance : Σ_p r(p), la redondance d'appartenance sommée sur les - * personnes. + * personnes, que mesurer rend sous le nom totalRedondance. */ export const CRITERES = Object.freeze([ - 'ecartAPrioriMax', + 'ecartAuPlafondAPrioriMax', 'excedentCollisions', 'collisionsCumulees', 'rencontresRepetees', @@ -34,7 +35,7 @@ export const CRITERES = Object.freeze([ // Le critère qui lit le plafond a priori par personne : sauté quand il est // inconnu (§ 17, point 4). -const CRITERE_A_PRIORI = 'ecartAPrioriMax'; +const CRITERE_A_PRIORI = 'ecartAuPlafondAPrioriMax'; // Le plus grand élément d'une liste ; null pour une liste vide. function plusGrand(valeurs) { @@ -43,22 +44,16 @@ function plusGrand(valeurs) { return plus; } -// La somme d'une liste ; 0 pour une liste vide. -function somme(valeurs) { - let total = 0; - for (const valeur of valeurs) total += valeur; - return total; -} - // Valeur de chaque critère pour une proposition lue : { mesures, ecarts }, -// ecarts étant ses écarts au plafond a priori. La Map ne sert qu'à retrouver -// un critère par son nom ; CRITERES en fixe l'ordre. +// ecarts étant ses écarts au plafond a priori. Chaque grandeur se lit dans +// la mesure ou dans manque.js, aucune ne se refait ici. La Map ne sert qu'à +// retrouver un critère par son nom ; CRITERES en fixe l'ordre. const VALEUR = new Map([ - ['ecartAPrioriMax', ({ ecarts }) => plusGrand(ecarts)], + ['ecartAuPlafondAPrioriMax', ({ ecarts }) => plusGrand(ecarts)], ['excedentCollisions', ({ mesures }) => mesures.excedentCollisions], ['collisionsCumulees', ({ mesures }) => mesures.collisionsCumulees], ['rencontresRepetees', ({ mesures }) => mesures.rencontresRepetees.choisies], - ['redondance', ({ mesures }) => somme(mesures.redondance)], + ['redondance', ({ mesures }) => mesures.totalRedondance], ]); // Lève TypeError pour un identifiant qui n'est pas un entier, et RangeError diff --git a/src/moteur/classement.test.js b/src/moteur/classement.test.js index 515454b..48e88ee 100644 --- a/src/moteur/classement.test.js +++ b/src/moteur/classement.test.js @@ -30,7 +30,9 @@ function figer(valeur) { // plafond a priori de chacun, 8 − rencontres ; collisions est le triplet // (cumulées, distinctes, excédent) du § 5.4 ; repetees et imposees comptent // les rencontres répétées que le moteur a choisies et celles qu'imposent les -// réservations ; redondance est r(p) pour chacun. +// réservations ; redondance est Σ_p r(p), le total que mesurer rend sous le +// nom totalRedondance. La liste des r(p) n'y figure pas : classer ne la lit +// pas. function proposition( id, { @@ -38,7 +40,7 @@ function proposition( collisions = [0, 0, 0], repetees = 0, imposees = 0, - redondance = [0, 0, 0], + redondance = 0, } = {}, ) { const [collisionsCumulees, pairesDistinctes, excedentCollisions] = collisions; @@ -50,7 +52,7 @@ function proposition( pairesDistinctes, excedentCollisions, rencontresRepetees: { choisies: repetees, imposees }, - redondance, + totalRedondance: redondance, }, plafondsAPriori: [8, 8, 8], }; @@ -65,7 +67,7 @@ const permutations = ([a, b, c]) => [[a, b, c], [a, c, b], [b, a, c], [b, c, a], describe('classer : les critères et leur ordre (§ 5.7)', () => { test('CRITERES nomme les cinq critères dans leur ordre, et ne se modifie pas', () => { assert.deepEqual(CRITERES, [ - 'ecartAPrioriMax', + 'ecartAuPlafondAPrioriMax', 'excedentCollisions', 'collisionsCumulees', 'rencontresRepetees', @@ -104,7 +106,7 @@ describe('classer : les critères et leur ordre (§ 5.7)', () => { proposition(2, { collisions: [2, 1, 1], repetees: 1 }), // excédent 1 proposition(3, { collisions: [3, 3, 0] }), // 3 cumulées, excédent 0 proposition(4, { repetees: 1 }), // une rencontre répétée - proposition(5, { redondance: [1, 0, 0] }), // redondance 1 + proposition(5, { redondance: 1 }), // redondance 1 proposition(6), ]; assert.deepEqual(classer(figer(entrees)), { @@ -128,9 +130,11 @@ describe('classer : les critères et leur ordre (§ 5.7)', () => { assert.deepEqual(classer(entrees).ordre, [2, 1]); }); - test('la redondance se somme sur les personnes', () => { - // 1 : 1, 1, 1, somme 3 et plus grande 1 ; 2 : 2, 0, 0, somme 2 et plus grande 2. - const entrees = [proposition(1, { redondance: [1, 1, 1] }), proposition(2, { redondance: [2, 0, 0] })]; + test('la redondance se lit sur totalRedondance, la somme que mesurer rend', () => { + // 1 : r(p) vaut 1, 1, 1, somme 3 et plus grand 1 ; 2 : r(p) vaut 2, 0, + // 0, somme 2 et plus grand 2. La somme range 2 devant 1, le plus grand + // ferait l'inverse. + const entrees = [proposition(1, { redondance: 3 }), proposition(2, { redondance: 2 })]; assert.deepEqual(classer(entrees).ordre, [2, 1]); }); }); @@ -148,7 +152,7 @@ describe('classer : le critère sauté (§ 5.7, § 12.6)', () => { }); // Un seul a priori inconnu suffit : l'excédent ouvre alors le classement // de toutes, y compris de celle dont l'a priori est connu. - const attendu = { ordre: [2, 1], criteresAppliques: SANS_A_PRIORI, critereSaute: 'ecartAPrioriMax' }; + const attendu = { ordre: [2, 1], criteresAppliques: SANS_A_PRIORI, critereSaute: 'ecartAuPlafondAPrioriMax' }; assert.deepEqual(classer([premiere, inconnu(seconde)]), attendu); assert.deepEqual(classer([inconnu(premiere), seconde]), attendu); assert.deepEqual(classer([inconnu(premiere), inconnu(seconde)]), attendu); @@ -158,7 +162,7 @@ describe('classer : le critère sauté (§ 5.7, § 12.6)', () => { describe('classer : le départage par identifiant (§ 15.5, point 4)', () => { test("à égalité sur les cinq critères, l'identifiant croissant, quel que soit l'ordre reçu", () => { const egales = [7, 3, 5].map((id) => - proposition(id, { rencontres: [7, 8, 8], collisions: [2, 1, 1], repetees: 1, redondance: [1, 0, 0] }), + proposition(id, { rencontres: [7, 8, 8], collisions: [2, 1, 1], repetees: 1, redondance: 1 }), ); for (const ordreRecu of permutations(egales)) { assert.deepEqual(classer(ordreRecu).ordre, [3, 5, 7]); @@ -203,7 +207,7 @@ describe('classer : petite démonstration, conflit inévitable (§ 14.10, § 15. assert.deepEqual(classer(entrees.map(inconnu)), { ordre: [2, 1], criteresAppliques: SANS_A_PRIORI, - critereSaute: 'ecartAPrioriMax', + critereSaute: 'ecartAuPlafondAPrioriMax', }); }); }); @@ -216,7 +220,7 @@ describe('classer : les entrées reçues', () => { test("population vide, plafond a priori connu : le premier critère s'applique, sans rien départager", () => { // Une population vide n'a pas de plus grand écart au plafond a priori ; // ce n'est pas un plafond a priori inconnu, et rien n'est sauté. - const vide = (id) => ({ ...proposition(id, { rencontres: [], redondance: [] }), plafondsAPriori: [] }); + const vide = (id) => ({ ...proposition(id, { rencontres: [] }), plafondsAPriori: [] }); assert.deepEqual(classer([vide(2), vide(1)]), { ordre: [1, 2], criteresAppliques: [...CRITERES], @@ -238,7 +242,7 @@ describe('classer : les entrées reçues', () => { test('des mesures de populations de tailles différentes lèvent RangeError', () => { const quatre = { - ...proposition(2, { rencontres: [8, 8, 8, 8], redondance: [0, 0, 0, 0] }), + ...proposition(2, { rencontres: [8, 8, 8, 8] }), plafondsAPriori: [8, 8, 8, 8], }; assert.throws(() => classer([proposition(1), quatre]), RangeError); diff --git a/src/moteur/configuration.js b/src/moteur/configuration.js index 712f1e0..3f7a854 100644 --- a/src/moteur/configuration.js +++ b/src/moteur/configuration.js @@ -5,6 +5,13 @@ // que consomme le moteur (§ 4, § 5.2), et conversion d'un plan entre ses deux // formes (§ 8.9). Les formes sont décrites dans types.js. // +// Ce module définit aussi, une seule fois pour tout le moteur (§ 13.2), ce +// que les autres modules lisent de ces formes : les valeurs du contrat — +// STATUT, LIBRE, RESERVE, SANS_GROUPE —, la garde de forme d'un plan indexé, +// le nombre de places d'un tour, et la répartition des personnes entre les +// trois populations du § 5.4. Les autres modules et leurs épreuves les +// importent, sans en redéfinir aucun. +// // Trois familles de refus : // - ErreurConfiguration, avec un code, pour ce qu'une saisie, un import ou un // plan périmé peut porter : une donnée incohérente avec le reste de la @@ -26,15 +33,27 @@ // sont pas examinés. Aucune fonction ne modifie ce qu'elle reçoit. import { ErreurConfiguration } from './erreurs.js'; -// Statuts d'un participant (§ 4.2), valeurs de instance.statut. -const MOBILE = 0; -const PARTIELLEMENT_FIXE = 1; -const ANCRE = 2; +/** + * Statuts d'un participant (§ 4.2), valeurs de instance.statut. normaliser + * les dérive des réservations ; aucun n'est déclaré. Un partiellement fixé + * compte parmi les mobiles dans les populations du § 5.4. + */ +export const STATUT = Object.freeze({ MOBILE: 0, PARTIELLEMENT_FIXE: 1, ANCRE: 2 }); + +/** Case de instance.fixe sans table imposée. */ +export const LIBRE = -1; + +/** + * Case d'un plan indexé : la réserve. Elle vaut LIBRE, et une rangée de + * instance.fixe se lit ainsi comme le plan qui assied chacun à ses seuls + * tours fixés et le laisse en réserve aux autres : le diagnostic mesure ce + * plan, le plafond a priori part de cet itinéraire. + */ +export const RESERVE = -1; + +/** instance.groupe d'un participant sans appartenance. */ +export const SANS_GROUPE = -1; -// Case de instance.fixe sans table imposée. -const LIBRE = -1; -// Case d'un plan indexé : la réserve. -const RESERVE = -1; // Case d'un plan indexé qu'indexerPlan n'a pas encore remplie ; jamais rendue. const NON_PLACE = -2; @@ -97,9 +116,9 @@ function copierContraintes(contraintes) { } // Groupe de chaque participant présent, présents triés par identifiant -// croissant : un libellé reçoit le numéro de sa première apparition, −1 -// marque l'absence d'appartenance. L'égalité de chaînes fait le groupe. La -// Map ne sert qu'à retrouver un libellé déjà numéroté. +// croissant : un libellé reçoit le numéro de sa première apparition, +// SANS_GROUPE marque l'absence d'appartenance. L'égalité de chaînes fait le +// groupe. La Map ne sert qu'à retrouver un libellé déjà numéroté. function numeroterGroupes(presents) { const groupe = new Int32Array(presents.length); const groupes = []; @@ -107,7 +126,7 @@ function numeroterGroupes(presents) { for (let p = 0; p < presents.length; p += 1) { const { appartenance } = presents[p]; if (appartenance === null) { - groupe[p] = -1; + groupe[p] = SANS_GROUPE; continue; } if (!numeroDe.has(appartenance)) { @@ -218,13 +237,13 @@ function deriverStatuts(fixe, N, T, R) { if (t !== LIBRE) aucune = false; } if (aucune) { - statut[p] = MOBILE; + statut[p] = STATUT.MOBILE; } else if (memeTable) { - statut[p] = ANCRE; + statut[p] = STATUT.ANCRE; ancresParTable[premiere] += 1; k += 1; } else { - statut[p] = PARTIELLEMENT_FIXE; + statut[p] = STATUT.PARTIELLEMENT_FIXE; } } return { statut, ancresParTable, k }; @@ -415,21 +434,49 @@ export function indexerPlan(instance, plan) { return tableDe; } +/** + * La garde de forme d'un plan indexé, la seule du moteur : planDepuisIndex, + * mesurer et plafondsRealises l'appellent avant de lire une case. Lève + * RangeError quand tableDe n'a pas N × R cases, ou qu'une case n'est ni + * RESERVE ni un index de table entier de 0 à T − 1. Le message nomme alors + * le participant, par identifiant, le tour et la valeur de la première case + * fautive dans l'ordre des cases, p × R + r croissant ; une chaîne s'y cite + * entre guillemets, pour ne pas se lire comme le nombre qu'elle contient. + * + * Sans elle, une case de trop serait ignorée, et une chaîne numérique comme + * "0" assiérait la personne à la table qu'elle désigne là où une + * comparaison stricte à instance.fixe n'y reconnaîtrait pas sa réservation. + * + * @param {import('./types.js').Instance} instance + * @param {ArrayLike} tableDe + */ +export function exigerPlanIndexe({ N, T, R, ids }, tableDe) { + if (tableDe.length !== N * R) { + throw new RangeError(`plan indexé de ${tableDe.length} cases, N × R = ${N * R} attendues`); + } + for (let i = 0; i < tableDe.length; i += 1) { + const t = tableDe[i]; + if (t !== RESERVE && !(Number.isInteger(t) && t >= 0 && t < T)) { + throw new RangeError( + `plan indexé : participant ${ids[Math.floor(i / R)]}, tour ${(i % R) + 1}, ` + + `index de table ${decrire(t)} hors de −1..${T - 1}`, + ); + } + } +} + /** * Plan par identifiants d'un plan indexé : les tables dans l'ordre de * l'instance, chaque liste de table et chaque réserve en ids croissants - * (§ 8.9). Lève RangeError quand tableDe n'a pas N × R cases, ou qu'une case - * n'est ni −1 ni un index de table. + * (§ 8.9). Lève ce que lève exigerPlanIndexe. * * @param {import('./types.js').Instance} instance * @param {ArrayLike} tableDe * @returns {import('./types.js').Plan} */ export function planDepuisIndex(instance, tableDe) { + exigerPlanIndexe(instance, tableDe); const { N, T, R, ids, idsTables } = instance; - if (tableDe.length !== N * R) { - throw new RangeError(`plan indexé de ${tableDe.length} cases, N × R = ${N * R} attendues`); - } const tours = []; const reserves = []; for (let r = 0; r < R; r += 1) { @@ -439,16 +486,8 @@ export function planDepuisIndex(instance, tableDe) { // range chaque liste en ids croissants, sans tri. for (let p = 0; p < N; p += 1) { const t = tableDe[p * R + r]; - if (t === RESERVE) { - reserve.push(ids[p]); - } else if (Number.isInteger(t) && t >= 0 && t < T) { - listes[t].push(ids[p]); - } else { - throw new RangeError( - `plan indexé : participant ${ids[p]}, tour ${r + 1}, ` - + `index de table ${decrire(t)} hors de −1..${T - 1}`, - ); - } + if (t === RESERVE) reserve.push(ids[p]); + else listes[t].push(ids[p]); } tours.push(listes); reserves.push(reserve); @@ -456,6 +495,20 @@ export function planDepuisIndex(instance, tableDe) { return { tables: [...idsTables], tours, reserves }; } +/** + * Places qu'offre chaque tour : Σ c_t, la somme des capacités. Ni les + * participants ni les réservations n'y entrent. Le nombre de places + * manquantes et le diagnostic d'une salle tendue la lisent ici. + * + * @param {import('./types.js').Instance} instance T et capacite sont lus + * @returns {number} + */ +export function nombrePlaces({ T, capacite }) { + let places = 0; + for (let t = 0; t < T; t += 1) places += capacite[t]; + return places; +} + /** * Places qui manquent à chaque tour pour asseoir tous les participants : * max(0, N − Σ c_t). La quantité égale max(0, n − Σ (c_t − a_t)) et ne @@ -467,7 +520,36 @@ export function planDepuisIndex(instance, tableDe) { * @returns {number} */ export function nombrePlacesManquantes(instance) { - let places = 0; - for (let t = 0; t < instance.T; t += 1) places += instance.capacite[t]; - return Math.max(0, instance.N - places); + return Math.max(0, instance.N - nombrePlaces(instance)); +} + +/** + * Replie valeurs[p] sur les trois populations du § 5.4, en un passage, dans + * l'ordre canonique : chacun compte dans « tous », puis parmi les ancrés, ou + * parmi les mobiles, partiellement fixés compris. C'est le seul endroit qui + * range une personne dans sa population : les agrégats des indicateurs et + * les chiffres du manque le lisent tous deux. + * + * replier(cumul, valeur) rend le cumul suivant sans modifier celui qu'il + * reçoit : les trois populations partent du même initial, et une population + * sans membre le garde. + * + * @template C + * @param {ArrayLike<*>} valeurs une par personne, dans l'ordre de instance.ids + * @param {ArrayLike} statut instance.statut + * @param {C} initial + * @param {function(C, *): C} replier + * @returns {{tous: C, mobiles: C, ancres: C}} + */ +export function replierParPopulation(valeurs, statut, initial, replier) { + let tous = initial; + let mobiles = initial; + let ancres = initial; + for (let p = 0; p < valeurs.length; p += 1) { + const valeur = valeurs[p]; + tous = replier(tous, valeur); + if (statut[p] === STATUT.ANCRE) ancres = replier(ancres, valeur); + else mobiles = replier(mobiles, valeur); + } + return { tous, mobiles, ancres }; } diff --git a/src/moteur/configuration.test.js b/src/moteur/configuration.test.js index e1e2f35..4cf52c1 100644 --- a/src/moteur/configuration.test.js +++ b/src/moteur/configuration.test.js @@ -1,9 +1,11 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Épreuves de la configuration (§ 4, § 5.2, § 5.9) : l'instance indexée que -// rend normaliser et ses refus, le nombre de places manquantes, la conversion -// d'un plan entre la forme par identifiants et la forme indexée. Les +// Épreuves de la configuration (§ 4, § 5.2, § 5.9) : les valeurs du contrat +// de données, l'instance indexée que rend normaliser et ses refus, le nombre +// de places et de places manquantes, la conversion d'un plan entre la forme +// par identifiants et la forme indexée, la garde de forme d'un plan indexé +// et la répartition des personnes entre les trois populations du § 5.4. Les // identifiants diffèrent des index qu'ils reçoivent et des tables arrivent // hors de l'ordre de leurs identifiants : confondre un identifiant et un index // fait échouer une épreuve au lieu de passer inaperçu. @@ -11,11 +13,22 @@ import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { ErreurAnnulee, ErreurConfiguration } from './erreurs.js'; import { + LIBRE, + RESERVE, + SANS_GROUPE, + STATUT, + exigerPlanIndexe, indexerPlan, + nombrePlaces, nombrePlacesManquantes, normaliser, planDepuisIndex, + replierParPopulation, } from './configuration.js'; +import { mesurer } from './indicateurs.js'; +import { plafondsRealises } from './plafond.js'; + +const { MOBILE, PARTIELLEMENT_FIXE, ANCRE } = STATUT; const SANS_CONTRAINTE = { separerAppartenances: false, @@ -89,6 +102,17 @@ function ecartsDeRefus(cas) { .map(([libelle, ecart]) => `${libelle} : ${ecart}`); } +describe('valeurs du contrat de données (types.js)', () => { + test('STATUT, LIBRE, RESERVE et SANS_GROUPE valent ce que types.js écrit, et STATUT ne se modifie pas', () => { + // Seule épreuve qui écrit ces valeurs en clair : les autres les + // importent. LIBRE et RESERVE sont égales : une rangée de instance.fixe se + // lit comme le plan qui laisse en réserve chaque tour libre. + assert.deepEqual(STATUT, { MOBILE: 0, PARTIELLEMENT_FIXE: 1, ANCRE: 2 }); + assert.ok(Object.isFrozen(STATUT)); + assert.deepEqual([LIBRE, RESERVE, SANS_GROUPE], [-1, -1, -1]); + }); +}); + describe('normaliser : statuts (§ 4.2, § 4.3)', () => { test('le statut se dérive du fait : ancré, partiellement fixé ou mobile', () => { const instance = normaliser(configuration({ @@ -112,16 +136,21 @@ describe('normaliser : statuts (§ 4.2, § 4.3)', () => { // seul dernier tour ; 9 : chaque tour, la même table au premier et au // dernier, une autre entre les deux. Seuls 1 et 2 comptent dans k : un // partiellement fixé compte parmi les mobiles. - assert.deepEqual(instance.statut, Uint8Array.from([2, 2, 1, 1, 1, 0, 1, 1, 1])); + assert.deepEqual(instance.statut, Uint8Array.from([ + ANCRE, ANCRE, + PARTIELLEMENT_FIXE, PARTIELLEMENT_FIXE, PARTIELLEMENT_FIXE, + MOBILE, + PARTIELLEMENT_FIXE, PARTIELLEMENT_FIXE, PARTIELLEMENT_FIXE, + ])); assert.deepEqual(instance.fixe, Int32Array.from([ 0, 0, 0, 1, 1, 1, - -1, 2, -1, - 0, 1, -1, + LIBRE, 2, LIBRE, + 0, 1, LIBRE, 2, 2, 3, - -1, -1, -1, - 3, -1, -1, - -1, -1, 3, + LIBRE, LIBRE, LIBRE, + 3, LIBRE, LIBRE, + LIBRE, LIBRE, 3, 1, 2, 1, ])); assert.equal(instance.k, 2); @@ -135,8 +164,8 @@ describe('normaliser : statuts (§ 4.2, § 4.3)', () => { tours: 1, reservations: [auTour(1, 11, 1)], })); - assert.deepEqual(unTour.statut, Uint8Array.from([2, 0])); - assert.deepEqual(unTour.fixe, Int32Array.from([0, -1])); + assert.deepEqual(unTour.statut, Uint8Array.from([ANCRE, MOBILE])); + assert.deepEqual(unTour.fixe, Int32Array.from([0, LIBRE])); assert.equal(unTour.k, 1); }); }); @@ -162,7 +191,7 @@ describe('normaliser : ancrés et instance réduite (§ 5.2)', () => { assert.equal(instance.k, 4); assert.equal(instance.n, 16); assert.deepEqual(instance.ancresParTable, Int32Array.from([1, 1, 1, 1])); - assert.equal(instance.statut[instance.indexDe.get(2)], 1); + assert.equal(instance.statut[instance.indexDe.get(2)], PARTIELLEMENT_FIXE); }); test('ancresParTable suit les index de table, dans l’ordre de configuration.tables', () => { @@ -195,8 +224,8 @@ describe('normaliser : exclusion (§ 4.4)', () => { assert.equal(instance.k, 0); assert.equal(instance.n, 3); assert.deepEqual(instance.ancresParTable, Int32Array.from([0, 0])); - assert.deepEqual(instance.fixe, new Int32Array(6).fill(-1)); - assert.deepEqual(instance.statut, new Uint8Array(3)); + assert.deepEqual(instance.fixe, new Int32Array(6).fill(LIBRE)); + assert.deepEqual(instance.statut, new Uint8Array(3).fill(MOBILE)); }); test('la réservation d’un exclu rend sa place : elle n’entre pas dans la surréservation', () => { @@ -234,7 +263,7 @@ describe('normaliser : exclusion (§ 4.4)', () => { reservations: [tous(2, 11), tous(1, 12)], })); assert.equal(instance.k, 1); - assert.deepEqual(instance.statut, Uint8Array.from([2])); + assert.deepEqual(instance.statut, Uint8Array.from([ANCRE])); assert.deepEqual(instance.fixe, Int32Array.from([1, 1])); assert.deepEqual( ecartsDeRefus([[ @@ -290,7 +319,7 @@ describe('normaliser : appartenances', () => { const instance = normaliser(configuration({ participants, tables: tablesDe([11], 8), tours: 1 })); assert.deepEqual(instance.ids, [1, 2, 4, 5, 7, 9]); assert.deepEqual(instance.groupes, ['Y', 'Z', 'X']); - assert.deepEqual(instance.groupe, Int32Array.from([0, 1, 1, 2, -1, 0])); + assert.deepEqual(instance.groupe, Int32Array.from([0, 1, 1, 2, SANS_GROUPE, 0])); } }); @@ -308,7 +337,7 @@ describe('normaliser : appartenances', () => { ]; const instance = normaliser(configuration({ participants, tables: tablesDe([11], 8), tours: 1 })); assert.deepEqual(instance.groupes, ['X', 'x', 'X ', '']); - assert.deepEqual(instance.groupe, Int32Array.from([0, 1, 2, 0, 3, -1, 3])); + assert.deepEqual(instance.groupe, Int32Array.from([0, 1, 2, 0, 3, SANS_GROUPE, 3])); }); }); @@ -513,6 +542,25 @@ describe('normaliser : refus (§ 5.9, § 6.2)', () => { }); }); +describe('nombrePlaces (§ 5.9)', () => { + test('somme les capacités : ni les participants, ni les exclus, ni les réservations ne la changent', () => { + const tables = [ + { id: 13, numero: 1, capacite: 3 }, + { id: 11, numero: 2, capacite: 2 }, + { id: 12, numero: 3, capacite: 7 }, + ]; + const salle = (participants, reservations = []) => + normaliser(configuration({ participants, tables, tours: 2, reservations })); + assert.equal(nombrePlaces(salle(personnes(suite(1, 4)))), 12); + assert.equal(nombrePlaces(salle(personnes(suite(1, 20)))), 12); + assert.equal(nombrePlaces(salle([], [])), 12); + assert.equal( + nombrePlaces(salle([...personnes(suite(1, 4)), exclu(5)], [tous(1, 13), auTour(2, 12, 2), tous(5, 11)])), + 12, + ); + }); +}); + describe('nombrePlacesManquantes (§ 5.9)', () => { const salle = (reservations = [], participants = personnes(suite(1, 15))) => normaliser(configuration({ participants, tables: tablesDe([11, 12, 13], 4), tours: 2, reservations })); @@ -579,8 +627,8 @@ describe('plans : forme par identifiants et forme indexée (§ 8.9)', () => { // reste vide à un tour, et une liste de table comme une réserve portent // deux ids, dont l'ordre croissant se voit. const TABLE_DE = [ - -1, 0, - -1, 1, + RESERVE, 0, + RESERVE, 1, 0, 2, 2, 1, ]; @@ -610,13 +658,13 @@ describe('plans : forme par identifiants et forme indexée (§ 8.9)', () => { test('indexerPlan ∘ planDepuisIndex est l’identité sur chaque plan indexé de l’instance', () => { const instance = instancePlans(); const cases = instance.N * instance.R; - // Chaque case prend une valeur de −1 (la réserve) à T − 1. + // Chaque case prend une valeur de RESERVE, −1, à T − 1. const valeurs = instance.T + 1; let examines = 0; for (let rang = 0; rang < valeurs ** cases; rang += 1) { const tableDe = new Int32Array(cases); for (let i = 0, reste = rang; i < cases; i += 1, reste = Math.floor(reste / valeurs)) { - tableDe[i] = (reste % valeurs) - 1; + tableDe[i] = RESERVE + (reste % valeurs); } const retour = indexerPlan(instance, planDepuisIndex(instance, tableDe)); if (retour.length !== tableDe.length || retour.some((valeur, i) => valeur !== tableDe[i])) { @@ -719,30 +767,113 @@ describe('plans : forme par identifiants et forme indexée (§ 8.9)', () => { assert.deepEqual(ecartsDeRefus(cas), []); }); - test('planDepuisIndex refuse un plan indexé hors de la forme de l’instance', () => { + // TABLE_DE dont les cases nommées reçoivent une autre valeur : fautes est + // une liste de couples [case, valeur]. + const avecFautes = (fautes) => { + const tableDe = [...TABLE_DE]; + for (const [i, valeur] of fautes) tableDe[i] = valeur; + return tableDe; + }; + + // Plans indexés hors de la forme de l'instance, et le message qui les + // refuse. Les ids [3, 6, 8, 11] occupent les index 0 à 3 ; la case p × 2 + r + // est celle de l'index p au tour r + 1. Trois tables : un index de table va + // de 0 à 2. Une chaîne numérique n'est pas un index, et se cite entre + // guillemets. Deux cases fautives : la première dans l'ordre des cases est + // nommée, la case 1 (3 au tour 2) avant la case 2 (6 au tour 1). + const FAUTIFS = [ + ['longueur N × R − 1', TABLE_DE.slice(1), 'plan indexé de 7 cases, N × R = 8 attendues'], + ['longueur N × R + 1', [...TABLE_DE, RESERVE], 'plan indexé de 9 cases, N × R = 8 attendues'], + ['longueur N × T', Array(12).fill(RESERVE), 'plan indexé de 12 cases, N × R = 8 attendues'], + ['index de table T', avecFautes([[0, 3]]), 'participant 3, tour 1, index de table 3'], + ['index −2', avecFautes([[3, -2]]), 'participant 6, tour 2, index de table -2'], + ['index non entier', avecFautes([[5, 0.5]]), 'participant 8, tour 2, index de table 0.5'], + ['index null', avecFautes([[4, null]]), 'participant 8, tour 1, index de table null'], + ['chaîne "0"', avecFautes([[6, '0']]), 'participant 11, tour 1, index de table "0"'], + ['chaîne "-1"', avecFautes([[7, '-1']]), 'participant 11, tour 2, index de table "-1"'], + ['deux cases fautives', avecFautes([[2, 9], [1, 9]]), 'participant 3, tour 2, index de table 9'], + ].map(([libelle, tableDe, message]) => [ + libelle, + tableDe, + message.startsWith('plan indexé de') ? message : `plan indexé : ${message} hors de −1..2`, + ]); + + // « Nom : message » de l'erreur que lève fonction, « aucun refus » sinon. + function erreurDe(fonction) { + try { + fonction(); + } catch (erreur) { + return `${erreur?.name} : ${erreur?.message}`; + } + return 'aucun refus'; + } + + test('exigerPlanIndexe accepte un plan indexé de la forme de l’instance, Int32Array ou liste, sans le modifier', () => { const instance = instancePlans(); - const variantes = [ - ['longueur N × R − 1', TABLE_DE.slice(1)], - ['longueur N × R + 1', [...TABLE_DE, -1]], - ['longueur N × T', Array.from({ length: instance.N * instance.T }, () => -1)], - ['index de table T', TABLE_DE.map((valeur, i) => (i === 0 ? instance.T : valeur))], - ['index −2', TABLE_DE.map((valeur, i) => (i === 3 ? -2 : valeur))], - ['index non entier', TABLE_DE.map((valeur, i) => (i === 5 ? 0.5 : valeur))], - ]; - assert.ok(variantes.length > 0, 'aucune variante examinée'); + assert.equal(exigerPlanIndexe(instance, Int32Array.from(TABLE_DE)), undefined); + assert.equal(exigerPlanIndexe(instance, geler([...TABLE_DE])), undefined); + }); + + test('exigerPlanIndexe refuse un plan indexé hors forme : RangeError qui nomme le participant, le tour et la valeur de la première case fautive', () => { + const instance = instancePlans(); + assert.ok(FAUTIFS.length > 0, 'aucune variante examinée'); const ecarts = []; - for (const [libelle, tableDe] of variantes) { - try { - planDepuisIndex(instance, tableDe); - ecarts.push(`${libelle} : aucun refus`); - } catch (erreur) { - if (!(erreur instanceof RangeError)) { - ecarts.push(`${libelle} : ${erreur?.name} au lieu de RangeError`); - } - } + for (const [libelle, tableDe, message] of FAUTIFS) { + const recue = erreurDe(() => exigerPlanIndexe(instance, tableDe)); + const attendue = `RangeError : ${message}`; + if (recue !== attendue) ecarts.push(`${libelle} : « ${recue} » au lieu de « ${attendue} »`); } assert.deepEqual(ecarts, []); }); + + test('une seule garde : planDepuisIndex, mesurer et plafondsRealises lèvent l’erreur de exigerPlanIndexe, au même message', () => { + const instance = instancePlans(); + const appelants = [ + ['planDepuisIndex', (tableDe) => planDepuisIndex(instance, tableDe)], + ['mesurer', (tableDe) => mesurer(instance, tableDe)], + ['plafondsRealises', (tableDe) => plafondsRealises(instance, tableDe)], + ]; + const ecarts = []; + let confrontees = 0; + for (const [libelle, tableDe] of FAUTIFS) { + const attendue = erreurDe(() => exigerPlanIndexe(instance, tableDe)); + for (const [nom, appeler] of appelants) { + confrontees += 1; + const recue = erreurDe(() => appeler(tableDe)); + if (recue !== attendue) ecarts.push(`${nom}, ${libelle} : « ${recue} » au lieu de « ${attendue} »`); + } + } + assert.equal(confrontees, 3 * FAUTIFS.length); + assert.deepEqual(ecarts, []); + }); +}); + +describe('replierParPopulation (§ 5.4)', () => { + test('chacun compte dans tous, puis parmi les ancrés ou parmi les mobiles, partiellement fixés compris, dans l’ordre canonique', () => { + const statut = Uint8Array.from([ANCRE, MOBILE, PARTIELLEMENT_FIXE, ANCRE, MOBILE, PARTIELLEMENT_FIXE]); + const enListe = (cumul, valeur) => [...cumul, valeur]; + assert.deepEqual(replierParPopulation([10, 11, 12, 13, 14, 15], statut, [], enListe), { + tous: [10, 11, 12, 13, 14, 15], + mobiles: [11, 12, 14, 15], + ancres: [10, 13], + }); + }); + + test('les trois populations partent de la valeur initiale ; une population sans membre la garde', () => { + const plusGrand = (cumul, valeur) => (cumul === null || valeur > cumul ? valeur : cumul); + assert.deepEqual( + replierParPopulation([4, 9], Uint8Array.from([MOBILE, PARTIELLEMENT_FIXE]), null, plusGrand), + { tous: 9, mobiles: 9, ancres: null }, + ); + assert.deepEqual( + replierParPopulation([3], Uint8Array.from([ANCRE]), null, plusGrand), + { tous: 3, mobiles: null, ancres: 3 }, + ); + assert.deepEqual( + replierParPopulation([], new Uint8Array(0), 'vide', plusGrand), + { tous: 'vide', mobiles: 'vide', ancres: 'vide' }, + ); + }); }); describe('erreurs du moteur', () => { diff --git a/src/moteur/diagnostic.js b/src/moteur/diagnostic.js index 26b590c..acaa739 100644 --- a/src/moteur/diagnostic.js +++ b/src/moteur/diagnostic.js @@ -10,9 +10,10 @@ // ailleurs, jamais 0 par défaut. // // Les quantités que le moteur calcule déjà viennent de leur unique -// implémentation (§ 13.2) : places manquantes de configuration.js, plafond a -// priori de plafond.js, retours imposés de la mesure d'indicateurs.js. Le -// diagnostic ne modifie pas la configuration et lève ce que normaliser lève. +// implémentation (§ 13.2) : nombre de places et places manquantes de +// configuration.js, plafond a priori de plafond.js, retours imposés de la +// mesure d'indicateurs.js. Le diagnostic ne modifie pas la configuration et +// lève ce que normaliser lève. /** * @typedef {Object} PlancherCollisions @@ -39,19 +40,17 @@ * @property {number} retoursImposes * @property {{plancher: number, mobilesAuMoins: number}|null} ecartItineraire */ -import { nombrePlacesManquantes, normaliser } from './configuration.js'; +import { + LIBRE, + SANS_GROUPE, + STATUT, + nombrePlaces, + nombrePlacesManquantes, + normaliser, +} from './configuration.js'; import { mesurer } from './indicateurs.js'; import { plafondsAPriori } from './plafond.js'; -// Statuts d'un participant dans instance.statut (§ 4.2). -const MOBILE = 0; -const PARTIELLEMENT_FIXE = 1; -const ANCRE = 2; -// Case de instance.fixe sans table imposée. -const LIBRE = -1; -// instance.groupe d'un participant sans appartenance. -const SANS_GROUPE = -1; - // Paires que forment n personnes : C(n, 2). const paires = (n) => (n * (n - 1)) / 2; @@ -145,7 +144,7 @@ function animateursMemeAppartenance({ N, statut, groupe }) { let commun = SANS_GROUPE; let animateurs = 0; for (let p = 0; p < N; p += 1) { - if (statut[p] !== ANCRE) continue; + if (statut[p] !== STATUT.ANCRE) continue; if (groupe[p] === SANS_GROUPE || (animateurs > 0 && groupe[p] !== commun)) return false; commun = groupe[p]; animateurs += 1; @@ -205,10 +204,8 @@ function plafondPassantPar(configuration, instance, p, t) { // s'ajoutent au plafond hors du terme que n_p borne. function ecartItineraire(configuration, instance, aPriori) { const { N, T, R, capacite, ancresParTable, statut } = instance; - let places = 0; - for (let t = 0; t < T; t += 1) places += capacite[t]; - if (places !== N || statut.includes(PARTIELLEMENT_FIXE)) return null; - const mobile = statut.indexOf(MOBILE); + if (nombrePlaces(instance) !== N || statut.includes(STATUT.PARTIELLEMENT_FIXE)) return null; + const mobile = statut.indexOf(STATUT.MOBILE); if (mobile === -1) return null; const plafondCommun = aPriori[mobile]; const parGabarit = new Map(); diff --git a/src/moteur/diagnostic.test.js b/src/moteur/diagnostic.test.js index 25b36cf..a2bd4b2 100644 --- a/src/moteur/diagnostic.test.js +++ b/src/moteur/diagnostic.test.js @@ -11,7 +11,7 @@ import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { CATALOGUE, PLAN_PARFAIT_PETITE } from '../demo/catalogue.js'; -import { indexerPlan, normaliser } from './configuration.js'; +import { LIBRE, STATUT, indexerPlan, normaliser } from './configuration.js'; import { diagnostiquer } from './diagnostic.js'; import { ErreurConfiguration } from './erreurs.js'; import { mesurer } from './indicateurs.js'; @@ -24,10 +24,6 @@ const SANS_CONTRAINTE = { varierAppartenances: false, }; -// instance.statut d'un mobile, d'un ancré. -const MOBILE = 0; -const ANCRE = 2; - /** * Configuration écrite à la main. tables porte une paire [id, capacité] par * table, dans l'ordre de la configuration, le numéro suivant la position ; @@ -81,7 +77,7 @@ function enumererPlans(instance, visiter) { return; } const r = i % R; - for (const t of fixe[i] === -1 ? toutes : [fixe[i]]) { + for (const t of fixe[i] === LIBRE ? toutes : [fixe[i]]) { if (occupation[t * R + r] === capacite[t]) continue; occupation[t * R + r] += 1; tableDe[i] = t; @@ -133,7 +129,7 @@ function ecartsItineraireSurTousLesPlans(configuration, seuil) { for (let p = 0; p < instance.N; p += 1) { const ecartItineraire = aPriori[p] - realises[p]; maximum = Math.max(maximum, ecartItineraire); - if (instance.statut[p] !== ANCRE && ecartItineraire >= seuil) mobiles += 1; + if (instance.statut[p] !== STATUT.ANCRE && ecartItineraire >= seuil) mobiles += 1; } plusPetitMaximum = Math.min(plusPetitMaximum, maximum); moinsDeMobiles = Math.min(moinsDeMobiles, mobiles); @@ -152,7 +148,7 @@ function ecartsItineraireSurTousLesPlans(configuration, seuil) { function meilleursItineraires(configuration) { const instance = normaliser(configuration); const { T, R, capacite, ancresParTable } = instance; - const mobile = instance.statut.indexOf(MOBILE); + const mobile = instance.statut.indexOf(STATUT.MOBILE); assert.notEqual(mobile, -1, 'aucun mobile'); const admissibles = []; for (let t = 0; t < T; t += 1) if (ancresParTable[t] < capacite[t]) admissibles.push(t); diff --git a/src/moteur/indicateurs.js b/src/moteur/indicateurs.js index 5220b76..4536bfc 100644 --- a/src/moteur/indicateurs.js +++ b/src/moteur/indicateurs.js @@ -14,6 +14,12 @@ // sans appartenance ne sont pas collègues. Un ensemble vide rend null, jamais // 0 : le taux de diversité de qui ne rencontre aucun affilié, le minimum et la // moyenne d'une population sans membre. +import { + RESERVE, + SANS_GROUPE, + exigerPlanIndexe, + replierParPopulation, +} from './configuration.js'; /** * @typedef {Object} Agregat @@ -39,6 +45,9 @@ * @property {number[]} retoursImposes par personne * @property {TroisAgregats} aggRencontres * @property {TroisAgregats} aggRedondance + * @property {number} totalRedondance Σ_p r(p), le seul total de la + * redondance : la recherche et le + * classement le lisent * @property {number} collisionsCumulees Σ paires de même groupe × tours ensemble * @property {number} pairesDistinctes paires de même groupe réunies ≥ 1 fois * @property {number} excedentCollisions cumulées − distinctes @@ -53,44 +62,9 @@ * ordre de instance.groupes */ -// Case d'un plan indexé : la réserve. -const RESERVE = -1; -// instance.groupe d'un participant sans appartenance. -const SANS_GROUPE = -1; -// instance.statut d'un ancré ; 0 marque un mobile, 1 un partiellement fixé. -const ANCRE = 2; // Plus grand compte que tient un Uint16Array. const UINT16_MAX = 0xffff; -// Valeur citée dans un message : une chaîne s'écrit entre guillemets, pour -// ne pas se lire comme le nombre qu'elle contient. -const decrire = (valeur) => (typeof valeur === 'string' ? JSON.stringify(valeur) : String(valeur)); - -// Lève RangeError quand tableDe n'a pas N × R cases, ou qu'une case n'est ni -// −1 ni un index de table entier ; le message nomme alors le participant et -// le tour. Sans cette garde, deux écarts passeraient sans bruit : une case de -// trop serait ignorée ; une chaîne numérique comme "0" assiérait la personne -// à la table qu'elle désigne, mais compterRetours, qui la compare strictement -// à instance.fixe, n'y reconnaîtrait pas sa réservation. Une case manquante -// ou toute autre valeur lèverait dans compterRencontres une TypeError qui ne -// nomme ni la personne ni le tour. -function exigerForme({ N, T, R, ids }, tableDe) { - if (tableDe.length !== N * R) { - throw new RangeError(`plan indexé de ${tableDe.length} cases, N × R = ${N * R} attendues`); - } - 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 && !(Number.isInteger(t) && t >= 0 && t < T)) { - throw new RangeError( - `plan indexé : participant ${ids[p]}, tour ${r + 1}, ` - + `index de table ${decrire(t)} hors de −1..${T - 1}`, - ); - } - } - } -} - // Rang de la paire (a, b), a < b, dans le triangle supérieur d'une matrice // N × N rangé ligne par ligne : la ligne a s'ouvre après les // (N − 1) + (N − 2) + … + (N − a) paires des lignes qui la précèdent. @@ -232,13 +206,16 @@ function compterRetours({ N, T, R, fixe }, tableDe) { return { toursAssis, retoursChoisis, retoursImposes }; } -// Ajoute une valeur à une population. min reste null tant que la population -// est vide : aucun Infinity ne sert de minimum initial. -function ajouter(population, valeur) { - if (population.min === null || valeur < population.min) population.min = valeur; - population.somme += valeur; - population.effectif += 1; -} +// Population sans membre. min reste null tant qu'aucune valeur n'y entre : +// aucun Infinity ne sert de minimum initial. +const VIDE = Object.freeze({ min: null, somme: 0, effectif: 0 }); + +// La population, une valeur de plus ; celle qu'elle reçoit reste intacte. +const ajouter = ({ min, somme, effectif }, valeur) => ({ + min: min === null || valeur < min ? valeur : min, + somme: somme + valeur, + effectif: effectif + 1, +}); // Agrégat d'une population ; la moyenne d'une population vide vaut null. const conclure = ({ min, somme, effectif }) => ({ @@ -247,17 +224,10 @@ const conclure = ({ min, somme, effectif }) => ({ effectif, }); -// Minimum, moyenne et effectif de valeurs[p] sur les trois populations, en un -// passage : chacun compte dans « tous », puis parmi les ancrés ou parmi les -// mobiles, qui comprennent les partiellement fixés (§ 5.4). +// Minimum, moyenne et effectif de valeurs[p] sur les trois populations du +// § 5.4, que replierParPopulation forme. function troisAgregats(valeurs, statut) { - const tous = { min: null, somme: 0, effectif: 0 }; - const mobiles = { min: null, somme: 0, effectif: 0 }; - const ancres = { min: null, somme: 0, effectif: 0 }; - for (let p = 0; p < valeurs.length; p += 1) { - ajouter(tous, valeurs[p]); - ajouter(statut[p] === ANCRE ? ancres : mobiles, valeurs[p]); - } + const { tous, mobiles, ancres } = replierParPopulation(valeurs, statut, VIDE, ajouter); return { tous: conclure(tous), mobiles: conclure(mobiles), ancres: conclure(ancres) }; } @@ -265,15 +235,17 @@ const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0); /** * Mesure complète d'un plan indexé : la mesure qui fait foi (§ 5.10). Ne - * modifie ni l'instance ni le plan. Lève RangeError quand tableDe n'a pas - * N × R cases, ou qu'une case n'est ni −1 ni un index de table. + * modifie ni l'instance ni le plan. Lève ce que lève exigerPlanIndexe + * (configuration.js) avant de lire une case : une case manquante ou d'une + * autre forme lèverait plus loin une erreur qui ne nommerait ni la personne + * ni le tour. * * @param {import('./types.js').Instance} instance - * @param {ArrayLike} tableDe tableDe[p * R + r] = index de table, −1 pour la réserve + * @param {ArrayLike} tableDe tableDe[p * R + r] = index de table, RESERVE pour la réserve * @returns {Mesures} */ export function mesurer(instance, tableDe) { - exigerForme(instance, tableDe); + exigerPlanIndexe(instance, tableDe); const { N, groupe, groupes, statut } = instance; const paires = lirePaires(instance, compterRencontres(instance, tableDe)); const { collisions, distinctes } = paires; @@ -311,6 +283,7 @@ export function mesurer(instance, tableDe) { retoursImposes, aggRencontres: troisAgregats(rencontres, statut), aggRedondance: troisAgregats(redondance, statut), + totalRedondance: somme(redondance), collisionsCumulees, pairesDistinctes, excedentCollisions: collisionsCumulees - pairesDistinctes, diff --git a/src/moteur/indicateurs.test.js b/src/moteur/indicateurs.test.js index c85442e..0767d0d 100644 --- a/src/moteur/indicateurs.test.js +++ b/src/moteur/indicateurs.test.js @@ -135,6 +135,7 @@ describe('mesurer', () => { mobiles: { min: 6, moyenne: 6, effectif: 12 }, ancres: AUCUN, }, + totalRedondance: 72, collisionsCumulees: 0, pairesDistinctes: 0, excedentCollisions: 0, @@ -236,6 +237,21 @@ describe('mesurer', () => { assert.deepEqual(mesures.rencontresRepetees, { choisies: 1, imposees: 0 }); }); + test('totalRedondance somme r(p) sur les personnes, et non son plus grand', () => { + // Le plan de l'épreuve précédente : r(p) vaut 1, 0, 1, 0 et 0. Le total + // est le seul Σ r(p) du moteur ; la recherche et le classement le lisent. + const mesures = mesurerPlan({ + appartenance: ['X', 'X', 'Y', 'X', null], + capacite: 3, + tours: [ + [[1, 2, 3], [4], [], [5]], + [[], [1, 4], [2, 3], [5]], + ], + }); + assert.deepEqual(mesures.redondance, [1, 0, 1, 0, 0]); + assert.equal(mesures.totalRedondance, 2); + }); + test("F(p) ne retient que les affiliés : qui ne croise aucun affilié n'a pas de taux de diversité", () => { // 3, seul affilié de la table, rencontre deux personnes sans // appartenance ; chacune d'elles rencontre un seul affilié, 3. @@ -505,6 +521,7 @@ describe('mesurer', () => { retoursImposes: [], aggRencontres: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN }, aggRedondance: { tous: AUCUN, mobiles: AUCUN, ancres: AUCUN }, + totalRedondance: 0, collisionsCumulees: 0, pairesDistinctes: 0, excedentCollisions: 0, diff --git a/src/moteur/integration.long.test.js b/src/moteur/integration.long.test.js index 977c614..418ca14 100644 --- a/src/moteur/integration.long.test.js +++ b/src/moteur/integration.long.test.js @@ -1,18 +1,20 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Épreuve d'intégration lourde du moteur, sur la grande démonstration -// (§ 5.5, § 12.10.4, § 15.1). Trois propositions traversent la chaîne +// Épreuves d'intégration lourdes du moteur. D'abord la grande démonstration +// (§ 5.5, § 12.10.4, § 15.1) : trois propositions traversent la chaîne // d'integration.test.js, de rechercher au certificat. La configuration est // exactement tendue et impose ses planchers, que le diagnostic chiffre avant // la recherche : 99 retours imposés par les 33 ancrages ; un écart // d'itinéraire maximal d'au moins 1, porté par au moins 24 mobiles ; un // certificat « minimum atteint » hors d'atteinte. Les seuils de qualité sont -// posés après mesure, la mesure en commentaire (§ 14.1). +// posés après mesure, la mesure en commentaire (§ 14.1). Puis la grille de +// propriétés du § 14.12, sur des configurations tirées. import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import { CATALOGUE } from '../demo/catalogue.js'; -import { indexerPlan, normaliser } from './configuration.js'; +import { FLUX, creerPcg32 } from '../demo/prng.js'; +import { STATUT, indexerPlan, nombrePlaces, normaliser } from './configuration.js'; import { diagnostiquer } from './diagnostic.js'; import { mesurer } from './indicateurs.js'; import { @@ -22,12 +24,17 @@ import { troisChiffres, } from './manque.js'; import { plafondsAPriori, plafondsRealises } from './plafond.js'; -import { rechercher } from './recherche.js'; +import { + creerEtat, + defaire, + proposerEtAppliquer, + rechercher, + regenerer, + scoreComplet, + scoreIncremental, +} from './recherche.js'; import { verifierIndicateurs, verifierInvariants } from './verification.js'; -// Statut d'un ancré dans instance.statut (§ 4.2) ; les autres sont mobiles. -const ANCRE = 2; - // Compte d'arrêt choisi par la mesure. À 1 000 000 mouvements, chacune des // trois propositions atteint, sur chaque seuil ci-dessous, la meilleure // valeur que la configuration permette, sans rencontre répétée ni retour @@ -80,6 +87,17 @@ const COLLISIONS_CUMULEES_MAX = 0; // Plus grand élément d'une liste non vide. const plusGrand = (valeurs) => valeurs.reduce((plus, valeur) => Math.max(plus, valeur)); +// Nombre de personnes non ancrées dont l'écart d'itinéraire, ecarts[p] dans +// l'ordre canonique, atteint plancher. Le diagnostic en écrit le minimum, +// mobilesAuMoins, à côté de plancher. +function mobilesAuPlancher(instance, ecarts, plancher) { + let compte = 0; + for (let p = 0; p < instance.N; p += 1) { + if (instance.statut[p] !== STATUT.ANCRE && ecarts[p] >= plancher) compte += 1; + } + return compte; +} + // La génération et ses mesures, calculées au premier appel puis partagées // par les épreuves du fichier : la recherche en est le coût. Chaque // proposition porte la liste de ses violations ; ses mesures, ses plafonds @@ -157,10 +175,7 @@ describe('intégration : la grande démonstration de bout en bout (§ 5.5, § 12 chiffres.ecartItineraireMax.tous >= plancher, `proposition ${id} : écart d'itinéraire maximal ${chiffres.ecartItineraireMax.tous}, plancher ${plancher}`, ); - let mobilesSous = 0; - for (let p = 0; p < instance.N; p += 1) { - if (instance.statut[p] !== ANCRE && ecarts[p] >= plancher) mobilesSous += 1; - } + const mobilesSous = mobilesAuPlancher(instance, ecarts, plancher); assert.ok( mobilesSous >= mobilesAuMoins, `proposition ${id} : ${mobilesSous} mobiles sous leur plafond a priori, au moins ${mobilesAuMoins} attendus`, @@ -200,3 +215,326 @@ describe('intégration : la grande démonstration de bout en bout (§ 5.5, § 12 } }); }); + +// Grille de propriétés (§ 14.12). Les quatre démonstrations ont quatre +// tours, chacun y porte une appartenance, aucune n'a de partiellement fixé +// ni d'exclu, et une seule laisse des places vides : la grille tire des +// configurations hors de ce cadre, d'une graine écrite ici. Les propriétés +// éprouvées valent pour tout plan que rend la recherche, quelle qu'en soit +// la qualité : un compte d'arrêt bref suffit. Un historique court fait jouer +// la bascule et la relance de la descente : avec cette graine, 86 des 460 +// propositions basculent dans l'ordre des contraintes, et 74 relancent leur +// descente au moins une fois. +const GRILLE = Object.freeze({ graine: 1_729, configurations: 300, arret: 3_000, nombre: 2 }); + +// Une salle sur cinq est trop petite, deux sont tendues, deux ont des places +// vides. +const SALLES = Object.freeze(['tendue', 'tendue', 'places vides', 'places vides', 'trop petite']); +const LIBELLES = Object.freeze(['A', 'B', 'C']); + +// Mouvements appliqués où la marche confronte le score tenu à jour au score +// recalculé, après le placement initial ; un mouvement appliqué sur trois est +// défait, comme dans recherche.long.test.js. +const PALIERS = Object.freeze([1, 10, 100, 1_000]); +const DEFAIT_TOUS_LES = 3; + +// k entiers distincts de 0 à n − 1, tirés sans remise par le mélange +// partiel de Fisher et Yates. +function tirerSansRemise(rng, n, k) { + const pile = Array.from({ length: n }, (_, i) => i); + for (let i = 0; i < k; i += 1) { + const j = i + rng.borne(n - i); + [pile[i], pile[j]] = [pile[j], pile[i]]; + } + return pile.slice(0, k); +} + +// Une configuration de la grille, ses réglages de génération, sa salle et le +// nombre de places qui lui manquent, tirés de rng : +// - de 2 à 6 tables de 2 à 6 sièges, de 1 à 5 tours ; +// - N = S personnes présentes dans une salle tendue, S = Σ c_t ; S − v, v de +// 1 à ⌊S / 2⌋, dans une salle à places vides ; S + 1 à S + 3 dans une salle +// trop petite ; +// - chacun une fois sur deux : des ancrés, des réservations d'une partie des +// tours, un ou deux exclus. Une réservation d'une partie des tours fait un +// partiellement fixé quand R ≥ 2, parfois deux fois à la même table, et un +// ancré par la portée « tour » quand R = 1 ; +// - de 1 à 3 appartenances, et des personnes sans appartenance ; +// - des identifiants de participant croissants à trous, des identifiants de +// table dans un ordre quelconque, une liste de participants mélangée ; +// - une réservation d'exclu une fois sur deux, suspendue et sans siège, sa +// table fût-elle pleine (§ 4.4), et une réservation en double une fois sur +// quatre ; +// - chaque contrainte active trois fois sur quatre ; la graine de la +// génération, et un historique de 16 à 64 cases. +// Une réservation de personne présente ne se pose que sur une table où il +// reste un siège à chacun de ses tours : la configuration ne lève jamais +// SURRESERVATION, et normaliser l'accepte toujours. +function tirerConfiguration(rng) { + const T = 2 + rng.borne(5); + const capacites = Array.from({ length: T }, () => 2 + rng.borne(5)); + const R = 1 + rng.borne(5); + const S = capacites.reduce((somme, c) => somme + c, 0); + const salle = SALLES[rng.borne(SALLES.length)]; + let N = S; + if (salle === 'places vides') N = S - 1 - rng.borne(Math.floor(S / 2)); + if (salle === 'trop petite') N = S + 1 + rng.borne(3); + const avecAncres = rng.borne(2) === 0; + const avecToursReserves = rng.borne(2) === 0; + const exclus = rng.borne(2) === 0 ? 1 + rng.borne(2) : 0; + const G = 1 + rng.borne(LIBELLES.length); + + const ids = []; + for (let i = 0, id = 0; i < N + exclus; i += 1) { + id += 1 + rng.borne(2); + ids.push(id); + } + const idsExclus = new Set(tirerSansRemise(rng, ids.length, exclus).map((i) => ids[i])); + const participants = ids.map((id) => { + const g = rng.borne(G + 1); + return { + id, + nom: `P${id}`, + appartenance: g < G ? LIBELLES[g] : null, + exclu: idsExclus.has(id), + }; + }); + const idsTables = []; + for (let t = 0, id = 0; t < T; t += 1) { + id += 1 + rng.borne(3); + idsTables.push(id); + } + rng.melanger(idsTables); + const tables = idsTables.map((id, t) => ({ id, numero: t + 1, capacite: capacites[t] })); + + // fixees[t × R + r] : les personnes présentes que les réservations + // assoient à la table t au tour r. + const fixees = new Int32Array(T * R); + const siegeLibre = (t, r) => fixees[t * R + r] < capacites[t]; + const tousLesTours = Array.from({ length: R }, (_, r) => r); + const reservations = []; + for (const { id, exclu } of participants) { + const role = rng.borne(6); + if (exclu) { + if (role < 3) { + const table = idsTables[rng.borne(T)]; + const portee = role === 0 ? { portee: 'tous' } : { portee: 'tour', tour: 1 + rng.borne(R) }; + reservations.push({ participant: id, table, ...portee }); + } + } else if (avecAncres && role === 0) { + const t = rng.borne(T); + if (tousLesTours.every((r) => siegeLibre(t, r))) { + for (const r of tousLesTours) fixees[t * R + r] += 1; + reservations.push({ participant: id, table: idsTables[t], portee: 'tous' }); + } + } else if (avecToursReserves && role === 1) { + const tours = R === 1 ? 1 : 1 + rng.borne(R - 1); + for (const r of tirerSansRemise(rng, R, tours)) { + const t = rng.borne(T); + if (!siegeLibre(t, r)) continue; + fixees[t * R + r] += 1; + reservations.push({ participant: id, table: idsTables[t], portee: 'tour', tour: r + 1 }); + } + } + } + if (reservations.length > 0 && rng.borne(4) === 0) { + reservations.push({ ...reservations[rng.borne(reservations.length)] }); + } + rng.melanger(participants); + const contraintes = { + separerAppartenances: rng.borne(4) > 0, + nouveauxVoisins: rng.borne(4) > 0, + nouvelleTable: rng.borne(4) > 0, + varierAppartenances: rng.borne(4) > 0, + }; + const reglages = { + graine: rng.suivant(), + arret: GRILLE.arret, + nombre: GRILLE.nombre, + historique: 16 + rng.borne(49), + }; + return { + configuration: { participants, tables, tours: R, reservations, contraintes }, + reglages, + salle, + placesManquantes: Math.max(0, N - S), + }; +} + +// Une marche depuis le placement initial de la proposition de graine +// dérivée graine, mouvements tirés du même générateur que sa descente : +// chaque palier ajoute à fautes un désaccord entre le score tenu à jour et le +// score recalculé. Rend vrai quand la marche atteint le dernier palier. Une +// configuration où les mouvements ne s'appliquent pas l'arrête plus tôt : +// celle dont chaque tour n'a de sièges mobiles qu'à une seule table, ou n'y +// a que des places fantômes. +function marcher(nom, instance, graine, fautes) { + const rng = creerPcg32(graine, FLUX.RECHERCHE); + const etat = creerEtat(instance, rng); + const confronter = (appliques) => { + const tenu = scoreIncremental(etat); + const recalcule = scoreComplet(etat); + if (tenu.some((valeur, k) => valeur !== recalcule[k])) { + fautes.push(`${nom}, ${appliques} mouvements : score tenu ${tenu}, recalculé ${recalcule}`); + } + }; + confronter(0); + let appliques = 0; + let palier = 0; + for (let essai = 0; palier < PALIERS.length && essai < 100 * PALIERS.at(-1); essai += 1) { + const mouvement = proposerEtAppliquer(etat, rng); + if (mouvement === null) continue; + appliques += 1; + if (appliques % DEFAIT_TOUS_LES === 0) defaire(etat, mouvement); + if (appliques === PALIERS[palier]) { + confronter(appliques); + palier += 1; + } + } + return palier === PALIERS.length; +} + +// Ajoute à fautes ce qu'une proposition enfreint : les deux vérificateurs, +// les retours imposés du diagnostic, ses planchers de collisions et d'écart +// d'itinéraire, et sa régénération à partir de sa graine dérivée, de son +// compte d'arrêt et de son historique, sans son plan ni son identifiant. Ses +// mesures ne se lisent que sur un plan sans violation. +function juger(nom, { configuration, instance, aPriori, diagnostic }, proposition, fautes) { + const { graine, arret, historique, plan } = proposition; + const violations = verifierInvariants(instance, plan); + if (violations.length > 0) { + fautes.push(`${nom} : ${JSON.stringify(violations)}`); + return; + } + const tableDe = indexerPlan(instance, plan); + const mesures = mesurer(instance, tableDe); + const realises = plafondsRealises(instance, tableDe); + const indicateurs = verifierIndicateurs(mesures, aPriori, realises); + if (indicateurs.length > 0) fautes.push(`${nom} : ${JSON.stringify(indicateurs)}`); + if (mesures.totalRetoursImposes !== diagnostic.retoursImposes) { + fautes.push(`${nom} : ${mesures.totalRetoursImposes} retours imposés, ${diagnostic.retoursImposes} au diagnostic`); + } + for (const plancher of diagnostic.collisions) { + const mesure = mesures.parGroupe.find(({ groupe }) => groupe === plancher.groupe); + if (mesure.collisionsCumulees < plancher.plancherCumulees + || mesure.pairesDistinctes < plancher.plancherPairesDistinctes + || (plancher.plancherExcedent !== null && mesure.excedent < plancher.plancherExcedent)) { + fautes.push( + `${nom}, groupe ${plancher.groupe} : collisions ${mesure.collisionsCumulees} sur ` + + `${mesure.pairesDistinctes} paires, excédent ${mesure.excedent}, sous le plancher ` + + JSON.stringify(plancher), + ); + } + } + if (diagnostic.ecartItineraire !== null) { + const { plancher, mobilesAuMoins } = diagnostic.ecartItineraire; + const { ecartItineraireMax } = troisChiffres(instance, mesures, aPriori, realises); + const mobilesSous = mobilesAuPlancher(instance, ecartsItineraire(aPriori, realises), plancher); + if (ecartItineraireMax.tous < plancher || mobilesSous < mobilesAuMoins) { + fautes.push( + `${nom} : écart d'itinéraire maximal ${ecartItineraireMax.tous}, ${mobilesSous} mobiles ` + + `à au moins ${plancher}, sous le plancher ${JSON.stringify(diagnostic.ecartItineraire)}`, + ); + } + } + if (JSON.stringify(regenerer(configuration, { graine, arret, historique })) !== JSON.stringify(plan)) { + fautes.push(`${nom} : la régénération rend un autre plan`); + } +} + +// Planchers de la grille (§ 14.2) : ce que la boucle doit avoir éprouvé pour +// que ses propriétés disent quelque chose. Un générateur qui cesserait de +// tirer l'un de ces cas laisserait les propriétés vraies sans les éprouver. +// Les comptes lisent donc ce que la boucle a éprouvé, l'instance, le +// diagnostic ou la marche, et non l'étiquette que le générateur a tirée. +// Seules les salles trop petites se comptent sur leur étiquette : une salle +// ainsi étiquetée qui ne l'est pas fait échouer la boucle, qui exige son +// refus. Chaque plancher est posé sous le compte que donne la graine écrite, +// en commentaire. +const PLANCHERS = Object.freeze([ + ['configurations éprouvées', 'eprouvees', 200], // 230 sur 300 + ['salles trop petites refusées', 'tropPetites', 50], // 70 + ['salles tendues', 'tendues', 90], // 120 + ['salles à places vides', 'placesVides', 80], // 110 + ['avec ancrés', 'ancres', 90], // 122 + ['avec partiellement fixés', 'partiellementFixes', 60], // 87 + ['avec exclus', 'exclus', 90], // 122 + ['à un seul tour', 'unTour', 30], // 47 + ['avec retours imposés', 'retoursImposes', 70], // 103 + ['avec plancher de collisions', 'plancherCollisions', 120], // 173 + ["avec plancher d'excédent", 'plancherExcedent', 10], // 15 + ["avec plancher d'écart d'itinéraire", 'plancherEcartItineraire', 35], // 50 + ['marches au dernier palier', 'marches', 400], // 460, une par proposition +]); + +describe('intégration : grille de propriétés sur des configurations tirées (§ 14.12)', () => { + test(`${GRILLE.configurations} configurations : chaque proposition tient les deux vérificateurs, les retours imposés et les planchers du diagnostic, et se régénère ; le score tenu à jour égale le recalcul le long d'une marche tirée de sa graine dérivée ; une salle trop petite est refusée du nombre de places du diagnostic`, () => { + const rng = creerPcg32(GRILLE.graine, FLUX.DEMO); + const comptes = { + eprouvees: 0, + tropPetites: 0, + tendues: 0, + placesVides: 0, + ancres: 0, + partiellementFixes: 0, + exclus: 0, + unTour: 0, + retoursImposes: 0, + plancherCollisions: 0, + plancherExcedent: 0, + plancherEcartItineraire: 0, + marches: 0, + }; + const fautes = []; + for (let c = 1; c <= GRILLE.configurations; c += 1) { + const { configuration, reglages, salle, placesManquantes } = tirerConfiguration(rng); + const nom = `configuration ${c}`; + const instance = normaliser(configuration); + const diagnostic = diagnostiquer(configuration); + if (diagnostic.placesManquantes !== placesManquantes) { + fautes.push(`${nom} : ${diagnostic.placesManquantes} places manquantes au diagnostic, ${placesManquantes} tirées`); + } + if (salle === 'trop petite') { + comptes.tropPetites += 1; + try { + rechercher(configuration, reglages); + fautes.push(`${nom} : salle trop petite acceptée`); + } catch (erreur) { + if (erreur.code !== 'PLACES_MANQUANTES' + || erreur.details.placesManquantes !== diagnostic.placesManquantes) { + fautes.push(`${nom} : ${erreur.message}`); + } + } + continue; + } + comptes.eprouvees += 1; + if (nombrePlaces(instance) === instance.N) comptes.tendues += 1; + else comptes.placesVides += 1; + if (instance.k > 0) comptes.ancres += 1; + if (instance.statut.includes(STATUT.PARTIELLEMENT_FIXE)) comptes.partiellementFixes += 1; + if (instance.exclus.size > 0) comptes.exclus += 1; + if (instance.R === 1) comptes.unTour += 1; + if (diagnostic.retoursImposes > 0) comptes.retoursImposes += 1; + if (diagnostic.collisions.length > 0) comptes.plancherCollisions += 1; + if (diagnostic.collisions.some(({ plancherExcedent }) => plancherExcedent !== null)) { + comptes.plancherExcedent += 1; + } + if (diagnostic.ecartItineraire !== null) comptes.plancherEcartItineraire += 1; + const contexte = { configuration, instance, aPriori: plafondsAPriori(instance), diagnostic }; + const propositions = rechercher(configuration, reglages); + if (propositions.length !== GRILLE.nombre) fautes.push(`${nom} : ${propositions.length} propositions`); + for (const proposition of propositions) { + const nomProposition = `${nom}, proposition ${proposition.id}`; + juger(nomProposition, contexte, proposition, fautes); + if (marcher(nomProposition, instance, proposition.graine, fautes)) comptes.marches += 1; + } + } + // Les vingt premières fautes suffisent à désigner un défaut ; le message + // en donne le nombre. + assert.deepEqual(fautes.slice(0, 20), [], `${fautes.length} fautes`); + const sousLePlancher = PLANCHERS + .filter(([, cle, plancher]) => comptes[cle] < plancher) + .map(([libelle, cle, plancher]) => `${libelle} : ${comptes[cle]}, plancher ${plancher}`); + assert.deepEqual(sousLePlancher, [], JSON.stringify(comptes)); + }); +}); diff --git a/src/moteur/manque.js b/src/moteur/manque.js index b680bc5..1838991 100644 --- a/src/moteur/manque.js +++ b/src/moteur/manque.js @@ -17,13 +17,15 @@ // canonique, celui de instance.ids. // // Les agrégats portent sur les trois populations du § 5.4 : tous, les -// mobiles — partiellement fixés compris — et les ancrés. Une population vide -// rend null, jamais 0. +// mobiles — partiellement fixés compris — et les ancrés, que forme +// replierParPopulation (configuration.js). Une population vide rend null, +// jamais 0. // // Le plafond a priori vaut null quand il est inconnu (§ 12.10.9) : seul null // le dit. undefined, la valeur d'un champ manquant, lève TypeError au lieu // de passer pour inconnu. Une liste qui n'a pas une case par personne lève // RangeError : elle décrit une autre population. +import { replierParPopulation } from './configuration.js'; /** * @typedef {{tous: number|null, mobiles: number|null, ancres: number|null}} ParPopulation @@ -49,10 +51,6 @@ * @property {'A'|'B'|null} [domine] */ -// Statut d'un ancré dans instance.statut (§ 4.2) ; 0 marque un mobile, 1 un -// partiellement fixé. -const ANCRE = 2; - // Lève TypeError quand valeurs n'est pas une liste, faute de longueur // numérique ; attendu nomme ce que le paramètre nom admet. function exigerListe(valeurs, nom, attendu = 'liste') { @@ -83,19 +81,11 @@ function soustraire(gauche, droite) { return differences; } -// Replie valeurs[p] sur les trois populations en un passage : chacun compte -// dans « tous », puis parmi les ancrés ou parmi les mobiles. replier(cumul, -// valeur) rend le cumul suivant ; cumul vaut null au premier membre d'une -// population, et une population sans membre reste à null. -function parPopulation(valeurs, statut, replier) { - const chiffres = { tous: null, mobiles: null, ancres: null }; - for (let p = 0; p < valeurs.length; p += 1) { - const secondaire = statut[p] === ANCRE ? 'ancres' : 'mobiles'; - chiffres.tous = replier(chiffres.tous, valeurs[p]); - chiffres[secondaire] = replier(chiffres[secondaire], valeurs[p]); - } - return chiffres; -} +// Replie valeurs[p] sur les trois populations (replierParPopulation). +// replier(cumul, valeur) rend le cumul suivant ; cumul vaut null au premier +// membre d'une population, et une population sans membre reste à null. +const parPopulation = (valeurs, statut, replier) => + replierParPopulation(valeurs, statut, null, replier); // Le plus grand des valeurs repliées. const plusGrand = (cumul, valeur) => (cumul === null || valeur > cumul ? valeur : cumul); diff --git a/src/moteur/plafond.js b/src/moteur/plafond.js index 578bcd4..d2236bf 100644 --- a/src/moteur/plafond.js +++ b/src/moteur/plafond.js @@ -38,15 +38,13 @@ // Une personne se désigne par son index dans l'instance, une table par son // index ; un tableau rendu suit l'ordre de instance.ids. Aucune fonction ne // modifie l'instance. +// +// Une case d'itinéraire en réserve vaut RESERVE, et RESERVE vaut LIBRE +// (configuration.js) : une rangée de instance.fixe se lit comme l'itinéraire +// qui laisse en réserve chaque tour libre. +import { LIBRE, RESERVE, STATUT, exigerPlanIndexe } from './configuration.js'; import { ErreurConfiguration } from './erreurs.js'; -// Statut d'un ancré dans instance.statut (§ 4.2). -const ANCRE = 2; -// Case de instance.fixe sans table imposée, et case d'un itinéraire ou d'un -// plan indexé en réserve : la même valeur, si bien qu'une rangée de fixe se -// lit comme l'itinéraire qui laisse en réserve chaque tour libre. -const LIBRE = -1; -const RESERVE = -1; // Nombre de multiensembles au-delà duquel l'énumération refuse une // signature. const LIMITE_ENUMERATION = 300_000; @@ -60,11 +58,11 @@ function borne(instance, nP, ancres, sieges) { // n_p : les mobiles — partiellement fixés compris — que p peut rencontrer, // p excepté quand il en est un. const mobilesRencontrables = (instance, p) => - instance.statut[p] === ANCRE ? instance.n : instance.n - 1; + instance.statut[p] === STATUT.ANCRE ? instance.n : instance.n - 1; // Table d'ancrage de p, −1 s'il n'est pas ancré. const ancrageDe = (instance, p) => - instance.statut[p] === ANCRE ? instance.fixe[p * instance.R] : -1; + instance.statut[p] === STATUT.ANCRE ? instance.fixe[p * instance.R] : -1; // a_t vu de p : les ancrés de la table t, p non compté. const ancresVus = (instance, t, ancrage) => instance.ancresParTable[t] - (t === ancrage ? 1 : 0); @@ -103,12 +101,13 @@ function plafondSurRangee(instance, p, cases, debut, occupation) { return borne(instance, mobilesRencontrables(instance, p), ancres, sieges); } -// Lève RangeError quand une case de cases n'est ni −1 ni un index de table. -function exigerCases(cases, T, quoi) { - for (let i = 0; i < cases.length; i += 1) { - const t = cases[i]; - if (!Number.isInteger(t) || t < RESERVE || t >= T) { - throw new RangeError(`${quoi}, case ${i} : index de table ${String(t)} hors de −1..${T - 1}`); +// Lève RangeError quand une case de l'itinéraire n'est ni RESERVE ni un index +// de table. Un plan indexé passe par exigerPlanIndexe (configuration.js). +function exigerCases(itineraire, T) { + for (let i = 0; i < itineraire.length; i += 1) { + const t = itineraire[i]; + if (t !== RESERVE && !(Number.isInteger(t) && t >= 0 && t < T)) { + throw new RangeError(`itinéraire, case ${i} : index de table ${String(t)} hors de −1..${T - 1}`); } } } @@ -138,7 +137,7 @@ export function plafondItineraire(instance, p, itineraire) { if (itineraire.length !== R) { throw new RangeError(`itinéraire de ${itineraire.length} tours, ${R} attendus`); } - exigerCases(itineraire, T, 'itinéraire'); + exigerCases(itineraire, T); return plafondSurRangee(instance, p, itineraire, 0, null); } @@ -148,19 +147,15 @@ export function plafondItineraire(instance, p, itineraire) { * en réserve n'entre pas dans le plafond de la personne, ni dans * l'occupation d'aucune table. * - * Lève RangeError quand tableDe n'a pas N × R cases, ou qu'une case n'est ni - * −1 ni un index de table. + * Lève ce que lève exigerPlanIndexe (configuration.js). * * @param {import('./types.js').Instance} instance * @param {ArrayLike} tableDe plan indexé, [p × R + r] * @returns {number[]} */ export function plafondsRealises(instance, tableDe) { + exigerPlanIndexe(instance, tableDe); const { N, T, R } = instance; - if (tableDe.length !== N * R) { - throw new RangeError(`plan indexé de ${tableDe.length} cases, N × R = ${N * R} attendues`); - } - exigerCases(tableDe, T, 'plan indexé'); const occupation = new Int32Array(T * R); for (let p = 0; p < N; p += 1) { for (let r = 0; r < R; r += 1) { diff --git a/src/moteur/plafond.long.test.js b/src/moteur/plafond.long.test.js index a26a746..2197edc 100644 --- a/src/moteur/plafond.long.test.js +++ b/src/moteur/plafond.long.test.js @@ -9,7 +9,7 @@ import assert from 'node:assert/strict'; import fc from 'fast-check'; import { describe, test } from '../../test/lanceur.js'; -import { normaliser } from './configuration.js'; +import { LIBRE, STATUT, normaliser } from './configuration.js'; import { ErreurConfiguration } from './erreurs.js'; import { plafondsAPriori, plafondsAPrioriParEnumeration } from './plafond.js'; @@ -27,7 +27,7 @@ const GRAINE = 314_159; /** * Instance d'une salle : capacites[t] et ancres[t] décrivent la table * d'index t ; partiels[j][r] est la table imposée au j-ème partiellement - * fixé au tour r, −1 pour un tour libre. Identifiants 1 à N : les ancrés, + * fixé au tour r, LIBRE pour un tour libre. Identifiants 1 à N : les ancrés, * table par table, puis les partiels, puis les mobiles ; la table d'index t * a pour identifiant 100 + t. Lève ce que normaliser lève. */ @@ -47,7 +47,7 @@ function instanceDe({ capacites, ancres, mobiles, tours, partiels = [] }) { for (const tables of partiels) { const id = nouveau(); tables.forEach((t, r) => { - if (t < 0) return; + if (t === LIBRE) return; reservations.push({ participant: id, table: 100 + t, portee: 'tour', tour: r + 1 }); }); } @@ -79,7 +79,7 @@ function arbitraireCase(T, R) { ), ), ); - const tour = fc.oneof(fc.constant(-1), fc.integer({ min: 0, max: T - 1 })); + const tour = fc.oneof(fc.constant(LIBRE), fc.integer({ min: 0, max: T - 1 })); return fc .record({ salle: fc.array(table, { minLength: T, maxLength: T }), @@ -92,7 +92,7 @@ function arbitraireCase(T, R) { if (a < c) ouvertes.push(t); }); const partiels = rangs.map((tours) => - tours.map((rang) => (rang < 0 || ouvertes.length === 0 ? -1 : ouvertes[rang % ouvertes.length])), + tours.map((rang) => (rang === LIBRE || ouvertes.length === 0 ? LIBRE : ouvertes[rang % ouvertes.length])), ); return { capacites: salle.map(([c]) => c), @@ -107,7 +107,9 @@ function arbitraireCase(T, R) { describe("plafond a priori : la programmation dynamique contre l'énumération, grille large", () => { test('de 2 à 8 tables et de 2 à 5 tours, chaque case de la grille : le même plafond pour chacun', () => { const parCase = []; - const parStatut = [0, 0, 0]; + // Une valeur de STATUT et son compte ; la Map ne sert qu'à retrouver un + // statut. + const parStatut = new Map(); for (let T = 2; T <= 8; T += 1) { for (let R = 2; R <= 5; R += 1) { let acceptees = 0; @@ -122,7 +124,7 @@ describe("plafond a priori : la programmation dynamique contre l'énumération, } if (instance.N === 0) return; acceptees += 1; - for (let p = 0; p < instance.N; p += 1) parStatut[instance.statut[p]] += 1; + for (const statut of instance.statut) parStatut.set(statut, (parStatut.get(statut) ?? 0) + 1); assert.deepEqual(plafondsAPriori(instance), plafondsAPrioriParEnumeration(instance)); }), { seed: GRAINE + 10 * T + R, numRuns: 300 }, @@ -137,8 +139,10 @@ describe("plafond a priori : la programmation dynamique contre l'énumération, .filter(([, , acceptees]) => acceptees < 200) .map(([T, R, n]) => `${T} tables, ${R} tours : ${n} acceptées sur 300, 200 au moins`); assert.deepEqual(maigres, []); - assert.ok(parStatut[1] >= 5000, `${parStatut[1]} partiellement fixés comparés, 5000 au moins`); - assert.ok(parStatut[2] >= 5000, `${parStatut[2]} ancrés comparés, 5000 au moins`); + const partiels = parStatut.get(STATUT.PARTIELLEMENT_FIXE) ?? 0; + const ancres = parStatut.get(STATUT.ANCRE) ?? 0; + assert.ok(partiels >= 5000, `${partiels} partiellement fixés comparés, 5000 au moins`); + assert.ok(ancres >= 5000, `${ancres} ancrés comparés, 5000 au moins`); }); test("forme de la grande démonstration et sa variante : la programmation dynamique égale l'énumération", () => { diff --git a/src/moteur/plafond.test.js b/src/moteur/plafond.test.js index 88c348c..d942a8d 100644 --- a/src/moteur/plafond.test.js +++ b/src/moteur/plafond.test.js @@ -10,7 +10,14 @@ import assert from 'node:assert/strict'; import fc from 'fast-check'; import { describe, test } from '../../test/lanceur.js'; -import { indexerPlan, normaliser } from './configuration.js'; +import { + LIBRE, + RESERVE, + STATUT, + indexerPlan, + normaliser, + planDepuisIndex, +} from './configuration.js'; import { ErreurConfiguration } from './erreurs.js'; import { plafondItineraire, @@ -37,7 +44,7 @@ const suite = (debut, fin) => Array.from({ length: fin - debut + 1 }, (_, i) => * Instance d'une forme de salle. capacites[t] et ancres[t] décrivent la * table d'index t ; autres compte les participants non ancrés, dont les * premiers sont partiellement fixés selon partiels : partiels[j][r] est - * l'index de table imposé au tour r, −1 pour un tour libre. Les ancrés + * l'index de table imposé au tour r, LIBRE pour un tour libre. Les ancrés * prennent les identifiants pairs tant qu'il reste des non-ancrés, et les * identifiants de table décroissent quand l'index croît : un index lu comme * un identifiant désigne une autre personne ou une autre table. Rend @@ -66,7 +73,7 @@ function forme({ capacites, ancres, autres, tours, partiels = [] }) { } partiels.forEach((tables, j) => { tables.forEach((t, r) => { - if (t < 0) return; + if (t === LIBRE) return; reservations.push({ participant: idsAutres[j], table: idTable(t), @@ -115,14 +122,14 @@ const planIndexe = (instance, listes, reserves = listes.map(() => [])) => // tables qui en ont encore ; sans siège, une personne va en réserve. function planBrasse(instance, graine) { const { N, T, R, fixe, capacite } = instance; - const tableDe = new Int32Array(N * R).fill(-1); + const tableDe = new Int32Array(N * R).fill(RESERVE); const brassage = (i, r) => ((i + 1) * 2_654_435_761 + (r + 1) * 40_503 + graine * 97) % 1_000_003; for (let r = 0; r < R; r += 1) { const libres = Int32Array.from(capacite); const aPlacer = []; for (let p = 0; p < N; p += 1) { const t = fixe[p * R + r]; - if (t >= 0) { + if (t !== LIBRE) { tableDe[p * R + r] = t; libres[t] -= 1; } else { @@ -156,9 +163,9 @@ const ancresDe = (c) => { arbitrary: fc.constant(c), weight: 1 }, ); -// Un tour d'un non-ancré : libre (−1) une fois sur deux, fixé sinon à l'une -// des T tables. -const tourDe = (T) => fc.oneof(fc.constant(-1), fc.integer({ min: 0, max: T - 1 })); +// Un tour d'un non-ancré : libre (LIBRE) une fois sur deux, fixé sinon à +// l'une des T tables. +const tourDe = (T) => fc.oneof(fc.constant(LIBRE), fc.integer({ min: 0, max: T - 1 })); // Description d'une instance tirée dans les bornes données : chaque table // tire sa capacité puis ses ancrés, chaque partiel un tour par tour. Un @@ -189,9 +196,10 @@ function arbitraireForme({ tables, tours, capacites, mobiles, partiels }) { // Une instance que normaliser refuse — une table qui reçoit plus de // personnes fixées que de sièges — ou sans participant n'est pas comptée ; // une autre l'est, avec ses personnes par statut et ses tables pleines -// d'ancrés. Rend ces comptes. +// d'ancrés. Rend ces comptes ; parStatut associe une valeur de STATUT à son +// compte, et la Map ne sert qu'à retrouver un statut. function eprouverAccord(arbitraire, parametres) { - const bilan = { acceptees: 0, parStatut: [0, 0, 0], tablesPleines: 0 }; + const bilan = { acceptees: 0, parStatut: new Map(), tablesPleines: 0 }; fc.assert( fc.property(arbitraire, ({ salle, tours, mobiles, partiels }) => { let instance; @@ -209,7 +217,9 @@ function eprouverAccord(arbitraire, parametres) { } if (instance.N === 0) return; bilan.acceptees += 1; - for (let p = 0; p < instance.N; p += 1) bilan.parStatut[instance.statut[p]] += 1; + for (const statut of instance.statut) { + bilan.parStatut.set(statut, (bilan.parStatut.get(statut) ?? 0) + 1); + } if (salle.some(([c, a]) => a === c)) bilan.tablesPleines += 1; assert.deepEqual(plafondsAPriori(instance), plafondsAPrioriParEnumeration(instance)); }), @@ -310,9 +320,9 @@ describe('plafonds : valeurs exactes (§ 5.5, § 15)', () => { test('partiellement fixé : sa table fixée entre dans F, seuls ses tours libres varient', () => { // Un non-ancré de la grande forme, fixé au tour 1 à une table de 7. - const { instance, partiels, mobiles, ancres } = grandeForme([[29, -1, -1, -1]]); + const { instance, partiels, mobiles, ancres } = grandeForme([[29, LIBRE, LIBRE, LIBRE]]); const [p] = partiels; - assert.equal(instance.statut[p], 1); + assert.equal(instance.statut[p], STATUT.PARTIELLEMENT_FIXE); assert.equal(instance.k, 33); const aPriori = plafondsAPriori(instance); // La table de 7 entre dans F : 1 ancré et 5 sièges ; ses trois tours @@ -336,7 +346,7 @@ describe('plafonds : valeurs exactes (§ 5.5, § 15)', () => { ancres: [0, 2], autres: 3, tours: 2, - partiels: [[0, -1]], + partiels: [[0, LIBRE]], }); assert.equal(instance.n, 3); // Partiel et mobiles : seule la table 0 est admissible, 0 ancré et @@ -370,7 +380,7 @@ describe('plafonds : valeurs exactes (§ 5.5, § 15)', () => { // 5 table 1, occupée par 2 aux deux tours : 1 + 1 = 2 assert.deepEqual(plafondsRealises(instance, tableDe), [3, 3, 2, 2, 2]); const p = instance.indexDe.get(3); - assert.equal(plafondItineraire(instance, p, [0, -1]), 2); + assert.equal(plafondItineraire(instance, p, [0, RESERVE]), 2); // L'écart d'itinéraire porte ce que le manque ne porte pas : 4 − 2. assert.deepEqual(plafondsAPriori(instance), [4, 4, 4, 4, 4]); }); @@ -391,20 +401,20 @@ describe('plafonds : valeurs exactes (§ 5.5, § 15)', () => { attendu[mobiles[0]] = 0; assert.deepEqual(plafondsAPriori(instance), attendu); assert.deepEqual(plafondsAPrioriParEnumeration(instance), attendu); - assert.equal(plafondItineraire(instance, mobiles[0], [-1, -1]), 0); + assert.equal(plafondItineraire(instance, mobiles[0], [RESERVE, RESERVE]), 0); }); test('plafond a priori ≥ plafond réalisé pour chacun, sur trois plans quelconques de chaque forme', () => { const formes = [ forme({ capacites: [5, 5, 5, 5], ancres: [1, 1, 1, 1], autres: 16, tours: 5 }), grandeForme(), - grandeForme([[29, -1, -1, -1], [-1, 30, 30, -1]]), + grandeForme([[29, LIBRE, LIBRE, LIBRE], [LIBRE, 30, 30, LIBRE]]), forme({ capacites: [3, 3, 3, 3], ancres: [0, 0, 0, 0], autres: 12, tours: 4 }), forme({ capacites: [4, 4, 4], ancres: [1, 1, 1], autres: 7, tours: 2 }), - forme({ capacites: [5, 2], ancres: [0, 2], autres: 3, tours: 2, partiels: [[0, -1]] }), + forme({ capacites: [5, 2], ancres: [0, 2], autres: 3, tours: 2, partiels: [[0, LIBRE]] }), // Une place de moins que de participants : chaque plan met quelqu'un // en réserve. - forme({ capacites: [3, 3], ancres: [1, 0], autres: 6, tours: 3, partiels: [[-1, 1, -1]] }), + forme({ capacites: [3, 3], ancres: [1, 0], autres: 6, tours: 3, partiels: [[LIBRE, 1, LIBRE]] }), ]; const ecarts = []; let comparaisons = 0; @@ -455,6 +465,21 @@ describe('plafonds : valeurs exactes (§ 5.5, § 15)', () => { .filter((ecart) => ecart !== null); assert.deepEqual(ecarts, []); }); + + test('un plan indexé hors forme : plafondsRealises nomme le participant, le tour et la valeur, comme planDepuisIndex', () => { + // Quatre participants d'identifiants 1 à 4, deux tables, deux tours. La + // dernière case est celle de l'identifiant 4 au tour 2. La chaîne "-1" + // n'est pas la réserve : le message la cite entre guillemets, pour + // qu'elle ne se lise pas comme la valeur qu'elle contient. + const { instance } = forme({ capacites: [3, 3], ancres: [0, 0], autres: 4, tours: 2 }); + const fautif = [0, 0, 0, 0, 1, 1, 1, '-1']; + const attendu = { + name: 'RangeError', + message: 'plan indexé : participant 4, tour 2, index de table "-1" hors de −1..1', + }; + assert.throws(() => planDepuisIndex(instance, fautif), attendu); + assert.throws(() => plafondsRealises(instance, fautif), attendu); + }); }); describe("plafond a priori : la programmation dynamique contre l'énumération (§ 14.11, § 14.12)", () => { @@ -495,8 +520,10 @@ describe("plafond a priori : la programmation dynamique contre l'énumération ( // les acceptées, et les personnes comparées de chaque statut. const { acceptees, parStatut, tablesPleines } = bilan; assert.ok(acceptees >= 200, `${acceptees} instances acceptées, 200 au moins`); - assert.ok(parStatut[1] >= 100, `${parStatut[1]} partiellement fixés comparés, 100 au moins`); - assert.ok(parStatut[2] >= 100, `${parStatut[2]} ancrés comparés, 100 au moins`); + const partiels = parStatut.get(STATUT.PARTIELLEMENT_FIXE) ?? 0; + const ancres = parStatut.get(STATUT.ANCRE) ?? 0; + assert.ok(partiels >= 100, `${partiels} partiellement fixés comparés, 100 au moins`); + assert.ok(ancres >= 100, `${ancres} ancrés comparés, 100 au moins`); assert.ok(tablesPleines >= 50, `${tablesPleines} instances à table pleine d'ancrés, 50 au moins`); }); }); diff --git a/src/moteur/recherche.js b/src/moteur/recherche.js index c8e66da..cf436ec 100644 --- a/src/moteur/recherche.js +++ b/src/moteur/recherche.js @@ -14,7 +14,8 @@ // 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. +// propositions s'achèvent, et regenerer en rend le plan à partir de ces trois +// champs seuls (§ 8.9). Les deux passent par la même descente, proposer. // // 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 @@ -49,13 +50,14 @@ // 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). +// cumulées de même appartenance et leur excédent ; Σ_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 la mesure qui fait foi, dont il lit chaque grandeur au lieu de la +// refaire (§ 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 @@ -91,9 +93,17 @@ // 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 { + LIBRE, + RESERVE, + SANS_GROUPE, + nombrePlacesManquantes, + normaliser, + planDepuisIndex, +} from './configuration.js'; import { ErreurAnnulee, ErreurConfiguration } from './erreurs.js'; import { mesurer } from './indicateurs.js'; +import { ecartsAuPlafondAPriori } from './manque.js'; import { plafondsAPriori } from './plafond.js'; /** @@ -112,27 +122,23 @@ import { plafondsAPriori } from './plafond.js'; * * @typedef {Object} EtatRecherche * Rendu par creerEtat ; les champs qui suivent se lisent, aucun ne s'écrit - * hors de ce module. + * hors de ce module. Un champ qui tient une grandeur que mesurer rend en + * porte le nom. * @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} excedentCollisions collisions cumulées − paires distinctes * @property {number} totalRetoursChoisis + * @property {number} totalRedondance Σ_p r(p) * * @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 @@ -164,6 +170,17 @@ function exigerReglage(nom, valeur, min, max) { } } +// Lève une RangeError qui nomme le premier des trois réglages qu'une +// proposition porte à sortir de son domaine, contrôlés dans cet ordre : +// graine de 0 à 2^32 − 1, arret et historique ≥ 1. rechercher et regenerer +// l'appellent tous deux : ce que l'un admet, l'autre l'admet, et regenerer +// accepte les champs de toute proposition que rechercher rend. +function exigerChampsProposition({ graine, arret, historique }) { + exigerReglage('graine', graine, 0, MOT_MAX); + exigerReglage('arret', arret, 1, Number.MAX_SAFE_INTEGER); + exigerReglage('historique', historique, 1, Number.MAX_SAFE_INTEGER); +} + // 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 @@ -171,8 +188,8 @@ function exigerReglage(nom, valeur, min, max) { // 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 placesManquantes = nombrePlacesManquantes(instance); + if (placesManquantes > 0) throw new ErreurConfiguration('PLACES_MANQUANTES', { placesManquantes }); const { N, T, R, capacite, fixe } = instance; const debut = new Int32Array(T + 1); @@ -280,29 +297,35 @@ function changerAffilie(etat, p, g, sens) { etat.appartenancesVues[p] += sens; vues = sens; } - etat.sommeRedondance += sens - vues; + etat.totalRedondance += 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. +// propage : les collisions cumulées pour deux personnes de la même +// appartenance. Quand la paire était réunie avant un ajout, ou le reste +// après un retrait, la rencontre ajoutée ou retirée est une répétition : +// elle compte dans Σ max(0, M − 1) et, entre collègues, dans l'excédent de +// collisions, qui vaut Σ max(0, M − 1) sur les paires de même appartenance. +// Sinon la paire devient réunie ou cesse de l'être : les rencontres et les +// affiliés des deux suivent. 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; + const memeGroupe = ga === gb && etat.affilie[a] === 1; if (memeGroupe) etat.collisionsCumulees += sens; - if ((sens > 0 ? avant : apres) !== 0) return; - if (memeGroupe) etat.pairesDistinctes += sens; + if ((sens > 0 ? avant : apres) !== 0) { + etat.excedentPaires += sens; + if (memeGroupe) etat.excedentCollisions += sens; + return; + } 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); + if (etat.affilie[b] === 1) changerAffilie(etat, a, gb, sens); + if (etat.affilie[a] === 1) changerAffilie(etat, b, ga, sens); } // Ajoute sens aux visites de p à la table t, et ajuste les retours choisis. @@ -367,6 +390,11 @@ function etatInitial(structure, rng) { instance, contraintes: instance.contraintes, groupe: instance.groupe, + // affilie[p] vaut 1 quand p porte une appartenance, 0 pour SANS_GROUPE. + // modifierPaire le lit au lieu de comparer un groupe à SANS_GROUPE : sous + // le lanceur d'épreuves, chaque lecture d'une valeur importée passe par + // un accesseur, que la boucle de recherche paierait à chaque paire. + affilie: Uint8Array.from(instance.groupe, (g) => (g === SANS_GROUPE ? 0 : 1)), N, T, R, @@ -390,10 +418,10 @@ function etatInitial(structure, rng) { ecartAuPlafondAPrioriMax: 0, carresEcartsAuPlafondAPriori: 0, collisionsCumulees: 0, - pairesDistinctes: 0, + excedentCollisions: 0, excedentPaires: 0, totalRetoursChoisis: 0, - sommeRedondance: 0, + totalRedondance: 0, }; for (let p = 0; p < N; p += 1) { etat.histogramme[aPriori[p]] += 1; @@ -424,16 +452,17 @@ function etatInitial(structure, rng) { // É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é. +// recalculé ; une grandeur que mesurer rend y porte son nom, et la +// composition n'en refait aucune. 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[2] = separer ? q.excedentCollisions : 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; + sortie[5] = contraintes.varierAppartenances ? q.totalRedondance : 0; return sortie; } @@ -531,13 +560,25 @@ function descendre(structure, rng, { arret, historique, dejaFaits, total, signal return meilleurPlan; } +// Le plan de la proposition de graine dérivée graineDerivee : la descente +// depuis creerPcg32(graineDerivee, FLUX.RECHERCHE), rendue par identifiants. +// rechercher et regenerer l'appellent tous deux. À instance égale, le plan ne +// dépend que de la graine dérivée, d'options.arret et d'options.historique : +// les autres options ne servent qu'à rapporter l'avancement et à lire le +// signal. +function proposer(instance, structure, graineDerivee, options) { + const rng = creerPcg32(graineDerivee, FLUX.RECHERCHE); + return planDepuisIndex(instance, descendre(structure, rng, options)); +} + /** * 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. + * trois décident de son plan, que regenerer rend à partir d'eux. À + * 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 @@ -549,8 +590,9 @@ function descendre(structure, rng, { arret, historique, dejaFaits, total, signal * 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. + * ('PLACES_MANQUANTES', { placesManquantes }) quand les tables n'offrent pas + * assez de sièges, placesManquantes étant le nombre de places qui manquent à + * chaque tour, celui que rend nombrePlacesManquantes. * * @param {import('./types.js').Configuration} configuration * @param {ReglagesGeneration} reglages @@ -559,10 +601,8 @@ function descendre(structure, rng, { arret, historique, dejaFaits, total, signal */ 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); + exigerChampsProposition({ graine, arret, historique }); 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); @@ -571,8 +611,7 @@ export function rechercher(configuration, reglages, { signal, progression } = {} 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, { + const plan = proposer(instance, structure, graineDerivee, { arret, historique, dejaFaits: (id - 1) * arret, @@ -580,17 +619,49 @@ export function rechercher(configuration, reglages, { signal, progression } = {} signal, progression, }); - const plan = planDepuisIndex(instance, meilleur); propositions.push({ id, graine: graineDerivee, arret, historique, plan }); } return propositions; } +/** + * Le plan d'une proposition, régénéré à partir de ses seuls champs (§ 5.7, + * § 8.9) : la descente part de proposition.graine, la graine dérivée qu'elle + * porte, et compte proposition.arret mouvements sous un historique de + * proposition.historique cases. Ces trois champs décident du plan : à + * configuration égale, regenerer rend le plan que rechercher a rendu pour + * cette proposition, quel que soit son rang dans sa génération. La descente + * elle-même fait aussi partie du résultat, ordre des tirages, PATIENCE et + * ordres d'acceptation : une proposition rendue par une autre version de ce + * module n'est pas garantie régénérable (§ 8.9). Aucun autre champ n'est lu, + * ni id ni plan. Ne modifie ni la configuration ni la proposition. + * + * Lève RangeError quand l'un des trois champs n'est pas un entier de son + * domaine, graine de 0 à 2^32 − 1, arret et historique ≥ 1 : un champ absent + * ne reçoit pas la valeur par défaut de rechercher, qui ferait régénérer une + * autre proposition sans le dire. Lève ce que rechercher lève pour la + * configuration : ErreurConfiguration de normaliser, PLACES_MANQUANTES. + * + * @param {import('./types.js').Configuration} configuration + * @param {{graine: number, arret: number, historique: number}} proposition + * @returns {import('./types.js').Plan} + */ +export function regenerer(configuration, { graine, arret, historique }) { + exigerChampsProposition({ graine, arret, historique }); + const instance = normaliser(configuration); + return proposer(instance, preparer(instance), graine, { + arret, + historique, + dejaFaits: 0, + total: arret, + }); +} + /** * É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. + * { placesManquantes }) comme rechercher. * * @param {import('./types.js').Instance} instance * @param {ReturnType} rng @@ -611,11 +682,19 @@ export function scoreIncremental(etat) { } /** - * 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. + * Le même n-uplet, recalculé de zéro sur etat.tableDe à partir de la mesure + * qui fait foi, dont chaque grandeur se lit au lieu de se refaire : les + * écarts au plafond a priori par ecartsAuPlafondAPriori (manque.js), sur les + * plafonds de plafondsAPriori ; l'excédent de collisions, les collisions + * cumulées, les retours choisis et Σ r(p) par mesurer. Une redéfinition de + * l'une d'elles dans son module change donc ce score, et son accord avec le + * score tenu à jour tombe. + * + * Seule Σ_paires max(0, M − 1), que la mesure ne rend pas, se calcule ici : + * elle 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[]} @@ -623,16 +702,12 @@ export function scoreIncremental(etat) { 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]; + for (const ecart of ecartsAuPlafondAPriori(mesures, plafondsAPriori(instance))) { 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) { @@ -643,14 +718,16 @@ export function scoreComplet(etat) { } let reunions = 0; for (const o of occupation) reunions += (o * (o - 1)) / 2; + let sommeRencontres = 0; + for (const rencontres of mesures.rencontres) sommeRencontres += rencontres; const grandeurs = { ecartAuPlafondAPrioriMax, carresEcartsAuPlafondAPriori, + excedentCollisions: mesures.excedentCollisions, collisionsCumulees: mesures.collisionsCumulees, - pairesDistinctes: mesures.pairesDistinctes, excedentPaires: reunions - sommeRencontres / 2, totalRetoursChoisis: mesures.totalRetoursChoisis, - sommeRedondance: mesures.redondance.reduce((somme, r) => somme + r, 0), + totalRedondance: mesures.totalRedondance, }; return composer(instance.contraintes, grandeurs, new Array(TAILLE_SCORE)); } diff --git a/src/moteur/recherche.long.test.js b/src/moteur/recherche.long.test.js index 6d84e8d..f110be2 100644 --- a/src/moteur/recherche.long.test.js +++ b/src/moteur/recherche.long.test.js @@ -40,8 +40,10 @@ const DEFAIT_TOUS_LES = 3; 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]); +// valeurs, rang par rang, chacune un nombre fini : deux champs absents, deux +// undefined, ne s'accordent pas. +const egales = (a, b) => + a.length === b.length && a.every((valeur, i) => Number.isFinite(valeur) && valeur === b[i]); // Ajoute à releve un libellé « palier : grandeur » pour chaque grandeur où // l'état tenu à jour diffère du recalcul complet. @@ -55,8 +57,9 @@ function relever(etat, instance, palier, releve) { 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('excedentCollisions', [etat.excedentCollisions], [mesures.excedentCollisions]); confronter('totalRetoursChoisis', [etat.totalRetoursChoisis], [mesures.totalRetoursChoisis]); + confronter('totalRedondance', [etat.totalRedondance], [mesures.totalRedondance]); } describe('recherche : incrémental contre recalcul complet (§ 5.10, § 14.10)', () => { diff --git a/src/moteur/recherche.test.js b/src/moteur/recherche.test.js index 4a7e1ad..9c4bf4e 100644 --- a/src/moteur/recherche.test.js +++ b/src/moteur/recherche.test.js @@ -4,25 +4,35 @@ // É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 ; +// graine dérivée, de son compte d'arrêt et de son historique, trois champs à +// partir desquels regenerer le rend ; 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é. +// recherche.js, récrit ici à la lettre ; un échange avec une place fantôme +// fait passer une personne à une autre table, comme le dit son paragraphe +// « Mouvement » ; 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 { + LIBRE, + RESERVE, + SANS_GROUPE, + indexerPlan, + normaliser, + planDepuisIndex, +} from './configuration.js'; import { ErreurAnnulee } from './erreurs.js'; import { mesurer } from './indicateurs.js'; import { plafondsAPriori } from './plafond.js'; @@ -31,6 +41,7 @@ import { defaire, proposerEtAppliquer, rechercher, + regenerer, scoreComplet, scoreIncremental, } from './recherche.js'; @@ -158,7 +169,7 @@ function placementLitteral(instance, rng) { const libres = []; for (let p = 0; p < N; p += 1) { const t = fixe[p * R + r]; - if (t === -1) { + if (t === LIBRE) { libres.push(p); } else { tableDe[p * R + r] = t; @@ -190,7 +201,7 @@ function scoreOracle(instance, tableDe, aPriori) { 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; + if (t !== RESERVE && t === tableDe[b * R + r]) M[a * N + b] += 1; } } } @@ -207,12 +218,12 @@ function scoreOracle(instance, tableDe, aPriori) { excedentPaires += m - 1; for (const [p, q] of [[a, b], [b, a]]) { rencontres[p] += 1; - if (groupe[q] !== -1) { + if (groupe[q] !== SANS_GROUPE) { affilies[p] += 1; appartenances[p].add(groupe[q]); } } - if (groupe[a] !== -1 && groupe[a] === groupe[b]) { + if (groupe[a] !== SANS_GROUPE && groupe[a] === groupe[b]) { cumulees += m; distinctes += 1; } @@ -432,6 +443,153 @@ describe('rechercher : reproductibilité et identifiants (§ 5.7, § 19.4)', () }); }); +describe('regenerer : une proposition depuis ses seuls champs (§ 5.7, § 8.9)', () => { + test("pour chaque proposition d'une génération, regenerer rend le même plan à partir de ses trois champs, sans lire ni son plan ni son rang", () => { + // arret 5 000 n'est pas un multiple de l'historique 37 : une descente + // dont le plan dépendrait de son rang dans la génération, par le compte + // des mouvements que la génération a déjà faits, ne se régénérerait pas + // au-delà de la première proposition. Les propositions se régénèrent de + // la dernière à la première, et regenerer ne reçoit que leur graine + // dérivée, leur compte d'arrêt et leur historique, ni leur plan ni leur + // identifiant : chacune sort de ces trois champs, et non de son rang ou + // d'un état que la précédente aurait laissé. salleReservee porte des + // places fantômes et des partiellement fixés. + const reglages = { graine: 5, arret: 5_000, nombre: 3, historique: 37 }; + let regenerees = 0; + let conflit = null; + for (const [nom, construire] of [ + ['salleReservee', salleReservee], + ['petite-conflit', () => demo('petite-conflit')], + ]) { + const configuration = construire(); + const propositions = rechercher(configuration, reglages); + for (const { id, graine, arret, historique, plan } of [...propositions].reverse()) { + assert.deepStrictEqual( + regenerer(configuration, { graine, arret, historique }), + plan, + `${nom}, proposition ${id}`, + ); + regenerees += 1; + } + assert.deepStrictEqual(configuration, construire(), nom); + // L'historique décide du plan : sous la longueur par défaut, les mêmes + // graines donnent d'autres plans, ceux que rendrait une régénération + // qui ignorerait ce champ. + assert.notDeepStrictEqual( + rechercher(configuration, { ...reglages, historique: HISTORIQUE_PAR_DEFAUT }).map(({ plan }) => plan), + propositions.map(({ plan }) => plan), + nom, + ); + conflit = propositions; + } + assert.equal(regenerees, 6); + // Dans la variante conflit, chaque descente atteint un écart nul, + // bascule, puis relance : la réplique littérale rend les mêmes plans, et + // compte bascules et relances. La régénération rejoue donc aussi le + // placement neuf qu'une relance tire. + const repliques = descentesALaLettre(demo('petite-conflit'), reglages, { bascule: true }); + assert.deepStrictEqual(repliques.map(({ plan }) => plan), conflit.map(({ plan }) => plan)); + assert.deepEqual( + repliques.map(({ bascules, relances }) => [bascules, relances > 0]), + [ + [1, true], + [1, true], + [1, true], + ], + ); + }); + + test("le compte d'arrêt décide du plan : à chaque compte de 1 à 32, regenerer rend chaque plan, et un compte au moins change un plan du précédent ; à 100, regenerer rend chaque plan, et la moitié comme le double de ce compte en donnent d'autres", () => { + // Là où la descente ne progresse plus, comme à 5 000 mouvements dans + // l'épreuve précédente, la moitié ou le double du compte rendent les + // mêmes plans, et une régénération qui compterait autrement passerait. + // + // Sur ses premiers mouvements, la descente change souvent de meilleure + // affectation. À chaque compte de 1 à COMPTES_COURTS, regenerer rend le + // plan de chaque proposition à partir de ses trois champs, et l'épreuve + // exige qu'un compte au moins de la boucle change un plan par rapport au + // compte précédent : une régénération qui compterait un mouvement de + // moins rend un autre plan à ce compte, et une qui en compterait un de + // plus, au compte précédent. + // + // À 100 mouvements, la descente progresse encore. Une descente plus + // courte refait, mouvement pour mouvement, le début de celle-ci, et la + // meilleure affectation ne cède qu'à une meilleure : des plans qui + // changent à arret / 2 et à 2 × arret changent à tout compte hors de cet + // intervalle. + const COMPTES_COURTS = 32; + const configuration = demo('petite-conflit'); + const reglages = { graine: 5, arret: 100, nombre: 3, historique: 37 }; + let precedents = null; + let changements = 0; + for (let compte = 1; compte <= COMPTES_COURTS; compte += 1) { + const courtes = rechercher(configuration, { ...reglages, arret: compte }); + for (const { id, graine, arret, historique, plan } of courtes) { + assert.deepStrictEqual( + regenerer(configuration, { graine, arret, historique }), + plan, + `arret ${compte}, proposition ${id}`, + ); + } + const plans = courtes.map(({ plan }) => JSON.stringify(plan)); + if (precedents !== null && plans.some((plan, i) => plan !== precedents[i])) changements += 1; + precedents = plans; + } + assert.ok(changements > 0, `aucun compte de 2 à ${COMPTES_COURTS} ne change un plan du compte précédent`); + const propositions = rechercher(configuration, reglages); + for (const { id, graine, arret, historique, plan } of [...propositions].reverse()) { + assert.deepStrictEqual(regenerer(configuration, { graine, arret, historique }), plan, `proposition ${id}`); + } + for (const arret of [reglages.arret / 2, 2 * reglages.arret]) { + assert.notDeepStrictEqual( + rechercher(configuration, { ...reglages, arret }).map(({ plan }) => plan), + propositions.map(({ plan }) => plan), + `arret ${arret}`, + ); + } + }); + + test('champ hors de son domaine ou absent : RangeError qui le nomme ; salle trop petite : PLACES_MANQUANTES, comme rechercher', () => { + // Un champ absent ne prend pas la valeur par défaut de rechercher : une + // proposition qui ne porte pas son historique ne se régénère pas sous une + // longueur supposée. Les deux bornes de la graine sont admises. + const champs = { graine: 1, arret: 10, historique: 5 }; + for (const graine of [0, 2 ** 32 - 1]) { + assert.equal(regenerer(demo('petite'), { ...champs, graine }).tours.length, 4, `graine ${graine}`); + } + const cas = [ + [{ ...champs, graine: -1 }, 'graine'], + [{ ...champs, graine: 2 ** 32 }, 'graine'], + [{ ...champs, graine: 1.5 }, 'graine'], + [{ arret: 10, historique: 5 }, 'graine'], + [{ ...champs, arret: 0 }, 'arret'], + [{ ...champs, arret: '10' }, 'arret'], + [{ ...champs, historique: 0 }, 'historique'], + [{ graine: 1, arret: 10 }, 'historique'], + ]; + for (const [proposition, nom] of cas) { + assert.throws( + () => regenerer(demo('petite'), proposition), + (erreur) => erreur instanceof RangeError && erreur.message.startsWith(`${nom} :`), + JSON.stringify(proposition), + ); + } + const tropPetite = configurationDe({ + appartenances: Array(11).fill(null), + tables: [ + { id: 1, capacite: 4 }, + { id: 2, capacite: 4 }, + ], + tours: 2, + }); + assert.throws(() => regenerer(tropPetite, champs), { + name: 'ErreurConfiguration', + code: 'PLACES_MANQUANTES', + details: { placesManquantes: 3 }, + }); + }); +}); + 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; @@ -497,7 +655,9 @@ describe('rechercher : plans valides (§ 5.2, § 5.9, § 14.12)', () => { 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). + // change rien au compte (§ 5.9). Le détail se nomme placesManquantes, + // comme dans le diagnostic : « manque » désigne plafond réalisé − + // rencontres (§ 5.5). const tables = [ { id: 1, capacite: 4 }, { id: 2, capacite: 4 }, @@ -517,13 +677,13 @@ describe('rechercher : plans valides (§ 5.2, § 5.9, § 14.12)', () => { assert.throws(() => rechercher(configuration, reglages), { name: 'ErreurConfiguration', code: 'PLACES_MANQUANTES', - details: { manque: 1 }, + details: { placesManquantes: 1 }, }); } const unDePlus = configurationDe({ appartenances: Array(13).fill(null), tables, tours: 2 }); assert.throws(() => rechercher(unDePlus, reglages), { code: 'PLACES_MANQUANTES', - details: { manque: 3 }, + details: { placesManquantes: 3 }, }); }); }); @@ -644,6 +804,32 @@ describe('état de recherche : placement initial et score incrémental (§ 5.10) }); }); +describe("rechercher : l'échange avec une place fantôme (paragraphe « Mouvement » de recherche.js)", () => { + test("une personne passe au siège vide d'une autre table : deux tables de 3 pour A, A, B, B, aucune collision sur vingt graines", () => { + // Le placement initial assied trois personnes à la première table et la + // quatrième à la seconde : deux d'une même appartenance s'y retrouvent. + // Un échange entre deux personnes garde trois et une ; seul l'échange + // avec une place fantôme fait passer quelqu'un à la seconde table. À + // deux par table, chacune peut réunir un A et un B, sans collision. + const configuration = configurationDe({ + appartenances: ['A', 'A', 'B', 'B'], + tables: [ + { id: 1, capacite: 3 }, + { id: 2, capacite: 3 }, + ], + tours: 1, + contraintes: { ...SANS_CONTRAINTE, separerAppartenances: true }, + }); + const instance = normaliser(configuration); + const collisions = []; + for (let graine = 0; graine < 20; graine += 1) { + const [{ plan }] = rechercher(configuration, { graine, arret: 2_000, nombre: 1 }); + collisions.push(mesurer(instance, indexerPlan(instance, plan)).collisionsCumulees); + } + assert.deepEqual(collisions, Array(20).fill(0)); + }); +}); + 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 : diff --git a/src/moteur/types.js b/src/moteur/types.js index e10cb14..dbf31e3 100644 --- a/src/moteur/types.js +++ b/src/moteur/types.js @@ -3,7 +3,9 @@ // Contrat de données du moteur : les formes qu'il reçoit et celles qu'il // rend. Ce module ne porte que des définitions JSDoc et n'exécute rien ; un -// module cite un type par import('./types.js').Instance. +// module cite un type par import('./types.js').Instance. Les valeurs que ces +// formes portent — STATUT, LIBRE, RESERVE, SANS_GROUPE — sont définies et +// exportées par configuration.js, leur seul propriétaire. /** * @typedef {Object} Participant @@ -54,20 +56,31 @@ * @property {number[]} idsTables index → id table, ordre de configuration.tables * @property {Map} indexTableDe * @property {Int32Array} capacite par index de table - * @property {Int32Array} groupe par index participant ; −1 sans appartenance + * @property {Int32Array} groupe par index participant ; SANS_GROUPE (−1) + * sans appartenance * @property {string[]} groupes index de groupe → libellé ; ordre de première * apparition en parcourant les ids croissants * @property {Int32Array} fixe [p * R + r] → index de table imposé (r à - * partir de 0), −1 si libre - * @property {Uint8Array} statut 0 mobile, 1 partiellement fixé, 2 ancré (§ 4.2) + * partir de 0), LIBRE (−1) si libre + * @property {Uint8Array} statut STATUT.MOBILE (0), STATUT.PARTIELLEMENT_FIXE + * (1), STATUT.ANCRE (2) (§ 4.2) * @property {Int32Array} ancresParTable a_t * @property {number} k ancrés * @property {number} n N − k * @property {Contraintes} contraintes * * Un plan indexé est un Int32Array `tableDe` de longueur N × R : - * tableDe[p * R + r] = index de table, −1 pour la réserve. + * tableDe[p * R + r] = index de table, RESERVE (−1) pour la réserve. + * exigerPlanIndexe (configuration.js) en garde la forme. * * L'ordre canonique des tableaux par personne est celui de `instance.ids` : * tout tableau rendu par le moteur et indexé par personne suit cet ordre. + * + * Une personne que le moteur désigne sans identifiant l'est par son index + * canonique, son rang dans `instance.ids`, et `instance.ids[index]` en donne + * l'identifiant. C'est le cas d'une violation de verifierIndicateurs, + * { code, index } : ses trois listes ne portent pas d'identifiant, et la + * traduction revient à l'appelant, qui tient l'instance. Les détails d'une + * ErreurConfiguration et les violations de verifierInvariants désignent au + * contraire les participants par identifiant. */ diff --git a/src/moteur/verification.js b/src/moteur/verification.js index 5610a3e..a2450b4 100644 --- a/src/moteur/verification.js +++ b/src/moteur/verification.js @@ -8,6 +8,7 @@ // est une violation de la liste rendue, et la liste vide dit que tout tient. // Aucun ne modifie ce qu'il reçoit. Les formes reçues sont décrites dans // types.js. +import { LIBRE } from './configuration.js'; /** * @typedef {{code: string, participant?: number, table?: number, tour?: number, @@ -18,8 +19,6 @@ * instance.ids */ -// Case de instance.fixe sans table imposée. -const LIBRE = -1; // Index de table d'une liste que plan.tables ne rattache à aucune table de // l'instance. Aucune case de instance.fixe ne le porte : une réservation ne // s'honore jamais dans une telle liste.