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:
Yvv
2026-08-11 09:22:19 +02:00
co-authored by Claude Fable 5
parent 53d8752e40
commit f707b5f15d
18 changed files with 10324 additions and 181 deletions
+167
View File
@@ -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
}