forked from yvv/decision
- 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>
417 lines
16 KiB
TypeScript
417 lines
16 KiB
TypeScript
/**
|
||
* 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
|
||
}
|