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>
168 lines
7.0 KiB
TypeScript
168 lines
7.0 KiB
TypeScript
/**
|
|
* Pact settings resolution — the Pact IS the settings store (Δ10).
|
|
*
|
|
* resolveSettings() reads the Pact clauses that carry a settingKey, finds the
|
|
* 'current' ClauseVersion bearing a settingValue for each, and produces the
|
|
* resolved CollectiveSettings consumed by triage.ts and state.ts. Pure
|
|
* function — NOT a settings table: every threshold is a voted clause.
|
|
*
|
|
* Rules (BLUEPRINT-V2.md « Modèle de domaine » + Δ10):
|
|
* - any absent key falls back to SETTINGS_DEFAULTS;
|
|
* - invalid values (wrong type, negative number, unknown enum member) fall
|
|
* back to the default SILENTLY — a corrupted Pact never crashes the engine;
|
|
* - any unresolved OPTIONAL protocolByRange key stays undefined: the caller
|
|
* falls back to the consent protocol;
|
|
* - a missing 'protocols.consent' (corrupted bundle) still returns a full
|
|
* settings object with protocolByRange.consent === '' — hasConsentProtocol()
|
|
* lets the triage route 'advice' with the banner « Aucun protocole — crée-le
|
|
* ou décide sur avis », NEVER a crash.
|
|
*/
|
|
|
|
import type {
|
|
Clause,
|
|
ClauseVersion,
|
|
CollectiveSettings,
|
|
Id,
|
|
InertiaPreset,
|
|
Json,
|
|
} from '../types/domain'
|
|
|
|
/** Default triage settings — used for every key the Pact does not resolve. */
|
|
export const SETTINGS_DEFAULTS = {
|
|
smallGroupMax: 5,
|
|
collectiveMin: 50,
|
|
consentMax: 7,
|
|
objectionWindowHours: 48,
|
|
adviceWindowHours: 72,
|
|
framingDays: 14,
|
|
concernEscalateRatio: 0.5,
|
|
recurrenceThreshold: 3,
|
|
reviewDelayDays: 90,
|
|
requireEffects: 'binding',
|
|
} as const satisfies CollectiveSettings['triage']
|
|
|
|
const INERTIA_PRESETS: readonly InertiaPreset[] = ['low', 'standard', 'high', 'max']
|
|
|
|
/** Most recent wins when a clause carries several 'current' versions (data anomaly). */
|
|
function stampOf(version: ClauseVersion): string {
|
|
return version.adoptedAt ?? version.updatedAt
|
|
}
|
|
|
|
/** The settingValue of the clause's 'current' version, if any. */
|
|
function currentValueOf(clause: Clause, versions: ClauseVersion[]): Json | undefined {
|
|
let best: ClauseVersion | undefined
|
|
for (const version of versions) {
|
|
if (version.clauseId !== clause.id || version.archivedAt) continue
|
|
if (version.status !== 'current' || version.settingValue === undefined) continue
|
|
if (!best || stampOf(version) > stampOf(best)) best = version
|
|
}
|
|
return best?.settingValue
|
|
}
|
|
|
|
/** First non-archived clause holding this settingKey with a resolvable current value. */
|
|
function resolveRaw(key: string, clauses: Clause[], versions: ClauseVersion[]): Json | undefined {
|
|
for (const clause of clauses) {
|
|
if (clause.settingKey !== key || clause.archivedAt) continue
|
|
const value = currentValueOf(clause, versions)
|
|
if (value !== undefined) return value
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/** Wrong type or negative number ⇒ default, silently. */
|
|
function numberOrDefault(value: Json | undefined, fallback: number): number {
|
|
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
|
|
}
|
|
|
|
/** Unknown enum member ⇒ default, silently. */
|
|
function requireEffectsOrDefault(value: Json | undefined): 'none' | 'structural' | 'binding' {
|
|
return value === 'none' || value === 'structural' || value === 'binding'
|
|
? value
|
|
: SETTINGS_DEFAULTS.requireEffects
|
|
}
|
|
|
|
/** Protocol ids must be non-empty strings — anything else counts as unresolved. */
|
|
function idOrUndefined(value: Json | undefined): Id | undefined {
|
|
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
|
|
}
|
|
|
|
/**
|
|
* Resolve the collective's settings from its Pact clauses.
|
|
*
|
|
* Recognized setting keys:
|
|
* triage.smallGroupMax · triage.collectiveMin · triage.consentMax ·
|
|
* triage.objectionWindowHours · triage.adviceWindowHours ·
|
|
* triage.framingDays · triage.concernEscalateRatio ·
|
|
* triage.recurrenceThreshold · triage.reviewDelayDays ·
|
|
* triage.requireEffects · protocols.consent · protocols.nuanced ·
|
|
* protocols.large · protocols.parametric · protocols.election ·
|
|
* protocols.clauseByInertia.{low,standard,high,max}
|
|
*/
|
|
export function resolveSettings(clauses: Clause[], versions: ClauseVersion[]): CollectiveSettings {
|
|
const raw = (key: string): Json | undefined => resolveRaw(key, clauses, versions)
|
|
|
|
const triage: CollectiveSettings['triage'] = {
|
|
smallGroupMax: numberOrDefault(raw('triage.smallGroupMax'), SETTINGS_DEFAULTS.smallGroupMax),
|
|
collectiveMin: numberOrDefault(raw('triage.collectiveMin'), SETTINGS_DEFAULTS.collectiveMin),
|
|
consentMax: numberOrDefault(raw('triage.consentMax'), SETTINGS_DEFAULTS.consentMax),
|
|
objectionWindowHours: numberOrDefault(
|
|
raw('triage.objectionWindowHours'),
|
|
SETTINGS_DEFAULTS.objectionWindowHours,
|
|
),
|
|
adviceWindowHours: numberOrDefault(
|
|
raw('triage.adviceWindowHours'),
|
|
SETTINGS_DEFAULTS.adviceWindowHours,
|
|
),
|
|
framingDays: numberOrDefault(raw('triage.framingDays'), SETTINGS_DEFAULTS.framingDays),
|
|
concernEscalateRatio: numberOrDefault(
|
|
raw('triage.concernEscalateRatio'),
|
|
SETTINGS_DEFAULTS.concernEscalateRatio,
|
|
),
|
|
recurrenceThreshold: numberOrDefault(
|
|
raw('triage.recurrenceThreshold'),
|
|
SETTINGS_DEFAULTS.recurrenceThreshold,
|
|
),
|
|
reviewDelayDays: numberOrDefault(raw('triage.reviewDelayDays'), SETTINGS_DEFAULTS.reviewDelayDays),
|
|
requireEffects: requireEffectsOrDefault(raw('triage.requireEffects')),
|
|
}
|
|
|
|
// Consent is the MANDATORY invariant key. When unfindable (corrupted
|
|
// bundle), we still return a complete object with consent === '' so the
|
|
// caller can degrade gracefully (hasConsentProtocol) — never a crash.
|
|
const protocolByRange: CollectiveSettings['protocolByRange'] = {
|
|
consent: idOrUndefined(raw('protocols.consent')) ?? '',
|
|
}
|
|
|
|
const nuanced = idOrUndefined(raw('protocols.nuanced'))
|
|
if (nuanced) protocolByRange.nuanced = nuanced
|
|
const large = idOrUndefined(raw('protocols.large'))
|
|
if (large) protocolByRange.large = large
|
|
const parametric = idOrUndefined(raw('protocols.parametric'))
|
|
if (parametric) protocolByRange.parametric = parametric
|
|
const election = idOrUndefined(raw('protocols.election'))
|
|
if (election) protocolByRange.election = election
|
|
|
|
// The Ğ1 heritage lives here, intact: the map is exposed only when ALL four
|
|
// presets resolve (the type is a complete Record — a partial map would lie).
|
|
// Otherwise the caller falls back to consent, like any optional key.
|
|
const byInertia: Partial<Record<InertiaPreset, Id>> = {}
|
|
for (const preset of INERTIA_PRESETS) {
|
|
const id = idOrUndefined(raw(`protocols.clauseByInertia.${preset}`))
|
|
if (id) byInertia[preset] = id
|
|
}
|
|
if (INERTIA_PRESETS.every((preset) => byInertia[preset] !== undefined)) {
|
|
protocolByRange.clauseByInertia = byInertia as Record<InertiaPreset, Id>
|
|
}
|
|
|
|
return { triage, protocolByRange }
|
|
}
|
|
|
|
/**
|
|
* True when the collective has a resolvable consent protocol.
|
|
* False ⇒ the triage routes 'advice' with the banner
|
|
* « Aucun protocole — crée-le ou décide sur avis » — never a crash.
|
|
*/
|
|
export function hasConsentProtocol(settings: CollectiveSettings): boolean {
|
|
return settings.protocolByRange.consent.trim().length > 0
|
|
}
|