Files
decision/frontend/app/engine/nuanced.ts
T
YvvandClaude Fable 5 53d8752e40 v2 : conception ultracode + moteur de formules porté en TS
- Blueprint définitif (docs/dev/BLUEPRINT-V2.md + JSON) : panel 4 visions
  + 3 juges, corpus doctrinal (Mon nom est personne, Une économie du don,
  SejeteralO), 4 critiques d'intégration, 3 vérificateurs, réparations
- Corpus d'intention + lectures brutes archivés
- Moteur porté Python→TS pur : threshold (inertie WoT, Smith, ComTech),
  nuancé 6 niveaux, DSL modeParams — 45 tests vitest, parité croisée
  vérifiée (Forgeron W=7224 T=120 ⇒ 94)
- Contrat de domaine v2 (types/domain.ts) + lexique UI (lexicon.ts)
- vitest + idb-keyval en dépendances

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 05:59:33 +02:00

110 lines
3.0 KiB
TypeScript

/**
* Six-level nuanced vote evaluation.
*
* Exact TypeScript port of backend/app/engine/nuanced_vote.py
* (Python is the reference implementation).
*
* Levels:
* 0 - CONTRE
* 1 - PAS DU TOUT
* 2 - PAS D'ACCORD
* 3 - NEUTRE
* 4 - D'ACCORD
* 5 - TOUT A FAIT
*
* Adoption rule:
* The sum of votes at levels 3 + 4 + 5 must be >= thresholdPct% of total votes.
* A minimum number of participants is also required.
*/
export const LEVEL_LABELS: Record<number, string> = {
0: 'CONTRE',
1: 'PAS DU TOUT',
2: "PAS D'ACCORD",
3: 'NEUTRE',
4: "D'ACCORD",
5: 'TOUT A FAIT',
}
export const NUM_LEVELS = 6
/** Result shape mirrors the Python dict returned by evaluate_nuanced(). */
export interface NuancedResult {
total: number
per_level_counts: Record<number, number>
positive_count: number
positive_pct: number
threshold_met: boolean
min_participants_met: boolean
adopted: boolean
}
/**
* Round to 2 decimals with ties-to-even, mirroring Python's round(x, 2).
*/
function round2(value: number): number {
const scaled = value * 100
const floor = Math.floor(scaled)
const diff = scaled - floor
let rounded: number
if (diff > 0.5) {
rounded = floor + 1
} else if (diff < 0.5) {
rounded = floor
} else {
rounded = floor % 2 === 0 ? floor : floor + 1
}
return rounded / 100
}
/**
* Evaluate a nuanced vote from a list of individual vote levels.
*
* @param votes - List of vote levels (each 0-5), one entry per voter
* @param thresholdPct - Minimum percentage of positive votes (levels 3-5) for adoption
* @param minParticipants - Minimum number of participants required for validity
* @returns Aggregated result (counts, percentage, adoption flags)
* @throws Error if any vote value is outside the 0-5 integer range
*/
export function nuancedResult(
votes: number[],
thresholdPct: number = 80,
minParticipants: number = 59,
): NuancedResult {
// Validate vote levels (integer check added: JS has no KeyError safety net)
for (const v of votes) {
if (!Number.isInteger(v) || v < 0 || v > 5) {
throw new Error(`Niveau de vote invalide : ${v}. Les niveaux valides sont 0-5.`)
}
}
const total = votes.length
const perLevelCounts: Record<number, number> = {}
for (let level = 0; level < NUM_LEVELS; level++) {
perLevelCounts[level] = 0
}
for (const v of votes) {
perLevelCounts[v] = (perLevelCounts[v] ?? 0) + 1
}
// Positive = levels 3 (NEUTRE), 4 (D'ACCORD), 5 (TOUT A FAIT)
const positiveCount = (perLevelCounts[3] ?? 0) + (perLevelCounts[4] ?? 0) + (perLevelCounts[5] ?? 0)
const positivePct = total > 0 ? (positiveCount / total) * 100.0 : 0.0
const thresholdMet = positivePct >= thresholdPct
const minParticipantsMet = total >= minParticipants
const adopted = thresholdMet && minParticipantsMet
return {
total,
per_level_counts: perLevelCounts,
positive_count: positiveCount,
positive_pct: round2(positivePct),
threshold_met: thresholdMet,
min_participants_met: minParticipantsMet,
adopted,
}
}