- 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>
342 lines
14 KiB
TypeScript
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)),
|
|
)
|
|
}
|