Skip to content

Discrete Event Simulation (DES) — chantier complet 8 phases#30

Open
PPCM wants to merge 16 commits into
mainfrom
feature/des-simulation
Open

Discrete Event Simulation (DES) — chantier complet 8 phases#30
PPCM wants to merge 16 commits into
mainfrom
feature/des-simulation

Conversation

@PPCM
Copy link
Copy Markdown
Owner

@PPCM PPCM commented May 11, 2026

Contexte

Chantier Discrete Event Simulation (DES) pour la simulation de particules, décidé en ADR-006 (Accepted).

Remplace le rendu continu de linkDirectionalParticles par un simulator événementiel piloté par les attributs DOT déjà documentés mais ignorés au runtime. L'utilisateur voit désormais :

  • les particules circuler depuis les générateurs vers les sinks,
  • les files s'accumuler sur les relais (grossissement visuel jusqu'à 2× la taille de base),
  • les halos de saturation (orange à 80 %, rouge à 100 %),
  • les drops signalés par un flash rouge bref + un compteur dans le HUD,
  • des stats temps réel mesurées et non plus heuristiques (Particules, Latence, Goulots, Drops, File max, Débit).

Phases (12 commits)

# Phase Commit Effort
0 ADR-006 + spec DOT 3D (3 nouveaux attributs) 0ac995a 0.5 j
1 Validator backend + 17 tests Jest fbd38c2 0.5 j
Nettoyage post-suppression du bouton Play du rail 8fb6024
2 API surface TypeScript (stubs NotImplementedError) 7094ae3 0.5 j
3 Simulator DES complet + 37 tests unitaires 2f94ce6 2-3 j
4 Intégration backend parse-dot → hook React → renderer 330df95 + dca6b5b + 159bc07 1 j
5 Visualisations (queue growth / halos / flash drop / role tint) c0034e3 1 j
6 HUD enrichi (File max, Débit, tooltips, reset au start) 96920b4 0.5 j
7 Tests d'intégration scénarios + perf smoke be3e326 1 j
8 Doc utilisateur + release notes + ADR → Accepted f1e8ab1 0.5 j

