Files
decision/frontend/app/engine/parametric.ts
T
YvvandClaude Fable 5 f707b5f15d 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>
2026-08-11 09:22:19 +02:00

417 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
}