forked from yvv/decision
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:
@@ -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 }))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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’à l’intégration ou une réponse motivée.',
|
||||
influx: 'Le périmètre déborde — élargis d’un cran ou motive publiquement son maintien.',
|
||||
matterMissing: 'Le collectif s’instruit 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 d’ouvrir.',
|
||||
assent: 'Il manque un accord explicite — la fenêtre se prolonge.',
|
||||
openObjection: 'Une objection reste ouverte — le collectif l’entend avant d’adopter.',
|
||||
dossierEmpty: 'Le dossier n’a 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'
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user