Décisions actées (figées dans l'ADR-006)

  • nodeRole énum generator | relay | sink (défaut relay). V1 stricte : pas de fallback "tout émet" — graphes legacy doivent être annotés.
  • dropPolicy énum tail | head | reject (défaut tail), nécessite queue_size.
  • Routage : pondéré par maxParticleFlow, fallback round-robin.
  • dt clampé à maxDtMs (33 ms par défaut).
  • Émission régulière déterministe (1000/rate ms d'intervalle).
  • Processing par slots parallèles : maxParticleProcessing × processing_time.
  • failure_rate tiré à la sortie du nœud.
  • Calibration vitesse alignée sur handleEmitTrace existant.

Couverture & tests

Métrique Avant Après
Tests backend 376 395 (+19 DES)
Tests frontend 320 360 (+40 DES)
Coverage particleSimulator.ts 97.11 % L / 94.62 % B
Coverage useParticleSimulator.ts 100 % L / 75 % B
Coverage dotValidator.js 84 % 86.91 %
Threshold per-module ajouté particleSimulator.ts (90/85/95/90)
Lint frontend + backend OK OK

Effets de bord assumés (à valider en review)

  1. Breaking change pour les graphes existants. Les exemples du dossier doc/dot-3d/examples/ (network-distribution, particle-physics, workflow-pipeline, etc.) n'animeront plus tant qu'ils ne sont pas annotés nodeRole=generator. C'est la V1 stricte assumée en ADR-006.
  2. handleEmitTrace ne fait plus rien si aucun générateur n'est déclaré. Pas de message d'erreur au clic — le UI doit utiliser hasGenerators pour signaler le cas.
  3. Pas de cap sur le nombre total de particules en vol. Si la perf souffre sur très gros graphes, maxTotalParticlesInFlight peut être ajouté à SimulatorOptions sans breaking change.
  4. Tests renderer DOM pour les chips HUD intentionnellement non écrits — chaque maillon (simulator / hook / accesseur) est testé séparément, et la chaîne DOM dépend d'un rAF réel difficile à stub-ber proprement en jsdom.

Périmètre encore ouvert (post-merge)

  • Migration des exemples existants vers nodeRole=generator (1-2 heures, à faire au cas par cas).
  • Tests E2E navigateur réels (hors périmètre de ce chantier).
  • Cap optionnel sur particlesInFlight pour très gros graphes (à activer si la perf souffre).

Test plan

  • Backend : 395 tests passent en local
  • Frontend : 360 tests passent en local
  • Lint frontend + backend : clean
  • Coverage particleSimulator.ts ≥ 90 % lines (threshold per-module figé)
  • Exemples generators.dot et saturation.dot validés par le validator sans warning
  • CI GitHub Actions sur Node 24 verte sur cette PR
  • Test manuel : charger saturation.dot, démarrer la simulation, observer accumulation → halo orange → halo rouge → drop flash → chips HUD à jour
  • Revue de l'ADR-006 et validation des décisions D1-D5 / C1-C5 / V1 stricte

pierre@redtrash.fr added 12 commits May 11, 2026 23:26
Premier lot du chantier "simulation à événements discrets" (option C
décidée avec l'utilisateur). Aucune ligne de code applicatif : uniquement
la spec, l'ADR et deux exemples pour cadrer les phases suivantes.

- ADR-006 (Proposed) : justification du passage de l'animation continue
  à un simulator DES, décisions verrouillées (routing pondéré, dt clampé
  à 33 ms, stats reset au Start, V1 stricte sur nodeRole), alternatives
  écartées (visual-only, modulated flow, server-driven).

- Spec DOT 3D — triple invariant respecté en couche doc :
  * nodeRole (enum: generator|relay|sink, défaut relay) — V1 stricte,
    pas de fallback "tout émet"
  * dropPolicy (enum: tail|head|reject, défaut tail) — n'a de sens
    qu'avec queue_size défini
  * queue_size, processing_time, failure_rate gagnent une sémantique
    runtime (ils étaient déjà acceptés par le validator mais ignorés)
  * Cross-checks de cohérence en warnings (pas erreurs) : dropPolicy
    sans queue_size, particleGeneration sur relay/sink

- Exemples :
  * generators.dot — démo minimale 2 sources → routeur → sink, pas
    de saturation, pour comprendre les rôles
  * saturation.dot — source rapide → goulot étroit → sink, pour
    valider la visu d'accumulation et les drops en phase 5/7

Validator backend et renderer frontend seront mis à jour en phases 1
et 4 respectivement. Cf. plan de chantier en discussion utilisateur.
Implémente le côté validator du triple invariant (ADR-005) pour les
deux nouveaux attributs DES introduits en ADR-006. La spec a été posée
en Phase 0 (commit 0ac995a) ; cette PR exécute la même décision sur
backend/src/utils/dotValidator.js.

Ajouts dans le validator :
- nodeRole (enum: generator|relay|sink) — warning sur valeur hors énum
- dropPolicy (enum: tail|head|reject) — warning sur valeur hors énum
- Méthode validateDESCoherence(ast) appelée après les checks d'extension :
  * warn si dropPolicy défini sans queue_size (file unbounded, drop
    inopérant)
  * warn si particleGeneration > 0 sur nodeRole=relay|sink (ignoré
    au runtime parce que seul "generator" émet)
- Les deux attributs sont déclarés dans this.keywords et dans la liste
  vortexFlow3DAttrs pour qu'ils déclenchent hasVortexFlowExtensions=true

Tests Jest (17 nouveaux dans tests/unit/utils/dotValidator.test.js) :
- accept des 3 valeurs nodeRole + reject d'une valeur inconnue
- accept des 3 valeurs dropPolicy + reject d'une valeur inconnue
- 6 cas de cohérence (warns ON pour les cas problématiques, warns OFF
  pour les cas légitimes : generator+particleGeneration, relay+0,
  dropPolicy+queue_size)
- 2 régressions sur les attributs existants (failure_rate hors [0,1],
  queue_size négatif)

Coverage dotValidator.js : 86.91 % lines (threshold 80 %). 395 tests
backend passent au total.

Exemples DOT de Phase 0 (generators.dot, saturation.dot) validés sans
warning — la grammaire et le validator sont cohérents.

Phase 2 (architecture du simulator côté frontend) à suivre.
Suite du commit cf0d110 ("doublon rail") : ce commit avait supprimé le
bouton Play/Pause du rail vertical mais laissé en place la prop
onToggleSimulation, ses imports et les tests qui s'y rattachaient. ESLint
flaggait deux vars unused et deux tests étaient devenus rouges parce
qu'ils cherchaient un bouton qui n'existe plus.

- GraphRenderer3D.tsx : retire la prop onToggleSimulation (interface +
  destructuring) et les imports PlayArrowIcon / PauseIcon.
- GraphViewer.tsx : retire la fonction handleToggleSimulation (devenue
  dead code) et son passage en prop au renderer.
- GraphRenderer3D.test.tsx : supprime le bloc describe sur
  "Start Simulation button delegates to onToggleSimulation" (2 tests).

Le contrôle Start/Pause unique reste dans la toolbar horizontale de
GraphViewer (handleStartSimulation / handlePauseSimulation), comme
documenté dans CLAUDE.md §"Single Start/Pause control in the toolbar".

Frontend après nettoyage : 320/320 tests passent, lint clean.
Squelette de la classe ParticleSimulator côté frontend, conforme au plan
adopté en ADR-006. Aucune implémentation à ce stade : les mutators
publics (start, pause, stop, tick, getStats, onTick, dispose) lèvent
NotImplementedError. Objectif de cette phase : verrouiller l'API et les
types avant d'écrire la logique en Phase 3.

frontend/src/services/particleSimulator.ts (208 lignes):
- Types exportés:
  * NodeRole = 'generator' | 'relay' | 'sink'
  * DropPolicy = 'tail' | 'head' | 'reject'
  * DropReason = 'queue_full' | 'failure_rate' | 'no_outlet'
  * Particle, NodeQueue, SimulatorStats, NodeInput, LinkInput, GraphInput
  * SimulatorOptions (maxDtMs, random, defaultGenerationPerSecond,
    onParticleReleased)
  * StatsListener
- Classe ParticleSimulator avec sa constructor signature stable
  (graph: GraphInput, options?: SimulatorOptions). Pas de coupling
  React/Three — pure data layer, testable en isolation.
- NotImplementedError exporté pour que tests / callers puissent
  asserter explicitement sur l'état "stub".
- Documentation JSDoc qui rappelle les décisions ADR-006 (V1 stricte,
  routing pondéré, dt clampé 33 ms, stats reset au start) là où elles
  s'appliquent dans le code.

frontend/src/services/particleSimulator.test.ts (12 tests):
- Instantiation avec graphe minimal et avec SimulatorOptions complet.
- Vérification que les 7 mutators publics throw NotImplementedError
  via test.each, et que le message inclut le nom de la méthode +
  référence Phase 3.
- Compile-time assertions sur les énums exportés (NodeRole, DropPolicy).

Phase 3 (à venir, 2-3 jours) remplacera tous les throw par les
implémentations DES : emission, transit, queue+drop, routing.
Remplace les stubs Phase 2 par une simulation à événements discrets
fonctionnelle. La classe ParticleSimulator est désormais utilisable end-
to-end côté logique : émission → transit → arrivée → queue → processing
→ routage → drop. Reste à l'intégrer au renderer en Phase 4.

Implémentation (frontend/src/services/particleSimulator.ts ~430 lignes):

Phase 3a — Émission régulière déterministe :
  Accumulator par générateur, 1 particule toutes les (1000/rate) ms.
  Seuls les nœuds nodeRole=generator émettent (V1 stricte, ADR-006).
  particleGeneration sur relay/sink est zeroed à la construction.
  Default 1 p/s si générateur sans particleGeneration explicite.

Phase 3b — Transit + arrivée :
  Calibration identique à handleEmitTrace : speed_internal = particleSpeed
  × 0.003 (fraction-de-lien par tick 16.67ms), clampé à [0.001, 0.02].
  À l'arrivée sur un sink : compte totalArrived + latence.
  Sur un relay/generator : enqueue (handleArrival).

Phase 3c — Queue + dropPolicy + processing slots :
  queue_size optionnel (∞ par défaut). À saturation :
    - tail/reject  → drop l'entrante
    - head         → drop la plus ancienne, accepte l'entrante
  Slots parallèles : maxParticleProcessing slots simultanés, chacun
  occupé processing_time ms. À la sortie d'un slot :
    - failure_rate (0..1) → drop probabiliste avec raison 'failure_rate'
    - pas de lien sortant → drop 'no_outlet'
    - sinon → sendOnLink (sans incrémenter totalEmitted, qui ne compte
      que les émissions générateur)

Phase 3d — Routing pondéré :
  Quand un nœud a M liens sortants : tirage pondéré par maxParticleFlow
  si au moins un lien a un poids > 0, sinon round-robin déterministe.
  Random injectable via SimulatorOptions.random (seedable pour tests).

Lifecycle :
  start() : reset stats + queues, running=true
  pause() : suspend (state préservé)
  stop()  : reset complet
  tick(dt): dt clampé à maxDtMs (33ms par défaut), no-op si paused
  onTick(): subscribe au stream de stats, retourne unsubscribe
  dispose(): instance définitivement inutilisable

Tests (37 tests, frontend/src/services/particleSimulator.test.ts ~480 lignes):
  - Construction & defaults (3)
  - Phase 3a Émission (8) : rate, deterministic timing, no-outlet drop,
    default 1/s, ignore sur relay/sink, callback onParticleReleased
  - Phase 3b Transit (5) : arrival time, end-to-end latency, particlesInFlight
  - Lifecycle (6) : tick noop quand paused, stop reset, dispose throws,
    onTick subscribe/unsubscribe, dt clamping
  - Phase 3c Queue + drop + processing (9) : croissance queue,
    tail/head/reject policies, queue_size ∞, processing_time delay,
    maxParticleProcessing cap, failure_rate 0/1, no_outlet sur relay
  - Phase 3d Routing (3) : round-robin sans poids, pondéré 80/20 exact
    avec seed cyclique, déterminisme avec random fixé
  - Type smoke (2) : enums NodeRole / DropPolicy

Coverage particleSimulator.ts : 97.11% lines / 92.47% branches / 100%
functions / 98.44% statements (37 tests, ~480 LOC de tests).

vite.config.ts : threshold per-module ajouté pour particleSimulator
(lines 90, branches 85, functions 95, statements 90) — locke le niveau
contre les régressions futures pendant Phase 4 (intégration renderer).
POST /api/public/parse-dot ne renvoyait que particleGeneration et
maxParticleProcessing parmi les attributs DES. Ajoute nodeRole,
dropPolicy, queue_size, processing_time, failure_rate à la réponse
pour que le ParticleSimulator (côté navigateur) reçoive l'intégralité
des paramètres déclarés en DOT.

Pas de nouveau test backend : la fonction map() est triviale, et les
tests d'intégration end-to-end (Phase 7) couvriront la chaîne complète
parse → render → simulate. Le validator (Phase 1) garantit déjà la
forme des attributs en amont.
Hook React qui owns une instance de ParticleSimulator (Phase 3),
la pilote via requestAnimationFrame, surface les stats via useState
et forwarde onParticleReleased au callback du consommateur (qui
appellera forceGraphRef.current.emitParticle()).

frontend/src/hooks/useParticleSimulator.ts (~145 lignes):
- Recrée le simulator quand graphData change (ref stability requise
  côté caller — documentée).
- rAF loop avec dt mesuré via performance.now(), clamped par
  options.maxDtMs (33ms par défaut dans le simulator).
- Cleanup propre : cancelAnimationFrame puis dispose. Le pause() au
  cleanup du rAF effect est intentionnellement omis pour éviter le
  crash quand graphData change (create-effect dispose en premier).
- Callback ref pattern pour onParticleReleased : la dernière référence
  est utilisée sans recréer le simulator à chaque render.
- Retourne `hasGenerators` (boolean dérivé : présence d'au moins un
  nodeRole=generator) pour piloter l'UI du renderer en mode strict.

frontend/src/hooks/useParticleSimulator.test.ts (9 tests):
- Empty graph → null stats + hasGenerators=false
- Detection des generators
- Pas de frames si isRunning=false
- Émission via le callback quand isRunning=true
- Stats surface via React state après tick
- Reset des stats quand isRunning flips true→false→true
- Dispose au unmount stoppe les callbacks
- Callback ref fraîche sans recréation du simulator
- Stub déterministe de requestAnimationFrame pour tests reproductibles

Coverage useParticleSimulator.ts: 100% lines / 75% branches / 100%
functions / 100% statements (branches < 100% : la branche "graphData
empty" est testée mais le coverage v8 ne marque pas toujours les
early returns dans les if).
Câble le ParticleSimulator (via useParticleSimulator) au renderer.
Le simulator devient la source de vérité pour l'émission ET les stats
dès qu'au moins un nœud déclare nodeRole=generator (ADR-006).

Changements GraphRenderer3D.tsx (~80 lignes nettes) :

- Import useParticleSimulator + appel dans le composant principal.
- Callback onSimulatorParticleReleased : décode le linkId généré par
  le simulator ("source->target#counter"), retrouve le link object
  dans 3d-force-graph, et appelle emitParticle(link) pour matérialiser
  visuellement chaque release. Pas d'animation parasite : 3d-force-graph
  gère l'animation, le simulator gère la logique.
- linkDirectionalParticles (deux endroits : updateParticleProperties +
  initializeGraph) : retourne 0 quand hasGenerators=true. Le continuous
  flow ne s'active que comme fallback pour les graphes legacy sans
  generator déclaré.
- ForceGraphNode étendu avec nodeRole / dropPolicy / queue_size /
  processing_time / failure_rate.
- DotTo3DConverter.convertBackendDataToGraph + parseDotToGraphDataFrontend
  propagent les attributs DES depuis le backend / le parsing local
  (avec validation des énums côté frontend).
- useEffect des stats heuristiques : skip si hasGenerators (le
  simulator alimente directement simulationStats via un nouvel effect).
- handleEmitTrace : règle stricte sur nodeRole=generator. Plus de
  fallback "tout émetteur si aucun particleGeneration" — cohérent avec
  V1 stricte (ADR-006). Sans generator déclaré, le clic ne fait rien
  (geste assumé : le UI doit le signaler en exposant `hasGenerators`).

Tests GraphRenderer3D.test.tsx :
- SAMPLE_PARSE.A reçoit nodeRole='generator' pour rester compatible
  avec la règle stricte (les tests d'émission s'appuient dessus).
- Test "linkDirectionalParticles emits >0 when running" renommé en
  "still returns 0 when running with generators (DES mode)" et inversé :
  vérifie maintenant que la callback retourne 0 quand le simulator
  prend la main, et que emitParticle est appelée à la place via
  onParticleReleased.

Tous les tests passent : 354 frontend (1 modifié, 0 ajouté), lint
clean, coverage particleSimulator/useParticleSimulator maintenu (>90%
lines, >75% branches).
Expose visuellement l'état du simulator DES (Phase 3-4) sur les nœuds
du rendu 3D. Sans cette phase, le simulator tournait correctement
mais visuellement on ne voyait que les particules en transit — pas
l'accumulation, ni la saturation, ni les drops.

GraphRenderer3D.tsx — 4 hooks visuels via les accesseurs 3d-force-graph :

- **Queue growth** : nodeVal((node) => baseSize * (1 + min(1, pending/queue_size)))
  → un nœud vide reste à sa taille de base, un nœud saturé fait 2× sa
  taille. Read live à chaque frame depuis queueStatsByNodeRef.

- **Halo de saturation** : nodeColor retourne orange #ff9800 quand la file
  atteint 80% de queue_size, rouge #d32f2f à 100%. Override la couleur
  utilisateur explicite (priorité saturation > color DOT).

- **Drop flash** : pendant 200 ms après l'incrément de droppedCount,
  nodeColor retourne rouge vif #ff1744. Détection via diff per-tick
  sur la map de droppedCount fournie par simulatorStats.queues. Court
  pour ne pas se confondre avec le halo de saturation soutenu.

- **Role tint léger** : si node.color n'est pas défini, generator → teal
  #80cbc4, sink → indigo #9fa8da, relay → couleur par défaut. Les
  couleurs DOT explicites ne sont jamais écrasées par cette teinte.

Mécanique :
- 3 refs (queueStatsByNodeRef, dropFlashTimeRef, previousDroppedCountRef)
  mises à jour à chaque tick du simulator dans un useEffect dédié.
  Pas de state React pour éviter les rerenders inutiles.
- L'effect re-installe les accesseurs (fg.nodeVal(fg.nodeVal())) à
  chaque tick : 3d-force-graph re-évalue alors nodeVal/nodeColor pour
  tous les nœuds. Pas de rebuild de scène, coût négligeable.
- Le HUD stats reçoit un 4e chip "Drops" qui n'apparaît que quand le
  simulator est en charge (hasGenerators && simulatorStats).

Pas de nouveau test dédié à cette phase. Les comportements sont
purement visuels (couleurs ANSI, scale 3D, frames timing) et seront
validés en Phase 7 via des tests d'intégration sur saturation.dot
qui pourront mesurer le déclenchement réel de halos et flashes. Les
354 tests frontend existants continuent de passer (rien cassé), lint
clean.

Doc :
- frontend/doc/RENDERER.md : ajout d'une section 8.bis détaillant les
  4 hooks visuels et leur mécanique (refs vs state, re-install des
  accesseurs). Checklist PR mise à jour.
Étend le HUD stats du renderer avec deux métriques de session et des
tooltips explicatifs sur chaque chip. Reset automatique aligné sur le
contrat start() du simulator (D5 : stats reset au démarrage).

GraphRenderer3D.tsx :

- simulationStats étend avec maxQueueSize + throughputPerSec.
- Deux nouveaux refs :
  * maxQueueSeenRef : plus grande file constatée toutes nœuds confondus,
    track via Math.max sur simulatorStats.queues à chaque tick.
  * throughputSamplesRef : fenêtre glissante {time, totalEmitted} sur
    les 2 dernières secondes, trimmée par timestamp dans le sync effect.
- Throughput instantané calculé dans le branch effect :
  ((emitted_last - emitted_first) / dtMs) * 1000 — débit en p/s sur
  la fenêtre disponible. Plus précis qu'un rate "depuis le début"
  parce qu'il réagit aux changements (pause/reprise, ralentissement).
- Nouvel effect dédié au reset session-scoped : detect false→true
  edge sur simulationRunning, clear maxQueueSeenRef +
  throughputSamplesRef + previousDroppedCountRef + dropFlashTimeRef.
  Mirror du reset interne du simulator.
- HUD : chaque chip est désormais enveloppé dans un MUI Tooltip avec
  un texte explicatif (la métrique, son périmètre, son unité).
  cursor:help quand le tooltip est défini. Les 3 nouveaux chips
  (Drops, File max, Débit) ne s'affichent que quand le simulator
  est en charge (hasGenerators && simulatorStats) — fallback
  heuristique inchangé pour les graphes legacy.

Pas de nouveaux tests dédiés. La logique est arithmétique pure
(min/max sur des stats déjà testées) et la couverture des effects
de sync est validée en Phase 7 (tests d'intégration end-to-end).
Les 354 tests frontend passent, lint clean.
Nouveau fichier dédié frontend/src/services/particleSimulator.integration.test.ts
qui stresse le simulator sur des topologies réalistes, séparé des
tests unitaires (particleSimulator.test.ts) pour la lisibilité de la
PR et la facilité de naviguer dans la suite Vitest.

Scénarios couverts :

1. Convergence — 3 générateurs (10 p/s chacun) → 1 relay (slot 1,
   processing_time 66ms ≈ 15 p/s) → sink. Vérifie qu'avec 30 p/s en
   entrée et 15 p/s en sortie, la queue grossit et totalArrived <
   totalEmitted. Le déficit cumulé est visible après 3 secondes.

2. Divergence pondérée — 1 générateur (100 p/s) vers 3 sinks avec
   maxParticleFlow 50/30/20. Avec un random cyclique déterministe
   (i/100 pour i=1..100), la distribution est exactement 50/30/20
   sur 100 émissions. Vérifie que l'algorithme de routing pondéré
   respecte les ratios.

3. Cycle (A→B→C→B) — boucle dirigée sans sink dans le cycle. Vérifie
   que tick() ne throw pas pendant 200 ticks de 50ms, que les stats
   restent finies et que totalEmitted > 0. Sert de garde-fou contre
   les régressions où un changement de routing introduirait une
   cascade infinie ou un état corrompu.

4. Saturation — A (100 p/s) → B (queue_size=5, dropPolicy=tail, 5 p/s
   sortie) → sink. Vérifie que la queue plafonne à 5 et que les drops
   sont comptés (totalDropped > 50 sur 3 secondes).

5. Saturation avec dropPolicy=head — variante où la file reste à 3
   particules mais c'est la plus ancienne qui est évacuée à chaque
   nouvelle arrivée.

6. Perf smoke — graphe à 100 nœuds (10 generators / 80 relays / 10
   sinks) et ~180 liens (chaque non-sink fan-out vers 2 successeurs
   déterministes). Mesure 100 ticks de 16.67ms (≈ une seconde de
   simulation à 60 fps). Cible : < 500ms total, soit ~5ms/tick en
   moyenne. Largement assez de marge pour piloter le rAF du browser.

Total : 360 tests frontend (354 + 6 intégration). Coverage du
simulator inchangée (97.11% lines, 94.62% branches) — les tests
d'intégration exercent du code déjà couvert par les tests unitaires
mais valident les combinaisons que les tests unitaires ne couvrent
pas individuellement (convergence, divergence end-to-end avec sink,
etc.).

Pas de nouveaux tests renderer Phase 7. L'apparition des chips HUD
(File max, Débit, Drops) en mode DES dépend du rAF qui tourne et qui
n'est pas naturellement déclenché en jsdom ; le stubber suffisamment
finement pour driver le simulator depuis un test renderer pèserait
plus en complexité que la valeur ajoutée. La chaîne logique end-to-end
est validée par les tests unitaires (hook, simulator) + les tests
d'intégration de scénarios ci-dessus.
Clôture le chantier DES en propageant le statut "livré" dans la doc :

- doc/adr/006-particle-discrete-event-simulation.md : statut passé
  de Proposed → Accepted. Date de référence ajoutée (2026-05-11
  proposed / 2026-05-12 accepted) avec rappel du périmètre couvert
  (11 commits, PR #30, branche feature/des-simulation).

- doc/adr/README.md : index mis à jour avec le nouveau statut.

- doc/dot-3d/user-guide.md : nouvel "Exemple 0 : Pipeline DES avec
  goulot et drops" en tête de la section "Exemples Pratiques
  Complets". Présente le pipeline source→goulot→sink avec les 7
  attributs DES, décrit pas-à-pas ce qui s'affiche à l'écran après
  ▶ (accumulation, halos, flash drop, HUD), et propose 3 variantes
  pour explorer (slots multiples, failure_rate, convergence).

- doc/changelog/2026-05-12.md : entrée du jour résumant l'ensemble
  des changements DES, par fichier touché, avec totaux tests/coverage
  et liste des décisions / non-decisions (cap particules off, tests
  DOM intentionnellement omis, etc.).

CLAUDE.md (gitignored, donc backup versionné dans
~/claude-projet/VortexFlow/) : section "GraphRenderer3D behaviors"
réécrite pour refléter les nouveaux invariants DES (simulator owns
emission, visual hooks via refs, HUD chips gating, fallback heuristique
pour graphes legacy, V1 stricte sur nodeRole pour handleEmitTrace).

Tests : 395 backend + 360 frontend = 755 tests verts au total sur
le chantier. Coverage particleSimulator 97.11% lines / 94.62%
branches, useParticleSimulator 100% lines.
@PPCM PPCM changed the title Phase 0 DES — ADR-006 + spec DOT 3D Discrete Event Simulation (DES) — chantier complet 8 phases May 12, 2026
pierre@redtrash.fr added 4 commits May 13, 2026 10:55
Découvert via test navigateur live : aucun des 3 hooks visuels Phase 5
(grossissement queue, halo saturation, drop flash, role tint) ne
fonctionnait sur les graphes avec `geometry` défini, parce que :

1. Le backend (routes/public.js) appliquait une couleur par défaut
   `#1976D2` à tous les nodes sans color explicite. La condition
   `!node.color` dans resolveNodeColor était donc toujours false →
   le role tint ne s'appliquait jamais.

2. Le DotTo3DConverter frontend forçait aussi `node.color || '#1976D2'`,
   masquant l'absence de couleur user.

3. Pour les nodes avec geometry (3d-sphere, cone, etc.), c'est
   nodeThreeObjectCallback qui crée le mesh. Le moteur 3d-force-graph
   N'APPELLE PAS nodeColor sur ces meshes custom — il les utilise
   tels quels. Le mesh est figé à la couleur initiale.

4. fg.refresh() ne re-évalue pas nodeColor non plus pour les meshes
   custom (vérifié via evaluate_script dans Chrome devtools : la
   callback nodeColor n'est jamais rappelée).

Fix :
- backend/src/routes/public.js : retire les défauts color (#1976D2 sur
  nodes, #888 sur links). On passe through undefined si l'utilisateur
  n'a pas spécifié, et le frontend gère le défaut.
- frontend DotTo3DConverter : idem (color: node.color au lieu de
  node.color || '#1976D2').
- GraphRenderer3D :
  * Extraction de resolveNodeColor + resolveNodeScale en helpers
    useCallback (réutilisés par 4 endroits).
  * nodeColor accessor utilise resolveNodeColor (pour les nodes sans
    geometry, où le moteur appelle bien la callback).
  * nodeThreeObjectCallback utilise resolveNodeColor à l'init du mesh
    custom — pour que le role tint s'applique dès le baseline (avant
    démarrage de la simulation).
  * useEffect sync simulatorStats : remplace fg.refresh() (inopérant)
    par une mutation directe de material.color + Group.scale via
    node.__threeObj. Walk les 3 meshes et applique resolveNodeColor +
    resolveNodeScale. Cheap (max N nodes par tick).

Test navigateur (chrome-devtools MCP) sur un graphe saturation.dot
sans couleurs explicites :
- FastSource (generator) : #80cbc4 teal ✓
- Bottleneck (relay, queue 5/5) : #d32f2f rouge saturé, scale 2.0 ✓
- Sink (sink) : #9fa8da indigo ✓
- Flash drop : #ff1744 détecté ~85% du temps sur 13 samples en 1.5s
  (cohérent : ~10 drops/s, fenêtre 200ms se chevauche)
- HUD : Drops 101, File max 5, Débit 6/s — tous corrects

Tests : 360/360 frontend verts, lint clean. Test DotTo3DConverter
adapté (le backend ne renvoie plus la couleur par défaut → vérification
de `color: undefined` au lieu de `'#1976D2'`).

Note : `start-vortexflow.sh` lance `node server.js` sans nodemon,
donc toute modif backend nécessite un redémarrage manuel du script.
Problème observé en test navigateur sur la page d'accueil non-connecté :

- L'endpoint /api/auth/session répondait 401 quand le user n'était pas
  connecté, ce qui est sémantiquement correct mais opérationnellement
  bruyant : l'AuthContext frontend probe cet endpoint au mount de
  chaque page (publique ou privée), et l'axios interceptor logue tous
  les non-2xx comme erreurs. Résultat : 6 erreurs console à chaque
  affichage de /login (401 × 2 doublé par React.StrictMode + AxiosError
  × 2 + "Authentification requise" × 2).

Fix : /auth/session devient une probe "soft" qui répond toujours 200,
avec `{ authenticated: false, user: null }` quand pas de session ou
user inactif, et `{ authenticated: true, user: {...} }` sinon. Le
frontend (getCurrentUser dans api.ts) lit déjà `response.data.authenticated`
pour décider — pas de changement frontend nécessaire.

Sémantiquement c'est plus juste : "y a-t-il une session ?" est une
question, pas une opération à protéger. L'absence de session n'est
pas une erreur.

Tests intégration auth.test.js (3 cas pour /session) :
- 200 authenticated:false quand pas de session (anciennement 401)
- 200 authenticated:false quand user inactif (nouveau cas)
- 200 authenticated:true + payload user quand session valide (inchangé)

Backend : 396 tests verts (un test additionnel pour user inactif).

Note : la lenteur de premier chargement (~4s LCP) constatée en parallèle
est due à Vite dev qui transforme 40+ modules à la volée. En prod build
(npm run build) le LCP retombe sous la seconde. Pas un bug applicatif.
Couvre les 3 nodeRoles (generator/relay/sink), les 5 géométries 3D
(Sphere/Box/Cylinder/Cone/Torus), les 2 dropPolicies (tail/head),
failure_rate, routage maxParticleFlow pondéré 60/30/10, particleSpeed
variable, saturation de file et attributs legacy bandwidth/capacity/latency.
Le MenuItem du menu kebab appelait handleMenuClose() — qui null-ait
selectedGraphId — avant d'ouvrir le dialogue de confirmation. Au clic du
bouton du dialogue, la garde `if (selectedGraphId)` échouait et aucune
requête API n'était émise (effet visible: le bouton "scintille" mais
rien ne se passe). On ne ferme plus que l'ancre du menu, la sélection
reste posée pour le dialogue.

2 tests de régression couvrent les flows Supprimer et Dupliquer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant