/** * Mode-params DSL: parse and format compact parameter strings. * * Exact TypeScript port of backend/app/engine/mode_params.py * (Python is the reference implementation for parsing; the Python * module has no formatter, so formatModeParams() is defined as the * canonical inverse of parseModeParams()). * * A mode-params string encodes voting formula parameters in a compact format. * Example: "D30M50B.1G.2T.1" * * Supported codes: * D = duration_days (int) * M = majority_pct (int, 0-100) * B = base_exponent (float) * G = gradient_exponent (float) * C = constant_base (float) * S = smith_exponent (float) * T = techcomm_exponent (float) * N = ratio_multiplier (float) * R = is_ratio_mode (bool, 0 or 1) * * Values may start with a dot for decimals < 1, e.g. "B.1" means base_exponent=0.1. */ export interface ModeParams { duration_days: number majority_pct: number base_exponent: number gradient_exponent: number constant_base: number smith_exponent: number | null techcomm_exponent: number | null ratio_multiplier: number | null is_ratio_mode: boolean } type CodeType = 'int' | 'float' | 'bool' // Ordered list of recognised codes and their target keys + types const CODES: Record = { D: { key: 'duration_days', type: 'int' }, M: { key: 'majority_pct', type: 'int' }, B: { key: 'base_exponent', type: 'float' }, G: { key: 'gradient_exponent', type: 'float' }, C: { key: 'constant_base', type: 'float' }, S: { key: 'smith_exponent', type: 'float' }, T: { key: 'techcomm_exponent', type: 'float' }, N: { key: 'ratio_multiplier', type: 'float' }, R: { key: 'is_ratio_mode', type: 'bool' }, } // Regex: a single uppercase letter followed by a numeric value (int or float, // possibly starting with '.' for values like .1 meaning 0.1) const PARAM_RE = /([A-Z])(\d*\.?\d+)/g function getDefaults(): ModeParams { return { duration_days: 30, majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.2, constant_base: 0.0, smith_exponent: null, techcomm_exponent: null, ratio_multiplier: null, is_ratio_mode: false, } } /** * Parse a mode-params string into a parameter object. * * Defaults are applied for any code not present in the string. * * @param paramsStr - Compact parameter string, e.g. "D30M50B.1G.2T.1" * @returns Parsed parameters with defaults for codes not found * @throws Error if an unrecognised code letter is found */ export function parseModeParams(paramsStr: string): ModeParams { const result = getDefaults() if (!paramsStr || !paramsStr.trim()) { return result } for (const match of paramsStr.matchAll(PARAM_RE)) { const code = match[1]! const rawValue = match[2]! const entry = CODES[code] if (!entry) { throw new Error(`Code de parametre inconnu : '${code}'`) } const { key, type } = entry if (type === 'int') { // Python: int(float(raw)) truncates toward zero ;(result as Record)[key] = Math.trunc(parseFloat(rawValue)) } else if (type === 'float') { ;(result as Record)[key] = parseFloat(rawValue) } else { ;(result as Record)[key] = parseFloat(rawValue) !== 0.0 } } return result } /** * Format a numeric value in DSL form: 0.1 -> ".1", 1 -> "1", 2.5 -> "2.5". */ function formatValue(value: number): string { const s = String(value) return value > 0 && value < 1 ? s.replace(/^0\./, '.') : s } /** * Format a parameter object back into a canonical mode-params string. * * Canonical form matches the strings used across the project * (e.g. "D30M50B.1G.2S.1"): D, M, B, G always present in that order, * then C when non-zero, then S/T/N when set, then R1 when ratio mode. * Round-trip: parseModeParams(formatModeParams(p)) is identical to p. * * @param params - Parameters to encode (missing keys take defaults) * @returns Compact string, e.g. "D30M50B.1G.2" */ export function formatModeParams(params: Partial = {}): string { const p = getDefaults() for (const key of Object.keys(p) as Array) { const value = params[key] if (value !== undefined) { ;(p[key] as ModeParams[typeof key]) = value } } const parts: string[] = [ `D${formatValue(p.duration_days)}`, `M${formatValue(p.majority_pct)}`, `B${formatValue(p.base_exponent)}`, `G${formatValue(p.gradient_exponent)}`, ] if (p.constant_base !== 0) { parts.push(`C${formatValue(p.constant_base)}`) } if (p.smith_exponent !== null) { parts.push(`S${formatValue(p.smith_exponent)}`) } if (p.techcomm_exponent !== null) { parts.push(`T${formatValue(p.techcomm_exponent)}`) } if (p.ratio_multiplier !== null) { parts.push(`N${formatValue(p.ratio_multiplier)}`) } if (p.is_ratio_mode) { parts.push('R1') } return parts.join('') }