/** * 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 }