v2 : moteurs purs complets + ambiances + persistance + seed Atelier du Canal
- 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>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user