gestion_table_tournante_libre/src/moteur/configuration.js

556 lines
22 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Passage de la configuration, forme par identifiants, à l'instance indexée
// 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.
//
2026-10-06 04:23:02 -04:00
// 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
// configuration, ou une capacité, un nombre de tours, un tour désigné hors
// de son domaine ;
// - TypeError, quand la configuration sort de l'une des formes que garantit
// le code qui la bâtit — stockage, import, interface — et qu'aucune saisie
// ne produit : un identifiant de participant ou de table qui n'est pas un
// entier ≥ 1, une appartenance ni chaîne ni null, un exclu présent et non
// booléen, une portée ni « tous » ni « tour », des contraintes qui ne sont
// pas un objet de quatre booléens, une liste qui n'est pas un tableau. Ces
// formes sont les seules que normaliser contrôle par TypeError ; une valeur
// qui en sort est une faute de l'appelant, qu'aucune valeur par défaut ne
// masque ;
// - RangeError, pour un plan indexé qui n'a pas la forme de l'instance : seul
// le moteur produit cette forme, et l'écart y est une faute de code.
//
// Les champs que normaliser ne lit pas — nom, prénom, numéro de table — ne
// sont pas examinés. Aucune fonction ne modifie ce qu'elle reçoit.
import { ErreurConfiguration } from './erreurs.js';
2026-10-06 04:23:02 -04:00
/**
* 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 d'un plan indexé qu'indexerPlan n'a pas encore remplie ; jamais rendue.
const NON_PLACE = -2;
// Plus grand entier que représente un Int32Array : une capacité au-delà s'y
// tronquerait sans bruit.
const ENTIER_32_MAX = 2 ** 31 - 1;
const NOMS_CONTRAINTES = [
'separerAppartenances',
'nouveauxVoisins',
'nouvelleTable',
'varierAppartenances',
];
// 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));
function exigerListe(valeur, nom) {
if (!Array.isArray(valeur)) throw new TypeError(`configuration.${nom} : liste attendue`);
}
function exigerIdentifiant(id, quoi) {
if (!Number.isInteger(id) || id < 1) {
throw new TypeError(`${quoi} d'identifiant ${decrire(id)} : entier ≥ 1 attendu`);
}
}
function exigerAppartenance({ id, appartenance }) {
if (appartenance !== null && typeof appartenance !== 'string') {
throw new TypeError(
`participant ${id} : appartenance chaîne ou null attendue, reçu ${decrire(appartenance)}`,
);
}
}
// Vrai pour un participant exclu ; un champ exclu absent vaut false.
function estExclu({ id, exclu }) {
if (exclu === undefined) return false;
if (typeof exclu !== 'boolean') {
throw new TypeError(`participant ${id} : exclu booléen attendu, reçu ${decrire(exclu)}`);
}
return exclu;
}
// Copie des quatre contraintes : une modification ultérieure de la
// configuration n'atteint pas l'instance.
function copierContraintes(contraintes) {
if (typeof contraintes !== 'object' || contraintes === null) {
throw new TypeError('configuration.contraintes : objet attendu');
}
const copie = {};
for (const nom of NOMS_CONTRAINTES) {
if (typeof contraintes[nom] !== 'boolean') {
throw new TypeError(`contrainte ${nom} : booléen attendu, reçu ${decrire(contraintes[nom])}`);
}
copie[nom] = contraintes[nom];
}
return copie;
}
// Groupe de chaque participant présent, présents triés par identifiant
2026-10-06 04:23:02 -04:00
// 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 = [];
const numeroDe = new Map();
for (let p = 0; p < presents.length; p += 1) {
const { appartenance } = presents[p];
if (appartenance === null) {
2026-10-06 04:23:02 -04:00
groupe[p] = SANS_GROUPE;
continue;
}
if (!numeroDe.has(appartenance)) {
numeroDe.set(appartenance, groupes.length);
groupes.push(appartenance);
}
groupe[p] = numeroDe.get(appartenance);
}
return { groupe, groupes };
}
// Tables imposées par les réservations : fixe[p * R + r] = index de table,
// LIBRE sinon. Chaque réservation est vérifiée dans sa forme — participant et
// table connus, tour dans 1..R —, exclus compris ; celle d'un exclu s'arrête
// là, suspendue, sans effet (§ 4.4). Une réservation qui répète la table déjà
// imposée au même tour ne change rien ; une autre table au même tour lève
// RESERVATION_CONFLIT.
function fixerReservations(reservations, contexte) {
const { connus, indexDe, indexTableDe, idsTables, N, R } = contexte;
const fixe = new Int32Array(N * R).fill(LIBRE);
for (let i = 0; i < reservations.length; i += 1) {
const { participant: id, table: idTable, portee, tour } = reservations[i];
if (!connus.has(id)) {
throw new ErreurConfiguration('RESERVATION_INCONNUE', { reservation: i, participant: id });
}
const t = indexTableDe.get(idTable);
if (t === undefined) {
throw new ErreurConfiguration('RESERVATION_INCONNUE', { reservation: i, table: idTable });
}
// Tours couverts, numérotés à partir de 0 : chacun des R tours pour la
// portée « tous », le tour désigné pour la portée « tour ».
let premier;
let dernier;
if (portee === 'tous') {
premier = 0;
dernier = R - 1;
} else if (portee === 'tour') {
if (!Number.isInteger(tour) || tour < 1 || tour > R) {
throw new ErreurConfiguration('RESERVATION_TOUR', { reservation: i, tour });
}
premier = tour - 1;
dernier = tour - 1;
} else {
throw new TypeError(
`réservation ${i} : portée ${decrire(portee)}, « tous » ou « tour » attendue`,
);
}
const p = indexDe.get(id);
if (p === undefined) continue;
for (let r = premier; r <= dernier; r += 1) {
const imposee = fixe[p * R + r];
if (imposee === LIBRE) {
fixe[p * R + r] = t;
} else if (imposee !== t) {
throw new ErreurConfiguration('RESERVATION_CONFLIT', {
participant: id,
tour: r + 1,
tables: [idsTables[imposee], idTable],
});
}
}
}
return fixe;
}
// Lève SURRESERVATION quand, à un tour, une table reçoit plus de personnes
// fixées que de sièges (§ 5.9). Le compte porte sur les personnes, lues dans
// fixe : une réservation répétée ne compte qu'une fois. Les tables se
// parcourent par index, chacune par tours croissants.
function verifierSurreservation(fixe, { N, T, R, capacite, idsTables }) {
const fixees = new Int32Array(T * R);
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
const t = fixe[p * R + r];
if (t !== LIBRE) fixees[t * R + r] += 1;
}
}
for (let t = 0; t < T; t += 1) {
for (let r = 0; r < R; r += 1) {
if (fixees[t * R + r] > capacite[t]) {
throw new ErreurConfiguration('SURRESERVATION', {
table: idsTables[t],
tour: r + 1,
reservees: fixees[t * R + r],
capacite: capacite[t],
});
}
}
}
}
// Statut de chaque participant, dérivé de fixe et non de la forme des
// réservations (§ 4.2) : ancré quand la même table est imposée à chacun des R
// tours, mobile quand aucune ne l'est, partiellement fixé sinon. Un ancré
// compte dans k et dans ancresParTable ; un partiellement fixé compte parmi
// les mobiles.
function deriverStatuts(fixe, N, T, R) {
const statut = new Uint8Array(N);
const ancresParTable = new Int32Array(T);
let k = 0;
for (let p = 0; p < N; p += 1) {
const premiere = fixe[p * R];
let memeTable = true;
let aucune = true;
for (let r = 0; r < R; r += 1) {
const t = fixe[p * R + r];
if (t !== premiere) memeTable = false;
if (t !== LIBRE) aucune = false;
}
if (aucune) {
2026-10-06 04:23:02 -04:00
statut[p] = STATUT.MOBILE;
} else if (memeTable) {
2026-10-06 04:23:02 -04:00
statut[p] = STATUT.ANCRE;
ancresParTable[premiere] += 1;
k += 1;
} else {
2026-10-06 04:23:02 -04:00
statut[p] = STATUT.PARTIELLEMENT_FIXE;
}
}
return { statut, ancresParTable, k };
}
/**
* Instance indexée d'une configuration (§ 4, § 5.2). Les participants exclus
* en sont absents (§ 4.4) : ni index ni groupe, et leurs réservations,
* suspendues, ne fixent rien et ne prennent aucun siège.
*
* Lève ErreurConfiguration sur la première règle enfreinte — participants,
* puis tables, nombre de tours, réservations une à une, surréservation :
* PARTICIPANT_DOUBLON, TABLE_DOUBLON, CAPACITE (non entière, < 2, ou au-delà
* d'un Int32Array), TOURS (R non entier ou < 1), RESERVATION_INCONNUE
* (participant ou table), RESERVATION_TOUR (tour absent ou hors de 1..R),
* RESERVATION_CONFLIT (une personne à deux tables d'un même tour),
* SURRESERVATION (plus de personnes fixées que de sièges à une table, à un
* tour). Une salle trop petite n'est pas refusée : nombrePlacesManquantes la
* chiffre, et la recherche la refuse (§ 5.9). Les détails désignent
* participants et tables par identifiant, les tours par leur numéro à partir
* de 1, et la réservation fautive par son rang dans
* configuration.reservations, à partir de 0.
*
* Lève TypeError sur les seules formes qu'énumère l'en-tête du module :
* identifiants de participant et de table, appartenance, exclu, portée,
* contraintes, listes. Une capacité, un nombre de tours ou un tour désigné
* hors de leur forme lèvent CAPACITE, TOURS ou RESERVATION_TOUR ; une
* réservation qui désigne un participant ou une table absents de la
* configuration lève RESERVATION_INCONNUE, quelle que soit la forme de la
* valeur qui les désigne.
*
* @param {import('./types.js').Configuration} configuration
* @returns {import('./types.js').Instance}
*/
export function normaliser(configuration) {
const { participants, tables, tours: R, reservations } = configuration;
exigerListe(participants, 'participants');
exigerListe(tables, 'tables');
exigerListe(reservations, 'reservations');
const contraintes = copierContraintes(configuration.contraintes);
// connus porte tous les identifiants, exclus compris : un doublon reste un
// doublon, et la réservation d'un exclu n'est pas inconnue.
const connus = new Set();
const presents = [];
const exclus = [];
for (const participant of participants) {
exigerIdentifiant(participant.id, 'participant');
if (connus.has(participant.id)) {
throw new ErreurConfiguration('PARTICIPANT_DOUBLON', { participant: participant.id });
}
connus.add(participant.id);
exigerAppartenance(participant);
if (estExclu(participant)) exclus.push(participant.id);
else presents.push(participant);
}
presents.sort((a, b) => a.id - b.id);
exclus.sort((a, b) => a - b);
const T = tables.length;
const idsTables = [];
const indexTableDe = new Map();
const capacite = new Int32Array(T);
for (let t = 0; t < T; t += 1) {
const { id, capacite: places } = tables[t];
exigerIdentifiant(id, 'table');
if (indexTableDe.has(id)) throw new ErreurConfiguration('TABLE_DOUBLON', { table: id });
if (!Number.isInteger(places) || places < 2 || places > ENTIER_32_MAX) {
throw new ErreurConfiguration('CAPACITE', { table: id, capacite: places });
}
idsTables.push(id);
indexTableDe.set(id, t);
capacite[t] = places;
}
if (!Number.isInteger(R) || R < 1) throw new ErreurConfiguration('TOURS', { tours: R });
const N = presents.length;
const ids = presents.map((participant) => participant.id);
const indexDe = new Map(ids.map((id, p) => [id, p]));
const { groupe, groupes } = numeroterGroupes(presents);
const fixe = fixerReservations(reservations, { connus, indexDe, indexTableDe, idsTables, N, R });
verifierSurreservation(fixe, { N, T, R, capacite, idsTables });
const { statut, ancresParTable, k } = deriverStatuts(fixe, N, T, R);
return {
N,
T,
R,
ids,
indexDe,
exclus: new Set(exclus),
idsTables,
indexTableDe,
capacite,
groupe,
groupes,
fixe,
statut,
ancresParTable,
k,
n: N - k,
contraintes,
};
}
/**
* Plan indexé d'un plan par identifiants : tableDe[p * R + r] = index de
* table, −1 pour la réserve. plan.tables déclare chaque table de l'instance
* une fois, et chaque liste se rattache à sa table par l'identifiant déclaré
* à la même position ; l'ordre des tables et celui des ids dans une liste
* sont indifférents. Un plan accepté revient donc de planDepuisIndex à
* l'identique, à ces deux ordres près.
*
* La forme indexée n'a pas de case pour un participant absent d'un tour,
* placé deux fois ou étranger à l'instance, ni pour une table que le plan ne
* déclare pas : au lieu de perdre l'écart, indexerPlan lève
* ErreurConfiguration — PLAN_TOURS (tours ou réserves en nombre ≠ R),
* PLAN_TABLE_INCONNUE, PLAN_TABLE_DOUBLON, PLAN_TABLE_ABSENTE (une table de
* l'instance que plan.tables ne déclare pas), PLAN_LISTES (un tour sans
* exactement une liste par table déclarée), PLAN_INCONNU, PLAN_EXCLU_PLACE
* (participant exclu de l'instance), PLAN_DOUBLE_PLACE (deux fois dans un
* tour, réserve comprise), PLAN_NON_ASSIS (ni à une table ni en réserve à un
* tour). Les capacités et les réservations ne sont pas examinées.
*
* @param {import('./types.js').Instance} instance
* @param {import('./types.js').Plan} plan
* @returns {Int32Array}
*/
export function indexerPlan(instance, plan) {
const { N, T, R, ids, idsTables, indexDe, indexTableDe, exclus } = instance;
if (plan.tours.length !== R || plan.reserves.length !== R) {
throw new ErreurConfiguration('PLAN_TOURS', {
attendu: R,
tours: plan.tours.length,
reserves: plan.reserves.length,
});
}
// Index de table de chaque liste, par position dans plan.tables.
const tableDeListe = [];
const declarees = new Set();
for (const id of plan.tables) {
const t = indexTableDe.get(id);
if (t === undefined) throw new ErreurConfiguration('PLAN_TABLE_INCONNUE', { table: id });
if (declarees.has(id)) throw new ErreurConfiguration('PLAN_TABLE_DOUBLON', { table: id });
declarees.add(id);
tableDeListe.push(t);
}
// Sans id inconnu ni doublon, une table manque exactement quand le plan en
// déclare moins que l'instance ; la première absente, dans l'ordre de
// l'instance, est nommée.
if (tableDeListe.length !== T) {
const absente = idsTables.find((id) => !declarees.has(id));
throw new ErreurConfiguration('PLAN_TABLE_ABSENTE', { table: absente });
}
const tableDe = new Int32Array(N * R).fill(NON_PLACE);
const placer = (id, r, t) => {
const p = indexDe.get(id);
if (p === undefined) {
const code = exclus.has(id) ? 'PLAN_EXCLU_PLACE' : 'PLAN_INCONNU';
throw new ErreurConfiguration(code, { participant: id, tour: r + 1 });
}
if (tableDe[p * R + r] !== NON_PLACE) {
throw new ErreurConfiguration('PLAN_DOUBLE_PLACE', { participant: id, tour: r + 1 });
}
tableDe[p * R + r] = t;
};
for (let r = 0; r < R; r += 1) {
const listes = plan.tours[r];
if (listes.length !== tableDeListe.length) {
throw new ErreurConfiguration('PLAN_LISTES', {
tour: r + 1,
listes: listes.length,
tables: tableDeListe.length,
});
}
for (let i = 0; i < listes.length; i += 1) {
for (const id of listes[i]) placer(id, r, tableDeListe[i]);
}
for (const id of plan.reserves[r]) placer(id, r, RESERVE);
for (let p = 0; p < N; p += 1) {
if (tableDe[p * R + r] === NON_PLACE) {
throw new ErreurConfiguration('PLAN_NON_ASSIS', { participant: ids[p], tour: r + 1 });
}
}
}
return tableDe;
}
2026-10-06 04:23:02 -04:00
/**
* 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<number>} 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
2026-10-06 04:23:02 -04:00
* (§ 8.9). Lève ce que lève exigerPlanIndexe.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe
* @returns {import('./types.js').Plan}
*/
export function planDepuisIndex(instance, tableDe) {
2026-10-06 04:23:02 -04:00
exigerPlanIndexe(instance, tableDe);
const { N, T, R, ids, idsTables } = instance;
const tours = [];
const reserves = [];
for (let r = 0; r < R; r += 1) {
const listes = Array.from({ length: T }, () => []);
const reserve = [];
// Les ids croissent avec l'index : parcourir les participants par index
// range chaque liste en ids croissants, sans tri.
for (let p = 0; p < N; p += 1) {
const t = tableDe[p * R + r];
2026-10-06 04:23:02 -04:00
if (t === RESERVE) reserve.push(ids[p]);
else listes[t].push(ids[p]);
}
tours.push(listes);
reserves.push(reserve);
}
return { tables: [...idsTables], tours, reserves };
}
2026-10-06 04:23:02 -04:00
/**
* 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
* dépend d'aucune réservation (§ 5.9) : un ancré retire une personne des
* mobiles et un siège de la capacité libre, un partiellement fixé reste
* parmi les mobiles et ne retire rien.
*
* @param {import('./types.js').Instance} instance
* @returns {number}
*/
export function nombrePlacesManquantes(instance) {
2026-10-06 04:23:02 -04:00
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<number>} 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 };
}