Generating must never block the screen or record a plan that breaks a rule. The controller refuses before launching, in a fixed order: an out-of-range setting named by the engine's own check, the session's refusals, then what makes the run useless. It runs the search in a worker or in the thread, records only propositions whose invariants hold, skips plans already present, and keeps the last duration for the session only. The evaluation table ranks propositions on the minimum of encounters and names the reservations a proposition does not honour. Checked: 2112 node and 60 node-long tests; coverage floors kept. --- FR --- [ADD] application : génération, travailleur, table d'évaluation Générer ne doit jamais bloquer l'écran ni enregistrer un plan qui enfreint une règle. Le contrôleur refuse avant tout lancement, dans un ordre fixe : un réglage hors de son domaine, nommé par la règle même du moteur, les refus de la séance, puis ce qui rend le calcul vain. Il lance la recherche dans un travailleur ou dans le fil, n'enregistre que les propositions dont les invariants tiennent, écarte les plans déjà présents, et garde la dernière durée pour la seule séance. La table d'évaluation classe les propositions sur le minimum de rencontres et nomme les réservations qu'une proposition ne tient pas. Vérifié : 2112 node et 60 node-long ; seuils de couverture tenus. Assisted-by: Claude Opus 5.5
441 lines
19 KiB
JavaScript
441 lines
19 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|
|
|
// La génération (§ 5.7, § 5.8, § 5.10) : les exécuteurs qui font tourner la
|
|
// recherche du moteur, et le contrôleur qui la lance depuis la séance.
|
|
//
|
|
// Un exécuteur rend, pour une configuration et des réglages, une exécution :
|
|
// son résultat, une promesse des propositions de rechercher ; ses
|
|
// avancements ; son annulation, qui rejette le résultat par ErreurAnnulee,
|
|
// sans résultat partiel. L'exécuteur direct calcule dans le fil, une
|
|
// microtâche après le lancement : il sert les épreuves et le premier
|
|
// montage de l'application ; celui du travailleur (I13) calcule hors du fil
|
|
// et s'annule par terminate(). Une erreur de la recherche devient la même
|
|
// dans les deux, par versErreurGeneration.
|
|
//
|
|
// Le contrôleur ne numérote rien et ne mesure aucun plan. Il refuse avant
|
|
// tout lancement un réglage hors de son domaine (REGLAGE_HORS_DOMAINE), puis
|
|
// ce que l'enregistrement refuserait — les refus de la séance, lus dans son
|
|
// état observable, dans l'ordre de son contrat —, puis
|
|
// ce qui rend la génération vaine ou impossible ; il garde la graine
|
|
// proposée et la durée du dernier calcul, en mémoire, pour la séance de
|
|
// l'application ; il vérifie les invariants de chaque proposition rendue,
|
|
// écarte celles dont le plan est déjà présent, et confie les autres à la
|
|
// commande enregistrerGeneration, une seule entrée du journal. Le nombre de
|
|
// propositions d'une génération est un réglage, jamais une constante d'ici.
|
|
//
|
|
// Le compte d'inactivité de la séance est suspendu pendant toute la
|
|
// génération (debuterCalcul), et repart à sa fin, quelle qu'elle soit
|
|
// (finirCalcul, dans un finally). Passer en lecture, ou fermer l'événement,
|
|
// annule la génération en cours (surAnnulation).
|
|
import { normaliser, nombrePlacesManquantes } from '../moteur/configuration.js';
|
|
import { ErreurAnnulee, ErreurConfiguration } from '../moteur/erreurs.js';
|
|
import { rechercher, verifierReglages } from '../moteur/recherche.js';
|
|
import { verifierInvariants } from '../moteur/verification.js';
|
|
import { GENERATION_PAR_DEFAUT, configurationDepuisCharge } from '../stockage/document.js';
|
|
import { derive, planDepuisFichier, versFichier } from '../stockage/placements.js';
|
|
import { lireEntier } from './capacite.js';
|
|
|
|
// src/interface reçoit l'annulation d'ici : elle ne joint pas le moteur.
|
|
export { ErreurAnnulee } from '../moteur/erreurs.js';
|
|
|
|
/** Au-delà de cette durée estimée, en secondes, la génération demande confirmation (§ 5.10). */
|
|
export const SEUIL_GENERATION_LONGUE_S = 60;
|
|
|
|
/** Le plafond d'usage d'une génération, en secondes (§ 5.10) : un repère dit, jamais un refus. */
|
|
export const PLAFOND_USAGE_S = 30 * 60;
|
|
|
|
// Sans durée mesurée, une génération de plus de mouvements que ce compte —
|
|
// dix fois le réglage par défaut — demande confirmation.
|
|
const MOUVEMENTS_SANS_MESURE = 10 * GENERATION_PAR_DEFAUT.nombre * GENERATION_PAR_DEFAUT.arret;
|
|
|
|
// La plus grande graine, et le modulo de la suivante.
|
|
const GRAINE_MAX = 2 ** 32 - 1;
|
|
const GRAINES = 2 ** 32;
|
|
|
|
// Les champs des réglages, dans l'ordre où leurs refus se rendent, et le
|
|
// domaine de chacun ; max null : sans borne.
|
|
const CHAMPS = [
|
|
['graine', 0, GRAINE_MAX],
|
|
['nombre', 1, null],
|
|
['arret', 1, null],
|
|
['historique', 1, null],
|
|
];
|
|
|
|
const versSection = (section) => ({ geste: 'ouvrirSection', section });
|
|
|
|
/**
|
|
* Refus ou échec d'une génération : son code et ses détails — ceux de la
|
|
* séance, du moteur ou du contrôleur, tels quels. Le texte affiché est celui
|
|
* de son code, nu (§ 14.6) ; le message, code et détails en JSON, sert aux
|
|
* traces.
|
|
*/
|
|
export class ErreurGeneration extends Error {
|
|
/**
|
|
* @param {string} code
|
|
* @param {Object} [details]
|
|
*/
|
|
constructor(code, details = {}) {
|
|
super(`${code} ${JSON.stringify(details)}`);
|
|
this.code = code;
|
|
this.details = details;
|
|
}
|
|
}
|
|
ErreurGeneration.prototype.name = 'ErreurGeneration';
|
|
|
|
/**
|
|
* Les réglages d'une génération, contrôlés par la règle de rechercher
|
|
* (verifierReglages) avant tout calcul. Lève, pour le premier réglage hors
|
|
* de son domaine, ErreurGeneration('REGLAGE_HORS_DOMAINE', { reglage,
|
|
* remede: null }), reglage étant son nom — graine, arret, historique ou
|
|
* nombre —, sans le message du moteur ; ne rend rien sinon. Toute autre
|
|
* erreur du contrôle est relevée telle quelle.
|
|
*
|
|
* @param {Object} reglages
|
|
*/
|
|
export function exigerReglages(reglages) {
|
|
try {
|
|
verifierReglages(reglages);
|
|
} catch (erreur) {
|
|
if (erreur instanceof RangeError && typeof erreur.reglage === 'string') {
|
|
throw new ErreurGeneration('REGLAGE_HORS_DOMAINE', { reglage: erreur.reglage, remede: null });
|
|
}
|
|
throw erreur;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* L'erreur d'une recherche, la même dans les deux exécuteurs : ErreurAnnulee
|
|
* telle quelle ; une ErreurConfiguration devient ErreurGeneration de son code
|
|
* et de ses détails ; toute autre, RangeError comprise, est rendue telle
|
|
* quelle, pour être relevée. Les réglages sont contrôlés avant le calcul
|
|
* (exigerReglages) : une RangeError qui survient pendant lui vient d'ailleurs,
|
|
* d'un abonné aux avancements par exemple, et ne se dit pas comme un réglage.
|
|
*
|
|
* @param {unknown} erreur
|
|
* @returns {unknown}
|
|
*/
|
|
export function versErreurGeneration(erreur) {
|
|
if (erreur instanceof ErreurAnnulee) return erreur;
|
|
if (erreur instanceof ErreurConfiguration) return new ErreurGeneration(erreur.code, erreur.details);
|
|
return erreur;
|
|
}
|
|
|
|
/**
|
|
* @typedef {Object} Execution
|
|
* @property {(fn: (fait: number, total: number) => void) => () => void} surAvancement
|
|
* abonne fn aux avancements ; rend la fonction qui désabonne
|
|
* @property {Promise<import('../moteur/recherche.js').Proposition[]>} resultat
|
|
* rejetée par ErreurAnnulee à l'annulation, par ErreurGeneration sur
|
|
* une erreur du moteur
|
|
* @property {() => void} annuler sans résultat partiel
|
|
*
|
|
* @typedef {{lancer: (configuration: Object, reglages: Object) => Execution}} Executeur
|
|
*/
|
|
|
|
/**
|
|
* L'exécuteur direct : rechercher dans le fil, une microtâche après lancer.
|
|
* Rien d'autre ne s'exécute pendant la recherche. annuler(), avant ce départ
|
|
* ou depuis un rappel d'avancement, fait lever ErreurAnnulee à la lecture
|
|
* suivante du signal par le moteur : avant la proposition suivante, au plus
|
|
* 1 000 mouvements plus tard. Chaque avancement est celui du moteur, toutes
|
|
* les 1 000 itérations.
|
|
*
|
|
* @returns {Executeur}
|
|
*/
|
|
export function creerExecuteurDirect() {
|
|
return {
|
|
lancer(configuration, reglages) {
|
|
const signal = { aborted: false };
|
|
const abonnes = new Set();
|
|
const progression = (fait, total) => {
|
|
for (const fn of [...abonnes]) fn(fait, total);
|
|
};
|
|
const resultat = Promise.resolve().then(() => {
|
|
exigerReglages(reglages);
|
|
try {
|
|
return rechercher(configuration, reglages, { signal, progression });
|
|
} catch (erreur) {
|
|
throw versErreurGeneration(erreur);
|
|
}
|
|
});
|
|
return {
|
|
surAvancement(fn) {
|
|
abonnes.add(fn);
|
|
return () => {
|
|
abonnes.delete(fn);
|
|
};
|
|
},
|
|
resultat,
|
|
annuler() {
|
|
signal.aborted = true;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
// Le plan d'une proposition du moteur, rangé comme planDepuisFichier rend
|
|
// celui d'une proposition enregistrée : la même mise en forme, versFichier
|
|
// puis planDepuisFichier, pour que deux plans égaux s'écrivent pareil.
|
|
function planRange(proposition, configuration, produitVersion) {
|
|
const fichier = versFichier(proposition, configuration, { produitVersion, attribuerSieges: false, decalage: 0 });
|
|
return JSON.stringify(planDepuisFichier(fichier));
|
|
}
|
|
|
|
/**
|
|
* Le contrôleur de génération d'une séance (§ 5.7, § 5.10).
|
|
*
|
|
* @param {Object} parametres
|
|
* @param {Object} parametres.seance la séance d'A1 et A2 : etat,
|
|
* subscribe, surAnnulation, executer, debuterCalcul, finirCalcul
|
|
* @param {Executeur} parametres.executeur
|
|
* @param {import('./horloge.js').Horloge} parametres.horloge instant() mesure
|
|
* la durée d'un calcul
|
|
* @param {string} parametres.produitVersion
|
|
* @returns {Object} le contrôleur : grainePropose, reglagesProposes, valider,
|
|
* estimer, derniereGeneration, generer, annuler, subscribe
|
|
*/
|
|
export function creerControleurGeneration({ seance, executeur, horloge, produitVersion }) {
|
|
// graine : la graine proposée ; base : celle de l'événement ouvert, null
|
|
// sans événement ; mesure : le dernier calcul abouti, null avant lui ;
|
|
// execution : celle en cours, null hors génération ; enCours vaut vrai du
|
|
// lancement à la fin de l'enregistrement.
|
|
let graine = 1;
|
|
let base = null;
|
|
let mesure = null;
|
|
let execution = null;
|
|
let observable = { enCours: false, fait: 0, total: 0, annulee: null };
|
|
const abonnes = new Set();
|
|
|
|
function publier(changements) {
|
|
observable = { ...observable, ...changements };
|
|
for (const fn of [...abonnes]) fn(observable);
|
|
}
|
|
|
|
// Une ouverture : l'événement passe de null, ou d'une autre base, à une
|
|
// base ; la graine proposée repart de 1.
|
|
seance.subscribe((etat) => {
|
|
const suivante = etat.evenement?.base ?? null;
|
|
if (suivante !== null && suivante !== base) graine = 1;
|
|
base = suivante;
|
|
});
|
|
|
|
// Les réglages de génération de la charge ouverte, ceux d'une charge
|
|
// neuve sans événement.
|
|
const reglagesDeLaCharge = () => seance.etat().charge?.reglages.generation ?? GENERATION_PAR_DEFAUT;
|
|
|
|
function annulerAvec(cause) {
|
|
if (execution === null) return;
|
|
const courante = execution;
|
|
publier({ annulee: cause });
|
|
courante.annuler();
|
|
}
|
|
|
|
function estimer({ nombre, arret }) {
|
|
const mouvements = nombre * arret;
|
|
if (mesure === null) return { inconnue: true };
|
|
const secondes = (mouvements * mesure.dureeMs) / (mesure.mouvements * 1000);
|
|
return {
|
|
secondes,
|
|
longue: secondes > SEUIL_GENERATION_LONGUE_S,
|
|
auDelaDuPlafond: secondes > PLAFOND_USAGE_S,
|
|
mesure: { ...mesure },
|
|
};
|
|
}
|
|
|
|
// Longue : au-delà du seuil estimé, ou, sans mesure, au-delà de dix fois
|
|
// le réglage par défaut.
|
|
function estLongue(reglages, estimation) {
|
|
if (estimation.inconnue === true) return reglages.nombre * reglages.arret > MOUVEMENTS_SANS_MESURE;
|
|
return estimation.longue;
|
|
}
|
|
|
|
// Les refus d'avant lancement : les réglages d'abord, puis l'ordre du
|
|
// contrat ; rend la configuration et son instance quand la génération peut
|
|
// partir.
|
|
function exigerLancement(reglages, confirmee) {
|
|
exigerReglages(reglages);
|
|
const etat = seance.etat();
|
|
if (etat.evenement === null) throw new ErreurGeneration('AUCUN_EVENEMENT', { remede: null });
|
|
if (etat.refus !== null) throw new ErreurGeneration(etat.refus.code, etat.refus.details);
|
|
if (execution !== null || observable.enCours) throw new ErreurGeneration('GENERATION_EN_COURS', { remede: null });
|
|
const { charge } = etat;
|
|
if (!charge.participants.some(({ exclu }) => !exclu)) {
|
|
throw new ErreurGeneration('AUCUN_PARTICIPANT', { remede: versSection('participants') });
|
|
}
|
|
if (charge.tables.length === 0) throw new ErreurGeneration('AUCUNE_TABLE', { remede: versSection('tables') });
|
|
const configuration = configurationDepuisCharge(charge);
|
|
let instance;
|
|
try {
|
|
instance = normaliser(configuration);
|
|
} catch (erreur) {
|
|
if (erreur instanceof ErreurConfiguration) throw new ErreurGeneration(erreur.code, { ...erreur.details, remede: null });
|
|
throw erreur;
|
|
}
|
|
const placesManquantes = nombrePlacesManquantes(instance);
|
|
if (placesManquantes > 0) {
|
|
throw new ErreurGeneration('PLACES_MANQUANTES', { placesManquantes, remede: versSection('tables') });
|
|
}
|
|
const estimation = estimer(reglages);
|
|
if (!confirmee && estLongue(reglages, estimation)) {
|
|
throw new ErreurGeneration('CONFIRMATION_REQUISE', { estimation, remede: null });
|
|
}
|
|
return { configuration, instance };
|
|
}
|
|
|
|
// Le calcul confié à l'exécuteur : ses avancements publiés, son annulation
|
|
// par la séance suivie ; rend les propositions et la durée du calcul. Une
|
|
// annulation lève ErreurAnnulee, même quand l'exécuteur a rendu un
|
|
// résultat entre-temps.
|
|
async function calculer(configuration, reglages) {
|
|
const debut = horloge.instant();
|
|
execution = executeur.lancer(configuration, reglages);
|
|
const desabonner = execution.surAvancement((fait, total) => publier({ fait, total }));
|
|
const desannuler = seance.surAnnulation(() => annulerAvec('seance'));
|
|
try {
|
|
const propositions = await execution.resultat;
|
|
if (observable.annulee !== null) throw new ErreurAnnulee();
|
|
return { propositions, dureeMs: horloge.instant() - debut };
|
|
} finally {
|
|
desabonner();
|
|
desannuler();
|
|
execution = null;
|
|
}
|
|
}
|
|
|
|
// Les propositions à enregistrer, par identifiant du moteur croissant :
|
|
// chacune sans violation des invariants, sinon GENERATION_INVARIANT_VIOLE,
|
|
// rang compris — la place de la proposition dans la suite des graines du
|
|
// lancement, qui n'est l'identifiant d'aucune proposition enregistrée ; puis sans
|
|
// celles dont le plan est celui d'une proposition présente sans dérive, ou
|
|
// d'une proposition déjà gardée de ce lancement.
|
|
function trier(propositions, configuration, instance) {
|
|
const ordonnees = [...propositions].sort((a, b) => a.id - b.id);
|
|
for (const proposition of ordonnees) {
|
|
const violations = verifierInvariants(instance, proposition.plan);
|
|
if (violations.length > 0) {
|
|
throw new ErreurGeneration('GENERATION_INVARIANT_VIOLE', { rang: proposition.id, violations, remede: null });
|
|
}
|
|
}
|
|
const charge = seance.etat().charge;
|
|
const presents = new Set(
|
|
charge.propositions.filter((p) => derive(p, charge).length === 0).map((p) => JSON.stringify(planDepuisFichier(p))),
|
|
);
|
|
const gardees = [];
|
|
for (const proposition of ordonnees) {
|
|
const plan = planRange(proposition, configuration, produitVersion);
|
|
if (presents.has(plan)) continue;
|
|
presents.add(plan);
|
|
gardees.push(proposition);
|
|
}
|
|
return { gardees, identiques: ordonnees.length - gardees.length };
|
|
}
|
|
|
|
// Les identifiants que l'enregistrement a donnés : ceux des propositions
|
|
// de la charge qui n'étaient pas avant le geste, et qui portent la graine,
|
|
// l'arrêt et l'historique d'une proposition gardée ; croissants.
|
|
function ajouteesDans(charge, avant, gardees) {
|
|
const cles = new Set(gardees.map(({ graine: g, arret, historique }) => `${g}|${arret}|${historique}`));
|
|
return charge.propositions
|
|
.filter(({ id, graine: g, arret, historique }) => !avant.has(id) && cles.has(`${g}|${arret}|${historique}`))
|
|
.map(({ id }) => id)
|
|
.sort((a, b) => a - b);
|
|
}
|
|
|
|
async function generer(reglages, { confirmee = false } = {}) {
|
|
const { configuration, instance } = exigerLancement(reglages, confirmee);
|
|
graine = (reglages.graine + 1) % GRAINES;
|
|
publier({ enCours: true, fait: 0, total: reglages.nombre * reglages.arret, annulee: null });
|
|
seance.debuterCalcul();
|
|
try {
|
|
const { propositions, dureeMs } = await calculer(configuration, reglages);
|
|
mesure = {
|
|
dureeMs,
|
|
mouvements: reglages.nombre * reglages.arret,
|
|
participants: instance.N,
|
|
nombre: reglages.nombre,
|
|
arret: reglages.arret,
|
|
};
|
|
const { gardees, identiques } = trier(propositions, configuration, instance);
|
|
if (gardees.length === 0) return { ajoutees: [], identiques, dureeMs };
|
|
const avant = new Set(seance.etat().charge.propositions.map(({ id }) => id));
|
|
await seance.executer('enregistrerGeneration', { propositions: gardees, configuration, produitVersion });
|
|
return { ajoutees: ajouteesDans(seance.etat().charge, avant, gardees), identiques, dureeMs };
|
|
} finally {
|
|
seance.finirCalcul();
|
|
publier({ enCours: false });
|
|
}
|
|
}
|
|
|
|
return {
|
|
/** La graine proposée : 1 à chaque ouverture, puis la dernière lancée plus un, modulo 2^32. */
|
|
grainePropose: () => graine,
|
|
/** { graine, nombre, arret, historique } : la graine proposée, les trois autres de la charge. */
|
|
reglagesProposes() {
|
|
const { nombre, arret, historique } = reglagesDeLaCharge();
|
|
return { graine, nombre, arret, historique };
|
|
},
|
|
/**
|
|
* Les saisies des quatre champs, chacune lue par lireEntier (NON_ENTIER
|
|
* {saisie}), puis bornée à son domaine (HORS_DOMAINE {min, max}, max null
|
|
* sans borne) ; un historique non saisi — absent, null ou blanc — vaut
|
|
* celui de la charge. Rend { reglages, refus } : reglages null dès
|
|
* qu'un champ est refusé, refus dans l'ordre des champs.
|
|
*/
|
|
valider(saisies) {
|
|
const reglages = {};
|
|
const refus = [];
|
|
for (const [champ, min, max] of CHAMPS) {
|
|
const saisie = saisies[champ];
|
|
if (champ === 'historique' && (saisie === undefined || saisie === null || (typeof saisie === 'string' && saisie.trim() === ''))) {
|
|
reglages.historique = reglagesDeLaCharge().historique;
|
|
continue;
|
|
}
|
|
const lu = lireEntier(saisie);
|
|
if (lu.refus !== undefined) {
|
|
refus.push({ champ, code: lu.refus.code, details: lu.refus.details });
|
|
} else if (lu.valeur < min || (max !== null && lu.valeur > max)) {
|
|
refus.push({ champ, code: 'HORS_DOMAINE', details: { min, max, remede: null } });
|
|
} else {
|
|
reglages[champ] = lu.valeur;
|
|
}
|
|
}
|
|
return { reglages: refus.length === 0 ? reglages : null, refus };
|
|
},
|
|
/**
|
|
* L'estimation des réglages, proportionnelle aux mouvements de la
|
|
* dernière mesure : { inconnue: true } sans elle, sinon { secondes,
|
|
* longue, auDelaDuPlafond, mesure }.
|
|
*/
|
|
estimer,
|
|
/** La mesure du dernier calcul abouti de ce contrôleur, en mémoire ; null avant lui. */
|
|
derniereGeneration: () => (mesure === null ? null : { ...mesure }),
|
|
/**
|
|
* Lance une génération et l'enregistre : { ajoutees, identiques, dureeMs }.
|
|
* Refuse avant tout lancement, par ErreurGeneration, dans cet ordre :
|
|
* REGLAGE_HORS_DOMAINE {reglage} ; AUCUN_EVENEMENT ; le refus courant de
|
|
* la séance ; GENERATION_EN_COURS ;
|
|
* AUCUN_PARTICIPANT ; AUCUNE_TABLE ; le refus de normaliser ;
|
|
* PLACES_MANQUANTES ; CONFIRMATION_REQUISE {estimation}, que
|
|
* confirmee lève. Après la recherche : GENERATION_INVARIANT_VIOLE
|
|
* {rang, violations}, puis ce
|
|
* qu'executer lève, tel quel. Annulée : ErreurAnnulee.
|
|
*/
|
|
generer,
|
|
/** Annule la génération en cours, sans résultat partiel ; annulee vaut 'commande'. */
|
|
annuler: () => annulerAvec('commande'),
|
|
/**
|
|
* Le contrat des magasins de Svelte sur { enCours, fait, total, annulee } :
|
|
* annulee, la cause de la dernière annulation, 'commande' ou 'seance',
|
|
* null dès le lancement suivant.
|
|
*/
|
|
subscribe(fn) {
|
|
abonnes.add(fn);
|
|
fn(observable);
|
|
return () => {
|
|
abonnes.delete(fn);
|
|
};
|
|
},
|
|
};
|
|
}
|