Files
decision/frontend/app/engine/triage.ts
T
YvvandClaude Fable 5 f707b5f15d 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>
2026-08-11 09:22:19 +02:00

342 lines
14 KiB
TypeScript

/**
* Routing engine — UI name « Le chemin » (BLUEPRINT-V2.md « Algorithme de triage »).
*
* Pure function triage(input, ctx, settings) → Verdict. No store, no I/O:
* the store assembles TriageContext, the engine only reasons on it.
* Rule codes (R-U, R0a…R6) NEVER reach the card: the UI shows the one-sentence
* French explanation alone; codes live in the « pourquoi ? » disclosure,
* the PV and the derogation journal.
*
* Rule order — first match wins:
* R-U (urgency overlay) · R0a already decided · R0b my mandate ·
* R0c someone else's mandate · R2 self only · R3 targeted clause ·
* R4 small reversible group · R5 collective default · R6 recurrence overlay.
*
* DOCUMENTED INTERPRETATION CHOICES:
* - R0b comes BEFORE R2: a selfOnly decision whose scoped circles are covered
* by my mandate still routes 'mandate' (the mandate trace duty prevails).
* - R0a yields ONLY when the author explicitly targets the clause
* (amendsClauseId set): contesting the rule IS R3, and R3 looks the clause
* up in ctx.matchingClauses — R0a fires for a bare match.
* - Specified fallback (Δ10): protocolByRange.consent unresolvable ⇒ route
* 'advice' with the « Aucun protocole » banner, prior to EVERYTHING except
* R2 (solo needs no protocol) and R0a (record needs no protocol).
* Its rule code is 'R5' — it degrades the collective default.
* - Urgency refused (urgent && irreversible): the underlying route is kept
* with its MINIMAL windows (never doubled), rule 'R-U', refusal sentence.
* - R6 is an overlay: it sets Verdict.suggestion but never changes route or
* rule; 'claim-mandate' wins over 'protocolize' when both thresholds match.
*/
import type {
Clause,
CollectiveSettings,
Id,
Mandate,
TriageContext,
TriageInput,
Verdict,
} from '~/types/domain'
import {
BINARY_COST,
METHOD_LABELS,
NO_PROTOCOL_BANNER,
PARAMETRIC_ALT,
RECORD_ALT,
URGENT_BADGE,
URGENT_REFUSED,
} from '~/lexicon'
// TODO-lexicon: alternative costs missing from app/lexicon.ts (frozen file) —
// move these two constants there once it thaws.
const PARAMETRIC_COST = 'formuler les curseurs avant de voter'
const RECORD_COST = 'aucune fenêtre — la décision est déjà prise'
/** Verdict before the permanent alternatives are attached. */
type BaseVerdict = Omit<Verdict, 'alternatives'>
// ─────────────────────────────────────────────────────────────
// Parametric hint — number / % / amount (€, DU) in the capture sentence
// ─────────────────────────────────────────────────────────────
const DIGIT_PERCENT_CURRENCY = /\d|%|€/
// Ğ1 universal dividend — uppercase only: the French article « du » never counts.
const DU_UNIT = /\bDU\b/
/** Detect a number, percentage or amount (€, DU, digits) in the title. */
export function detectParametricHint(title: string): boolean {
return DIGIT_PERCENT_CURRENCY.test(title) || DU_UNIT.test(title)
}
// ─────────────────────────────────────────────────────────────
// Main entry
// ─────────────────────────────────────────────────────────────
export function triage(
input: TriageInput,
ctx: TriageContext,
settings: CollectiveSettings,
): Verdict {
const hint = detectParametricHint(input.title)
let base = routeIgnoringUrgency(input, ctx, settings)
// R-U — urgency overlay.
if (input.urgent) {
if (input.reversibility === 'irreversible') {
// Refusal: normal route, MINIMAL windows (never doubled), no chain.
base = { ...base, rule: 'R-U', explanation: URGENT_REFUSED }
} else {
// Conservatory: the lightest legitimate route NOW, doubled window,
// chained ratification (created by the store when validating).
base = {
...base,
rule: 'R-U',
explanation: `${URGENT_BADGE}.`,
conservatoryChain: true,
...(base.windowHours !== undefined ? { windowHours: base.windowHours * 2 } : {}),
}
}
}
// R6 — recurrence & maturation overlay (never blocking, never re-routing).
const suggestion = recurrenceOverlay(input, ctx, settings)
return {
...base,
...(hint ? { parametricHint: true } : {}),
...(suggestion ? { suggestion } : {}),
alternatives: buildAlternatives(hint),
}
}
// ─────────────────────────────────────────────────────────────
// Rules R0a → R5 (urgency stripped)
// ─────────────────────────────────────────────────────────────
function routeIgnoringUrgency(
input: TriageInput,
ctx: TriageContext,
settings: CollectiveSettings,
): BaseVerdict {
// R0a — already decided. Skipped when the author explicitly targets the
// clause (that is R3 — contesting the rule). Route 'record' proposed as
// principal: act under the standing rule, and keep the trace.
const matched = ctx.matchingClauses[0]
if (matched && !input.amendsClauseId) {
return {
route: 'record',
rule: 'R0a',
explanation: `C'est déjà décidé (${matched.code}, ${matched.title}) — agis, ou conteste la règle.`,
reviewRequired: false,
engravingSuggested: false,
}
}
// Specified fallback (Δ10): no consent protocol resolvable — prior to
// everything except R2/record. Rule code 'R5': the collective default,
// degraded to advice because the collective has no protocol yet.
if (!settings.protocolByRange.consent) {
if (input.scope.selfOnly) return r2SelfOnly(input)
return {
route: 'advice',
rule: 'R5',
explanation: `${NO_PROTOCOL_BANNER}.`,
windowHours: settings.triage.adviceWindowHours,
reviewRequired: false,
engravingSuggested: false,
}
}
// R0b — my mandate covers. Before R2: even selfOnly, the mandate trace
// duty prevails. Empty scope.circleIds never counts as covered (a trivial
// ⊆ match would hand every uncircled decision to the first mandate).
const myMandate = coveringMandate(input, ctx.myActiveMandates)
if (myMandate) {
return {
route: 'mandate',
rule: 'R0b',
explanation: `Ton mandat ${myMandate.title} couvre — décide, c'est tracé.`,
windowHours: settings.triage.objectionWindowHours,
reviewRequired: false,
engravingSuggested: false,
}
}
// R0c — someone else's mandate covers. TriageContext carries no person
// names: the mandate TITLE names the power, the store resolves the holder.
const otherMandate = coveringMandate(input, ctx.otherActiveMandates)
if (otherMandate) {
return {
route: 'transmit',
rule: 'R0c',
explanation: `Le mandat ${otherMandate.title} couvre — transmets à sa ou son titulaire.`,
reviewRequired: false,
engravingSuggested: false,
}
}
// R2 — self only.
if (input.scope.selfOnly) return r2SelfOnly(input)
// R3 — targeted clause: its inertia applies, vote of those it governs.
if (input.amendsClauseId) return r3TargetedClause(input, ctx, settings)
const n = ctx.computedConcernedIds.length
// R4 — small reversible group: ask for advice, then decide.
if (input.reversibility === 'easy' && n <= settings.triage.smallGroupMax) {
const plural = n > 1 ? 's' : ''
return {
route: 'advice',
rule: 'R4',
explanation: `Réversible et ${n} personne${plural} concernée${plural} — demande leur avis puis décide.`,
windowHours: settings.triage.adviceWindowHours,
reviewRequired: false,
engravingSuggested: false,
}
}
// R5 — collective default, modality by perimeter size.
return r5Collective(input, n, settings)
}
/** R2 — « Personne d'autre n'est concerné ». Zero window. On irreversible the
* engine SUGGESTS a review (reviewRequired) — the UI shows it pre-checked but
* REMOVABLE: on oneself the tool never interposes; the pre-check is a UI fact,
* the engine only recommends. */
function r2SelfOnly(input: TriageInput): BaseVerdict {
return {
route: 'solo',
rule: 'R2',
explanation: 'Personne d\'autre n\'est concerné — décide.',
reviewRequired: input.reversibility === 'irreversible',
engravingSuggested: false,
}
}
/** R3 — amendsClauseId set. Protocol resolved by the clause's inertia
* (clauseByInertia), specified fallback → consent. The clause is looked up in
* ctx.matchingClauses (the Q0 index); if absent, consent applies and the
* sentence names « cette règle » instead of a code. */
function r3TargetedClause(
input: TriageInput,
ctx: TriageContext,
settings: CollectiveSettings,
): BaseVerdict {
const clause: Clause | undefined = ctx.matchingClauses.find(
c => c.id === input.amendsClauseId,
)
const protocolId: Id
= (clause && settings.protocolByRange.clauseByInertia?.[clause.inertia])
?? settings.protocolByRange.consent
const name = clause ? clause.code : 'cette règle'
return {
route: 'collective',
rule: 'R3',
explanation: `Tu proposes une version de ${name} — son inertie s'applique : vote de ceux qu'elle gouverne.`,
protocolId,
...(input.weight === 'structural' ? { framingDays: settings.triage.framingDays } : {}),
reviewRequired: false,
engravingSuggested: false,
}
}
/** R5 — modality by computed perimeter size, protocols resolved by the Pact
* with the specified fallback → consent. */
function r5Collective(
input: TriageInput,
n: number,
settings: CollectiveSettings,
): BaseVerdict {
const range = settings.protocolByRange
let protocolId: Id
let explanation: string
if (n <= settings.triage.consentMax) {
protocolId = range.consent
explanation = `Vous êtes ${n} — un tour d'accord suffit : sans objection, c'est adopté.`
} else if (n <= settings.triage.collectiveMin) {
protocolId = range.nuanced ?? range.consent
explanation = `Vous êtes ${n} — vote nuancé : chacun se prononce en nuances, pas en camps.`
} else {
protocolId = range.large ?? range.consent
explanation = `Vous êtes ${n} — la modalité que votre Pacte a choisie s'applique.`
}
const structural = input.weight === 'structural'
return {
route: 'collective',
rule: 'R5',
explanation,
protocolId,
...(structural ? { framingDays: settings.triage.framingDays } : {}),
reviewRequired: structural || input.reversibility === 'irreversible',
engravingSuggested: structural,
}
}
// ─────────────────────────────────────────────────────────────
// R6 overlay & permanent alternatives
// ─────────────────────────────────────────────────────────────
/** R6 — recurrence & maturation. Overlay only: a suggestion, never a route.
* 'claim-mandate' (recent adopted look-alikes) wins over 'protocolize'
* (recorded look-alikes) when both thresholds are reached. */
function recurrenceOverlay(
input: TriageInput,
ctx: TriageContext,
settings: CollectiveSettings,
): Verdict['suggestion'] | undefined {
const threshold = settings.triage.recurrenceThreshold
if (ctx.similarRecentCount >= threshold) {
return {
kind: 'claim-mandate',
prefill: {
title: input.title,
domainTags: input.tags,
domainCircleIds: input.scope.circleIds,
},
}
}
if (ctx.similarRecordedCount >= threshold) {
return {
kind: 'protocolize',
prefill: { title: input.title, tags: input.tags },
}
}
return undefined
}
/** The permanent « Je choisis autrement » alternatives — ALWAYS present.
* Parametric comes FIRST when a number/%/amount was detected (parametricHint);
* otherwise record leads (the observatory gesture stays one tap away). */
function buildAlternatives(parametricFirst: boolean): Verdict['alternatives'] {
const parametric = {
route: 'collective' as const,
label: PARAMETRIC_ALT,
cost: PARAMETRIC_COST,
}
const record = { route: 'record' as const, label: RECORD_ALT, cost: RECORD_COST }
const binary = {
route: 'collective' as const,
label: METHOD_LABELS.binary,
cost: BINARY_COST,
}
return parametricFirst ? [parametric, record, binary] : [record, parametric, binary]
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
/** A mandate covers when scope.circleIds is a NON-EMPTY subset of its
* domain.circleIds. Defensive status filter: only 'active' mandates count. */
function coveringMandate(input: TriageInput, mandates: Mandate[]): Mandate | undefined {
const { circleIds } = input.scope
if (circleIds.length === 0) return undefined
return mandates.find(
m => m.status === 'active' && circleIds.every(id => m.domain.circleIds.includes(id)),
)
}