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 }))
|
||||
}
|
||||
Reference in New Issue
Block a user