forked from yvv/decision
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>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Ported from backend/app/tests/test_mode_params.py — every pytest case
|
||||
* exists here with the same expected values, plus tests for
|
||||
* formatModeParams() (no Python counterpart: defined as the canonical
|
||||
* inverse of parseModeParams, matching the seed DSL strings).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatModeParams, parseModeParams } from '../../app/engine/modeParams'
|
||||
|
||||
describe('parseModeParams', () => {
|
||||
it('D30M50B.1G.2 => standard Licence G1 params', () => {
|
||||
const result = parseModeParams('D30M50B.1G.2')
|
||||
expect(result.duration_days).toBe(30)
|
||||
expect(result.majority_pct).toBe(50)
|
||||
expect(result.base_exponent).toBe(0.1)
|
||||
expect(result.gradient_exponent).toBe(0.2)
|
||||
// Optional criteria absent
|
||||
expect(result.smith_exponent).toBeNull()
|
||||
expect(result.techcomm_exponent).toBeNull()
|
||||
})
|
||||
|
||||
it('D30M50B.1G.2S.1 => standard + smith_exponent=0.1', () => {
|
||||
const result = parseModeParams('D30M50B.1G.2S.1')
|
||||
expect(result.duration_days).toBe(30)
|
||||
expect(result.majority_pct).toBe(50)
|
||||
expect(result.base_exponent).toBe(0.1)
|
||||
expect(result.gradient_exponent).toBe(0.2)
|
||||
expect(result.smith_exponent).toBe(0.1)
|
||||
expect(result.techcomm_exponent).toBeNull()
|
||||
})
|
||||
|
||||
it('D30M50B.1G.2T.1 => standard + techcomm_exponent=0.1', () => {
|
||||
const result = parseModeParams('D30M50B.1G.2T.1')
|
||||
expect(result.duration_days).toBe(30)
|
||||
expect(result.majority_pct).toBe(50)
|
||||
expect(result.base_exponent).toBe(0.1)
|
||||
expect(result.gradient_exponent).toBe(0.2)
|
||||
expect(result.smith_exponent).toBeNull()
|
||||
expect(result.techcomm_exponent).toBe(0.1)
|
||||
})
|
||||
|
||||
it('D30M50B1G.5C10 => integer base, gradient=0.5, constant=10', () => {
|
||||
const result = parseModeParams('D30M50B1G.5C10')
|
||||
expect(result.duration_days).toBe(30)
|
||||
expect(result.majority_pct).toBe(50)
|
||||
expect(result.base_exponent).toBe(1.0)
|
||||
expect(result.gradient_exponent).toBe(0.5)
|
||||
expect(result.constant_base).toBe(10.0)
|
||||
})
|
||||
|
||||
it('empty string returns all defaults', () => {
|
||||
const result = parseModeParams('')
|
||||
expect(result.duration_days).toBe(30)
|
||||
expect(result.majority_pct).toBe(50)
|
||||
expect(result.base_exponent).toBe(0.1)
|
||||
expect(result.gradient_exponent).toBe(0.2)
|
||||
expect(result.constant_base).toBe(0.0)
|
||||
expect(result.smith_exponent).toBeNull()
|
||||
expect(result.techcomm_exponent).toBeNull()
|
||||
expect(result.ratio_multiplier).toBeNull()
|
||||
expect(result.is_ratio_mode).toBe(false)
|
||||
})
|
||||
|
||||
it('whitespace-only string treated as empty', () => {
|
||||
const result = parseModeParams(' ')
|
||||
expect(result.duration_days).toBe(30)
|
||||
})
|
||||
|
||||
it('result exposes exactly the expected keys', () => {
|
||||
const result = parseModeParams('D30M50B.1G.2')
|
||||
const expectedKeys = [
|
||||
'duration_days',
|
||||
'majority_pct',
|
||||
'base_exponent',
|
||||
'gradient_exponent',
|
||||
'constant_base',
|
||||
'smith_exponent',
|
||||
'techcomm_exponent',
|
||||
'ratio_multiplier',
|
||||
'is_ratio_mode',
|
||||
].sort()
|
||||
expect(Object.keys(result).sort()).toEqual(expectedKeys)
|
||||
})
|
||||
|
||||
it('unknown code letter throws (Python parity)', () => {
|
||||
expect(() => parseModeParams('D30X5')).toThrow(/inconnu/)
|
||||
})
|
||||
|
||||
it('D7R1N2.5 => ratio mode with multiplier (Python cross-check)', () => {
|
||||
const result = parseModeParams('D7R1N2.5')
|
||||
expect(result.duration_days).toBe(7)
|
||||
expect(result.is_ratio_mode).toBe(true)
|
||||
expect(result.ratio_multiplier).toBe(2.5)
|
||||
// Untouched defaults
|
||||
expect(result.majority_pct).toBe(50)
|
||||
expect(result.base_exponent).toBe(0.1)
|
||||
expect(result.gradient_exponent).toBe(0.2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatModeParams', () => {
|
||||
it('defaults format to the canonical standard string', () => {
|
||||
expect(formatModeParams()).toBe('D30M50B.1G.2')
|
||||
expect(formatModeParams({})).toBe('D30M50B.1G.2')
|
||||
})
|
||||
|
||||
it('round-trips the seed DSL strings', () => {
|
||||
for (const s of ['D30M50B.1G.2', 'D30M50B.1G.2S.1', 'D30M50B.1G.2T.1']) {
|
||||
expect(formatModeParams(parseModeParams(s))).toBe(s)
|
||||
}
|
||||
})
|
||||
|
||||
it('formats integer base and constant', () => {
|
||||
expect(formatModeParams(parseModeParams('D30M50B1G.5C10'))).toBe('D30M50B1G.5C10')
|
||||
})
|
||||
|
||||
it('formats ratio mode and multiplier', () => {
|
||||
expect(formatModeParams(parseModeParams('D7R1N2.5'))).toBe('D7M50B.1G.2N2.5R1')
|
||||
})
|
||||
|
||||
it('parse(format(p)) is identical to p', () => {
|
||||
for (const s of ['D30M50B.1G.2S.1', 'D30M50B1G.5C10', 'D7R1N2.5', 'D90M66B.2G.3S.2T.1']) {
|
||||
const p = parseModeParams(s)
|
||||
expect(parseModeParams(formatModeParams(p))).toEqual(p)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Ported from backend/app/tests/test_nuanced.py — every pytest case
|
||||
* exists here with the same expected values.
|
||||
*
|
||||
* Levels: 0-CONTRE, 1-PAS DU TOUT, 2-PAS D'ACCORD, 3-NEUTRE, 4-D'ACCORD, 5-TOUT A FAIT
|
||||
* Positive = levels 3 + 4 + 5
|
||||
* Adoption requires: positive_pct >= threshold (80%) AND total >= min_participants (59).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nuancedResult } from '../../app/engine/nuanced'
|
||||
|
||||
/** Build a votes array from level repetitions, e.g. fill([5, 20], [4, 20]). */
|
||||
function fill(...groups: Array<[level: number, count: number]>): number[] {
|
||||
const votes: number[] = []
|
||||
for (const [level, count] of groups) {
|
||||
for (let i = 0; i < count; i++) votes.push(level)
|
||||
}
|
||||
return votes
|
||||
}
|
||||
|
||||
describe('nuancedResult — adoption', () => {
|
||||
it('59 positive + 10 negative = 69 total => adopted (85.51% >= 80%)', () => {
|
||||
const votes = fill([5, 20], [4, 20], [3, 19], [2, 5], [1, 3], [0, 2])
|
||||
const result = nuancedResult(votes, 80, 59)
|
||||
|
||||
expect(result.total).toBe(69)
|
||||
expect(result.positive_count).toBe(59)
|
||||
expect(result.positive_pct).toBeCloseTo(85.51, 1)
|
||||
// Python cross-check: round(59/69*100, 2) == 85.51
|
||||
expect(result.positive_pct).toBe(85.51)
|
||||
expect(result.threshold_met).toBe(true)
|
||||
expect(result.min_participants_met).toBe(true)
|
||||
expect(result.adopted).toBe(true)
|
||||
})
|
||||
|
||||
it('all 59 voters at level 5 => 100% positive, adopted', () => {
|
||||
const votes = fill([5, 59])
|
||||
const result = nuancedResult(votes, 80, 59)
|
||||
|
||||
expect(result.total).toBe(59)
|
||||
expect(result.positive_count).toBe(59)
|
||||
expect(result.positive_pct).toBe(100.0)
|
||||
expect(result.adopted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nuancedResult — rejection', () => {
|
||||
it('40 positive + 30 negative = 70 total => threshold not met (57.14% < 80%)', () => {
|
||||
const votes = fill([5, 15], [4, 15], [3, 10], [2, 10], [1, 10], [0, 10])
|
||||
const result = nuancedResult(votes, 80, 59)
|
||||
|
||||
expect(result.total).toBe(70)
|
||||
expect(result.positive_count).toBe(40)
|
||||
expect(result.positive_pct).toBeCloseTo(57.14, 1)
|
||||
expect(result.threshold_met).toBe(false)
|
||||
expect(result.min_participants_met).toBe(true) // 70 >= 59
|
||||
expect(result.adopted).toBe(false)
|
||||
})
|
||||
|
||||
it('55 total < 59 min participants => rejected despite 90.9% positive', () => {
|
||||
const votes = fill([5, 30], [4, 10], [3, 10], [1, 3], [0, 2])
|
||||
const result = nuancedResult(votes, 80, 59)
|
||||
|
||||
expect(result.total).toBe(55)
|
||||
expect(result.positive_count).toBe(50)
|
||||
expect(result.positive_pct).toBeGreaterThan(80)
|
||||
expect(result.threshold_met).toBe(true)
|
||||
expect(result.min_participants_met).toBe(false)
|
||||
expect(result.adopted).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nuancedResult — edge cases', () => {
|
||||
it('exactly 80% positive votes passes the threshold', () => {
|
||||
// 80 positive out of 100 = exactly 80%
|
||||
const votes = fill([5, 40], [4, 20], [3, 20], [2, 10], [1, 5], [0, 5])
|
||||
const result = nuancedResult(votes, 80, 59)
|
||||
|
||||
expect(result.total).toBe(100)
|
||||
expect(result.positive_count).toBe(80)
|
||||
expect(result.positive_pct).toBe(80.0)
|
||||
expect(result.threshold_met).toBe(true)
|
||||
expect(result.min_participants_met).toBe(true)
|
||||
expect(result.adopted).toBe(true)
|
||||
})
|
||||
|
||||
it('79 positive out of 100 = 79% < 80% => rejected', () => {
|
||||
const votes = fill([5, 39], [4, 20], [3, 20], [2, 11], [1, 5], [0, 5])
|
||||
const result = nuancedResult(votes, 80, 59)
|
||||
|
||||
expect(result.total).toBe(100)
|
||||
expect(result.positive_count).toBe(79)
|
||||
expect(result.positive_pct).toBe(79.0)
|
||||
expect(result.threshold_met).toBe(false)
|
||||
expect(result.adopted).toBe(false)
|
||||
})
|
||||
|
||||
it('zero votes => not adopted', () => {
|
||||
const result = nuancedResult([], 80, 59)
|
||||
expect(result.total).toBe(0)
|
||||
expect(result.positive_count).toBe(0)
|
||||
expect(result.positive_pct).toBe(0.0)
|
||||
expect(result.adopted).toBe(false)
|
||||
})
|
||||
|
||||
it('vote level outside 0-5 throws', () => {
|
||||
expect(() => nuancedResult([5, 3, 6])).toThrow(/invalide/)
|
||||
})
|
||||
|
||||
it('per-level breakdown is correct', () => {
|
||||
const votes = [0, 1, 2, 3, 4, 5, 5, 4, 3]
|
||||
const result = nuancedResult(votes, 50, 1)
|
||||
|
||||
expect(result.per_level_counts).toEqual({ 0: 1, 1: 1, 2: 1, 3: 2, 4: 2, 5: 2 })
|
||||
expect(result.positive_count).toBe(6) // 2+2+2
|
||||
expect(result.total).toBe(9)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Ported from backend/app/tests/test_threshold.py — every pytest case
|
||||
* exists here with the same expected values. Exact integer expectations
|
||||
* were cross-checked by executing the Python engine (backend/.venv).
|
||||
*
|
||||
* Real-world reference case:
|
||||
* Vote Engagement Forgeron v2.0.0 (Feb 2026)
|
||||
* wotSize=7224, votesFor=97, votesAgainst=23, total=120
|
||||
* params M=50, B=0.1, G=0.2 => threshold=94 => adopted (97 >= 94)
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { smithThreshold, techcommThreshold, wotThreshold } from '../../app/engine/threshold'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WoT threshold: real-world vote Forgeron
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('wotThreshold — Forgeron reference case', () => {
|
||||
it('forgeron vote passes (97 for out of 120 total, wot=7224)', () => {
|
||||
const threshold = wotThreshold(7224, 120, 50, 0.1, 0.2)
|
||||
// With low participation (120/7224 ~ 1.66%), near-unanimity is required.
|
||||
// The historical threshold was 94, and 97 >= 94.
|
||||
expect(97).toBeGreaterThanOrEqual(threshold)
|
||||
// The threshold should be high relative to total votes (inertia effect)
|
||||
expect(threshold).toBeGreaterThan(60)
|
||||
})
|
||||
|
||||
it('forgeron threshold value is in a reasonable range', () => {
|
||||
const threshold = wotThreshold(7224, 120, 50, 0.1, 0.2)
|
||||
// At ~1.66% participation, inertia should push threshold close to 78-95%
|
||||
// of total votes.
|
||||
expect(threshold).toBeGreaterThanOrEqual(80)
|
||||
expect(threshold).toBeLessThanOrEqual(120)
|
||||
})
|
||||
|
||||
it('forgeron threshold is exactly 94 (Python cross-check)', () => {
|
||||
expect(wotThreshold(7224, 120, 50, 0.1, 0.2)).toBe(94)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WoT threshold: low participation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('wotThreshold — low participation', () => {
|
||||
it('10 votes out of 7224 requires near-unanimity', () => {
|
||||
const threshold = wotThreshold(7224, 10, 50, 0.1, 0.2)
|
||||
// With participation ratio 10/7224 ~ 0.14%, threshold should be
|
||||
// very close to totalVotes (near-unanimity).
|
||||
expect(threshold).toBeGreaterThanOrEqual(9)
|
||||
expect(threshold).toBeLessThanOrEqual(10)
|
||||
// Python cross-check: exact value
|
||||
expect(threshold).toBe(9)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WoT threshold: high participation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('wotThreshold — high participation', () => {
|
||||
it('3000 votes out of 7224 approaches simple majority', () => {
|
||||
const threshold = wotThreshold(7224, 3000, 50, 0.1, 0.2)
|
||||
// With ~42% participation, the inertia factor diminishes.
|
||||
const simpleMajority = Math.ceil(3000 * 0.5)
|
||||
expect(threshold).toBeGreaterThanOrEqual(simpleMajority)
|
||||
// Should be noticeably less than near-unanimity
|
||||
expect(threshold).toBeLessThan(2700)
|
||||
// Python cross-check: exact value
|
||||
expect(threshold).toBe(1742)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WoT threshold: edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('wotThreshold — edge cases', () => {
|
||||
it('zero total votes => ceil(C + B^W)', () => {
|
||||
const threshold = wotThreshold(7224, 0, 50, 0.1, 0.2)
|
||||
// B^W = 0.1^7224 is effectively 0
|
||||
const expected = Math.ceil(0.0 + 0.1 ** 7224)
|
||||
expect(threshold).toBe(expected)
|
||||
expect(threshold).toBe(0)
|
||||
})
|
||||
|
||||
it('throws on wotSize = 0', () => {
|
||||
expect(() => wotThreshold(0, 10)).toThrow(/wotSize/)
|
||||
})
|
||||
|
||||
it('throws on negative totalVotes', () => {
|
||||
expect(() => wotThreshold(100, -1)).toThrow(/totalVotes/)
|
||||
})
|
||||
|
||||
it('throws on majorityPct out of range', () => {
|
||||
expect(() => wotThreshold(100, 10, 150)).toThrow(/majorityPct/)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WoT threshold: Python↔TS parity (values executed from backend/.venv)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('wotThreshold — Python parity cases', () => {
|
||||
it('T > W (100 wot, 150 votes) => 69', () => {
|
||||
expect(wotThreshold(100, 150, 50, 0.1, 0.2)).toBe(69)
|
||||
})
|
||||
|
||||
it('T = W (full participation) => simple majority 50', () => {
|
||||
expect(wotThreshold(100, 100, 50, 0.1, 0.2)).toBe(50)
|
||||
})
|
||||
|
||||
it('constantBase C=10 on Forgeron numbers => 96', () => {
|
||||
expect(wotThreshold(7224, 120, 50, 0.1, 0.2, 10.0)).toBe(96)
|
||||
})
|
||||
|
||||
it('majorityPct 80 on Forgeron numbers => 110', () => {
|
||||
expect(wotThreshold(7224, 120, 80, 0.1, 0.2)).toBe(110)
|
||||
})
|
||||
|
||||
it('minimal corpus W=1, T=1 => 1', () => {
|
||||
expect(wotThreshold(1, 1, 50, 0.1, 0.2)).toBe(1)
|
||||
})
|
||||
|
||||
it('W=10, T=5 => 3', () => {
|
||||
expect(wotThreshold(10, 5, 50, 0.1, 0.2)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Smith threshold
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('smithThreshold — ceil(smithSize ^ S)', () => {
|
||||
it('smithSize=20, exponent=0.1 => ceil(20^0.1) = 2', () => {
|
||||
const result = smithThreshold(20, 0.1)
|
||||
expect(result).toBe(Math.ceil(20 ** 0.1))
|
||||
// 20^0.1 ~ 1.35, ceil => 2
|
||||
expect(result).toBe(2)
|
||||
})
|
||||
|
||||
it('smithSize=1 => ceil(1^0.1) = 1', () => {
|
||||
expect(smithThreshold(1, 0.1)).toBe(1)
|
||||
})
|
||||
|
||||
it('throws on smithSize = 0', () => {
|
||||
expect(() => smithThreshold(0)).toThrow()
|
||||
})
|
||||
|
||||
it('smithSize=1000, exponent=0.5 => 32 (Python cross-check)', () => {
|
||||
expect(smithThreshold(1000, 0.5)).toBe(32)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TechComm threshold
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('techcommThreshold — ceil(cotecSize ^ T)', () => {
|
||||
it('cotecSize=5, exponent=0.1 => ceil(5^0.1) = 2', () => {
|
||||
const result = techcommThreshold(5, 0.1)
|
||||
expect(result).toBe(Math.ceil(5 ** 0.1))
|
||||
// 5^0.1 ~ 1.175, ceil => 2
|
||||
expect(result).toBe(2)
|
||||
})
|
||||
|
||||
it('cotecSize=1 => 1', () => {
|
||||
expect(techcommThreshold(1, 0.1)).toBe(1)
|
||||
})
|
||||
|
||||
it('throws on cotecSize = 0', () => {
|
||||
expect(() => techcommThreshold(0)).toThrow()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user