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
+101
View File
@@ -0,0 +1,101 @@
/**
* Perimeter engine — who is concerned by THIS decision.
*
* Pure functions — no store, no I/O. The store assembles the inputs and
* persists the result as Concern rows (origin 'computed').
*
* The computed union (BLUEPRINT-V2.md « Périmètres et inclusion ») :
* members of the scoped circles persons named by the author
* holders of active mandates whose domain intersects the scoped circles.
* Every inclusion carries its human-readable French reason — auditable in
* one tap, never « la machine t'a désigné ».
*
* MEMBERSHIP CHOICE (v2, documented): circle membership is NOMINATIVE per
* circle (Circle.memberIds is the ONLY membership rule). Circle nesting
* (parentCircleId, « couches d'oignon ») does NOT propagate membership:
* a parent circle does NOT automatically include the members of its child
* circles, nor the reverse. Nesting serves ONE gesture — widening the
* perimeter by one notch (« élargir d'un cran ») — which explicitly adds
* the parent circle to the scope; the computation never infers it.
*/
import type { Circle, Decision, Id, Mandate } from '~/types/domain'
export interface ConcernedEntry {
personId: Id
/** French reason shown on tap (becomes Concern.reason). */
reason: string
}
/**
* Flatten circle ids into the set of their DIRECT nominative members.
*
* NON-PROPAGATION (see module header): only Circle.memberIds of the listed
* circles count. Members of child (or parent) circles are NOT included —
* widening is a scope gesture, never an automatic inference.
* Unknown circle ids are ignored (robustness over crash).
*/
export function expandCircleMembers(circleIds: Id[], circles: Circle[]): Set<Id> {
const members = new Set<Id>()
for (const circleId of circleIds) {
const circle = circles.find(c => c.id === circleId)
if (!circle) continue
for (const personId of circle.memberIds) members.add(personId)
}
return members
}
/**
* Compute the concerned persons of a decision scope, each with its reason.
*
* Deduplication: first reason wins, with source priority
* circle membership > named by author > mandate holder.
* The author is EXCLUDED from the list (they decide, they are not
* « concerned » by their own perimeter).
*
* Mandates count only when status 'active' and when their domain.circleIds
* INTERSECTS the scoped circles (any shared circle is enough — a mandate
* holder is concerned as soon as the decision touches their domain).
*/
export function computeConcerned(
scope: Decision['scope'],
circles: Circle[],
mandates: Mandate[],
authorId: Id,
): ConcernedEntry[] {
// Map preserves insertion order; first reason wins (priority by pass order).
const concerned = new Map<Id, string>()
// 1. Members of the scoped circles (highest priority reason).
for (const circleId of scope.circleIds) {
const circle = circles.find(c => c.id === circleId)
if (!circle) continue
for (const personId of circle.memberIds) {
if (personId === authorId) continue
if (!concerned.has(personId)) {
concerned.set(personId, `membre du cercle ${circle.name}`)
}
}
}
// 2. Persons named by the author.
for (const personId of scope.personIds) {
if (personId === authorId) continue
if (!concerned.has(personId)) {
concerned.set(personId, 'nommé·e par l\'auteur')
}
}
// 3. Holders of active mandates whose domain intersects the scoped circles.
const scopedCircleIds = new Set(scope.circleIds)
for (const mandate of mandates) {
if (mandate.status !== 'active') continue
if (!mandate.domain.circleIds.some(id => scopedCircleIds.has(id))) continue
if (mandate.holderId === authorId) continue
if (!concerned.has(mandate.holderId)) {
concerned.set(mandate.holderId, `titulaire du mandat ${mandate.title}`)
}
}
return [...concerned].map(([personId, reason]) => ({ personId, reason }))
}
+416
View File
@@ -0,0 +1,416 @@
/**
* Parametric decision engine — « Réglage collectif » (collective tuning).
*
* Pure functions, no I/O — the single implementation (BLUEPRINT-V2.md Δ2, Δ3,
* Δ15, Δ16). Everything here is math over ParamSpec + vote vectors; the human
* gesture (crystallization) lives in state.ts/UI, never here.
*
* LOCKED SPECS (blueprint repairs):
* - LOW median, element by element: for an even vote count, take the LOWER
* central element (index floor((n-1)/2) after ascending sort). Invariant:
* every median value is a value someone actually voted, so the step is
* honored BY CONSTRUCTION — « une position que chacun aurait pu proposer ».
* - constraint 'sum100' requires EXACTLY ONE 'share' param with derived:true
* (the absorption variable), resolved linearly: 100 Σ other shares.
* 'slider' params live outside the constraint and pass through untouched.
* - At vote time the resolved derived must stay within its bounds, otherwise
* the vote is rejected (validateVote).
* - At crystallization the derived is never aggregated: it is resolved from
* the median of the voted shares; if it exits its bounds ⇒ clamp to the
* violated bound + PROPORTIONAL renormalization of the non-derived shares
* (each multiplied by (100 clampedDerived) / Σ median shares) so the
* sum-100 invariant is restored. The renormalized shares may leave the
* step grid — accepted and documented: this is the one specified exception.
* - Degenerate cases: 0 votes ⇒ baseline vector, never an empty screen.
* - computeMyImpact 'linear-share' NEVER invents a number: missing attribute
* or empty declaring corpus ⇒ null.
* - detectBimodality is a simple documented heuristic, NEVER blocking:
* it returns false on any degenerate input instead of throwing.
*
* Code and comments in English; thrown error messages in French (UI-facing).
*/
import type { ParamDef, ParamSpec } from '../types/domain'
/** Blueprint limit: a small manipulable space (SejeteralO lesson). */
const MAX_PARAMS = 7
/**
* Absolute tolerance for floating-point comparisons (bounds and step grid).
* Vote values are human-scale (percent shares, bounded sliders), so an
* absolute epsilon is safe: 0.1 + 0.2 must be accepted as a 0.3 step value.
*/
const FLOAT_EPS = 1e-6
/** Params that are actually voted, in spec order (derived excluded). */
function votableParams(spec: ParamSpec): ParamDef[] {
return spec.params.filter(p => p.derived !== true)
}
/**
* Guarded indexed access (project compiles with noUncheckedIndexedAccess).
* Every call site is protected by a prior length check or loop bound —
* this throw is an internal-invariant guard, not a reachable user error.
*/
function at(arr: number[], i: number): number {
const v = arr[i]
if (v === undefined) {
throw new Error('Incohérence interne : index hors du vecteur.')
}
return v
}
// ---------------------------------------------------------------------------
// validateParamSpec
// ---------------------------------------------------------------------------
/**
* Validate a ParamSpec at creation time. Throws (French message) when:
* - no param, or more than 7 params;
* - a business label is missing (never raw a, b, c);
* - bounds are inconsistent (min >= max) or step is not strictly positive;
* - a baseline lies outside its own bounds;
* - constraint 'sum100' does not have EXACTLY ONE 'share' param with
* derived:true (0 or 2+ derived, or derived on a 'slider');
* - a derived param exists without a constraint able to resolve it.
*/
export function validateParamSpec(spec: ParamSpec): void {
if (spec.params.length === 0) {
throw new Error('Au moins un paramètre est requis.')
}
if (spec.params.length > MAX_PARAMS) {
throw new Error(
`Trop de paramètres : ${spec.params.length} (maximum ${MAX_PARAMS}).`,
)
}
for (const p of spec.params) {
if (typeof p.label !== 'string' || p.label.trim() === '') {
throw new Error(`Libellé métier manquant pour le paramètre « ${p.key} ».`)
}
if (!(p.min < p.max)) {
throw new Error(
`Bornes incohérentes pour « ${p.key} » : min (${p.min}) doit être strictement inférieur à max (${p.max}).`,
)
}
if (!(p.step > 0)) {
throw new Error(
`Pas invalide pour « ${p.key} » : ${p.step} (doit être strictement positif).`,
)
}
if (p.baseline !== undefined && (p.baseline < p.min || p.baseline > p.max)) {
throw new Error(
`Statu quo hors bornes pour « ${p.key} » : ${p.baseline} (bornes ${p.min}${p.max}).`,
)
}
}
const derived = spec.params.filter(p => p.derived === true)
if (spec.constraint === 'sum100') {
if (derived.some(p => p.kind !== 'share')) {
throw new Error(
'Un paramètre dérivé doit être une part (kind « share »), pas un curseur.',
)
}
if (derived.length === 0) {
throw new Error(
'Contrainte sum100 : exactement une part dérivée est requise (aucune trouvée).',
)
}
if (derived.length > 1) {
throw new Error(
`Contrainte sum100 : exactement une part dérivée est requise (${derived.length} trouvées).`,
)
}
} else if (derived.length > 0) {
throw new Error(
'Paramètre dérivé sans contrainte : rien ne permet de le résoudre.',
)
}
}
// ---------------------------------------------------------------------------
// validateVote
// ---------------------------------------------------------------------------
/**
* Validate one vote vector against the spec.
* `values` follows the order of spec.params EXCLUDING derived params
* (Vote.values contract). Throws (French message) when:
* - the vector length does not match the number of votable params;
* - a value is not a finite number (NaN / ±Infinity sanitization);
* - a value is out of bounds or off the step grid (FLOAT_EPS tolerance);
* - constraint 'sum100': the resolved derived (100 Σ voted shares) would
* exit its own [min, max] bounds ⇒ the vote is rejected.
*/
export function validateVote(spec: ParamSpec, values: number[]): void {
const votable = votableParams(spec)
if (values.length !== votable.length) {
throw new Error(
`Nombre de valeurs invalide : ${values.length} reçues, ${votable.length} attendues.`,
)
}
votable.forEach((p, i) => {
const v = values[i]
if (typeof v !== 'number' || !Number.isFinite(v)) {
throw new Error(
`Valeur invalide pour « ${p.label} » : un nombre fini est attendu.`,
)
}
if (v < p.min - FLOAT_EPS || v > p.max + FLOAT_EPS) {
throw new Error(
`Valeur hors bornes pour « ${p.label} » : ${v} (bornes ${p.min}${p.max}).`,
)
}
// Step grid: v must equal min + k×step for an integer k (float tolerance).
const k = Math.round((v - p.min) / p.step)
if (Math.abs(p.min + k * p.step - v) > FLOAT_EPS) {
throw new Error(
`Valeur non alignée sur le pas pour « ${p.label} » : ${v} (pas de ${p.step} depuis ${p.min}).`,
)
}
})
if (spec.constraint === 'sum100') {
const derivedParam = spec.params.find(p => p.derived === true)
if (derivedParam) {
let shareSum = 0
votable.forEach((p, i) => {
if (p.kind === 'share') shareSum += at(values, i)
})
const resolved = 100 - shareSum
if (
resolved < derivedParam.min - FLOAT_EPS
|| resolved > derivedParam.max + FLOAT_EPS
) {
throw new Error(
`La part calculée « ${derivedParam.label} » sortirait de ses bornes : ${resolved} (bornes ${derivedParam.min}${derivedParam.max}).`,
)
}
}
}
}
// ---------------------------------------------------------------------------
// resolveDerived
// ---------------------------------------------------------------------------
/**
* Expand a votable vector into the COMPLETE vector in spec.params order.
* The derived share (sum100) is resolved linearly: 100 Σ other shares.
* 'slider' params live outside the constraint and pass through untouched.
* Throws on a length mismatch (misuse guard — same message as validateVote).
*/
export function resolveDerived(spec: ParamSpec, values: number[]): number[] {
const votable = votableParams(spec)
if (values.length !== votable.length) {
throw new Error(
`Nombre de valeurs invalide : ${values.length} reçues, ${votable.length} attendues.`,
)
}
let shareSum = 0
votable.forEach((p, i) => {
if (p.kind === 'share') shareSum += at(values, i)
})
let cursor = 0
return spec.params.map(p => (p.derived === true ? 100 - shareSum : at(values, cursor++)))
}
// ---------------------------------------------------------------------------
// medianByElement
// ---------------------------------------------------------------------------
/**
* LOW median, element by element.
* Each column is sorted ascending and the element at index floor((n-1)/2) is
* taken — for an even n this is the LOWER of the two central elements.
* Invariant: every median value is a value actually voted by someone, so the
* step grid is honored by construction (« une position que chacun aurait pu
* proposer »).
* 0 votes ⇒ [] (the caller falls back to the baseline vector).
*/
export function medianByElement(votesValues: number[][]): number[] {
const n = votesValues.length
if (n === 0) return []
const width = votesValues[0]?.length ?? 0
const lowMedianIndex = Math.floor((n - 1) / 2)
const medians: number[] = []
for (let j = 0; j < width; j++) {
const column = votesValues.map(v => at(v, j)).sort((a, b) => a - b)
medians.push(at(column, lowMedianIndex))
}
return medians
}
// ---------------------------------------------------------------------------
// crystallize
// ---------------------------------------------------------------------------
/**
* Compute the crystallized position: LOW median of the VOTED vectors, then
* derived resolution. Returns the complete vector in spec.params order.
*
* sum100 repair (locked spec): the derived is never aggregated — it is
* resolved from the median of the voted shares. If it exits its bounds:
* - clamp it to the violated bound;
* - renormalize the non-derived shares PROPORTIONALLY to restore sum 100:
* each share is multiplied by (100 clampedDerived) / Σ median shares.
* (Renormalized shares may leave the step grid — accepted, documented.)
* - degenerate sub-case Σ median shares = 0: proportionality is undefined,
* the remainder (100 clampedDerived) is spread equally instead.
* 'slider' params are outside the constraint and are never renormalized.
*
* 0 votes ⇒ the baseline vector (spec.params[i].baseline ?? min) — never an
* empty screen; the crystallization GESTURE itself stays human (Δ3).
*/
export function crystallize(spec: ParamSpec, votesValues: number[][]): number[] {
if (votesValues.length === 0) {
return spec.params.map(p => p.baseline ?? p.min)
}
const median = medianByElement(votesValues)
const full = resolveDerived(spec, median)
if (spec.constraint !== 'sum100') return full
const derivedIndex = spec.params.findIndex(p => p.derived === true)
const derivedParam = spec.params[derivedIndex]
if (derivedIndex === -1 || derivedParam === undefined) {
return full // unreachable on a validated spec
}
const resolved = at(full, derivedIndex)
const withinBounds
= resolved >= derivedParam.min - FLOAT_EPS
&& resolved <= derivedParam.max + FLOAT_EPS
if (withinBounds) return full
// Clamp to the violated bound, then restore the sum-100 invariant.
const clamped = Math.min(Math.max(resolved, derivedParam.min), derivedParam.max)
const remainder = 100 - clamped
let shareSum = 0
let shareCount = 0
spec.params.forEach((p, i) => {
if (p.derived !== true && p.kind === 'share') {
shareSum += at(full, i)
shareCount++
}
})
return full.map((v, i) => {
if (i === derivedIndex) return clamped
const p = spec.params[i]
if (p === undefined || p.kind !== 'share') return v // sliders pass through untouched
if (shareSum === 0) return remainder / shareCount // degenerate: equal spread
return v * (remainder / shareSum) // proportional renormalization
})
}
// ---------------------------------------------------------------------------
// computeMyImpact
// ---------------------------------------------------------------------------
/** One line of the « Pour moi » card: my quota for one share param. */
export interface ImpactLine {
key: string
label: string
amount: number
}
/** « Pour moi » card content — per share param + total. */
export interface MyImpact {
perParam: ImpactLine[]
total: number
}
/**
* 'linear-share' personal impact (Δ15 — « Pour moi » card).
* Applies ONLY when constraint is 'sum100' AND resources.amount is set AND
* spec.impactAttrKey is set. For EACH share param p (derived included):
* amount(p) = resources.amount × value(p) / 100
* myQuota(p) = amount(p) × myAttr / Σ corpusAttrs
* `fullValues` is the COMPLETE vector in spec.params order (resolveDerived /
* crystallize output). 'slider' params are outside the constraint: no line.
*
* Returns null — NEVER an invented number — when myAttr is undefined,
* when Σ corpusAttrs is 0 (nobody declared), or on a malformed input.
*/
export function computeMyImpact(
spec: ParamSpec,
resources: { amount?: number },
fullValues: number[],
myAttr: number | undefined,
corpusAttrs: number[],
): MyImpact | null {
if (spec.constraint !== 'sum100') return null
if (!resources.amount || !Number.isFinite(resources.amount)) return null
if (!spec.impactAttrKey) return null
if (myAttr === undefined || !Number.isFinite(myAttr)) return null
if (fullValues.length !== spec.params.length) return null // misuse guard
const attrSum = corpusAttrs.reduce(
(sum, a) => sum + (Number.isFinite(a) ? a : 0),
0,
)
if (attrSum === 0) return null
const amount = resources.amount
const perParam: ImpactLine[] = []
let total = 0
spec.params.forEach((p, i) => {
if (p.kind !== 'share') return
const paramAmount = (amount * at(fullValues, i)) / 100
const myQuota = (paramAmount * myAttr) / attrSum
perParam.push({ key: p.key, label: p.label, amount: myQuota })
total += myQuota
})
return { perParam, total }
}
// ---------------------------------------------------------------------------
// detectBimodality
// ---------------------------------------------------------------------------
/**
* Simple documented heuristic over ONE param's voted values — NEVER blocking
* (it only feeds the non-blocking banner and the crystallization reminder,
* Δ16): two distinct positions are detected when, after ascending sort,
* the LARGEST gap between consecutive values satisfies ALL of:
* - n >= 4 (below that, no distribution to speak of);
* - gap > 40% of the total range (max min);
* - at least 2 values on EACH side of the gap (a single outlier is not a
* second position).
* Non-finite values are ignored; any degenerate input returns false.
*/
export function detectBimodality(values: number[]): boolean {
const sorted = values.filter(v => Number.isFinite(v)).sort((a, b) => a - b)
const n = sorted.length
if (n < 4) return false
const range = at(sorted, n - 1) - at(sorted, 0)
if (range <= 0) return false
let maxGap = 0
for (let i = 0; i < n - 1; i++) {
const gap = at(sorted, i + 1) - at(sorted, i)
if (gap > maxGap) maxGap = gap
}
if (maxGap <= 0.4 * range) return false
// The max gap must split the values 2+ / 2+ (ties: any qualifying position).
for (let i = 1; i <= n - 3; i++) {
if (at(sorted, i + 1) - at(sorted, i) === maxGap) return true
}
return false
}
+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
}
+248
View File
@@ -0,0 +1,248 @@
/**
* The ONE state machine of the decision (Δ29).
*
* Every mutation goes through canTransition(decision, to, ctx): the doctrinal
* guards (subsidiarity, instruction, consent) are tested code, not intentions.
* The context is assembled by the store; the function stays pure and testable.
*
* Guards, in order (BLUEPRINT-V2.md « Cycle de vie » + Δ13, Δ17, Δ27, Δ29):
* a) unknown transition;
* b) boundary — windowSuspendedAt suspends any exit from objection/advice;
* c) influx — concernEscalateRatio reached ⇒ widen or scopeKeptNote;
* d) matter — requireEffects at collective session opening;
* e) resources — « Ce que ça engage » at window/session opening;
* f) assent — non-easy objection windows adopt only on an explicit third-party
* agreement, never by pure silence;
* g) open objection — no adoption over an open objection;
* h) dossier — framing→closed only when every element child is terminal;
* i) crystallization — parametric sessions close by a dated human gesture,
* the engine NEVER crystallizes.
*
* Every refusal reason is one French sentence whose subject is the collective
* or the person — never the engine.
*/
import type {
Assent,
CollectiveSettings,
Concern,
Decision,
DecisionStatus,
ISODate,
Objection,
VoteSession,
} from '../types/domain'
/** Allowed transitions — statuses absent from the table are terminal. */
export const TRANSITIONS: Readonly<Partial<Record<DecisionStatus, readonly DecisionStatus[]>>> = {
draft: ['advice', 'objection', 'framing', 'voting', 'adopted', 'transmitted', 'rejected'],
advice: ['adopted', 'voting'],
objection: ['adopted', 'framing', 'voting'],
framing: ['voting', 'closed'],
voting: ['adopted', 'rejected'],
adopted: ['revoked', 'closed'],
}
/** Terminal statuses of an element child — condition of the dossier closure. */
export const TERMINAL_STATUSES: readonly DecisionStatus[] = [
'adopted',
'rejected',
'revoked',
'closed',
'transmitted',
]
/** Window states — a boundary objection suspends any exit from these. */
const WINDOW_STATUSES: readonly DecisionStatus[] = ['objection', 'advice']
/** Sources whose closing is subject to the influx guard. */
const INFLUX_SOURCES: readonly DecisionStatus[] = ['advice', 'objection', 'voting']
/** Adoption/closure targets watched by the influx guard. */
const INFLUX_TARGETS: readonly DecisionStatus[] = ['adopted', 'rejected', 'closed']
const REASONS = {
unknown: 'La décision ne peut pas prendre ce chemin depuis son état actuel.',
boundary:
'La frontière est contestée — le compte à rebours reste suspendu jusqu’à lintégration ou une réponse motivée.',
influx: 'Le périmètre déborde — élargis dun cran ou motive publiquement son maintien.',
matterMissing: 'Le collectif sinstruit avant de voter — formule au moins un effet recherché.',
matterTarget:
'Une décision structurante se mesure — donne une cible à au moins un effet recherché.',
resources:
'Le collectif doit savoir ce que ça engage — écris la note de ressources avant douvrir.',
assent: 'Il manque un accord explicite — la fenêtre se prolonge.',
openObjection: 'Une objection reste ouverte — le collectif lentend avant dadopter.',
dossierEmpty: 'Le dossier na pas d’éléments — découpe-le avant de le clore.',
dossierPending:
'Des éléments du dossier sont encore en cours — le dossier se clôt quand tous ont abouti.',
crystallization: 'Les votes sont figés — la cristallisation attend son geste.',
} as const
export interface TransitionContext {
concerns: Concern[]
settings: CollectiveSettings
session?: VoteSession
children?: Decision[]
assents?: Assent[]
objections?: Objection[]
now: ISODate
}
export type TransitionResult = { ok: true } | { ok: false; reason: string }
export type WindowOutcome = 'adopt' | 'extend' | 'wait'
function refuse(reason: string): TransitionResult {
return { ok: false, reason }
}
/** ≥1 non-archived Assent on this decision from someone other than the author. */
function hasThirdPartyAssent(decision: Decision, assents: Assent[] | undefined): boolean {
return (assents ?? []).some(
(assent) =>
assent.decisionId === decision.id &&
!assent.archivedAt &&
assent.personId !== decision.authorId,
)
}
/**
* Can this decision move to `to`? Pure — the store assembles ctx.
* Refusals carry one French sentence (subject: the collective or the person).
*/
export function canTransition(
decision: Decision,
to: DecisionStatus,
ctx: TransitionContext,
): TransitionResult {
const from = decision.status
// ── (a) Unknown transition ─────────────────────────────────────────────
// A window state may explicitly return to itself (re-arming after a
// resolved boundary objection, extension of a window) — nothing else loops.
const isWindowSelfReturn = WINDOW_STATUSES.includes(from) && to === from
if (!isWindowSelfReturn && !(TRANSITIONS[from] ?? []).includes(to)) {
return refuse(REASONS.unknown)
}
// ── (b) Boundary — the contestation of frontiers precedes substance ────
if (WINDOW_STATUSES.includes(from) && decision.windowSuspendedAt && to !== from) {
return refuse(REASONS.boundary)
}
// ── (c) Influx — the scale-up is DECIDED, never evaporated (Δ17) ───────
if (INFLUX_SOURCES.includes(from) && INFLUX_TARGETS.includes(to)) {
const concerns = ctx.concerns.filter(
(concern) => concern.decisionId === decision.id && !concern.archivedAt,
)
const computed = concerns.filter((concern) => concern.origin === 'computed').length
const declared = concerns.filter((concern) => concern.origin === 'declared').length
const ratioReached =
computed > 0 && declared >= ctx.settings.triage.concernEscalateRatio * computed
if (ratioReached && !decision.scopeKeptNote) {
return refuse(REASONS.influx)
}
}
const opensCollectiveSession = (from === 'draft' || from === 'framing') && to === 'voting'
// ── (d) Matter — no engaging collective vote without instruction ───────
if (opensCollectiveSession && decision.route === 'collective') {
const mode = ctx.settings.triage.requireEffects
const underGuard =
mode === 'binding'
? decision.weight === 'binding' || decision.weight === 'structural'
: mode === 'structural' && decision.weight === 'structural'
if (underGuard) {
const effects = decision.brief?.effects ?? []
if (effects.length === 0) {
return refuse(REASONS.matterMissing)
}
const hasMeasurableEffect = effects.some(
(effect) => effect.target !== undefined && effect.target.trim().length > 0,
)
if (decision.weight === 'structural' && !hasMeasurableEffect) {
return refuse(REASONS.matterTarget)
}
}
}
// ── (e) Resources — « Ce que ça engage » lives at the opening ──────────
const opensWindowOrSession =
opensCollectiveSession || (from === 'draft' && (to === 'objection' || to === 'advice'))
if (
opensWindowOrSession &&
decision.weight !== 'light' &&
decision.route !== 'solo' &&
decision.route !== 'record'
) {
const note = decision.resources?.note ?? ''
if (note.trim().length === 0) {
return refuse(REASONS.resources)
}
}
// ── (f) Assent — outside easy, agreement is a gesture, not silence (Δ27)
if (from === 'objection' && to === 'adopted' && decision.reversibility !== 'easy') {
if (!hasThirdPartyAssent(decision, ctx.assents)) {
return refuse(REASONS.assent)
}
}
// ── (g) Open objection — never adopted over someone's maintained voice ─
if (from === 'objection' && to === 'adopted') {
const hasOpenObjection = (ctx.objections ?? []).some(
(objection) =>
objection.decisionId === decision.id &&
!objection.archivedAt &&
objection.status === 'open',
)
if (hasOpenObjection) {
return refuse(REASONS.openObjection)
}
}
// ── (h) Dossier — the closure is a steward gesture on a complete map (Δ13)
if (from === 'framing' && to === 'closed') {
const elements = (ctx.children ?? []).filter(
(child) =>
child.chainKind === 'element' &&
child.parentDecisionId === decision.id &&
!child.archivedAt,
)
if (elements.length === 0) {
return refuse(REASONS.dossierEmpty)
}
if (elements.some((child) => !TERMINAL_STATUSES.includes(child.status))) {
return refuse(REASONS.dossierPending)
}
}
// ── (i) Crystallization — the engine NEVER crystallizes ────────────────
// A parametric session is recognized by decision.paramSpec, or by the
// 'frozen' status (only parametric sessions ever freeze).
if (from === 'voting' && (to === 'adopted' || to === 'rejected') && ctx.session) {
const isParametric = decision.paramSpec !== undefined || ctx.session.status === 'frozen'
const crystallized = ctx.session.status === 'closed' && !!ctx.session.crystallizedById
if (isParametric && !crystallized) {
return refuse(REASONS.crystallization)
}
}
return { ok: true }
}
/**
* Outcome of an objection window at its deadline, WITHOUT open objection
* (the caller handles open objections — escalation or suspension):
* - suspended boundary ⇒ 'wait' (the countdown is not running);
* - easy ⇒ 'adopt' (silence counts as agreement — only there);
* - otherwise ⇒ 'adopt' on a third-party Assent, else 'extend' by one notch
* (+objectionWindowHours, Fil reminder) — never adoption by pure silence.
*/
export function windowOutcome(decision: Decision, ctx: TransitionContext): WindowOutcome {
if (decision.windowSuspendedAt) return 'wait'
if (decision.reversibility === 'easy') return 'adopt'
return hasThirdPartyAssent(decision, ctx.assents) ? 'adopt' : 'extend'
}
+103
View File
@@ -108,3 +108,106 @@ export function techcommThreshold(cotecSize: number, exponent: number = 0.1): nu
}
return Math.ceil(cotecSize ** exponent)
}
// ─────────────────────────────────────────────────────────────
// Election — simple plurality (BLUEPRINT-V2.md Δ28, « Modalités » #4)
// ─────────────────────────────────────────────────────────────
import type { FormulaParams, Id, Vote } from '~/types/domain'
/**
* Outcome of an election tally.
*
* Discriminated union on `outcome`:
* - 'elected' — a single person leads by simple plurality.
* - 'tie' — several persons share the top count. The engine NEVER
* breaks a tie (no randomness, no first-come): the closure
* flow proposes a chained runoff among `exAequoIds`, or a
* draw only if the Pact planned it (FormulaParams.tieBreak).
* - 'rejected' — reason 'quorum': participants (blanks included) below
* electionMinParticipants; `required` = that quorum.
* reason 'no-designation': quorum reached (or absent) but
* every vote is blank — nobody was designated, and an
* empty tie would be meaningless; `required` still carries
* the quorum (0 when none) for display purposes.
*/
export type ElectionOutcome =
| {
outcome: 'elected'
winnerId: Id
counts: Record<Id, number>
blanks: number
participants: number
}
| {
outcome: 'tie'
exAequoIds: Id[]
counts: Record<Id, number>
blanks: number
participants: number
}
| {
outcome: 'rejected'
reason: 'quorum' | 'no-designation'
participants: number
required: number
}
/**
* Tally an election by simple plurality.
*
* CONTRACT — the caller passes the LAST ACTIVE votes only: one vote per
* voter, `supersedesVoteId` chains already resolved (the store filters
* superseded votes). The engine does NOT deduplicate by voterId;
* `participants` is simply `votes.length`.
*
* Rules (Δ28):
* - A vote without `choicePersonId` is a BLANK: it counts for
* participation (quorum), never for designation.
* - Quorum: when `formula.electionMinParticipants` is set and
* participants (blanks included) < quorum ⇒ rejected ('quorum').
* - Designation by simple PLURALITY of the non-blank votes.
* - Tie at the top ⇒ 'tie' with `exAequoIds` sorted (lexicographic —
* a deterministic display order, NEVER a tie-break: the engine does
* not pick a winner among equals, no randomness, no first-come).
* - Zero designation (all blanks) ⇒ rejected ('no-designation').
*
* @param votes - Last active votes of the session (see contract above)
* @param formula - Protocol formula params (only electionMinParticipants is read)
* @returns The election outcome — never a tie silently broken
*/
export function electionResult(
votes: Vote[],
formula: Pick<FormulaParams, 'electionMinParticipants'>,
): ElectionOutcome {
const participants = votes.length
const quorum = formula.electionMinParticipants ?? 0
if (formula.electionMinParticipants !== undefined && participants < formula.electionMinParticipants) {
return { outcome: 'rejected', reason: 'quorum', participants, required: formula.electionMinParticipants }
}
const counts: Record<Id, number> = {}
let blanks = 0
for (const vote of votes) {
if (vote.choicePersonId) {
counts[vote.choicePersonId] = (counts[vote.choicePersonId] ?? 0) + 1
} else {
blanks++ // blank: participation only, never designation
}
}
const designatedIds = Object.keys(counts)
if (designatedIds.length === 0) {
return { outcome: 'rejected', reason: 'no-designation', participants, required: quorum }
}
const topCount = Math.max(...designatedIds.map(id => counts[id]!))
const leaders = designatedIds.filter(id => counts[id] === topCount)
if (leaders.length === 1) {
return { outcome: 'elected', winnerId: leaders[0]!, counts, blanks, participants }
}
return { outcome: 'tie', exAequoIds: [...leaders].sort(), counts, blanks, participants }
}
+341
View File
@@ -0,0 +1,341 @@
/**
* 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)),
)
}