/** * 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 // ───────────────────────────────────────────────────────────── // 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)), ) }