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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user