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 }))
}