v2 : moteurs purs complets + ambiances + persistance + seed Atelier du Canal

- engine/ : parametric (médiane basse, cristallisation, impact linear-share,
  bimodalité), state (canTransition 9 gardes doctrinales + windowOutcome),
  settings (resolveSettings + replis), triage (R-U→R6, phrases françaises),
  impact (concernés calculés), électionResult (blanc, quorum, égalité sans
  départage machine) — 296 tests vitest verts
- moods.css v2 : Source/Margelle/Nappe/Minuit (champ lexical du puits),
  tokens routes/états, socle borderless, print A4, tampon 井
- data/persistence.ts : IndexedDB local-first, export/import Bundle, lignée
- Seed Atelier du Canal (145 Ko, tous les états de l'UI) + test
- backend/scripts/export_seed_bundle.py (extraction Ğ1, bundle à générer)
- test anti-lexique (marqueur ld-v2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-08-11 09:22:19 +02:00
co-authored by Claude Fable 5
parent 53d8752e40
commit f707b5f15d
18 changed files with 10324 additions and 181 deletions
+416
View File
@@ -0,0 +1,416 @@
/**
* Parametric decision engine — « Réglage collectif » (collective tuning).
*
* Pure functions, no I/O — the single implementation (BLUEPRINT-V2.md Δ2, Δ3,
* Δ15, Δ16). Everything here is math over ParamSpec + vote vectors; the human
* gesture (crystallization) lives in state.ts/UI, never here.
*
* LOCKED SPECS (blueprint repairs):
* - LOW median, element by element: for an even vote count, take the LOWER
* central element (index floor((n-1)/2) after ascending sort). Invariant:
* every median value is a value someone actually voted, so the step is
* honored BY CONSTRUCTION — « une position que chacun aurait pu proposer ».
* - constraint 'sum100' requires EXACTLY ONE 'share' param with derived:true
* (the absorption variable), resolved linearly: 100 Σ other shares.
* 'slider' params live outside the constraint and pass through untouched.
* - At vote time the resolved derived must stay within its bounds, otherwise
* the vote is rejected (validateVote).
* - At crystallization the derived is never aggregated: it is resolved from
* the median of the voted shares; if it exits its bounds ⇒ clamp to the
* violated bound + PROPORTIONAL renormalization of the non-derived shares
* (each multiplied by (100 clampedDerived) / Σ median shares) so the
* sum-100 invariant is restored. The renormalized shares may leave the
* step grid — accepted and documented: this is the one specified exception.
* - Degenerate cases: 0 votes ⇒ baseline vector, never an empty screen.
* - computeMyImpact 'linear-share' NEVER invents a number: missing attribute
* or empty declaring corpus ⇒ null.
* - detectBimodality is a simple documented heuristic, NEVER blocking:
* it returns false on any degenerate input instead of throwing.
*
* Code and comments in English; thrown error messages in French (UI-facing).
*/
import type { ParamDef, ParamSpec } from '../types/domain'
/** Blueprint limit: a small manipulable space (SejeteralO lesson). */
const MAX_PARAMS = 7
/**
* Absolute tolerance for floating-point comparisons (bounds and step grid).
* Vote values are human-scale (percent shares, bounded sliders), so an
* absolute epsilon is safe: 0.1 + 0.2 must be accepted as a 0.3 step value.
*/
const FLOAT_EPS = 1e-6
/** Params that are actually voted, in spec order (derived excluded). */
function votableParams(spec: ParamSpec): ParamDef[] {
return spec.params.filter(p => p.derived !== true)
}
/**
* Guarded indexed access (project compiles with noUncheckedIndexedAccess).
* Every call site is protected by a prior length check or loop bound —
* this throw is an internal-invariant guard, not a reachable user error.
*/
function at(arr: number[], i: number): number {
const v = arr[i]
if (v === undefined) {
throw new Error('Incohérence interne : index hors du vecteur.')
}
return v
}
// ---------------------------------------------------------------------------
// validateParamSpec
// ---------------------------------------------------------------------------
/**
* Validate a ParamSpec at creation time. Throws (French message) when:
* - no param, or more than 7 params;
* - a business label is missing (never raw a, b, c);
* - bounds are inconsistent (min >= max) or step is not strictly positive;
* - a baseline lies outside its own bounds;
* - constraint 'sum100' does not have EXACTLY ONE 'share' param with
* derived:true (0 or 2+ derived, or derived on a 'slider');
* - a derived param exists without a constraint able to resolve it.
*/
export function validateParamSpec(spec: ParamSpec): void {
if (spec.params.length === 0) {
throw new Error('Au moins un paramètre est requis.')
}
if (spec.params.length > MAX_PARAMS) {
throw new Error(
`Trop de paramètres : ${spec.params.length} (maximum ${MAX_PARAMS}).`,
)
}
for (const p of spec.params) {
if (typeof p.label !== 'string' || p.label.trim() === '') {
throw new Error(`Libellé métier manquant pour le paramètre « ${p.key} ».`)
}
if (!(p.min < p.max)) {
throw new Error(
`Bornes incohérentes pour « ${p.key} » : min (${p.min}) doit être strictement inférieur à max (${p.max}).`,
)
}
if (!(p.step > 0)) {
throw new Error(
`Pas invalide pour « ${p.key} » : ${p.step} (doit être strictement positif).`,
)
}
if (p.baseline !== undefined && (p.baseline < p.min || p.baseline > p.max)) {
throw new Error(
`Statu quo hors bornes pour « ${p.key} » : ${p.baseline} (bornes ${p.min}${p.max}).`,
)
}
}
const derived = spec.params.filter(p => p.derived === true)
if (spec.constraint === 'sum100') {
if (derived.some(p => p.kind !== 'share')) {
throw new Error(
'Un paramètre dérivé doit être une part (kind « share »), pas un curseur.',
)
}
if (derived.length === 0) {
throw new Error(
'Contrainte sum100 : exactement une part dérivée est requise (aucune trouvée).',
)
}
if (derived.length > 1) {
throw new Error(
`Contrainte sum100 : exactement une part dérivée est requise (${derived.length} trouvées).`,
)
}
} else if (derived.length > 0) {
throw new Error(
'Paramètre dérivé sans contrainte : rien ne permet de le résoudre.',
)
}
}
// ---------------------------------------------------------------------------
// validateVote
// ---------------------------------------------------------------------------
/**
* Validate one vote vector against the spec.
* `values` follows the order of spec.params EXCLUDING derived params
* (Vote.values contract). Throws (French message) when:
* - the vector length does not match the number of votable params;
* - a value is not a finite number (NaN / ±Infinity sanitization);
* - a value is out of bounds or off the step grid (FLOAT_EPS tolerance);
* - constraint 'sum100': the resolved derived (100 Σ voted shares) would
* exit its own [min, max] bounds ⇒ the vote is rejected.
*/
export function validateVote(spec: ParamSpec, values: number[]): void {
const votable = votableParams(spec)
if (values.length !== votable.length) {
throw new Error(
`Nombre de valeurs invalide : ${values.length} reçues, ${votable.length} attendues.`,
)
}
votable.forEach((p, i) => {
const v = values[i]
if (typeof v !== 'number' || !Number.isFinite(v)) {
throw new Error(
`Valeur invalide pour « ${p.label} » : un nombre fini est attendu.`,
)
}
if (v < p.min - FLOAT_EPS || v > p.max + FLOAT_EPS) {
throw new Error(
`Valeur hors bornes pour « ${p.label} » : ${v} (bornes ${p.min}${p.max}).`,
)
}
// Step grid: v must equal min + k×step for an integer k (float tolerance).
const k = Math.round((v - p.min) / p.step)
if (Math.abs(p.min + k * p.step - v) > FLOAT_EPS) {
throw new Error(
`Valeur non alignée sur le pas pour « ${p.label} » : ${v} (pas de ${p.step} depuis ${p.min}).`,
)
}
})
if (spec.constraint === 'sum100') {
const derivedParam = spec.params.find(p => p.derived === true)
if (derivedParam) {
let shareSum = 0
votable.forEach((p, i) => {
if (p.kind === 'share') shareSum += at(values, i)
})
const resolved = 100 - shareSum
if (
resolved < derivedParam.min - FLOAT_EPS
|| resolved > derivedParam.max + FLOAT_EPS
) {
throw new Error(
`La part calculée « ${derivedParam.label} » sortirait de ses bornes : ${resolved} (bornes ${derivedParam.min}${derivedParam.max}).`,
)
}
}
}
}
// ---------------------------------------------------------------------------
// resolveDerived
// ---------------------------------------------------------------------------
/**
* Expand a votable vector into the COMPLETE vector in spec.params order.
* The derived share (sum100) is resolved linearly: 100 Σ other shares.
* 'slider' params live outside the constraint and pass through untouched.
* Throws on a length mismatch (misuse guard — same message as validateVote).
*/
export function resolveDerived(spec: ParamSpec, values: number[]): number[] {
const votable = votableParams(spec)
if (values.length !== votable.length) {
throw new Error(
`Nombre de valeurs invalide : ${values.length} reçues, ${votable.length} attendues.`,
)
}
let shareSum = 0
votable.forEach((p, i) => {
if (p.kind === 'share') shareSum += at(values, i)
})
let cursor = 0
return spec.params.map(p => (p.derived === true ? 100 - shareSum : at(values, cursor++)))
}
// ---------------------------------------------------------------------------
// medianByElement
// ---------------------------------------------------------------------------
/**
* LOW median, element by element.
* Each column is sorted ascending and the element at index floor((n-1)/2) is
* taken — for an even n this is the LOWER of the two central elements.
* Invariant: every median value is a value actually voted by someone, so the
* step grid is honored by construction (« une position que chacun aurait pu
* proposer »).
* 0 votes ⇒ [] (the caller falls back to the baseline vector).
*/
export function medianByElement(votesValues: number[][]): number[] {
const n = votesValues.length
if (n === 0) return []
const width = votesValues[0]?.length ?? 0
const lowMedianIndex = Math.floor((n - 1) / 2)
const medians: number[] = []
for (let j = 0; j < width; j++) {
const column = votesValues.map(v => at(v, j)).sort((a, b) => a - b)
medians.push(at(column, lowMedianIndex))
}
return medians
}
// ---------------------------------------------------------------------------
// crystallize
// ---------------------------------------------------------------------------
/**
* Compute the crystallized position: LOW median of the VOTED vectors, then
* derived resolution. Returns the complete vector in spec.params order.
*
* sum100 repair (locked spec): the derived is never aggregated — it is
* resolved from the median of the voted shares. If it exits its bounds:
* - clamp it to the violated bound;
* - renormalize the non-derived shares PROPORTIONALLY to restore sum 100:
* each share is multiplied by (100 clampedDerived) / Σ median shares.
* (Renormalized shares may leave the step grid — accepted, documented.)
* - degenerate sub-case Σ median shares = 0: proportionality is undefined,
* the remainder (100 clampedDerived) is spread equally instead.
* 'slider' params are outside the constraint and are never renormalized.
*
* 0 votes ⇒ the baseline vector (spec.params[i].baseline ?? min) — never an
* empty screen; the crystallization GESTURE itself stays human (Δ3).
*/
export function crystallize(spec: ParamSpec, votesValues: number[][]): number[] {
if (votesValues.length === 0) {
return spec.params.map(p => p.baseline ?? p.min)
}
const median = medianByElement(votesValues)
const full = resolveDerived(spec, median)
if (spec.constraint !== 'sum100') return full
const derivedIndex = spec.params.findIndex(p => p.derived === true)
const derivedParam = spec.params[derivedIndex]
if (derivedIndex === -1 || derivedParam === undefined) {
return full // unreachable on a validated spec
}
const resolved = at(full, derivedIndex)
const withinBounds
= resolved >= derivedParam.min - FLOAT_EPS
&& resolved <= derivedParam.max + FLOAT_EPS
if (withinBounds) return full
// Clamp to the violated bound, then restore the sum-100 invariant.
const clamped = Math.min(Math.max(resolved, derivedParam.min), derivedParam.max)
const remainder = 100 - clamped
let shareSum = 0
let shareCount = 0
spec.params.forEach((p, i) => {
if (p.derived !== true && p.kind === 'share') {
shareSum += at(full, i)
shareCount++
}
})
return full.map((v, i) => {
if (i === derivedIndex) return clamped
const p = spec.params[i]
if (p === undefined || p.kind !== 'share') return v // sliders pass through untouched
if (shareSum === 0) return remainder / shareCount // degenerate: equal spread
return v * (remainder / shareSum) // proportional renormalization
})
}
// ---------------------------------------------------------------------------
// computeMyImpact
// ---------------------------------------------------------------------------
/** One line of the « Pour moi » card: my quota for one share param. */
export interface ImpactLine {
key: string
label: string
amount: number
}
/** « Pour moi » card content — per share param + total. */
export interface MyImpact {
perParam: ImpactLine[]
total: number
}
/**
* 'linear-share' personal impact (Δ15 — « Pour moi » card).
* Applies ONLY when constraint is 'sum100' AND resources.amount is set AND
* spec.impactAttrKey is set. For EACH share param p (derived included):
* amount(p) = resources.amount × value(p) / 100
* myQuota(p) = amount(p) × myAttr / Σ corpusAttrs
* `fullValues` is the COMPLETE vector in spec.params order (resolveDerived /
* crystallize output). 'slider' params are outside the constraint: no line.
*
* Returns null — NEVER an invented number — when myAttr is undefined,
* when Σ corpusAttrs is 0 (nobody declared), or on a malformed input.
*/
export function computeMyImpact(
spec: ParamSpec,
resources: { amount?: number },
fullValues: number[],
myAttr: number | undefined,
corpusAttrs: number[],
): MyImpact | null {
if (spec.constraint !== 'sum100') return null
if (!resources.amount || !Number.isFinite(resources.amount)) return null
if (!spec.impactAttrKey) return null
if (myAttr === undefined || !Number.isFinite(myAttr)) return null
if (fullValues.length !== spec.params.length) return null // misuse guard
const attrSum = corpusAttrs.reduce(
(sum, a) => sum + (Number.isFinite(a) ? a : 0),
0,
)
if (attrSum === 0) return null
const amount = resources.amount
const perParam: ImpactLine[] = []
let total = 0
spec.params.forEach((p, i) => {
if (p.kind !== 'share') return
const paramAmount = (amount * at(fullValues, i)) / 100
const myQuota = (paramAmount * myAttr) / attrSum
perParam.push({ key: p.key, label: p.label, amount: myQuota })
total += myQuota
})
return { perParam, total }
}
// ---------------------------------------------------------------------------
// detectBimodality
// ---------------------------------------------------------------------------
/**
* Simple documented heuristic over ONE param's voted values — NEVER blocking
* (it only feeds the non-blocking banner and the crystallization reminder,
* Δ16): two distinct positions are detected when, after ascending sort,
* the LARGEST gap between consecutive values satisfies ALL of:
* - n >= 4 (below that, no distribution to speak of);
* - gap > 40% of the total range (max min);
* - at least 2 values on EACH side of the gap (a single outlier is not a
* second position).
* Non-finite values are ignored; any degenerate input returns false.
*/
export function detectBimodality(values: number[]): boolean {
const sorted = values.filter(v => Number.isFinite(v)).sort((a, b) => a - b)
const n = sorted.length
if (n < 4) return false
const range = at(sorted, n - 1) - at(sorted, 0)
if (range <= 0) return false
let maxGap = 0
for (let i = 0; i < n - 1; i++) {
const gap = at(sorted, i + 1) - at(sorted, i)
if (gap > maxGap) maxGap = gap
}
if (maxGap <= 0.4 * range) return false
// The max gap must split the values 2+ / 2+ (ties: any qualifying position).
for (let i = 1; i <= n - 3; i++) {
if (at(sorted, i + 1) - at(sorted, i) === maxGap) return true
}
return false
}