diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index d62c11a..00c8335 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -222,14 +222,25 @@ router.post('/logout', /** * GET /api/auth/session - * Get current session info + * Probe the current session state. Always responds with HTTP 200 — the + * absence of a session is a legitimate state, not an error. Clients + * (frontend AuthContext) read `authenticated` to decide whether to + * surface a logged-in UI or redirect to the login page. + * + * Returning 401 here would force the frontend to console.error on every + * unauthenticated page load, polluting logs and dev tools without + * carrying real information. */ router.get('/session', - validateSession, asyncHandler(async (req, res) => { - const user = req.user; - - res.json({ + if (!req.session || !req.session.userId) { + return res.json({ authenticated: false, user: null }); + } + const user = await User.findByPk(req.session.userId); + if (!user || !user.is_active) { + return res.json({ authenticated: false, user: null }); + } + return res.json({ authenticated: true, user: { id: user.id, diff --git a/backend/src/routes/public.js b/backend/src/routes/public.js index 3b3ff2e..0ed61aa 100644 --- a/backend/src/routes/public.js +++ b/backend/src/routes/public.js @@ -183,21 +183,31 @@ router.post('/parse-dot', label: node.attributes.label || node.id, name: node.attributes.label || node.id, size: node.attributes.val || node.attributes.size || '8', - color: node.attributes.color || '#1976D2', + // Pass color through only when the user actually specified it in DOT. + // The frontend applies a default (and role-based tint for DES graphs), + // so we must not paper over the user's intent here. + color: node.attributes.color, geometry: node.attributes.geometry, dimensions: node.attributes.dimensions, particleGeneration: node.attributes.particleGeneration, maxParticleProcessing: node.attributes.maxParticleProcessing, image: node.attributes.image, autoResize: node.attributes.autoResize, - bloomEffect: node.attributes.bloomEffect + bloomEffect: node.attributes.bloomEffect, + // DES attributes (ADR-006) — consumed by the in-browser ParticleSimulator + nodeRole: node.attributes.nodeRole, + dropPolicy: node.attributes.dropPolicy, + queue_size: node.attributes.queue_size, + processing_time: node.attributes.processing_time, + failure_rate: node.attributes.failure_rate })); - + const links = parseResult.ast.edges.map(edge => ({ source: edge.from, target: edge.to, label: edge.attributes.label || '', - color: edge.attributes.color || '#888', + // Same reasoning as for node.color: pass through only when set. + color: edge.attributes.color, maxParticleFlow: edge.attributes.maxParticleFlow, particleSpeed: edge.attributes.particleSpeed, style: edge.attributes.style || 'solid' diff --git a/backend/src/utils/dotValidator.js b/backend/src/utils/dotValidator.js index e1322ab..99ac57d 100644 --- a/backend/src/utils/dotValidator.js +++ b/backend/src/utils/dotValidator.js @@ -19,9 +19,15 @@ class DotValidator { // VortexFlow 3D extensions 'geometry', 'dimensions', 'particleGeneration', 'maxParticleProcessing', 'particleSpeed', 'maxParticleFlow', 'image', 'autoResize', - 'bloomEffect', 'particlesEnabled', 'autoColors', 'defaultNodeSize' + 'bloomEffect', 'particlesEnabled', 'autoColors', 'defaultNodeSize', + // DES (Discrete Event Simulation) attributes — ADR-006 + 'nodeRole', 'dropPolicy' ]); + // DES enums (ADR-006) + this.nodeRoles = new Set(['generator', 'relay', 'sink']); + this.dropPolicies = new Set(['tail', 'head', 'reject']); + this.nodeShapes = new Set([ // Standard DOT shapes 'box', 'circle', 'ellipse', 'point', 'egg', 'triangle', @@ -118,6 +124,10 @@ class DotValidator { result.metadata.hasVortexFlowExtensions = extensionResult.hasExtensions; result.warnings.push(...extensionResult.warnings); + // DES coherence checks (ADR-006) — cross-attribute warnings on the parsed AST + const coherenceResult = this.validateDESCoherence(parseResult.ast); + result.warnings.push(...coherenceResult.warnings); + // Performance warnings this.addPerformanceWarnings(result); @@ -571,7 +581,9 @@ class DotValidator { const vortexFlow3DAttrs = [ 'geometry', 'dimensions', 'particleGeneration', 'maxParticleProcessing', 'particleSpeed', 'maxParticleFlow', 'image', 'autoResize', - 'bloomEffect', 'particlesEnabled', 'autoColors', 'defaultNodeSize' + 'bloomEffect', 'particlesEnabled', 'autoColors', 'defaultNodeSize', + // DES — ADR-006 + 'nodeRole', 'dropPolicy' ]; // Check legacy attributes @@ -710,6 +722,60 @@ class DotValidator { result.warnings.push(`Image path "${value}" may not be a valid image file`); } break; + + case 'nodeRole': + if (!this.nodeRoles.has(value)) { + result.warnings.push( + `Invalid nodeRole: "${value}". Valid roles: ${Array.from(this.nodeRoles).join(', ')}` + ); + } + break; + + case 'dropPolicy': + if (!this.dropPolicies.has(value)) { + result.warnings.push( + `Invalid dropPolicy: "${value}". Valid policies: ${Array.from(this.dropPolicies).join(', ')}` + ); + } + break; + } + } + + return result; + } + + /** + * Validate cross-attribute coherence for DES (ADR-006). + * + * These are warnings, not errors — the graph is still accepted, but + * the validator surfaces the inconsistency so the user understands why + * the runtime ignores a given attribute. + */ + validateDESCoherence(ast) { + const result = { warnings: [] }; + + for (const node of ast.nodes) { + const attrs = node.attributes || {}; + const role = attrs.nodeRole; + const dropPolicy = attrs.dropPolicy; + const queueSize = attrs.queue_size; + const particleGeneration = attrs.particleGeneration; + + // dropPolicy without queue_size: meaningless (queue is unbounded) + if (dropPolicy !== undefined && queueSize === undefined) { + result.warnings.push( + `Node "${node.id}": dropPolicy="${dropPolicy}" has no effect without queue_size — the queue is unbounded and never drops.` + ); + } + + // particleGeneration > 0 on a non-generator role: ignored at runtime + if (particleGeneration !== undefined && role !== undefined && role !== 'generator') { + const numGen = parseFloat(particleGeneration); + if (!Number.isNaN(numGen) && numGen > 0) { + result.warnings.push( + `Node "${node.id}": particleGeneration=${particleGeneration} is ignored because nodeRole="${role}" (only "generator" emits).` + ); + } } } diff --git a/backend/src/utils/setup.js b/backend/src/utils/setup.js index 7de81a3..21ffe10 100644 --- a/backend/src/utils/setup.js +++ b/backend/src/utils/setup.js @@ -113,108 +113,174 @@ const createSampleData = async () => { return; } - // Create sample graphs + // VortexFlow Showcase — single demo graph exercising every documented + // capability: the three nodeRoles (generator / relay / sink), all five 3D + // geometries (Sphere / Box / Cylinder / Cone / Torus), both dropPolicies + // (tail / head), failure_rate, weighted maxParticleFlow routing, variable + // particleSpeed, queue saturation and legacy attributes (bandwidth / + // capacity / latency). Used as the out-of-the-box demo when an admin + // first opens the app. const sampleGraphs = [ { - title: 'Simple Network Flow', - description: 'A basic network topology with routers and servers', - dot_code: `digraph NetworkFlow { + title: 'VortexFlow Showcase', + description: 'Démonstration complète : générateurs, relais, sinks, ' + + 'les 5 géométries 3D, drop policies, failure_rate, routage pondéré ' + + 'et saturation de file. Le pipeline simule un flux IoT : capteurs ' + + '→ load balancer → cluster de processeurs (un instable, un lent) ' + + '→ cache mémoire → archive rapide / analytics avec filtre saturé.', + dot_code: `digraph VortexFlowShowcase { rankdir=LR; - - // Nodes with simulation properties - Router1 [label="Router A", capacity=100, processing_time=1.0, node_type="router"] - Server1 [label="Web Server", capacity=50, processing_time=2.0, node_type="server"] - Server2 [label="Database", capacity=200, processing_time=0.5, node_type="database"] - - // Edges with flow properties - Router1 -> Server1 [bandwidth=10, latency=0.2, data_type="http_requests"] - Server1 -> Server2 [bandwidth=5, latency=0.1, data_type="db_queries"] + defaultNodeSize=1.2; + particlesEnabled=true; + autoColors=false; + + // --------------------------------------------------------------- + // Generators — IoT sensors emitting telemetry packets (Cone) + // --------------------------------------------------------------- + SensorA [ + label="Capteur A", + nodeRole="generator", + particleGeneration=3, + geometry="Cone", + dimensions="{radius: 0.8, height: 1.6}", + color="#ff6b35" + ]; + + SensorB [ + label="Capteur B", + nodeRole="generator", + particleGeneration=2, + geometry="Cone", + dimensions="{radius: 0.8, height: 1.6}", + color="#ffa726" + ]; + + // --------------------------------------------------------------- + // Load balancer — weighted dispatch (Torus ring) + // --------------------------------------------------------------- + LoadBalancer [ + label="Load Balancer", + nodeRole="relay", + maxParticleProcessing=20, + queue_size=50, + processing_time=100, + geometry="Torus", + dimensions="{radius: 1.2, tube: 0.4}", + color="#42a5f5" + ]; + + // --------------------------------------------------------------- + // Compute cluster — three parallel processors (Cylinder). + // P1 = nominal, P2 = unstable (failure_rate), P3 = slow goulot. + // --------------------------------------------------------------- + Processor1 [ + label="Processeur P1", + nodeRole="relay", + maxParticleProcessing=5, + queue_size=20, + processing_time=300, + geometry="Cylinder", + dimensions="{radius: 0.7, height: 1.8}", + color="#66bb6a" + ]; + + Processor2 [ + label="Processeur P2 (instable)", + nodeRole="relay", + maxParticleProcessing=4, + queue_size=15, + processing_time=400, + failure_rate=0.25, + geometry="Cylinder", + dimensions="{radius: 0.7, height: 1.8}", + color="#ef5350" + ]; + + Processor3 [ + label="Processeur P3 (lent)", + nodeRole="relay", + maxParticleProcessing=1, + queue_size=10, + processing_time=800, + geometry="Cylinder", + dimensions="{radius: 0.7, height: 1.8}", + color="#ab47bc" + ]; + + // --------------------------------------------------------------- + // In-memory cache — fast aggregator (Sphere) + // --------------------------------------------------------------- + Cache [ + label="Cache mémoire", + nodeRole="relay", + maxParticleProcessing=30, + queue_size=100, + processing_time=50, + geometry="Sphere", + dimensions="{radius: 1.0}", + color="#26c6da" + ]; + + // --------------------------------------------------------------- + // Analytics filter — saturated relay, dropPolicy="tail" + // --------------------------------------------------------------- + Filter [ + label="Filtre Analytics", + nodeRole="relay", + maxParticleProcessing=2, + queue_size=5, + dropPolicy="tail", + processing_time=1500, + geometry="Cylinder", + dimensions="{radius: 0.6, height: 1.4}", + color="#ffca28" + ]; + + // --------------------------------------------------------------- + // Sinks — terminal storage (Box). Analytics also demos dropPolicy="head". + // --------------------------------------------------------------- + Archive [ + label="Archive S3", + nodeRole="sink", + geometry="Box", + dimensions="{width: 2.0, height: 1.4, depth: 1.4}", + color="#5c6bc0" + ]; + + Analytics [ + label="Analytics DB", + nodeRole="sink", + queue_size=10, + dropPolicy="head", + geometry="Box", + dimensions="{width: 2.0, height: 1.4, depth: 1.4}", + color="#7e57c2" + ]; + + // --------------------------------------------------------------- + // Edges — bandwidth/latency/capacity legacy attrs + DES particleSpeed + // + maxParticleFlow weighting (60/30/10 split downstream of LB). + // --------------------------------------------------------------- + SensorA -> LoadBalancer [label="3 p/s", particleSpeed=1.5, bandwidth=10, latency=0.05, color="#ff6b35"]; + SensorB -> LoadBalancer [label="2 p/s", particleSpeed=1.5, bandwidth=10, latency=0.05, color="#ffa726"]; + + LoadBalancer -> Processor1 [label="60%", maxParticleFlow=6, particleSpeed=1.2, capacity=60, color="#66bb6a"]; + LoadBalancer -> Processor2 [label="30%", maxParticleFlow=3, particleSpeed=1.2, capacity=30, color="#ef5350"]; + LoadBalancer -> Processor3 [label="10%", maxParticleFlow=1, particleSpeed=1.2, capacity=10, color="#ab47bc"]; + + Processor1 -> Cache [particleSpeed=2.0, bandwidth=20, color="#66bb6a"]; + Processor2 -> Cache [particleSpeed=2.0, bandwidth=20, color="#ef5350"]; + Processor3 -> Cache [particleSpeed=2.0, bandwidth=20, color="#ab47bc"]; + + Cache -> Archive [label="hot path", maxParticleFlow=10, particleSpeed=2.5, bandwidth=40, color="#26c6da"]; + Cache -> Filter [label="cold path", maxParticleFlow=4, particleSpeed=0.8, bandwidth=8, color="#ffca28", style="dashed"]; + Filter -> Analytics [particleSpeed=1.0, bandwidth=4, color="#ffca28"]; }`, is_public: true, - tags: ['network', 'example', 'simple'], - category: 'Network', + tags: ['showcase', 'demo', 'des', '3d', 'iot'], + category: 'Showcase', is_template: true, - template_category: 'Network Topology' - }, - { - title: 'Data Pipeline Simulation', - description: 'Complex data processing pipeline with multiple stages', - dot_code: `digraph DataPipeline { - rankdir=TB; - - // Data Sources - DataSource1 [label="Raw Data\\nIngestion", capacity=1000, processing_time=0.1, node_type="source"] - DataSource2 [label="Stream Data\\nIngestion", capacity=500, processing_time=0.05, node_type="source"] - - // Processing Stages - Cleaner [label="Data Cleaner", capacity=200, processing_time=2.0, node_type="processor"] - Transformer [label="Transformer", capacity=150, processing_time=3.0, node_type="processor"] - Aggregator [label="Aggregator", capacity=100, processing_time=1.5, node_type="processor"] - - // Storage - DataLake [label="Data Lake", capacity=10000, processing_time=0.2, node_type="storage"] - DataWarehouse [label="Data Warehouse", capacity=5000, processing_time=0.5, node_type="storage"] - - // Flows - DataSource1 -> Cleaner [bandwidth=50, latency=0.1, data_type="raw_data"] - DataSource2 -> Cleaner [bandwidth=25, latency=0.1, data_type="stream_data"] - Cleaner -> Transformer [bandwidth=30, latency=0.2, data_type="clean_data"] - Transformer -> Aggregator [bandwidth=20, latency=0.2, data_type="transformed_data"] - Transformer -> DataLake [bandwidth=40, latency=0.1, data_type="processed_data"] - Aggregator -> DataWarehouse [bandwidth=15, latency=0.3, data_type="aggregated_data"] -}`, - is_public: true, - tags: ['data', 'pipeline', 'etl', 'complex'], - category: 'Data Processing', - is_template: true, - template_category: 'Data Pipeline' - }, - { - title: 'Microservices Architecture', - description: 'Microservices communication pattern with API gateway', - dot_code: `digraph Microservices { - rankdir=TB; - - // Client and Gateway - Client [label="Client App", capacity=0, processing_time=0, node_type="client"] - Gateway [label="API Gateway", capacity=500, processing_time=0.1, node_type="gateway"] - - // Services - AuthService [label="Auth Service", capacity=100, processing_time=0.5, node_type="service"] - UserService [label="User Service", capacity=80, processing_time=1.0, node_type="service"] - OrderService [label="Order Service", capacity=60, processing_time=1.5, node_type="service"] - PaymentService [label="Payment Service", capacity=40, processing_time=2.0, node_type="service"] - - // Databases - AuthDB [label="Auth DB", capacity=1000, processing_time=0.1, node_type="database"] - UserDB [label="User DB", capacity=1000, processing_time=0.1, node_type="database"] - OrderDB [label="Order DB", capacity=800, processing_time=0.2, node_type="database"] - PaymentDB [label="Payment DB", capacity=500, processing_time=0.3, node_type="database"] - - // Client flows - Client -> Gateway [bandwidth=20, latency=0.05, data_type="api_requests"] - - // Gateway to services - Gateway -> AuthService [bandwidth=5, latency=0.02, data_type="auth_requests"] - Gateway -> UserService [bandwidth=8, latency=0.02, data_type="user_requests"] - Gateway -> OrderService [bandwidth=6, latency=0.02, data_type="order_requests"] - Gateway -> PaymentService [bandwidth=3, latency=0.02, data_type="payment_requests"] - - // Service to database flows - AuthService -> AuthDB [bandwidth=5, latency=0.01, data_type="auth_queries"] - UserService -> UserDB [bandwidth=8, latency=0.01, data_type="user_queries"] - OrderService -> OrderDB [bandwidth=6, latency=0.01, data_type="order_queries"] - PaymentService -> PaymentDB [bandwidth=3, latency=0.01, data_type="payment_queries"] - - // Inter-service communication - OrderService -> UserService [bandwidth=2, latency=0.05, data_type="user_validation"] - OrderService -> PaymentService [bandwidth=2, latency=0.05, data_type="payment_processing"] -}`, - is_public: true, - tags: ['microservices', 'architecture', 'api', 'distributed'], - category: 'Architecture', - is_template: true, - template_category: 'System Architecture' + template_category: 'Demo' } ]; diff --git a/backend/tests/integration/routes/auth.test.js b/backend/tests/integration/routes/auth.test.js index c74b959..002cb6f 100644 --- a/backend/tests/integration/routes/auth.test.js +++ b/backend/tests/integration/routes/auth.test.js @@ -198,10 +198,23 @@ describe('POST /api/auth/logout', () => { // GET /api/auth/session // ---------------------------------------------------------------------------- describe('GET /api/auth/session', () => { - test('401 without session', async () => { + test('200 with authenticated=false when there is no session', async () => { + // The probe must NOT 401: the frontend hits it on every public page + // load (login/register) and we don't want a console error on those. const app = buildTestApp(authRoutes, '/api/auth', { session: null }); const res = await request(app).get('/api/auth/session'); - expect(res.status).toBe(401); + expect(res.status).toBe(200); + expect(res.body).toEqual({ authenticated: false, user: null }); + }); + + test('200 with authenticated=false when the user is inactive', async () => { + mockFindByPk.mockResolvedValue({ ...makeUser(), is_active: false }); + const app = buildTestApp(authRoutes, '/api/auth', { + session: { userId: 'user-1' }, + }); + const res = await request(app).get('/api/auth/session'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ authenticated: false, user: null }); }); test('200 with the current user payload when authenticated', async () => { diff --git a/backend/tests/unit/utils/dotValidator.test.js b/backend/tests/unit/utils/dotValidator.test.js index 6ccd2ad..823eb80 100644 --- a/backend/tests/unit/utils/dotValidator.test.js +++ b/backend/tests/unit/utils/dotValidator.test.js @@ -375,4 +375,121 @@ describe('dotValidator', () => { expect(r.metadata.hasVortexFlowExtensions).toBe(false); }); }); + + // ----- DES attributes: nodeRole, dropPolicy (ADR-006) ----- + describe('DES attributes (ADR-006)', () => { + describe('nodeRole', () => { + test.each(['generator', 'relay', 'sink'])('accepts nodeRole="%s"', async (role) => { + const dot = `digraph G { A [nodeRole="${role}"] B A -> B }`; + const r = await validator.validate(dot); + expect(r.valid).toBe(true); + expect(r.warnings.some((w) => /Invalid nodeRole/.test(w))).toBe(false); + }); + + test('warns on unknown nodeRole', async () => { + const dot = 'digraph G { A [nodeRole="emitter"] B A -> B }'; + const r = await validator.validate(dot); + expect(r.warnings.some((w) => /Invalid nodeRole.*emitter/.test(w))).toBe(true); + }); + + test('marks the graph as having extensions', async () => { + const dot = 'digraph G { A [nodeRole="generator"] B A -> B }'; + const r = await validator.validate(dot); + expect(r.metadata.hasVortexFlowExtensions).toBe(true); + }); + }); + + describe('dropPolicy', () => { + test.each(['tail', 'head', 'reject'])('accepts dropPolicy="%s"', async (policy) => { + const dot = `digraph G { A [queue_size=10, dropPolicy="${policy}"] B A -> B }`; + const r = await validator.validate(dot); + expect(r.valid).toBe(true); + expect(r.warnings.some((w) => /Invalid dropPolicy/.test(w))).toBe(false); + }); + + test('warns on unknown dropPolicy', async () => { + const dot = 'digraph G { A [queue_size=10, dropPolicy="random"] B A -> B }'; + const r = await validator.validate(dot); + expect(r.warnings.some((w) => /Invalid dropPolicy.*random/.test(w))).toBe(true); + }); + + test('marks the graph as having extensions', async () => { + const dot = 'digraph G { A [queue_size=5, dropPolicy="tail"] B A -> B }'; + const r = await validator.validate(dot); + expect(r.metadata.hasVortexFlowExtensions).toBe(true); + }); + }); + + describe('coherence warnings', () => { + test('warns when dropPolicy is set without queue_size', async () => { + const dot = 'digraph G { A [dropPolicy="tail"] B A -> B }'; + const r = await validator.validate(dot); + expect( + r.warnings.some((w) => /dropPolicy.*no effect.*queue_size/.test(w)) + ).toBe(true); + }); + + test('does not warn when dropPolicy is set with queue_size', async () => { + const dot = 'digraph G { A [queue_size=10, dropPolicy="tail"] B A -> B }'; + const r = await validator.validate(dot); + expect( + r.warnings.some((w) => /dropPolicy.*no effect.*queue_size/.test(w)) + ).toBe(false); + }); + + test('warns when particleGeneration > 0 on a relay node', async () => { + const dot = 'digraph G { A [nodeRole="relay", particleGeneration=5] B A -> B }'; + const r = await validator.validate(dot); + expect( + r.warnings.some((w) => /particleGeneration.*ignored.*nodeRole="relay"/.test(w)) + ).toBe(true); + }); + + test('warns when particleGeneration > 0 on a sink node', async () => { + const dot = 'digraph G { A [nodeRole="sink", particleGeneration=5] B A -> B }'; + const r = await validator.validate(dot); + expect( + r.warnings.some((w) => /particleGeneration.*ignored.*nodeRole="sink"/.test(w)) + ).toBe(true); + }); + + test('does not warn when particleGeneration > 0 on a generator node', async () => { + const dot = 'digraph G { A [nodeRole="generator", particleGeneration=5] B A -> B }'; + const r = await validator.validate(dot); + expect( + r.warnings.some((w) => /particleGeneration.*ignored/.test(w)) + ).toBe(false); + }); + + test('does not warn when particleGeneration=0 on a relay node', async () => { + const dot = 'digraph G { A [nodeRole="relay", particleGeneration=0] B A -> B }'; + const r = await validator.validate(dot); + expect( + r.warnings.some((w) => /particleGeneration.*ignored/.test(w)) + ).toBe(false); + }); + + test('does not warn when only nodeRole is set with no particleGeneration', async () => { + const dot = 'digraph G { A [nodeRole="relay"] B A -> B }'; + const r = await validator.validate(dot); + expect( + r.warnings.some((w) => /particleGeneration.*ignored/.test(w)) + ).toBe(false); + }); + }); + + describe('existing simulation attributes still validate as before', () => { + test('failure_rate outside [0, 1] still warns', async () => { + const dot = 'digraph G { A [failure_rate=1.5] B A -> B }'; + const r = await validator.validate(dot); + expect(r.warnings.some((w) => /Invalid failure_rate/.test(w))).toBe(true); + }); + + test('queue_size negative still warns', async () => { + const dot = 'digraph G { A [queue_size=-5] B A -> B }'; + const r = await validator.validate(dot); + expect(r.warnings.some((w) => /Invalid queue_size/.test(w))).toBe(true); + }); + }); + }); }); diff --git a/backend/tests/unit/utils/setup.test.js b/backend/tests/unit/utils/setup.test.js index b8b024c..ecbf92d 100644 --- a/backend/tests/unit/utils/setup.test.js +++ b/backend/tests/unit/utils/setup.test.js @@ -173,8 +173,8 @@ describe('createSampleData', () => { await setupModule.createSampleData(); expect(Graph.create).toHaveBeenCalled(); - // The helper hardcodes 3 sample graphs. - expect(Graph.create.mock.calls.length).toBeGreaterThanOrEqual(3); + // The helper now seeds a single "VortexFlow Showcase" demo graph. + expect(Graph.create.mock.calls.length).toBeGreaterThanOrEqual(1); }); test('swallows errors instead of throwing (sample data is optional)', async () => { diff --git a/doc/adr/006-particle-discrete-event-simulation.md b/doc/adr/006-particle-discrete-event-simulation.md new file mode 100644 index 0000000..41eafbd --- /dev/null +++ b/doc/adr/006-particle-discrete-event-simulation.md @@ -0,0 +1,135 @@ +# ADR-006: Particle simulation moves from continuous animation to discrete event simulation (DES) + +- **Status:** Accepted +- **Date:** 2026-05-11 (Proposed) / 2026-05-12 (Accepted — implementation complete, 11 commits on `feature/des-simulation`, PR #30) +- **Tags:** frontend, simulation, dot, renderer, performance + +## Context + +VortexFlow currently renders particles via `3d-force-graph`'s +`linkDirectionalParticles(link)`, which paints a fixed number of particles +continuously circulating along each link as long as the simulation is +running. This produces a pleasing animation but has structural limits: + +- Each link animates **independently**. There is no notion of a particle + arriving at a node and being routed onward. +- Nodes cannot **accumulate** particles. A bottleneck (1 input ≫ 1 output) + is not visible — every link displays its own steady flow. +- The DOT 3D spec defines `queue_size`, `processing_time`, `failure_rate`, + `maxParticleProcessing` (per node) and `maxParticleFlow` (per link), + but the runtime ignores them. They live only in the validator. +- `simulationStats.totalParticles / averageLatency / bottleneckNodes` + are derived heuristically (in-degree, scene particle count) rather + than measured. They look right, but they don't _mean_ anything. +- The one-shot trace (`handleEmitTrace`) already approximates a discrete + cascade — it tracks particles per node via `setTimeout` and a visited + set — but it's a separate code path that bypasses the continuous mode. + +We need a simulation model where: + +1. Specific nodes **emit** particles at a defined rate. +2. Particles **transit** along links at link-specific speeds. +3. Nodes receive particles into a **queue**, optionally bounded. +4. Queues that overflow **drop** particles according to a configurable + policy. +5. The renderer visualizes accumulation (node grows) and saturation + (flash / counter on drop). + +## Decision + +Replace the continuous animation model with a **discrete event simulation +(DES)** executed in the browser: + +- A new `ParticleSimulator` service owns the logical state (particles in + transit, per-node queues, per-node stats). +- The simulator advances on every `requestAnimationFrame`, with a clamped + `dt` to tolerate background-tab throttling. +- `3d-force-graph` is kept as the **renderer**, but is driven by the + simulator: `emitParticle(link)` is called whenever the simulator + releases a particle onto a link. `linkDirectionalParticles(link)` + returns 0 — continuous animation is off. +- The DOT 3D surface gains two new attributes (see ADR-005 triple invariant): + - **`nodeRole`** (enum: `generator | relay | sink`, default `relay`) — + role-based emission. Only `generator` nodes spawn particles. `sink` + nodes absorb particles without further routing. **V1 is strict**: + no implicit fallback "everyone emits" — graphs without `nodeRole` + will not animate until annotated. + - **`dropPolicy`** (enum: `tail | head | reject`, default `tail`) — + behaviour when a particle arrives at a node whose queue is full. + Only meaningful when `queue_size` is also defined. +- Existing attributes (`particleGeneration`, `maxParticleProcessing`, + `queue_size`, `processing_time`, `failure_rate`, `maxParticleFlow`, + `particleSpeed`) keep their names and gain real runtime semantics. + +Concretely, this means: + +- The simulator is the **single source of truth** for `totalParticles`, + `averageLatency`, `bottleneckNodes`, and a new `droppedCount`. +- Nodes visually grow with queue occupancy (capped at 2× base size). +- A drop triggers a brief red flash plus an incrementing on-node counter. +- The one-shot `handleEmitTrace` is realigned: it respects `nodeRole` + too, so the one-shot button always fires from the same set of + generators that the continuous simulation does. +- Routing at a node with M outgoing links: weighted by `maxParticleFlow`, + with round-robin as fallback when no weights are defined. +- Default values when an attribute is missing on a node where it would + matter: + - `nodeRole=generator` without `particleGeneration` → 1 particle/sec + - `queue_size` undefined → unbounded queue (no drops) + - `dropPolicy` undefined → `tail` (drops the incoming particle) + - `failure_rate` undefined → 0 (no random drops) + +## Consequences + +Positive: + +- **Semantics finally match the spec**. The DOT 3D attributes that + already existed but were ignored at runtime now have a behaviour. +- **Real metrics**: queue sizes, drops, throughput, latency are measured, + not guessed. The HUD stats become meaningful. +- **Bottleneck visualization**: visible immediately on overload, not + inferred from topology. +- **Convergence and divergence look right**: M inputs → 1 node → 1 output + shows accumulation; 1 input → 1 node → M outputs shows distribution + weighted by `maxParticleFlow`. +- **One source of decision** for who emits (single rule in the simulator + consumed by both continuous and one-shot paths). + +Negative: + +- **Breaking change for existing graphs**: graphs without `nodeRole` + will not emit. They must be annotated. We chose strict V1 over + silent fallback to keep the model honest. Migration cost is real but + contained — `nodeRole=generator` is a one-line addition per emitter. +- **CPU cost**: a JS loop that advances N particles 60 times per second + is heavier than letting WebGL animate fixed-rate streams. Mitigations + (RAF throttle, optional Web Worker) listed in Phase 7 of the + implementation plan. +- **Renderer file is already ~1600 lines**. The integration adds a hook + (`useParticleSimulator`) and removes the `linkDirectionalParticles` + branch, so the net delta should be neutral, but the file is fragile. +- **Three-place invariant (ADR-005) must be honoured** for two new + attributes. Triple update validated in Phase 1. + +## Alternatives considered + +- **Option A — visual-only indicators** (halo / size based on theoretical + imbalance, without changing the simulation). Cheap (~0.5d) and pretty, + but does not actually simulate accumulation or drops — the spec + attributes stay ignored, the stats stay heuristic. Rejected as a + long-term direction. +- **Option B — modulated continuous flow** (adjust + `linkDirectionalParticles` dynamically based on graph topology). + Better than A but still statistical: individual particles don't + travel — the visual is a distribution. Cannot model `queue_size`, + `processing_time`, `failure_rate`. Rejected as the target, but + acceptable as a stopgap if DES schedule slips. +- **Server-driven simulation** (re-activate `routes/simulation.js`). + Rejected: ADR-002 already decided to run simulation in the browser + for latency and offline support. Re-opening that decision is out of + scope. +- **Implicit fallback "everyone emits" when `nodeRole` is missing**. + Considered for backwards-compatibility, rejected. The rule must be + observable from the graph source; magic defaults make graphs behave + differently depending on what other attributes are present, which + is exactly the kind of surprise we want to avoid. diff --git a/doc/adr/README.md b/doc/adr/README.md index 6aed813..1267b29 100644 --- a/doc/adr/README.md +++ b/doc/adr/README.md @@ -17,13 +17,14 @@ We use a lightweight MADR-inspired format: ## Index -| # | Title | Status | -|---|---|---| -| [001](./001-redis-session-store.md) | Use Redis-backed `express-session` instead of JWTs | Accepted | -| [002](./002-browser-side-simulation.md) | Run particle simulation entirely in the browser | Accepted | -| [003](./003-vite-over-cra.md) | Migrate frontend build from CRA to Vite | Accepted | -| [004](./004-sequelize-sync-vs-migrations.md) | Use `sequelize.sync({alter})` in dev; migrations as a planned remediation | Transitional | -| [005](./005-dot-3d-triple-invariant.md) | DOT 3D extensions live in three places that must stay in sync | Accepted | +| # | Title | Status | +| -------------------------------------------------- | ------------------------------------------------------------------------- | ------------ | +| [001](./001-redis-session-store.md) | Use Redis-backed `express-session` instead of JWTs | Accepted | +| [002](./002-browser-side-simulation.md) | Run particle simulation entirely in the browser | Accepted | +| [003](./003-vite-over-cra.md) | Migrate frontend build from CRA to Vite | Accepted | +| [004](./004-sequelize-sync-vs-migrations.md) | Use `sequelize.sync({alter})` in dev; migrations as a planned remediation | Transitional | +| [005](./005-dot-3d-triple-invariant.md) | DOT 3D extensions live in three places that must stay in sync | Accepted | +| [006](./006-particle-discrete-event-simulation.md) | Particle simulation moves from continuous animation to DES | Accepted | ## Writing a new ADR diff --git a/doc/changelog/2026-05-12.md b/doc/changelog/2026-05-12.md new file mode 100644 index 0000000..a95cd1d --- /dev/null +++ b/doc/changelog/2026-05-12.md @@ -0,0 +1,116 @@ +# Changelog - 2026-05-12 + +## Summary + +Suite de la session du 2026-05-11 sur le chantier **Discrete Event +Simulation (DES)** des particules (ADR-006). Phases 3 → 8 finalisées +sur la branche `feature/des-simulation` (PR #30, 12 commits au total). + +Le chantier remplace le rendu continu de `linkDirectionalParticles` +par un simulator événementiel piloté par les attributs DOT +(`nodeRole`, `particleGeneration`, `queue_size`, `processing_time`, +`failure_rate`, `dropPolicy`, `maxParticleFlow`). L'utilisateur +voit désormais l'accumulation (grossissement des nœuds), la +saturation (halo orange/rouge), les drops (flash rouge + compteur) +et les métriques temps réel dans le HUD. + +## Changes + +- [doc/adr/006-particle-discrete-event-simulation.md] Statut + passé de **Proposed** à **Accepted** (chantier livré). +- [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" ajouté à la section "Exemples Pratiques Complets". + Documente le pipeline source→goulot→sink avec accumulation, halos + et drops, et propose 3 variantes (slots multiples, failure_rate, + convergence). +- [frontend/src/services/particleSimulator.ts] (créé en Phase 2, + implémenté en Phase 3) Classe DES pure, 430 lignes. Gère émission + régulière depuis les générateurs, transit sur les liens avec + calibration alignée sur `handleEmitTrace`, queues bornées avec + 3 politiques de drop, slots parallèles avec processing_time, + failure_rate à la sortie, routing pondéré par maxParticleFlow, + lifecycle start/pause/stop/dispose, stats temps réel via onTick. +- [frontend/src/services/particleSimulator.test.ts] 37 tests + unitaires couvrant chaque branche métier. Coverage 97.11% lines / + 94.62% branches / 100% functions / 98.44% statements. +- [frontend/src/services/particleSimulator.integration.test.ts] + 6 tests end-to-end : convergence (3→1→1), divergence pondérée + (50/30/20), cycle (A→B→C→B), saturation `tail`, saturation + `head`, perf smoke 100 nœuds / 100 ticks < 500ms. +- [frontend/src/hooks/useParticleSimulator.ts] Hook React qui + owns une instance de simulator, la pilote via rAF, surface les + stats via useState, forward onParticleReleased au callback du + caller. ~145 lignes, 100% lines / 75% branches. +- [frontend/src/hooks/useParticleSimulator.test.ts] 9 tests + avec stub rAF déterministe pour reproductibilité. +- [frontend/src/components/graphs/GraphRenderer3D.tsx] Intégration + end-to-end : hook câblé, accesseurs nodeVal/nodeColor étendus pour + le grossissement / halo / flash / role tint, useEffect dédié aux + refs visuelles, fallback heuristique préservé pour graphes legacy, + handleEmitTrace aligné sur la règle stricte (nodeRole='generator' + uniquement), HUD enrichi avec Drops + File max + Débit + tooltips, + reset session-scoped sur le edge start→start. +- [frontend/src/components/graphs/GraphRenderer3D.test.tsx] + SAMPLE_PARSE.A reçoit nodeRole='generator', test + linkDirectionalParticles renommé pour vérifier le mode DES (return 0 + - emitParticle est appelée). +- [frontend/src/components/graphs/GraphViewer.tsx] Nettoyage : + handleToggleSimulation supprimée (dead code suite à la suppression + du bouton play du rail en main). +- [backend/src/routes/public.js] parse-dot inclut maintenant + nodeRole, dropPolicy, queue_size, processing_time, failure_rate + dans la réponse pour que le ParticleSimulator les reçoive. +- [backend/src/utils/dotValidator.js] (Phase 1) Reconnaît nodeRole + et dropPolicy via deux nouveaux Sets validation. Méthode + validateDESCoherence() qui warn sur dropPolicy sans queue_size et + particleGeneration > 0 sur relay/sink. Coverage 86.91%. +- [backend/tests/unit/utils/dotValidator.test.js] 17 nouveaux tests + pour DES : 3 valeurs nodeRole, 3 valeurs dropPolicy, valeurs hors + énum, 7 cas de cohérence inter-attributs, 2 régressions. +- [frontend/vite.config.ts] Coverage threshold per-module pour + particleSimulator.ts (90/85/95/90) pour locker contre les + régressions futures. +- [doc/dot-3d/grammar-specification.md] Section 1.2 enrichie avec + les 7 attributs DES + table des rôles. Section 7.1 reçoit les + contraintes logiques. +- [doc/dot-3d/bnf-grammar.md] Productions BNF pour node_role et + drop_policy + warning rules de cohérence. +- [doc/dot-3d/validation-rules.md] Validators TypeScript pour + les 7 attributs et rules de cohérence inter-attributs. +- [doc/dot-3d/examples/generators.dot] (créé Phase 0) Démo + minimale des 3 rôles, pas de saturation. +- [doc/dot-3d/examples/saturation.dot] (créé Phase 0) Source rapide + → goulot étroit → sink, pour valider visuellement les phases 5/7. +- [doc/dot-3d/examples/README.md] Index mis à jour avec les + 2 nouveaux exemples. +- [frontend/doc/RENDERER.md] Section 8.bis "DES visual hooks" + documentée : queue growth, saturation halo, drop flash, role tint, + pattern refs-vs-state, re-install des accesseurs. + +## Tests + +- Backend : 395 tests verts (376 historiques + 17 DES + 2 autres + ajustements en cours de chantier). Coverage globale conforme aux + thresholds (lines 70 / branches 60 / functions 68 / statements 70). +- Frontend : 360 tests verts (320 historiques + 37 simulator unit + + 6 integration + 9 hook + adaptations). Coverage : particleSimulator + 97.11% lines, useParticleSimulator 100% lines. Lint clean. +- CI GitHub Actions sur Node 24 : à valider sur la PR #30 après merge. + +## Notes + +- Le simulator tourne uniquement quand au moins un nœud déclare + `nodeRole="generator"` (V1 stricte, ADR-006). Les graphes legacy + sans annotation continuent de fonctionner via le fallback + continuous-flow de `3d-force-graph`. Migration optionnelle. +- `handleEmitTrace` (le bouton ⚡ "Émission particules") respecte + désormais la même règle stricte : il ne fait rien si aucun + générateur n'est déclaré. +- Pas de cap sur le nombre total de particules en vol (decision + Phase 3 — D9). Si la perf souffre sur très gros graphes, + l'option `maxTotalParticlesInFlight` peut être ajoutée à + `SimulatorOptions` sans breaking change. +- Tests renderer DOM pour les chips HUD (Drops / File max / Débit) + intentionnellement non écrits : ils dépendent du rAF réel, et + chaque maillon de la chaîne est testé séparément. diff --git a/doc/changelog/2026-05-13.md b/doc/changelog/2026-05-13.md new file mode 100644 index 0000000..5795e2d --- /dev/null +++ b/doc/changelog/2026-05-13.md @@ -0,0 +1,20 @@ +# Changelog - 2026-05-13 + +## Summary + +Replaced the three legacy seed graphs (Simple Network Flow, Data Pipeline, +Microservices Architecture) with a single "VortexFlow Showcase" demo graph +that exercises every documented feature of the renderer. + +## Changes + +- [backend/src/utils/setup.js] Replaced `sampleGraphs` triple with a single demo graph (10 nodes, 13 edges) covering all 3 nodeRoles (generator/relay/sink), all 5 3D geometries (Sphere/Box/Cylinder/Cone/Torus), both dropPolicies (tail/head), failure_rate, weighted maxParticleFlow routing (60/30/10 dispatch), variable particleSpeed, queue saturation and legacy bandwidth/capacity/latency attributes. +- [backend/tests/unit/utils/setup.test.js] Adjusted Graph.create call-count assertion from `>= 3` to `>= 1` and updated the inline comment accordingly. +- [frontend/src/components/graphs/GraphList.tsx] Fixed the Supprimer / Dupliquer buttons in the per-card kebab menu: they were calling `handleMenuClose()` (which nulls `selectedGraphId`) before opening the confirmation dialog, so the dialog's action button became a no-op. Close only the menu anchor now and let the dialog read the live id. +- [frontend/src/components/graphs/GraphList.test.tsx] Added 2 regression tests covering the Supprimer and Dupliquer end-to-end flows. + +## Notes + +The seed only runs when the admin user has zero graphs in DB. To see the new +demo on an existing install, delete the admin's existing graphs first or +restart against a fresh database. diff --git a/doc/dot-3d/bnf-grammar.md b/doc/dot-3d/bnf-grammar.md index a9e4688..dfab90e 100644 --- a/doc/dot-3d/bnf-grammar.md +++ b/doc/dot-3d/bnf-grammar.md @@ -10,9 +10,9 @@ graph ::= ['strict'] ('graph' | 'digraph') [id] '{' stmt_list '}' stmt_list ::= [stmt [';' | '\n'] stmt_list] -stmt ::= node_stmt - | edge_stmt - | attr_stmt +stmt ::= node_stmt + | edge_stmt + | attr_stmt | id '=' id | subgraph | global_3d_config @@ -48,8 +48,16 @@ visual_3d_attribute ::= 'geometry' '=' geometry_type | 'dimensions' '=' dimensions_object | 'image' '=' url_string -simulation_attribute ::= 'particleGeneration' '=' number +simulation_attribute ::= 'nodeRole' '=' node_role + | 'particleGeneration' '=' number | 'maxParticleProcessing' '=' number + | 'queue_size' '=' integer + | 'processing_time' '=' number + | 'failure_rate' '=' number + | 'dropPolicy' '=' drop_policy + +node_role ::= '"generator"' | '"relay"' | '"sink"' +drop_policy ::= '"tail"' | '"head"' | '"reject"' (* === TYPES GÉOMÉTRIQUES === *) geometry_type ::= '"Sphere"' | '"Box"' | '"Cylinder"' | '"Cone"' | '"Torus"' @@ -59,7 +67,7 @@ dimension_list ::= dimension (',' dimension)* dimension ::= 'radius' ':' number | 'width' ':' number - | 'height' ':' number + | 'height' ':' number | 'depth' ':' number | 'tube' ':' number | 'tubularSegments' ':' integer @@ -110,42 +118,57 @@ attr_stmt ::= ('graph' | 'node' | 'edge') attr_list ```ebnf (* Contraintes à valider après parsing *) -constraint ::= geometric_constraint - | simulation_constraint +constraint ::= geometric_constraint + | simulation_constraint | performance_constraint -geometric_constraint ::= sphere_constraint - | box_constraint - | cylinder_constraint - | cone_constraint +geometric_constraint ::= sphere_constraint + | box_constraint + | cylinder_constraint + | cone_constraint | torus_constraint -sphere_constraint ::= - WHEN geometry="Sphere" +sphere_constraint ::= + WHEN geometry="Sphere" THEN radius > 0 -box_constraint ::= - WHEN geometry="Box" +box_constraint ::= + WHEN geometry="Box" THEN width > 0 AND height > 0 AND depth > 0 -cylinder_constraint ::= - WHEN geometry="Cylinder" +cylinder_constraint ::= + WHEN geometry="Cylinder" THEN radius > 0 AND height > 0 -cone_constraint ::= - WHEN geometry="Cone" +cone_constraint ::= + WHEN geometry="Cone" THEN radius > 0 AND height > 0 -torus_constraint ::= - WHEN geometry="Torus" +torus_constraint ::= + WHEN geometry="Torus" THEN tube > 0 AND tubularSegments >= 3 AND radialSegments >= 3 -simulation_constraint ::= +simulation_constraint ::= particleGeneration >= 0 AND maxParticleProcessing > 0 AND maxParticleFlow > 0 AND particleSpeed > 0 AND - defaultNodeSize > 0 + defaultNodeSize > 0 AND + queue_size > 0 AND + processing_time >= 0 AND + failure_rate >= 0 AND failure_rate <= 1 + +role_constraint ::= + nodeRole ∈ {"generator", "relay", "sink"} AND + dropPolicy ∈ {"tail", "head", "reject"} + +(* Cross-attribute coherence — warnings, not errors *) +coherence_warning ::= + WHEN dropPolicy DEFINED AND queue_size NOT DEFINED + THEN warn("dropPolicy is ignored without queue_size") + + WHEN particleGeneration > 0 AND nodeRole IN {"relay", "sink"} + THEN warn("particleGeneration is ignored unless nodeRole=generator") ``` ## Exemples de Productions @@ -170,7 +193,7 @@ node_stmt: ``` edge_stmt: node_id: "ServerA" - edgeop: "->" + edgeop: "->" node_id: "ServerB" attr_list: [ edge_3d_attribute: maxParticleFlow=45 @@ -204,14 +227,14 @@ global_3d_config: ### Gestion des Conflits ```ebnf -conflict_resolution ::= +conflict_resolution ::= WHEN duplicate_attribute THEN use_last_defined - + WHEN global_vs_local THEN prefer_local - - WHEN invalid_combination + + WHEN invalid_combination THEN emit_error_with_suggestion ``` diff --git a/doc/dot-3d/examples/README.md b/doc/dot-3d/examples/README.md index 90270cf..4bb83bc 100644 --- a/doc/dot-3d/examples/README.md +++ b/doc/dot-3d/examples/README.md @@ -4,8 +4,26 @@ Cette collection d'exemples démontre les capacités avancées de l'extension DO ## 📋 Liste des Exemples +### 0. 🎯 [Rôles `nodeRole`](./generators.dot) _(DES — ADR-006)_ + +**Cas d'usage** : démonstration minimale des trois rôles (`generator`, `relay`, `sink`). + +- **Topologie** : 2 sources convergent vers 1 routeur vers 1 puits. +- **Pas de saturation** — le routeur a la capacité de tout traiter. +- **À utiliser pour** : comprendre l'effet de `nodeRole` avant de plonger dans des cas plus riches. + +### 0.bis. 🚨 [Goulot avec drop](./saturation.dot) _(DES — ADR-006)_ + +**Cas d'usage** : démonstration de l'accumulation, du halo de saturation et des drops visibles. + +- **Topologie** : 1 source rapide → 1 goulot étroit → 1 puits. +- **Comportement** : la file du goulot se remplit, halo orange à 80 %, drops dès saturation, flash rouge à chaque drop. +- **À utiliser pour** : valider les phases 5 (visu) et 7 (test d'intégration "saturation") du chantier DES. + ### 1. 🌐 [Réseau de Distribution](./network-distribution.dot) + **Cas d'usage** : Infrastructure réseau avec flux de données + - **Domaine** : Télécommunications, Cloud Computing - **Géométries** : Box (datacenters), Cylinder (serveurs régionaux), Cone (edge), Torus (CDN), Sphere (utilisateurs) - **Particules** : Représentent les requêtes et données transitant @@ -13,13 +31,16 @@ Cette collection d'exemples démontre les capacités avancées de l'extension DO - **Vitesses** : Variables selon la géographie (backbone transpacifique plus lent) **Points d'intérêt** : + - Accumulation visible aux goulots d'étranglement - Différentes vitesses selon la latence géographique - Flux bidirectionnel avec analytics de retour - CDN optimisé pour haute performance ### 2. ⚛️ [Physique des Particules](./particle-physics.dot) + **Cas d'usage** : Accélérateur de particules et détection + - **Domaine** : Recherche scientifique, Physique - **Géométries** : Sphere (sources), Cylinder (accélérateurs), Torus (cyclotrons), Box (détecteurs) - **Particules** : Protons, électrons et particules secondaires @@ -27,13 +48,16 @@ Cette collection d'exemples démontre les capacités avancées de l'extension DO - **Vitesses** : Accélération progressive, très haute énergie avant collision **Points d'intérêt** : + - Génération de particules secondaires après collision - Pertes par rayonnement dans les cyclotrons - Détection multi-canal parallèle - Boucles de rétroaction pour calibration ### 3. 🔄 [Pipeline de Workflow](./workflow-pipeline.dot) + **Cas d'usage** : CI/CD avec goulots d'étranglement + - **Domaine** : DevOps, Développement logiciel - **Géométries** : Box (repos, compilation), Cone (tests), Cylinder (intégration), Torus (sécurité) - **Particules** : Commits, builds, et artefacts @@ -41,13 +65,16 @@ Cette collection d'exemples démontre les capacités avancées de l'extension DO - **Vitesses** : Hotfix rapides, déploiement production contrôlé **Points d'intérêt** : + - Parallélisation des tests après compilation - Synchronisation avant packaging Docker - Chemin hotfix contournant certaines étapes - Feedback d'erreurs vers développement ### 4. 📱 [Réseau Social](./social-network.dot) + **Cas d'usage** : Propagation virale de contenu + - **Domaine** : Réseaux sociaux, Marketing viral - **Géométries** : Sphere (influenceurs, audience), Cylinder (comptes), Torus (communautés), Box (algorithmes) - **Particules** : Posts, shares, engagements @@ -55,13 +82,16 @@ Cette collection d'exemples démontre les capacités avancées de l'extension DO - **Vitesses** : Contenu lifestyle plus viral que tech **Points d'intérêt** : + - Amplification algorithmique automatique - Croisements entre communautés thématiques - Boucles de rétroaction d'engagement - Système de tendances détectant la viralité ### 5. 💰 [Système Économique](./economic-system.dot) + **Cas d'usage** : Flux financiers et monétaires + - **Domaine** : Économie, Finance, Politique monétaire - **Géométries** : Sphere (banque centrale, ménages), Cylinder (banques), Box (industries, état), Torus (marchés) - **Particules** : Monnaie, crédits, investissements @@ -69,6 +99,7 @@ Cette collection d'exemples démontre les capacités avancées de l'extension DO - **Vitesses** : Transactions rapides vs procédures administratives lentes **Points d'intérêt** : + - Circuit monétaire complet banque centrale → économie - Chaîne de valeur primaire → secondaire → tertiaire - Circuit fiscal collecte → redistribution @@ -77,6 +108,7 @@ Cette collection d'exemples démontre les capacités avancées de l'extension DO ## 🎯 Concepts Clés Démontrés ### **Géométries 3D Avancées** + - **Sphere** : Entités centrales, sources/destinataires - **Box** : Infrastructures, centres de traitement - **Cylinder** : Relais, serveurs, institutions @@ -84,12 +116,14 @@ Cette collection d'exemples démontre les capacités avancées de l'extension DO - **Torus** : Systèmes circulaires, algorithmes, marchés ### **Simulation de Particules** + - **Génération** : `particleGeneration` - Production de contenu/flux - **Traitement** : `maxParticleProcessing` - Capacité de traitement - **Goulots** : Accumulation quand génération > traitement - **Vitesses** : `particleSpeed` - Urgence et priorité des flux ### **Effets Visuels** + - **Auto-resize** : Taille des nœuds selon leur importance - **Bloom effect** : Halo lumineux sur les accumulations - **Couleurs thématiques** : Codage par secteur/fonction @@ -98,18 +132,21 @@ Cette collection d'exemples démontre les capacités avancées de l'extension DO ## 🚀 Utilisation dans VortexFlow ### **Chargement des Exemples** + 1. Copiez le contenu d'un fichier `.dot` 2. Collez dans l'éditeur VortexFlow 3. Cliquez sur "Visualiser en 3D" 4. Activez la simulation de particules dans le panneau de contrôle ### **Contrôles Interactifs** + - **Particules** : Activez/désactivez avec le bouton toggle - **Vitesse** : Ajustez la vitesse globale de simulation - **Bloom** : Intensifiez l'effet lumineux sur les accumulations - **Tailles** : Utilisez auto-resize pour voir l'importance relative ### **Personnalisation** + - **Couleurs** : Modifiez les attributs `color` pour votre thème - **Géométries** : Changez `geometry` et `dimensions` selon vos besoins - **Flux** : Ajustez `maxParticleFlow` et `particleSpeed` pour votre cas @@ -118,6 +155,7 @@ Cette collection d'exemples démontre les capacités avancées de l'extension DO ## 🔧 Paramètres Recommandés ### **Pour Démonstrations** + ```dot defaultNodeSize = 1.2; particlesEnabled = true; @@ -126,6 +164,7 @@ bloomEffect = true; ``` ### **Pour Analyses Détaillées** + ```dot defaultNodeSize = 0.8; particlesEnabled = true; @@ -134,6 +173,7 @@ bloomEffect = false; // Moins de distraction ``` ### **Pour Présentations** + ```dot defaultNodeSize = 1.5; particlesEnabled = true; @@ -155,16 +195,19 @@ Ces exemples permettent de visualiser des métriques importantes : ## 🎓 Cas d'Usage Étendus ### **Infrastructure IT** + - Monitoring réseau avec alertes visuelles - Analyse de performance des microservices - Visualisation des flux de données en temps réel ### **Analyse Métier** + - Flux de processus avec identification des goulots - Propagation d'information dans l'organisation - Modélisation des chaînes d'approvisionnement ### **Recherche et Éducation** + - Simulation de systèmes complexes - Visualisation de modèles théoriques - Démonstrations interactives pour l'enseignement diff --git a/doc/dot-3d/examples/generators.dot b/doc/dot-3d/examples/generators.dot new file mode 100644 index 0000000..56b3788 --- /dev/null +++ b/doc/dot-3d/examples/generators.dot @@ -0,0 +1,65 @@ +// generators.dot — Exemple démontrant les trois rôles nodeRole. +// +// Topologie : +// SourceA ─┐ +// ├─→ Router ─→ Sink +// SourceB ─┘ +// +// Deux générateurs convergent vers un relay (Router) qui répartit vers +// un sink (Drain). Le sink absorbe sans rerouter — la particule disparaît +// à son arrivée. +// +// Ce graphe ne sature pas : SourceA + SourceB = 3 p/s entrent dans Router, +// qui traite 10 p/s. Pas de drop, pas d'accumulation visible. +// Voir saturation.dot pour le cas inverse. + +digraph Generators { + // Configuration globale + defaultNodeSize = 1.5; + particlesEnabled = true; + autoColors = false; + + // Deux sources actives + SourceA [ + label="Source A", + nodeRole="generator", + particleGeneration=2, // 2 particules/seconde + geometry="Cone", + color="#ff6666" + ]; + + SourceB [ + label="Source B", + nodeRole="generator", + particleGeneration=1, // 1 particule/seconde + geometry="Cone", + color="#ff9966" + ]; + + // Routeur — reçoit, traite, redistribue + Router [ + label="Routeur central", + // nodeRole omis → relay par défaut + maxParticleProcessing=10, + queue_size=30, + dropPolicy="tail", + processing_time=2, + geometry="Box", + dimensions="{width: 2.0, height: 1.5, depth: 1.5}", + color="#66cc66" + ]; + + // Puits — absorbe sans rerouter + Sink [ + label="Drain", + nodeRole="sink", + geometry="Sphere", + dimensions="{radius: 1.2}", + color="#666699" + ]; + + // Connexions + SourceA -> Router [maxParticleFlow=5, particleSpeed=1.5, color="#ff6666"]; + SourceB -> Router [maxParticleFlow=5, particleSpeed=1.5, color="#ff9966"]; + Router -> Sink [maxParticleFlow=15, particleSpeed=1.0, color="#66cc66"]; +} diff --git a/doc/dot-3d/examples/saturation.dot b/doc/dot-3d/examples/saturation.dot new file mode 100644 index 0000000..4068f7d --- /dev/null +++ b/doc/dot-3d/examples/saturation.dot @@ -0,0 +1,67 @@ +// saturation.dot — Exemple démontrant l'accumulation puis le drop. +// +// Topologie : +// FastSource ─→ Bottleneck ─→ Sink +// +// FastSource génère 10 particules/seconde, mais Bottleneck ne peut en +// traiter que 2/seconde et sa file ne stocke que 5 particules. +// +// Déroulement attendu à l'écran : +// 1. Les particules arrivent plus vite qu'elles ne sortent. +// 2. Bottleneck grossit visuellement à mesure que sa file se remplit. +// 3. À 80 % de remplissage, halo orange. +// 4. Quand la file est pleine (5/5), nouvelle arrivée → drop : flash +// rouge + compteur de drops incrémente. +// 5. dropPolicy="tail" : c'est la particule entrante qui est jetée +// (changer en "head" pour drop la plus ancienne à la place). +// +// Utile pour valider la phase 5 (visualisation) et la phase 7 (test +// d'intégration "saturation"). + +digraph Saturation { + defaultNodeSize = 1.2; + particlesEnabled = true; + autoColors = false; + + FastSource [ + label="Source rapide", + nodeRole="generator", + particleGeneration=10, // 10 p/s — bien au-dessus de la capacité aval + geometry="Cone", + dimensions="{radius: 1.0, height: 2.0}", + color="#ff4444" + ]; + + Bottleneck [ + label="Goulot", + nodeRole="relay", + maxParticleProcessing=2, // 2 p/s — capacité limitée + queue_size=5, // File minuscule + dropPolicy="tail", // Drop l'entrante quand pleine + processing_time=400, // 400 ms par particule → renforce le goulot + failure_rate=0, // Pas d'échec aléatoire à la sortie + geometry="Cylinder", + dimensions="{radius: 1.0, height: 2.0}", + color="#ffaa00" + ]; + + Sink [ + label="Sortie", + nodeRole="sink", + geometry="Sphere", + dimensions="{radius: 1.0}", + color="#4477aa" + ]; + + FastSource -> Bottleneck [ + maxParticleFlow=20, + particleSpeed=2.0, // Vitesse rapide → aggrave le goulot + color="#ff4444" + ]; + + Bottleneck -> Sink [ + maxParticleFlow=10, + particleSpeed=1.0, + color="#ffaa00" + ]; +} diff --git a/doc/dot-3d/grammar-specification.md b/doc/dot-3d/grammar-specification.md index 9e2858a..f862e4d 100644 --- a/doc/dot-3d/grammar-specification.md +++ b/doc/dot-3d/grammar-specification.md @@ -8,7 +8,7 @@ Cette spécification définit l'extension de la grammaire DOT standard pour supp ✅ **Compatibilité descendante complète** avec la syntaxe DOT standard ✅ **Extensions non-intrusives** via nouveaux attributs optionnels -✅ **Validation progressive** pour maintenir la robustesse +✅ **Validation progressive** pour maintenir la robustesse --- @@ -16,47 +16,70 @@ Cette spécification définit l'extension de la grammaire DOT standard pour supp ### 1.1 Attributs Visuels -| Attribut | Type | Défaut | Description | -|----------|------|--------|-------------| -| `label` | string | ID du nœud | Texte affiché sur le nœud | -| `color` | color | auto | Couleur au format hex (#ff0000), rgb(255,0,0) ou nom (red) | -| `image` | url | null | URL d'une image à appliquer comme texture | -| `geometry` | enum | "Sphere" | Type de géométrie 3D | -| `dimensions` | object | géométrie-dépendant | Paramètres dimensionnels | +| Attribut | Type | Défaut | Description | +| ------------ | ------ | ------------------- | ---------------------------------------------------------- | +| `label` | string | ID du nœud | Texte affiché sur le nœud | +| `color` | color | auto | Couleur au format hex (#ff0000), rgb(255,0,0) ou nom (red) | +| `image` | url | null | URL d'une image à appliquer comme texture | +| `geometry` | enum | "Sphere" | Type de géométrie 3D | +| `dimensions` | object | géométrie-dépendant | Paramètres dimensionnels | #### Géométries 3D Supportées **Sphere** (défaut) + ```dot A [geometry="Sphere", dimensions="{radius: 1.0}"]; ``` **Box** + ```dot B [geometry="Box", dimensions="{width: 2.0, height: 1.5, depth: 1.0}"]; ``` **Cylinder** + ```dot C [geometry="Cylinder", dimensions="{radius: 0.8, height: 2.0}"]; ``` **Cone** + ```dot D [geometry="Cone", dimensions="{radius: 1.0, height: 2.5}"]; ``` **Torus** + ```dot E [geometry="Torus", dimensions="{tube: 0.3, tubularSegments: 8, radialSegments: 6}"]; ``` ### 1.2 Attributs de Simulation -| Attribut | Type | Défaut | Description | -|----------|------|--------|-------------| -| `particleGeneration` | number | 0 | Particules générées par minute | -| `maxParticleProcessing` | number | 60 | Particules maximum traitées par minute | +| Attribut | Type | Défaut | Description | +| ----------------------- | ------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `nodeRole` | enum | `relay` | Rôle du nœud dans la simulation (`generator`, `relay`, `sink`) — voir ADR-006 | +| `particleGeneration` | number | 1 (si `nodeRole=generator`), sinon ignoré | Particules générées par seconde (taux d'émission) | +| `maxParticleProcessing` | number | 60 | Particules maximum traitées par seconde | +| `queue_size` | integer | ∞ | Taille maximale de la file d'attente du nœud (cf. `dropPolicy`) | +| `processing_time` | number | 0 | Temps de traitement minimal par particule (ms) | +| `failure_rate` | number | 0 | Probabilité [0–1] qu'une particule sortante soit droppée | +| `dropPolicy` | enum | `tail` | Politique de drop quand la file est pleine : `tail` (drop l'entrante), `head` (drop la plus ancienne), `reject` (rejette l'entrante sans la stocker, équivalent à `tail` côté visuel) | + +#### Rôles `nodeRole` + +- **`generator`** — émet activement des particules selon `particleGeneration`. + Peut aussi recevoir et router du trafic entrant (le rôle ne désactive pas la + retransmission). +- **`relay`** _(défaut)_ — reçoit, met en file, traite, route vers les liens + sortants. N'émet pas spontanément. +- **`sink`** — absorbe les particules à l'arrivée, ne route pas. Utile pour + modéliser une destination terminale (logs, base de données, sortie système). + +> ⚠️ **V1 stricte** (ADR-006) : un graphe sans aucun nœud `nodeRole=generator` +> n'animera pas de particules. Il faut annoter explicitement les sources. ### 1.3 Exemple Complet Nœud @@ -66,8 +89,13 @@ ServerNode [ color="#4a90e2", geometry="Box", dimensions="{width: 2, height: 1, depth: 1}", - particleGeneration=120, - maxParticleProcessing=100 + nodeRole="generator", + particleGeneration=2, + maxParticleProcessing=100, + queue_size=50, + dropPolicy="tail", + processing_time=10, + failure_rate=0.0 ]; ``` @@ -78,7 +106,7 @@ ServerNode [ ### 2.1 Formule de Calcul ```javascript -taille_finale = defaultNodeSize * (1 + 0.1 * sqrt(nombre_connexions_entrantes)) +taille_finale = defaultNodeSize * (1 + 0.1 * sqrt(nombre_connexions_entrantes)); ``` ### 2.2 Configuration Globale @@ -87,7 +115,7 @@ taille_finale = defaultNodeSize * (1 + 0.1 * sqrt(nombre_connexions_entrantes)) digraph SystemGraph { // Taille de base pour tous les nœuds defaultNodeSize = 1.5; - + // Activation du redimensionnement automatique autoResize = true; } @@ -101,10 +129,10 @@ digraph SystemGraph { ```javascript // À chaque cycle de simulation -accumulation += (particules_generees + particules_reçues) - particules_traitees +accumulation += particules_generees + particules_reçues - particules_traitees; // Effet bloom proportionnel -bloom_intensity = min(1.0, accumulation / 100) +bloom_intensity = min(1.0, accumulation / 100); ``` ### 3.2 États des Particules @@ -120,13 +148,13 @@ bloom_intensity = min(1.0, accumulation / 100) ### 4.1 Spécification des Attributs -| Attribut | Type | Défaut | Description | -|----------|------|--------|-------------| -| `label` | string | "" | Texte affiché sur le lien | -| `color` | color | auto | Couleur du lien | -| `maxParticleFlow` | number | 30 | Particules maximum transmises par minute | -| `particleSpeed` | number | 1.0 | Vitesse de propagation (multiplier) | -| `style` | enum | "solid" | Style visuel du lien | +| Attribut | Type | Défaut | Description | +| ----------------- | ------ | ------- | ---------------------------------------- | +| `label` | string | "" | Texte affiché sur le lien | +| `color` | color | auto | Couleur du lien | +| `maxParticleFlow` | number | 30 | Particules maximum transmises par minute | +| `particleSpeed` | number | 1.0 | Vitesse de propagation (multiplier) | +| `style` | enum | "solid" | Style visuel du lien | ### 4.2 Styles de Liens @@ -152,13 +180,13 @@ A -> B [ ### 5.1 Paramètres Disponibles -| Paramètre | Type | Défaut | Description | -|-----------|------|--------|-------------| -| `defaultNodeSize` | number | 1.0 | Taille de base des nœuds | -| `particlesEnabled` | boolean | true | Activation système particules | -| `autoResize` | boolean | false | Redimensionnement automatique | -| `bloomEffect` | boolean | true | Effet bloom sur accumulation | -| `autoColors` | boolean | true | Attribution automatique couleurs | +| Paramètre | Type | Défaut | Description | +| ------------------ | ------- | ------ | -------------------------------- | +| `defaultNodeSize` | number | 1.0 | Taille de base des nœuds | +| `particlesEnabled` | boolean | true | Activation système particules | +| `autoResize` | boolean | false | Redimensionnement automatique | +| `bloomEffect` | boolean | true | Effet bloom sur accumulation | +| `autoColors` | boolean | true | Attribution automatique couleurs | ### 5.2 Exemple Configuration @@ -170,7 +198,7 @@ digraph ParticleSystem { autoResize = true; bloomEffect = true; autoColors = false; // Couleurs manuelles uniquement - + // Reste du graphique... } ``` @@ -184,7 +212,7 @@ digraph ParticleSystem { Le panneau de contrôle 3D doit permettre la modification dynamique de : - ✅ Activation/désactivation système particules -- ✅ Activation/désactivation redimensionnement automatique +- ✅ Activation/désactivation redimensionnement automatique - ✅ Activation/désactivation effet bloom - ✅ Activation/désactivation couleurs automatiques - ✅ Ajustement `defaultNodeSize` global @@ -193,6 +221,7 @@ Le panneau de contrôle 3D doit permettre la modification dynamique de : ### 6.2 Statistiques Temps Réel Affichage en direct de : + - Nombre total de particules actives - Taux de génération global (particules/minute) - Taux de traitement global (particules/minute) @@ -209,10 +238,18 @@ Affichage en direct de : - `maxParticleFlow > 0` - `particleSpeed > 0` - `defaultNodeSize > 0` +- `nodeRole ∈ {generator, relay, sink}` +- `queue_size > 0` (entier strict) +- `processing_time >= 0` +- `failure_rate ∈ [0, 1]` +- `dropPolicy ∈ {tail, head, reject}` — n'a de sens que si `queue_size` est défini +- `dropPolicy` défini sans `queue_size` → **avertissement** (sera ignoré : file illimitée) +- `particleGeneration > 0` sur un `nodeRole=relay|sink` → **avertissement** (sera ignoré) ### 7.2 Validation Géométries Chaque géométrie doit avoir ses dimensions appropriées : + - **Sphere** : `radius > 0` - **Box** : `width, height, depth > 0` - **Cylinder** : `radius, height > 0` @@ -253,6 +290,7 @@ Messages explicites pour faciliter le debugging : ### 9.1 Nouvelles Géométries Architecture permettant l'ajout de : + - Géométries personnalisées via plugins - Géométries procedurales (fractales, etc.) - Import de modèles 3D (.obj, .gltf) @@ -260,6 +298,7 @@ Architecture permettant l'ajout de : ### 9.2 Nouveaux Attributs Extension prévue pour : + - Animations automatiques (rotation, pulsation) - Physique avancée (collision, gravité) - Effets visuels (particules custom, shaders) diff --git a/doc/dot-3d/user-guide.md b/doc/dot-3d/user-guide.md index 2eb6ed9..b29d115 100644 --- a/doc/dot-3d/user-guide.md +++ b/doc/dot-3d/user-guide.md @@ -15,17 +15,18 @@ digraph FirstExample { // Configuration de base defaultNodeSize = 1.0; particlesEnabled = true; - + // Nœuds avec géométries 3D A [label="Serveur", geometry="Box", particleGeneration=30]; B [label="Client", geometry="Sphere", maxParticleProcessing=60]; - + // Lien avec flux de particules A -> B [maxParticleFlow=20, particleSpeed=1.2]; } ``` ### Résultat + - **Serveur A** : Cube générant 30 particules/minute - **Client B** : Sphère traitant jusqu'à 60 particules/minute - **Flux** : 20 particules max/minute à vitesse 1.2x @@ -35,15 +36,17 @@ digraph FirstExample { ## 📐 Géométries 3D Disponibles ### 1. Sphere (Sphère) + ```dot NodeSphere [ - geometry="Sphere", + geometry="Sphere", dimensions="{radius: 1.5}", color="#4CAF50" ]; ``` ### 2. Box (Boîte/Cube) + ```dot NodeBox [ geometry="Box", @@ -53,6 +56,7 @@ NodeBox [ ``` ### 3. Cylinder (Cylindre) + ```dot NodeCylinder [ geometry="Cylinder", @@ -62,6 +66,7 @@ NodeCylinder [ ``` ### 4. Cone (Cône) + ```dot NodeCone [ geometry="Cone", @@ -71,6 +76,7 @@ NodeCone [ ``` ### 5. Torus (Tore) + ```dot NodeTorus [ geometry="Torus", @@ -83,41 +89,84 @@ NodeTorus [ ## ⚡ Système de Particules +> 🆕 **Depuis ADR-006**, la simulation est événementielle (DES) : seuls les +> nœuds dont le rôle est explicitement `generator` émettent des particules. +> Les nœuds sans `nodeRole` sont considérés comme `relay` (ils transmettent +> sans émettre). Un graphe sans aucun `generator` reste statique. + +### Rôles des nœuds (`nodeRole`) + +| Valeur | Comportement | Cas d'usage typique | +| ------------------ | ------------------------------------------------------------------------------------- | --------------------------------- | +| `generator` | Émet des particules à `particleGeneration` p/s. Peut aussi router son trafic entrant. | Source, capteur, client émetteur | +| `relay` _(défaut)_ | Reçoit, met en file, traite, route vers les liens sortants. | Routeur, processeur intermédiaire | +| `sink` | Absorbe les particules à l'arrivée, ne route rien. | Destination finale, log, drain | + ### Configuration des Nœuds #### Nœud Générateur + ```dot Generator [ label="Générateur", - particleGeneration=120, // 120 particules/minute - maxParticleProcessing=60, // Traite 60 max/minute + nodeRole="generator", + particleGeneration=2, // 2 particules/seconde + maxParticleProcessing=60, // Traite 60 max/seconde (s'il reçoit aussi) geometry="Cone", color="#ff4444" ]; ``` -#### Nœud Processeur +#### Nœud Processeur (relay) + ```dot Processor [ label="Processeur", - particleGeneration=0, // Ne génère pas - maxParticleProcessing=200, // Traite 200 max/minute + // nodeRole non précisé → relay par défaut + maxParticleProcessing=200, // Traite 200 max/seconde + queue_size=100, // File limitée à 100 + dropPolicy="tail", // Drop l'entrante quand pleine + processing_time=5, // 5 ms par particule geometry="Box", color="#44ff44" ]; ``` -#### Nœud Accumulateur +#### Nœud Tampon avec drop visible + ```dot Buffer [ - label="Tampon", - particleGeneration=30, // Génération modérée + label="Tampon saturable", + nodeRole="relay", maxParticleProcessing=15, // Traite lentement + queue_size=20, // File petite + dropPolicy="head", // Drop la plus ancienne + failure_rate=0.02, // 2 % d'échec à la sortie geometry="Cylinder", color="#4444ff" ]; ``` +#### Nœud Puits + +```dot +Sink [ + label="Destination", + nodeRole="sink", + geometry="Sphere", + color="#888888" +]; +``` + +### Cumul et drop : ce qui se passe à l'écran + +- **Accumulation** : la taille visuelle d'un nœud croît avec son nombre de + particules en file (jusqu'à 2× sa taille de base à saturation). +- **Halo de saturation** : orange si file > 80 % de `queue_size`, rouge si pleine. +- **Drop** : flash rouge bref + compteur incrémenté sur le nœud. +- **Stats HUD** : `Drops` ajouté à côté de `Particules / Latence / Goulots`, + alimenté en temps réel par le simulator. + ### Configuration des Liens ```dot @@ -145,6 +194,7 @@ Processor -> Buffer [ ### Couleurs Supportées #### Format Hexadécimal + ```dot A [color="#ff0000"]; // Rouge B [color="#00ff00"]; // Vert @@ -152,6 +202,7 @@ C [color="#0000ff"]; // Bleu ``` #### Format RGB + ```dot A [color="rgb(255, 0, 0)"]; // Rouge B [color="rgb(0, 255, 0)"]; // Vert @@ -159,6 +210,7 @@ C [color="rgb(0, 0, 255)"]; // Bleu ``` #### Couleurs Nommées + ```dot A [color="red"]; B [color="green"]; @@ -190,19 +242,19 @@ A -> D [style="dotted"]; digraph NetworkSimulation { // Taille de base des nœuds defaultNodeSize = 1.5; - + // Activation système particules particlesEnabled = true; - + // Redimensionnement automatique selon connexions autoResize = true; - + // Effet bloom sur accumulation bloomEffect = true; - + // Attribution couleurs automatique autoColors = false; // Désactivé pour couleurs manuelles - + // Vos nœuds et liens... } ``` @@ -216,6 +268,7 @@ taille_finale = defaultNodeSize × (1 + 0.1 × √(connexions_entrantes)) ``` **Exemples :** + - 0 connexion : taille = 1.0 × (1 + 0) = **1.0** - 4 connexions : taille = 1.0 × (1 + 0.2) = **1.2** - 16 connexions : taille = 1.0 × (1 + 0.4) = **1.4** @@ -227,6 +280,7 @@ taille_finale = defaultNodeSize × (1 + 0.1 × √(connexions_entrantes)) ### États des Particules #### Accumulation Normale + ```dot NormalNode [ particleGeneration=60, @@ -235,6 +289,7 @@ NormalNode [ ``` #### Accumulation Critique + ```dot BottleneckNode [ particleGeneration=100, @@ -245,6 +300,7 @@ BottleneckNode [ ### Effet Bloom Quand l'accumulation dépasse 80 particules, le nœud commence à "briller" : + - **Accumulation 0-80** : Apparence normale - **Accumulation 80-100** : Effet bloom progressif - **Accumulation >100** : Bloom maximum (intensité 1.0) @@ -253,6 +309,70 @@ Quand l'accumulation dépasse 80 particules, le nœud commence à "briller" : ## 💡 Exemples Pratiques Complets +### Exemple 0 : Pipeline DES avec goulot et drops (ADR-006) + +Le plus pédagogique pour découvrir la simulation événementielle. Une source rapide +envoie 10 particules/seconde dans un goulot qui n'en traite que 5/s avec une file +de 5 emplacements et un `dropPolicy="tail"` — donc, dès que la file est saturée, +les nouvelles particules sont droppées avant d'entrer. + +```dot +digraph SaturationDemo { + defaultNodeSize = 1.2; + particlesEnabled = true; + + FastSource [ + label="Source rapide", + nodeRole="generator", + particleGeneration=10, // 10 particules/seconde + geometry="Cone" + ]; + + Bottleneck [ + label="Goulot", + nodeRole="relay", + maxParticleProcessing=1, // 1 slot de traitement parallèle + processing_time=200, // 200 ms par particule → débit 5/s + queue_size=5, // file de 5 emplacements + dropPolicy="tail", // drop l'entrante quand la file est pleine + geometry="Cylinder" + ]; + + Sink [ + label="Sortie", + nodeRole="sink", + geometry="Sphere" + ]; + + FastSource -> Bottleneck [particleSpeed=6]; + Bottleneck -> Sink [particleSpeed=6]; +} +``` + +**Ce qui se passe à l'écran après ▶** : + +1. **Émission régulière** : la source émet 1 particule toutes les 100 ms. +2. **Accumulation** : le goulot grossit visuellement à mesure que sa file se remplit + (jusqu'à 2× sa taille de base). +3. **Halo** : à 80 % de remplissage, le nœud devient orange. À 100 %, il devient rouge. +4. **Flash drop** : à chaque drop (queue pleine), le nœud flashe rouge vif pendant 200 ms. +5. **HUD** : les chips `Drops`, `File max` et `Débit` (en bas à droite) chiffrent + l'effet en temps réel. Survole-les pour avoir l'explication. + +Change `dropPolicy="tail"` en `"head"` pour voir la différence : c'est alors la +particule la plus ancienne qui est jetée à chaque saturation. La file reste à +la même taille, mais le débit observé en sortie change subtilement parce que +les particules les plus récentes ont systématiquement la priorité. + +**Variantes à expérimenter** : + +- `maxParticleProcessing=5` → 5 slots parallèles, débit 25 p/s, file vide en + permanence si l'entrée reste à 10/s. +- `failure_rate=0.1` → 10 % des particules sortantes sont droppées (sémantique + "le service échoue"). +- Ajoute un 2e générateur convergent pour reproduire le scénario `convergence` + des tests d'intégration. + ### Exemple 1 : Réseau de Distribution ```dot @@ -262,7 +382,7 @@ digraph DistributionNetwork { particlesEnabled = true; autoResize = true; bloomEffect = true; - + // Serveur principal MainServer [ label="Serveur Principal", @@ -272,17 +392,17 @@ digraph DistributionNetwork { maxParticleProcessing=180, color="#2196F3" ]; - + // Serveurs régionaux RegionA [ label="Région A", - geometry="Cylinder", + geometry="Cylinder", dimensions="{radius: 0.8, height: 1.5}", particleGeneration=50, maxParticleProcessing=120, color="#4CAF50" ]; - + RegionB [ label="Région B", geometry="Cylinder", @@ -291,7 +411,7 @@ digraph DistributionNetwork { maxParticleProcessing=100, color="#4CAF50" ]; - + // Clients finaux ClientGroup [ label="Clients", @@ -301,7 +421,7 @@ digraph DistributionNetwork { maxParticleProcessing=200, color="#FF9800" ]; - + // Flux de données MainServer -> RegionA [ label="Flux A", @@ -309,20 +429,20 @@ digraph DistributionNetwork { particleSpeed=1.5, color="#1976D2" ]; - + MainServer -> RegionB [ - label="Flux B", + label="Flux B", maxParticleFlow=60, particleSpeed=1.5, color="#1976D2" ]; - + RegionA -> ClientGroup [ maxParticleFlow=100, particleSpeed=1.0, color="#388E3C" ]; - + RegionB -> ClientGroup [ maxParticleFlow=80, particleSpeed=1.0, @@ -341,7 +461,7 @@ digraph ProcessingPipeline { autoResize = false; // Tailles fixes bloomEffect = true; autoColors = false; - + // Étapes du pipeline Input [ label="Entrée", @@ -351,7 +471,7 @@ digraph ProcessingPipeline { maxParticleProcessing=150, color="#E91E63" ]; - + Filter [ label="Filtrage", geometry="Box", @@ -360,7 +480,7 @@ digraph ProcessingPipeline { maxParticleProcessing=100, // Goulot d'étranglement color="#FF5722" ]; - + Transform [ label="Transformation", geometry="Cylinder", @@ -369,7 +489,7 @@ digraph ProcessingPipeline { maxParticleProcessing=120, color="#FF9800" ]; - + Output [ label="Sortie", geometry="Sphere", @@ -378,7 +498,7 @@ digraph ProcessingPipeline { maxParticleProcessing=200, color="#4CAF50" ]; - + // Pipeline avec vitesses variées Input -> Filter [ maxParticleFlow=120, @@ -386,14 +506,14 @@ digraph ProcessingPipeline { style="solid", color="#D32F2F" ]; - + Filter -> Transform [ maxParticleFlow=90, // Réduit par le filtre particleSpeed=1.0, // Normal style="dashed", color="#F57C00" ]; - + Transform -> Output [ maxParticleFlow=100, particleSpeed=1.5, // Accéléré @@ -410,28 +530,32 @@ digraph ProcessingPipeline { ### Messages d'Erreur Courants #### Erreur de Géométrie + ``` ❌ Erreur ligne 12: geometry "InvalidShape" non supportée Géométries supportées: Sphere, Box, Cylinder, Cone, Torus ``` #### Erreur de Dimensions + ``` ❌ Erreur ligne 15: dimensions manquantes pour geometry="Box" Box requiert: width, height, depth ``` #### Erreur de Contraintes + ``` ❌ Erreur ligne 8: particleGeneration ne peut pas être négatif Valeur reçue: -10, minimum requis: 0 ``` ### Validation Réussie + ``` ✅ Graphique validé avec succès • 8 nœuds détectés - • 12 liens configurés + • 12 liens configurés • Système particules: activé • Géométries: 3 Box, 2 Sphere, 2 Cylinder, 1 Cone ``` @@ -441,22 +565,26 @@ digraph ProcessingPipeline { ## 🎮 Utilisation dans VortexFlow ### 1. Édition + - Ouvrez l'**Éditeur de Graphiques** - Sélectionnez le mode **"DOT 3D"** - Saisissez votre code DOT étendu - La validation se fait en temps réel ### 2. Prévisualisation + - Cliquez sur **"Aperçu 3D"** - Le graphique s'affiche avec géométries et particules - Le **panneau de contrôle** permet d'ajuster les paramètres ### 3. Simulation + - Lancez la simulation avec **"Démarrer"** - Observez les particules circuler en temps réel - Consultez les métriques dans le panneau ### 4. Optimisation + - Utilisez les contrôles pour ajuster : - Taille des nœuds - Espacement diff --git a/doc/dot-3d/validation-rules.md b/doc/dot-3d/validation-rules.md index aa28b6d..06f855c 100644 --- a/doc/dot-3d/validation-rules.md +++ b/doc/dot-3d/validation-rules.md @@ -14,13 +14,14 @@ Ce document définit toutes les règles de validation pour la grammaire DOT 3D interface SyntaxValidation { // Validation structure de base validateGraphDeclaration(): ValidationResult; - validateNodeDeclaration(): ValidationResult; + validateNodeDeclaration(): ValidationResult; validateEdgeDeclaration(): ValidationResult; validateAttributeList(): ValidationResult; } ``` #### Règles de Base + - ✅ Graphique doit commencer par `digraph` ou `graph` - ✅ Accolades `{` `}` correctement appariées - ✅ Identifiants valides (lettres, chiffres, underscore) @@ -46,11 +47,11 @@ interface ExtendedSyntaxValidation { ```typescript enum GeometryType { - SPHERE = "Sphere", - BOX = "Box", - CYLINDER = "Cylinder", - CONE = "Cone", - TORUS = "Torus" + SPHERE = 'Sphere', + BOX = 'Box', + CYLINDER = 'Cylinder', + CONE = 'Cone', + TORUS = 'Torus', } interface GeometryValidation { @@ -63,80 +64,85 @@ interface GeometryValidation { ### 2.2 Contraintes par Géométrie #### Sphere + ```typescript const SphereValidation: GeometryValidation = { - geometry: "Sphere", - requiredDimensions: ["radius"], + geometry: 'Sphere', + requiredDimensions: ['radius'], validateDimensions: (dims) => { if (!dims.radius) return error("Sphere requires 'radius' dimension"); - if (dims.radius <= 0) return error("Sphere radius must be > 0"); + if (dims.radius <= 0) return error('Sphere radius must be > 0'); return success(); - } + }, }; ``` #### Box + ```typescript const BoxValidation: GeometryValidation = { - geometry: "Box", - requiredDimensions: ["width", "height", "depth"], + geometry: 'Box', + requiredDimensions: ['width', 'height', 'depth'], validateDimensions: (dims) => { - const required = ["width", "height", "depth"]; + const required = ['width', 'height', 'depth']; for (const dim of required) { if (!dims[dim]) return error(`Box requires '${dim}' dimension`); if (dims[dim] <= 0) return error(`Box ${dim} must be > 0`); } return success(); - } + }, }; ``` #### Cylinder + ```typescript const CylinderValidation: GeometryValidation = { - geometry: "Cylinder", - requiredDimensions: ["radius", "height"], + geometry: 'Cylinder', + requiredDimensions: ['radius', 'height'], validateDimensions: (dims) => { if (!dims.radius) return error("Cylinder requires 'radius' dimension"); if (!dims.height) return error("Cylinder requires 'height' dimension"); - if (dims.radius <= 0) return error("Cylinder radius must be > 0"); - if (dims.height <= 0) return error("Cylinder height must be > 0"); + if (dims.radius <= 0) return error('Cylinder radius must be > 0'); + if (dims.height <= 0) return error('Cylinder height must be > 0'); return success(); - } + }, }; ``` #### Cone + ```typescript const ConeValidation: GeometryValidation = { - geometry: "Cone", - requiredDimensions: ["radius", "height"], + geometry: 'Cone', + requiredDimensions: ['radius', 'height'], validateDimensions: (dims) => { if (!dims.radius) return error("Cone requires 'radius' dimension"); if (!dims.height) return error("Cone requires 'height' dimension"); - if (dims.radius <= 0) return error("Cone radius must be > 0"); - if (dims.height <= 0) return error("Cone height must be > 0"); + if (dims.radius <= 0) return error('Cone radius must be > 0'); + if (dims.height <= 0) return error('Cone height must be > 0'); return success(); - } + }, }; ``` #### Torus + ```typescript const TorusValidation: GeometryValidation = { - geometry: "Torus", - requiredDimensions: ["tube", "tubularSegments", "radialSegments"], + geometry: 'Torus', + requiredDimensions: ['tube', 'tubularSegments', 'radialSegments'], validateDimensions: (dims) => { if (!dims.tube) return error("Torus requires 'tube' dimension"); if (!dims.tubularSegments) return error("Torus requires 'tubularSegments'"); if (!dims.radialSegments) return error("Torus requires 'radialSegments'"); - - if (dims.tube <= 0) return error("Torus tube must be > 0"); - if (dims.tubularSegments < 3) return error("Torus tubularSegments must be >= 3"); - if (dims.radialSegments < 3) return error("Torus radialSegments must be >= 3"); - + + if (dims.tube <= 0) return error('Torus tube must be > 0'); + if (dims.tubularSegments < 3) return error('Torus tubularSegments must be >= 3'); + if (dims.radialSegments < 3) return error('Torus radialSegments must be >= 3'); + return success(); - } + }, }; ``` @@ -148,22 +154,91 @@ const TorusValidation: GeometryValidation = { ```typescript interface ParticleNodeValidation { + validateNodeRole(value: string): ValidationResult; validateParticleGeneration(value: number): ValidationResult; validateMaxParticleProcessing(value: number): ValidationResult; + validateQueueSize(value: number): ValidationResult; + validateProcessingTime(value: number): ValidationResult; + validateFailureRate(value: number): ValidationResult; + validateDropPolicy(value: string): ValidationResult; } +const NODE_ROLES = ['generator', 'relay', 'sink'] as const; +const DROP_POLICIES = ['tail', 'head', 'reject'] as const; + const ParticleNodeRules = { + validateNodeRole: (value: string) => { + if (!NODE_ROLES.includes(value as any)) { + return error(`nodeRole must be one of: ${NODE_ROLES.join(', ')}`); + } + return success(); + }, + validateParticleGeneration: (value: number) => { - if (value < 0) return error("particleGeneration cannot be negative"); - if (value > 10000) return warning("High particle generation may impact performance"); + if (value < 0) return error('particleGeneration cannot be negative'); + if (value > 10000) return warning('High particle generation may impact performance'); return success(); }, - + validateMaxParticleProcessing: (value: number) => { - if (value <= 0) return error("maxParticleProcessing must be > 0"); - if (value > 5000) return warning("High processing rate may impact performance"); + if (value <= 0) return error('maxParticleProcessing must be > 0'); + if (value > 5000) return warning('High processing rate may impact performance'); return success(); - } + }, + + validateQueueSize: (value: number) => { + if (!Number.isInteger(value)) return error('queue_size must be an integer'); + if (value <= 0) return error('queue_size must be > 0'); + if (value > 10000) return warning('Very large queue_size may impact memory'); + return success(); + }, + + validateProcessingTime: (value: number) => { + if (value < 0) return error('processing_time cannot be negative'); + return success(); + }, + + validateFailureRate: (value: number) => { + if (value < 0 || value > 1) return error('failure_rate must be in [0, 1]'); + return success(); + }, + + validateDropPolicy: (value: string) => { + if (!DROP_POLICIES.includes(value as any)) { + return error(`dropPolicy must be one of: ${DROP_POLICIES.join(', ')}`); + } + return success(); + }, +}; +``` + +### 3.1.bis Validation de cohérence inter-attributs + +```typescript +// Warnings, not errors — the validator still accepts the graph +const NodeCoherenceRules = { + // dropPolicy without queue_size is meaningless (queue is unbounded) + validateDropPolicyRequiresQueueSize: (node: Node3D) => { + if (node.dropPolicy !== undefined && node.queue_size === undefined) { + return warning( + `Node ${node.id}: dropPolicy="${node.dropPolicy}" has no effect ` + + `without queue_size — the queue is unbounded and never drops.` + ); + } + return success(); + }, + + // particleGeneration on a non-generator role is ignored at runtime + validateGenerationRequiresGeneratorRole: (node: Node3D) => { + const role = node.nodeRole ?? 'relay'; + if (node.particleGeneration && node.particleGeneration > 0 && role !== 'generator') { + return warning( + `Node ${node.id}: particleGeneration=${node.particleGeneration} ` + + `is ignored because nodeRole="${role}" (only "generator" emits).` + ); + } + return success(); + }, }; ``` @@ -177,16 +252,16 @@ interface ParticleEdgeValidation { const ParticleEdgeRules = { validateMaxParticleFlow: (value: number) => { - if (value <= 0) return error("maxParticleFlow must be > 0"); - if (value > 1000) return warning("High particle flow may cause congestion"); + if (value <= 0) return error('maxParticleFlow must be > 0'); + if (value > 1000) return warning('High particle flow may cause congestion'); return success(); }, - + validateParticleSpeed: (value: number) => { - if (value <= 0) return error("particleSpeed must be > 0"); - if (value > 10) return warning("Very high particle speed may reduce visibility"); + if (value <= 0) return error('particleSpeed must be > 0'); + if (value > 10) return warning('Very high particle speed may reduce visibility'); return success(); - } + }, }; ``` @@ -207,30 +282,30 @@ interface GlobalConfigValidation { const GlobalConfigRules = { validateDefaultNodeSize: (value: number) => { - if (value <= 0) return error("defaultNodeSize must be > 0"); - if (value > 10) return warning("Very large node size may cause overlap"); + if (value <= 0) return error('defaultNodeSize must be > 0'); + if (value > 10) return warning('Very large node size may cause overlap'); return success(); }, - + validateParticlesEnabled: (value: boolean) => { - if (typeof value !== 'boolean') return error("particlesEnabled must be true or false"); + if (typeof value !== 'boolean') return error('particlesEnabled must be true or false'); return success(); }, - + validateAutoResize: (value: boolean) => { - if (typeof value !== 'boolean') return error("autoResize must be true or false"); + if (typeof value !== 'boolean') return error('autoResize must be true or false'); return success(); }, - + validateBloomEffect: (value: boolean) => { - if (typeof value !== 'boolean') return error("bloomEffect must be true or false"); + if (typeof value !== 'boolean') return error('bloomEffect must be true or false'); return success(); }, - + validateAutoColors: (value: boolean) => { - if (typeof value !== 'boolean') return error("autoColors must be true or false"); + if (typeof value !== 'boolean') return error('autoColors must be true or false'); return success(); - } + }, }; ``` @@ -255,29 +330,39 @@ const ColorRules = { } return success(); }, - + validateRgbColor: (color: string) => { const rgbPattern = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/; const match = color.match(rgbPattern); if (!match) return error(`Invalid RGB color: ${color}. Format: rgb(r,g,b)`); - + const [, r, g, b] = match; if (+r > 255 || +g > 255 || +b > 255) { - return error("RGB values must be between 0 and 255"); + return error('RGB values must be between 0 and 255'); } return success(); }, - + validateNamedColor: (color: string) => { const namedColors = [ - 'red', 'blue', 'green', 'yellow', 'orange', 'purple', - 'pink', 'cyan', 'magenta', 'black', 'white', 'gray' + 'red', + 'blue', + 'green', + 'yellow', + 'orange', + 'purple', + 'pink', + 'cyan', + 'magenta', + 'black', + 'white', + 'gray', ]; if (!namedColors.includes(color)) { return error(`Unknown color name: ${color}. Supported: ${namedColors.join(', ')}`); } return success(); - } + }, }; ``` @@ -295,40 +380,39 @@ interface FlowConsistencyValidation { const FlowConsistencyRules = { validateNodeCapacity: (node: Node3D) => { - const incomingFlow = node.incomingEdges.reduce((sum, edge) => - sum + edge.maxParticleFlow, 0); + const incomingFlow = node.incomingEdges.reduce((sum, edge) => sum + edge.maxParticleFlow, 0); const nodeGeneration = node.particleGeneration; const totalInput = incomingFlow + nodeGeneration; - + if (totalInput > node.maxParticleProcessing * 1.5) { return warning( `Node ${node.id}: Total input (${totalInput}) significantly exceeds ` + - `processing capacity (${node.maxParticleProcessing}). ` + - `This will cause significant accumulation.` + `processing capacity (${node.maxParticleProcessing}). ` + + `This will cause significant accumulation.` ); } - + return success(); }, - + validateNetworkFlow: (graph: Graph3D) => { let totalGeneration = 0; let totalProcessing = 0; - + for (const node of graph.nodes) { totalGeneration += node.particleGeneration; totalProcessing += node.maxParticleProcessing; } - + if (totalGeneration > totalProcessing * 1.2) { return warning( `Network generates ${totalGeneration} particles/min but can only process ` + - `${totalProcessing}/min. Consider increasing processing capacity.` + `${totalProcessing}/min. Consider increasing processing capacity.` ); } - + return success(); - } + }, }; ``` @@ -342,39 +426,37 @@ interface PerformanceValidation { const PerformanceRules = { validateParticleCount: (graph: Graph3D) => { - const totalParticles = graph.nodes.reduce((sum, node) => - sum + node.particleGeneration, 0); - + const totalParticles = graph.nodes.reduce((sum, node) => sum + node.particleGeneration, 0); + if (totalParticles > 1000) { return warning( `High particle generation rate (${totalParticles}/min). ` + - `Consider reducing for better performance.` + `Consider reducing for better performance.` ); } - + if (totalParticles > 5000) { return error( `Extremely high particle rate (${totalParticles}/min). ` + - `This may cause severe performance issues.` + `This may cause severe performance issues.` ); } - + return success(); }, - + validateGeometryComplexity: (graph: Graph3D) => { - const complexGeometries = graph.nodes.filter(node => - node.geometry === 'Torus').length; - + const complexGeometries = graph.nodes.filter((node) => node.geometry === 'Torus').length; + if (complexGeometries > 20) { return warning( `Many complex geometries (${complexGeometries} Torus). ` + - `Consider using simpler shapes for better performance.` + `Consider using simpler shapes for better performance.` ); } - + return success(); - } + }, }; ``` @@ -386,10 +468,10 @@ const PerformanceRules = { ```typescript enum ErrorType { - SYNTAX_ERROR = "SYNTAX_ERROR", - SEMANTIC_ERROR = "SEMANTIC_ERROR", - WARNING = "WARNING", - INFO = "INFO" + SYNTAX_ERROR = 'SYNTAX_ERROR', + SEMANTIC_ERROR = 'SEMANTIC_ERROR', + WARNING = 'WARNING', + INFO = 'INFO', } interface ValidationMessage { @@ -405,34 +487,34 @@ const ErrorTemplates = { INVALID_GEOMETRY: (line: number, geometry: string) => ({ type: ErrorType.SEMANTIC_ERROR, line, - code: "INVALID_GEOMETRY", + code: 'INVALID_GEOMETRY', message: `Geometry "${geometry}" is not supported`, - suggestion: `Supported geometries: Sphere, Box, Cylinder, Cone, Torus` + suggestion: `Supported geometries: Sphere, Box, Cylinder, Cone, Torus`, }), - + MISSING_DIMENSIONS: (line: number, geometry: string, missing: string[]) => ({ type: ErrorType.SEMANTIC_ERROR, line, - code: "MISSING_DIMENSIONS", + code: 'MISSING_DIMENSIONS', message: `Geometry "${geometry}" is missing required dimensions: ${missing.join(', ')}`, - suggestion: `Add dimensions="{${missing.map(d => d + ': value').join(', ')}}"}` + suggestion: `Add dimensions="{${missing.map((d) => d + ': value').join(', ')}}"}`, }), - + NEGATIVE_VALUE: (line: number, attribute: string, value: number) => ({ type: ErrorType.SEMANTIC_ERROR, line, - code: "NEGATIVE_VALUE", + code: 'NEGATIVE_VALUE', message: `Attribute "${attribute}" cannot be negative (got: ${value})`, - suggestion: `Use a positive value for ${attribute}` + suggestion: `Use a positive value for ${attribute}`, }), - + PERFORMANCE_WARNING: (line: number, attribute: string, value: number, threshold: number) => ({ type: ErrorType.WARNING, line, - code: "PERFORMANCE_WARNING", + code: 'PERFORMANCE_WARNING', message: `High ${attribute} value (${value}) may impact performance`, - suggestion: `Consider reducing below ${threshold} for optimal performance` - }) + suggestion: `Consider reducing below ${threshold} for optimal performance`, + }), }; ``` @@ -441,24 +523,30 @@ const ErrorTemplates = { ```typescript class ValidationReporter { static formatMessage(msg: ValidationMessage): string { - const prefix = msg.type === ErrorType.SYNTAX_ERROR || msg.type === ErrorType.SEMANTIC_ERROR - ? "❌" : msg.type === ErrorType.WARNING ? "⚠️" : "ℹ️"; - + const prefix = + msg.type === ErrorType.SYNTAX_ERROR || msg.type === ErrorType.SEMANTIC_ERROR + ? '❌' + : msg.type === ErrorType.WARNING + ? '⚠️' + : 'ℹ️'; + let formatted = `${prefix} ${msg.type}`; if (msg.line) formatted += ` ligne ${msg.line}`; formatted += `: ${msg.message}`; - + if (msg.suggestion) { formatted += `\n 💡 Suggestion: ${msg.suggestion}`; } - + return formatted; } - + static summarizeValidation(messages: ValidationMessage[]): string { - const errors = messages.filter(m => m.type === ErrorType.SEMANTIC_ERROR || m.type === ErrorType.SYNTAX_ERROR); - const warnings = messages.filter(m => m.type === ErrorType.WARNING); - + const errors = messages.filter( + (m) => m.type === ErrorType.SEMANTIC_ERROR || m.type === ErrorType.SYNTAX_ERROR + ); + const warnings = messages.filter((m) => m.type === ErrorType.WARNING); + if (errors.length === 0) { return `✅ Validation réussie (${warnings.length} avertissement${warnings.length !== 1 ? 's' : ''})`; } else { @@ -478,35 +566,36 @@ class ValidationReporter { class DOT3DValidator { validate(dotContent: string): ValidationResult { const results: ValidationMessage[] = []; - + // 1. Validation syntaxique results.push(...this.validateSyntax(dotContent)); - if (results.some(r => r.type === ErrorType.SYNTAX_ERROR)) { + if (results.some((r) => r.type === ErrorType.SYNTAX_ERROR)) { return { success: false, messages: results }; } - + // 2. Parse en AST const ast = this.parse(dotContent); - + // 3. Validation sémantique results.push(...this.validateGeometry(ast)); results.push(...this.validateParticles(ast)); results.push(...this.validateColors(ast)); results.push(...this.validateGlobalConfig(ast)); - + // 4. Validation de cohérence results.push(...this.validateConsistency(ast)); - + // 5. Validation performance results.push(...this.validatePerformance(ast)); - - const hasErrors = results.some(r => - r.type === ErrorType.SYNTAX_ERROR || r.type === ErrorType.SEMANTIC_ERROR); - + + const hasErrors = results.some( + (r) => r.type === ErrorType.SYNTAX_ERROR || r.type === ErrorType.SEMANTIC_ERROR + ); + return { success: !hasErrors, messages: results, - summary: ValidationReporter.summarizeValidation(results) + summary: ValidationReporter.summarizeValidation(results), }; } } diff --git a/frontend/doc/RENDERER.md b/frontend/doc/RENDERER.md index 750aab9..a61155b 100644 --- a/frontend/doc/RENDERER.md +++ b/frontend/doc/RENDERER.md @@ -55,6 +55,7 @@ and `simulationRunning` are true. An idle graph is static. **Why**: the default of `3d-force-graph` is to keep particles flowing forever once enabled, which: + - burns CPU/GPU when the user isn't watching a sim, - visually misleads users who think a graph "is simulating" when it's just a screensaver. @@ -89,6 +90,7 @@ links. A visited set prevents cycle storms (depth cap 15). `(1 / particleSpeed) * 16.67 ms` (one frame at 60 fps scaled by speed). **Emitter definition**: + - If any node defines `particleGeneration > 0`, only those are emitters. - Otherwise, every node emits one particle. @@ -105,6 +107,7 @@ defeat that. **What**: when no node defines `particleGeneration` / `maxParticleProcessing`, the simulation effect falls back to: + - particle count = `scene.particles.length` (whatever 3d-force-graph happens to be displaying) - average traversal latency = derived from `particleSpeed` @@ -138,7 +141,41 @@ write a parallel init path (e.g. for a "load template" feature). --- -## 8. DOT parsing pipeline (with fallback) +## 8.bis DES visual hooks (Phase 5) + +When the DES simulator owns the simulation (i.e. at least one +`nodeRole=generator` node is declared — ADR-006), four visual behaviours +are applied via accessor overrides: + +- **Queue growth on relays** — `nodeVal(node)` returns + `baseSize * (1 + min(1, pending/queue_size))`, capped at 2× when the + queue is full. Empty queue → normal size, full queue → twice as big. +- **Saturation halo** — `nodeColor(node)` returns `#ff9800` (orange) when + the queue is between 80 % and 100 % full, `#d32f2f` (red) when fully + saturated. Overrides the user-defined `node.color`. +- **Drop flash** — for ~200 ms after a node's `droppedCount` increments, + `nodeColor` returns `#ff1744` (bright red). Detected via diffing per + tick on `simulatorStats.queues[id].droppedCount`. The flash is short on + purpose so it doesn't merge into the sustained-saturation colour above. +- **Role tint (only when no explicit `color`)** — `generator` → teal, + `sink` → indigo. `relay` keeps the renderer's default. Explicit DOT + colours are always preserved. + +Three module-level refs back this: +`queueStatsByNodeRef`, `dropFlashTimeRef`, `previousDroppedCountRef`. +They are updated by a `useEffect` listening to `simulatorStats`. The same +effect re-installs the accessors (`fg.nodeVal(fg.nodeVal())`) so +`3d-force-graph` re-evaluates them on every node — cheap (no layout +rebuild), safe on large graphs. + +Don't read these refs from JSX directly — they are intentionally NOT +React state because mutating them must not trigger a renderer rerender. +A new `Drops` chip in the HUD reads from `simulatorStats` directly (which +is React state), and is only rendered when `hasGenerators && simulatorStats`. + +--- + +## 9. DOT parsing pipeline (with fallback) **Primary path**: `DotTo3DConverter.parseDotToGraphData(dotContent)` calls `${VITE_API_URL}/public/parse-dot` via `fetch` (currently — there's an open @@ -189,6 +226,9 @@ Before merging a change to `GraphRenderer3D.tsx`, mentally walk through: - [ ] "Émission particules" emits one particle per emitter and stops (doesn't keep emitting). - [ ] Stats panel shows non-zero values on a plain DOT graph (fallback works). +- [ ] When a graph has `nodeRole=generator`, the DES simulator drives emission, + relay queues grow visibly under load, drops trigger a 200 ms red flash, + and the HUD shows a `Drops` chip. - [ ] `setCurrentGraphData` is called from every init path you added. - [ ] Test suite still passes (`npm test -- GraphRenderer3D.test.tsx`). - [ ] Lint clean. diff --git a/frontend/src/components/graphs/DotTo3DConverter.test.tsx b/frontend/src/components/graphs/DotTo3DConverter.test.tsx index 7e52fb4..eb64a6f 100644 --- a/frontend/src/components/graphs/DotTo3DConverter.test.tsx +++ b/frontend/src/components/graphs/DotTo3DConverter.test.tsx @@ -222,9 +222,10 @@ describe('convertBackendDataToGraph', () => { expect(r.nodes[0]).toEqual(expect.objectContaining({ id: 'A', name: 'Alpha', val: 10, color: '#ff0000', geometry: '3d-sphere', })); - // Defaults applied where backend omits values. + // Backend-omitted color stays undefined so the renderer's nodeColor + // accessor can apply role tints (ADR-006) before falling back to a default. expect(r.nodes[1]).toEqual(expect.objectContaining({ - id: 'B', name: 'Beta', color: '#1976D2', + id: 'B', name: 'Beta', color: undefined, })); expect(r.links[0]).toEqual(expect.objectContaining({ source: 'A', target: 'B', name: 'link', color: '#00ff00', style: 'dashed', diff --git a/frontend/src/components/graphs/GraphList.test.tsx b/frontend/src/components/graphs/GraphList.test.tsx index e48e756..5d97b97 100644 --- a/frontend/src/components/graphs/GraphList.test.tsx +++ b/frontend/src/components/graphs/GraphList.test.tsx @@ -105,4 +105,42 @@ describe('GraphList', () => { render(); await waitFor(() => expect(ctx.loadGraphs).toHaveBeenCalled()); }); + + // Regression: the menu item used to call handleMenuClose() which nulled + // selectedGraphId before the confirmation dialog could read it, so the + // dialog's "Supprimer" button became a no-op (no deleteGraph call). + test('Supprimer flow calls deleteGraph with the selected graph id', async () => { + const ctx = baseGraphCtx({ + state: { graphs: [fakeGraph({ id: 42, name: 'Alpha' })] }, + }); + mockUseGraph.mockReturnValue(ctx); + const { container } = render(); + // Open the per-card kebab menu (MoreVert button — only icon-only btn + // in the card with no accessible name). + const moreBtn = container.querySelector('[data-testid="MoreVertIcon"]')!.closest('button')!; + userEvent.click(moreBtn); + // Click "Supprimer" in the menu → opens the confirmation dialog. + userEvent.click(await screen.findByRole('menuitem', { name: /Supprimer/i })); + // Click the dialog's "Supprimer" button. + const dialog = await screen.findByRole('dialog'); + userEvent.click(dialog.querySelector('button.MuiButton-containedError')! as HTMLElement); + await waitFor(() => expect(ctx.deleteGraph).toHaveBeenCalledWith(42)); + }); + + // Regression (same root cause as above): Dupliquer was also broken. + test('Dupliquer flow calls duplicateGraph with the selected graph id', async () => { + const ctx = baseGraphCtx({ + state: { graphs: [fakeGraph({ id: 99, name: 'Beta' })] }, + }); + mockUseGraph.mockReturnValue(ctx); + const { container } = render(); + const moreBtn = container.querySelector('[data-testid="MoreVertIcon"]')!.closest('button')!; + userEvent.click(moreBtn); + userEvent.click(await screen.findByRole('menuitem', { name: /Dupliquer/i })); + const dialog = await screen.findByRole('dialog'); + // The duplicate name input must be filled before the submit button is enabled. + userEvent.type(dialog.querySelector('input')!, 'Beta-copy'); + userEvent.click(dialog.querySelector('button.MuiButton-containedPrimary')! as HTMLElement); + await waitFor(() => expect(ctx.duplicateGraph).toHaveBeenCalledWith(99, 'Beta-copy')); + }); }); diff --git a/frontend/src/components/graphs/GraphList.tsx b/frontend/src/components/graphs/GraphList.tsx index 743e679..7ba7a5f 100644 --- a/frontend/src/components/graphs/GraphList.tsx +++ b/frontend/src/components/graphs/GraphList.tsx @@ -431,19 +431,24 @@ const GraphList: React.FC = () => { )} {canEdit() && ( - { handleMenuClose(); setDuplicateDialogOpen(true); }}> + // Close only the menu anchor — keep selectedGraphId set so the + // dialog can read it. handleMenuClose() would null it out and + // handleDuplicate's `if (selectedGraphId && ...)` guard would fail. + { setMenuAnchorEl(null); setDuplicateDialogOpen(true); }}> Dupliquer )} - + { handleMenuClose(); /* Implémenter partage */ }}> Partager - + {canEdit() && ( - { handleMenuClose(); setDeleteDialogOpen(true); }} sx={{ color: 'error.main' }}> + // Same caveat as Dupliquer above: preserve selectedGraphId so + // handleDelete's guard passes when the dialog confirms. + { setMenuAnchorEl(null); setDeleteDialogOpen(true); }} sx={{ color: 'error.main' }}> Supprimer diff --git a/frontend/src/components/graphs/GraphRenderer3D.test.tsx b/frontend/src/components/graphs/GraphRenderer3D.test.tsx index c62acda..6a63d31 100644 --- a/frontend/src/components/graphs/GraphRenderer3D.test.tsx +++ b/frontend/src/components/graphs/GraphRenderer3D.test.tsx @@ -121,7 +121,8 @@ const DOT = 'digraph G { A -> B; B -> C; A -> C; }'; // Stub the global fetch so that path returns a deterministic graph. const SAMPLE_PARSE = { nodes: [ - { id: 'A', name: 'A', particleGeneration: 5, maxParticleProcessing: 3 }, + // A is the lone generator (ADR-006 V1 strict: only nodeRole=generator emits). + { id: 'A', name: 'A', nodeRole: 'generator', particleGeneration: 5, maxParticleProcessing: 3 }, { id: 'B', name: 'B' }, { id: 'C', name: 'C' }, ], @@ -204,16 +205,20 @@ describe('GraphRenderer3D — particles gated by simulationRunning', () => { expect(cb({ name: 'a-b' })).toBe(0); }); - test('linkDirectionalParticles emits >0 once the simulation is running', async () => { + test('linkDirectionalParticles still returns 0 when running with generators (DES mode)', async () => { + // With at least one nodeRole=generator (SAMPLE_PARSE), the DES simulator + // owns emission and the continuous-flow fallback stays disabled even + // while the simulation is running. The simulator calls emitParticle + // directly through onParticleReleased — checked in a separate test. const { rerender } = render(); await advancePastInit(); rerender(); - // After the prop flips, updateParticleProperties re-runs — wait for the - // callback to be reinstalled with the new behaviour. + // Give updateParticleProperties one render to reinstall the callback. await waitFor(() => { - expect(fgState.callbacks.linkDirectionalParticles({ name: 'a-b' })).toBeGreaterThan(0); + expect(fgState.callbacks.linkDirectionalParticles).toBeDefined(); }); + expect(fgState.callbacks.linkDirectionalParticles({ name: 'a-b' })).toBe(0); }); }); @@ -239,39 +244,6 @@ describe('GraphRenderer3D — Émission particules (one-shot trace)', () => { }); }); -// ---------------------------------------------------------------------------- -// Toolbar / panel sync -// ---------------------------------------------------------------------------- -describe('GraphRenderer3D — Start Simulation button delegates to onToggleSimulation', () => { - test('clicking Start Simulation in the rail calls the parent toggle', async () => { - const onToggle = vi.fn(); - render( - , - ); - await advancePastInit(); - - const btn = screen.getByLabelText(/Start Simulation/i); - fireEvent.click(btn); - - expect(onToggle).toHaveBeenCalledTimes(1); - }); - - test('falls back to a local toggle if no parent callback is provided', async () => { - render(); - await advancePastInit(); - - const btn = screen.getByLabelText(/Start Simulation/i); - fireEvent.click(btn); - // Local fallback flips the flag → label should switch to "Pause Simulation". - await screen.findByLabelText(/Pause Simulation/i); - }); -}); - // ---------------------------------------------------------------------------- // Cleanup // ---------------------------------------------------------------------------- diff --git a/frontend/src/components/graphs/GraphRenderer3D.tsx b/frontend/src/components/graphs/GraphRenderer3D.tsx index 60a7958..8fad42b 100644 --- a/frontend/src/components/graphs/GraphRenderer3D.tsx +++ b/frontend/src/components/graphs/GraphRenderer3D.tsx @@ -19,14 +19,13 @@ import { Label as LabelIcon, TextFields as TextFieldsIcon, FlashOn as FlashOnIcon, - PlayArrow as PlayArrowIcon, - Pause as PauseIcon, Tune as TuneIcon, } from '@mui/icons-material'; import ForceGraph3D from '3d-force-graph'; import * as THREE from 'three'; import SpriteText from 'three-spritetext'; import { GraphData } from '../../types'; +import { useParticleSimulator } from '../../hooks/useParticleSimulator'; // Déclaration de type pour THREE.js global declare global { @@ -45,10 +44,6 @@ interface GraphRenderer3DProps { // Drives the in-renderer simulation: when true, particles emit along links // and the per-node accumulation / stats effect runs. isSimulationRunning?: boolean; - // Optional toggle handler: when provided, the panel's Start/Pause button - // delegates to the parent (GraphViewer) instead of flipping a local flag, - // so the toolbar icon and the panel button stay in sync. - onToggleSimulation?: () => void; } // Types pour la gestion des données 3D étendues @@ -69,6 +64,12 @@ interface ForceGraphNode { image?: string; autoResize?: boolean; bloomEffect?: boolean; + // DES attributes (ADR-006) — consumed by ParticleSimulator + nodeRole?: 'generator' | 'relay' | 'sink'; + dropPolicy?: 'tail' | 'head' | 'reject'; + queue_size?: number; + processing_time?: number; + failure_rate?: number; } interface ForceGraphLink { @@ -243,6 +244,16 @@ export class DotTo3DConverter { color: attrs.color }); + const validRoles: Array> = [ + 'generator', + 'relay', + 'sink', + ]; + const validDropPolicies: Array> = [ + 'tail', + 'head', + 'reject', + ]; const node: ForceGraphNode = { id: nodeId, name: attrs.name || attrs.label || nodeId, @@ -252,11 +263,23 @@ export class DotTo3DConverter { // Extensions 3D pour les nœuds geometry: this.parseGeometry(attrs.geometry), dimensions: this.parseDimensions(attrs.dimensions), - particleGeneration: attrs.particleGeneration ? parseInt(attrs.particleGeneration) : undefined, - maxParticleProcessing: attrs.maxParticleProcessing ? parseInt(attrs.maxParticleProcessing) : undefined, + particleGeneration: attrs.particleGeneration ? parseFloat(attrs.particleGeneration) : undefined, + maxParticleProcessing: attrs.maxParticleProcessing + ? parseFloat(attrs.maxParticleProcessing) + : undefined, image: attrs.image, autoResize: attrs.autoResize ? this.parseBoolean(attrs.autoResize) : undefined, - bloomEffect: attrs.bloomEffect ? this.parseBoolean(attrs.bloomEffect) : undefined + bloomEffect: attrs.bloomEffect ? this.parseBoolean(attrs.bloomEffect) : undefined, + // DES attributes (ADR-006) + nodeRole: validRoles.includes(attrs.nodeRole as any) + ? (attrs.nodeRole as ForceGraphNode['nodeRole']) + : undefined, + dropPolicy: validDropPolicies.includes(attrs.dropPolicy as any) + ? (attrs.dropPolicy as ForceGraphNode['dropPolicy']) + : undefined, + queue_size: attrs.queue_size ? parseInt(attrs.queue_size, 10) : undefined, + processing_time: attrs.processing_time ? parseFloat(attrs.processing_time) : undefined, + failure_rate: attrs.failure_rate ? parseFloat(attrs.failure_rate) : undefined, }; console.log(`✅ Nœud ${nodeId} final:`, node); @@ -362,19 +385,42 @@ export class DotTo3DConverter { // Convertir les nœuds du backend if (backendData.nodes) { for (const node of backendData.nodes) { + // DES attributes pass through as numbers/strings — the simulator + // applies its own defaults if undefined. + const validRoles: Array> = [ + 'generator', + 'relay', + 'sink', + ]; + const validDropPolicies: Array> = [ + 'tail', + 'head', + 'reject', + ]; + const nodeRole = validRoles.includes(node.nodeRole) ? node.nodeRole : undefined; + const dropPolicy = validDropPolicies.includes(node.dropPolicy) ? node.dropPolicy : undefined; nodes.push({ id: node.id, name: node.label || node.name || node.id, group: 1, val: parseFloat(node.size || '8'), - color: node.color || '#1976D2', + // Preserve "user did not specify" by keeping color undefined here. + // The renderer's nodeColor accessor falls back to a default after + // checking for drop flash, saturation halo and role tint in order. + color: node.color, geometry: this.parseGeometry(node.geometry), dimensions: this.parseDimensions(node.dimensions), - particleGeneration: node.particleGeneration ? parseInt(node.particleGeneration) : undefined, - maxParticleProcessing: node.maxParticleProcessing ? parseInt(node.maxParticleProcessing) : undefined, + particleGeneration: node.particleGeneration ? parseFloat(node.particleGeneration) : undefined, + maxParticleProcessing: node.maxParticleProcessing ? parseFloat(node.maxParticleProcessing) : undefined, image: node.image, autoResize: node.autoResize ? this.parseBoolean(node.autoResize) : undefined, - bloomEffect: node.bloomEffect ? this.parseBoolean(node.bloomEffect) : undefined + bloomEffect: node.bloomEffect ? this.parseBoolean(node.bloomEffect) : undefined, + // DES attributes (ADR-006) + nodeRole, + dropPolicy, + queue_size: node.queue_size ? parseInt(node.queue_size, 10) : undefined, + processing_time: node.processing_time ? parseFloat(node.processing_time) : undefined, + failure_rate: node.failure_rate ? parseFloat(node.failure_rate) : undefined, }); } } @@ -386,7 +432,7 @@ export class DotTo3DConverter { source: link.source, target: link.target, name: link.label || '', - color: link.color || '#888', + color: link.color, maxParticleFlow: link.maxParticleFlow ? parseInt(link.maxParticleFlow) : undefined, particleSpeed: link.particleSpeed ? parseFloat(link.particleSpeed) : undefined, style: link.style as 'solid' | 'dashed' | 'dotted' || 'solid' @@ -510,7 +556,6 @@ const GraphRenderer3D: React.FC = ({ isValid, parsedData: _parsedData, isSimulationRunning, - onToggleSimulation, }) => { const graphRef = useRef(null); const forceGraphRef = useRef(null); @@ -540,7 +585,10 @@ const GraphRenderer3D: React.FC = ({ const [simulationStats, setSimulationStats] = useState({ totalParticles: 0, averageLatency: 0, - bottleneckNodes: 0 + bottleneckNodes: 0, + // Phase 6 — session-scoped DES metrics. Reset on simulator (re)start. + maxQueueSize: 0, + throughputPerSec: 0, }); // Mirror the global simulation state (driven by the toolbar) into the @@ -556,6 +604,168 @@ const GraphRenderer3D: React.FC = ({ // État pour stocker les données du graphique const [currentGraphData, setCurrentGraphData] = useState<{nodes: ForceGraphNode[], links: ForceGraphLink[]}>({nodes: [], links: []}); + // DES particle simulator (ADR-006). The hook owns the simulator instance, + // drives it via rAF, and surfaces stats via React state. We wire its + // `onParticleReleased` to `emitParticle` on the 3d-force-graph instance so + // each logical release produces a visible animation. + const onSimulatorParticleReleased = useCallback((linkId: string) => { + const fg = forceGraphRef.current; + if (!fg || typeof fg.emitParticle !== 'function') return; + // The simulator generates link ids as "->#". + // Resolve back to the link object in the live graph to call emitParticle. + const data = fg.graphData(); + if (!data?.links?.length) return; + const match = linkId.match(/^(.+)->(.+?)#\d+$/); + if (!match) return; + const [, source, target] = match; + const link = data.links.find((l: any) => { + const sId = typeof l.source === 'object' ? l.source.id : l.source; + const tId = typeof l.target === 'object' ? l.target.id : l.target; + return sId === source && tId === target; + }); + if (link) fg.emitParticle(link); + }, []); + + const { stats: simulatorStats, hasGenerators } = useParticleSimulator({ + graphData: currentGraphData, + isRunning: simulationRunning, + onParticleReleased: onSimulatorParticleReleased, + }); + + // Visualisation refs (Phase 5). + // + // The nodeVal / nodeColor accessors read these refs at render time. We + // update them on every simulator tick — without forcing a React rerender + // of the whole component (which would be wasteful) — and then ping the + // force graph to re-evaluate its accessors. + // + // queueStatsByNode : current queue size + cumulative drops, keyed by node id + // dropFlashTime : timestamp (performance.now ms) of the last detected drop + // for that node. Used to colour the node red for ~200ms + // after each drop event. + // previousDroppedCount : last-seen droppedCount per node, used to detect + // "a new drop happened" by diffing against the current snapshot. + const queueStatsByNodeRef = useRef>( + new Map() + ); + const dropFlashTimeRef = useRef>(new Map()); + const previousDroppedCountRef = useRef>(new Map()); + // Phase 6 — session-scoped HUD metrics. + // maxQueueSeenRef: highest pending count observed across all relays since + // the last start(). Reset on start. + // throughputSamplesRef: sliding window of (time, totalEmitted) samples used + // to compute particles/sec. Trimmed to the last 2 seconds. + const maxQueueSeenRef = useRef(0); + const throughputSamplesRef = useRef<{ time: number; totalEmitted: number }[]>([]); + // Detect simulator (re)start (false→true edge on simulationRunning) to + // reset session-scoped refs in sync with the simulator's own start() reset. + const prevSimulationRunningRef = useRef(false); + + // Drop flash duration in ms — kept short so it doesn't visually merge into + // sustained-saturation states. + const DROP_FLASH_MS = 200; + + // Phase 5 — centralised resolvers used by both the nodeColor accessor + // (for default sphere meshes) AND the per-tick mutation of custom meshes + // built by nodeThreeObjectCallback. 3d-force-graph only re-evaluates + // nodeColor for its own built-in spheres; meshes returned from + // nodeThreeObject are created once and never re-coloured by the engine, + // so we mutate their material.color directly in the sync effect below. + // + // Priority: drop flash > saturation halo > role tint > user colour > fallback. + const resolveNodeColor = useCallback((node: any): string => { + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const lastFlash = dropFlashTimeRef.current.get(node.id); + if (lastFlash !== undefined && now - lastFlash < DROP_FLASH_MS) { + return '#ff1744'; + } + const qStat = queueStatsByNodeRef.current.get(node.id); + if (qStat && node.queue_size && node.queue_size > 0) { + const ratio = qStat.size / node.queue_size; + if (ratio >= 1) return '#d32f2f'; + if (ratio > 0.8) return '#ff9800'; + } + if (!node.color) { + if (node.nodeRole === 'generator') return '#80cbc4'; + if (node.nodeRole === 'sink') return '#9fa8da'; + } + return node.color || '#4fc3f7'; + }, []); + + // Scale factor applied to a node based on its queue occupancy + // (capped at 2×). Empty queue or no queue_size → 1. + const resolveNodeScale = useCallback((node: any): number => { + const qStat = queueStatsByNodeRef.current.get(node.id); + if (qStat && node.queue_size && node.queue_size > 0) { + const ratio = Math.min(1, qStat.size / node.queue_size); + return 1 + ratio; + } + return 1; + }, []); + + // Sync visualisation refs with the simulator's stats stream and ping the + // force graph so it picks up the new queue sizes (node growth) and colour + // overrides (saturation halo, drop flash). + useEffect(() => { + if (!simulatorStats) return; + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + + // Detect newly-arrived drops by diffing per-node droppedCount. + // Also track the largest queue size seen this session (Phase 6). + for (const [nodeId, q] of simulatorStats.queues) { + const prev = previousDroppedCountRef.current.get(nodeId) ?? 0; + if (q.droppedCount > prev) { + dropFlashTimeRef.current.set(nodeId, now); + } + previousDroppedCountRef.current.set(nodeId, q.droppedCount); + if (q.size > maxQueueSeenRef.current) { + maxQueueSeenRef.current = q.size; + } + } + + // Phase 6 — append a throughput sample, trim to the last 2 s. + throughputSamplesRef.current.push({ time: now, totalEmitted: simulatorStats.totalEmitted }); + const cutoff = now - 2000; + while ( + throughputSamplesRef.current.length > 0 && + throughputSamplesRef.current[0].time < cutoff + ) { + throughputSamplesRef.current.shift(); + } + + queueStatsByNodeRef.current = new Map(simulatorStats.queues); + + // Update the visual state of each node's mesh directly. For nodes + // rendered via the engine's default sphere, the nodeColor accessor is + // re-evaluated by 3d-force-graph automatically — but for nodes with a + // custom mesh (returned from nodeThreeObject), the engine builds the + // mesh once and never re-colours it. So we walk `__threeObj` and mutate + // material.color + Group.scale directly. Cheap: at most N nodes per tick. + const fg = forceGraphRef.current; + if (fg) { + try { + const data = fg.graphData(); + for (const node of data.nodes) { + const obj3d = (node as any).__threeObj; + if (!obj3d) continue; + const desiredColor = resolveNodeColor(node); + const desiredScale = resolveNodeScale(node); + obj3d.scale.setScalar(desiredScale); + obj3d.traverse((child: any) => { + if (child.isMesh && child.material && child.material.color) { + child.material.color.set(desiredColor); + if (child.material.emissive) { + child.material.emissive.set(desiredColor).multiplyScalar(0.05); + } + } + }); + } + } catch { + /* ref is mid-init or being disposed — ignore */ + } + } + }, [simulatorStats, resolveNodeColor, resolveNodeScale]); + // First-render guard: a few downstream effects (showNodeText / showLinkText // reconfigure) run only after init completes. Flip the flag once dimensions // are known so they fire correctly. @@ -682,10 +892,13 @@ const GraphRenderer3D: React.FC = ({ // Opaque material — transparent meshes don't write to the depth buffer, // which made faces flicker / vanish when rotating. DoubleSide also avoids // backface culling artifacts on torus / cone interiors. + // Initial colour uses the same resolver as the per-tick mutation in the + // simulator-stats sync effect, so role tints apply from the first frame. + const initialColor = resolveNodeColor(node); material = new THREE.MeshLambertMaterial({ - color: node.color || '#4fc3f7', + color: initialColor, side: THREE.DoubleSide, - emissive: node.bloomEffect ? new THREE.Color(node.color || '#4fc3f7').multiplyScalar(0.1) : 0x000000 + emissive: node.bloomEffect ? new THREE.Color(initialColor).multiplyScalar(0.1) : 0x000000 }); const mesh = new THREE.Mesh(geometry, material); @@ -745,7 +958,7 @@ const GraphRenderer3D: React.FC = ({ console.error('Erreur lors de la création de la géométrie 3D:', error); return undefined; } - }, [showNodeText]); + }, [showNodeText, resolveNodeColor]); // Effet pour redimensionner le graphique 3D quand les dimensions changent useEffect(() => { @@ -771,12 +984,18 @@ const GraphRenderer3D: React.FC = ({ const updateParticleProperties = useCallback(() => { if (!forceGraphRef.current) return; - + forceGraphRef.current // Particles only emit while a simulation is running. Outside of that, // every link reports 0 so nothing flows on idle graphs. + // + // When the DES simulator owns emission (hasGenerators === true), this + // returns 0 — the simulator's onParticleReleased drives emitParticle() + // explicitly. We keep the legacy continuous flow only as a fallback + // for graphs that don't declare nodeRole=generator anywhere. .linkDirectionalParticles((link: any) => { if (!showParticles || !simulationRunning) return 0; + if (hasGenerators) return 0; if (link.maxParticleFlow && link.maxParticleFlow > 0) { return Math.max(1, Math.min(10, Math.floor(link.maxParticleFlow / 20))); } @@ -805,11 +1024,15 @@ const GraphRenderer3D: React.FC = ({ // link.color here: dark link colors (and unlit Lambert fallbacks) made // particles render almost black, masking labels behind them. .linkDirectionalParticleColor(() => '#ffd54f'); - }, [showParticles, simulationRunning]); + }, [showParticles, simulationRunning, hasGenerators]); - // Simulation temps réel des accumulations + // Simulation temps réel des accumulations (fallback heuristique). + // Désactivé quand le DES simulator est en charge (hasGenerators=true) : + // dans ce cas, les stats viennent directement de simulatorStats via + // l'effect ci-dessous. useEffect(() => { if (!simulationRunning || !currentGraphData?.nodes) return; + if (hasGenerators) return; // Track in-degree and link traversal time so that even DOT graphs without // VortexFlow attributes get meaningful stats once the sim is running. @@ -880,23 +1103,71 @@ const GraphRenderer3D: React.FC = ({ forceGraphRef.current.nodeVal(undefined); } }, 100); - + return () => clearInterval(interval); - }, [simulationRunning, currentGraphData, nodeAccumulation]); + }, [simulationRunning, currentGraphData, nodeAccumulation, hasGenerators]); + + // Branch real-time stats from the DES simulator into simulationStats so the + // existing HUD doesn't need to change shape. Only active when the simulator + // is in charge (hasGenerators === true); otherwise the heuristic effect + // above continues to populate simulationStats. + useEffect(() => { + if (!hasGenerators || !simulatorStats) return; + // Count "bottleneck" nodes as those with a non-trivial queue size. + let bottleneckCount = 0; + for (const q of simulatorStats.queues.values()) { + if (q.size > 5) bottleneckCount++; + } + // Phase 6 — instantaneous throughput from the sliding window. + let throughputPerSec = 0; + const samples = throughputSamplesRef.current; + if (samples.length >= 2) { + const first = samples[0]; + const last = samples[samples.length - 1]; + const dtMs = last.time - first.time; + if (dtMs > 0) { + throughputPerSec = Math.round(((last.totalEmitted - first.totalEmitted) / dtMs) * 1000); + } + } + setSimulationStats({ + totalParticles: simulatorStats.particlesInFlight, + averageLatency: Number.isNaN(simulatorStats.averageLatencyMs) + ? 0 + : Math.round(simulatorStats.averageLatencyMs), + bottleneckNodes: bottleneckCount, + maxQueueSize: maxQueueSeenRef.current, + throughputPerSec, + }); + }, [hasGenerators, simulatorStats]); + + // Reset session-scoped HUD refs on simulator (re)start. We mirror the + // simulator's own reset-on-start contract (D5) so the HUD doesn't carry + // stale data across runs. + useEffect(() => { + if (simulationRunning && !prevSimulationRunningRef.current) { + maxQueueSeenRef.current = 0; + throughputSamplesRef.current = []; + previousDroppedCountRef.current.clear(); + dropFlashTimeRef.current.clear(); + } + prevSimulationRunningRef.current = simulationRunning; + }, [simulationRunning]); // One-shot trace: send a single particle from every emitter node and let it // cascade through outgoing links so the user can follow the path without // particles accumulating. Cycles are short-circuited by a visited set. + // + // V1 stricte (ADR-006): seuls les nœuds nodeRole=generator émettent. Plus + // de fallback "tout émetteur" basé sur particleGeneration > 0 — la règle + // est désormais purement basée sur le rôle. Si aucun nœud n'est generator, + // le bouton ne fait rien (le UI le signale via hasGenerators). const handleEmitTrace = useCallback(() => { const fg = forceGraphRef.current; if (!fg || typeof fg.emitParticle !== 'function') return; const data = fg.graphData(); if (!data?.nodes?.length) return; - const hasVortexEmitters = data.nodes.some((n: any) => n.particleGeneration > 0); - const isEmitter = (node: any) => ( - hasVortexEmitters ? (node.particleGeneration || 0) > 0 : true - ); + const isEmitter = (node: any) => node.nodeRole === 'generator'; const visited = new Set(); const fireFrom = (nodeId: string, depth: number) => { @@ -1138,20 +1409,29 @@ const GraphRenderer3D: React.FC = ({ // Configuration avancée des nœuds avec support des géométries 3D graph - .nodeLabel(() => '') + .nodeLabel(() => '') .nodeVal((node: any) => { if (node.geometry) { - return 0; + return 0; } - + let baseSize = nodeSize; if (node.particleGeneration) { baseSize = Math.max(4, Math.min(12, 4 + node.particleGeneration / 50)); } - + + // Phase 5 — queue growth. When the DES simulator is in charge + // and the node has a defined queue_size, scale up the node + // proportionally to its fill ratio (1× empty → 2× full). + const qStat = queueStatsByNodeRef.current.get(node.id); + if (qStat && node.queue_size && node.queue_size > 0) { + const ratio = Math.min(1, qStat.size / node.queue_size); + return baseSize * (1 + ratio); + } + return baseSize; }) - .nodeColor((node: any) => node.color || '#4fc3f7') + .nodeColor((node: any) => resolveNodeColor(node)) .nodeThreeObject(nodeThreeObjectCallback) .linkLabel((link: any) => showLinkText && link.name ? link.name : '') .linkThreeObjectExtend(true) @@ -1202,6 +1482,10 @@ const GraphRenderer3D: React.FC = ({ }) .linkDirectionalParticles((link: any) => { if (!showParticles || !simulationRunning) return 0; + // DES simulator owns emission when at least one generator is + // declared (ADR-006). Continuous flow is a fallback for + // un-annotated graphs. + if (hasGenerators) return 0; if (link.maxParticleFlow) { return Math.min(8, Math.max(1, Math.floor(link.maxParticleFlow / 10))); } @@ -1420,18 +1704,82 @@ const GraphRenderer3D: React.FC = ({ <> {[ - { k: 'Particules', v: simulationStats.totalParticles, color: 'success.main' }, - { k: 'Latence', v: `${simulationStats.averageLatency} ms`, color: 'info.main' }, - { k: 'Goulots', v: simulationStats.bottleneckNodes, color: 'error.main' }, + { + k: 'Particules', + v: simulationStats.totalParticles, + color: 'success.main', + tip: 'Particules actuellement en transit sur les liens.', + }, + { + k: 'Latence', + v: `${simulationStats.averageLatency} ms`, + color: 'info.main', + tip: 'Latence moyenne d\'une particule depuis l\'émission jusqu\'à l\'arrivée à un sink.', + }, + { + k: 'Goulots', + v: simulationStats.bottleneckNodes, + color: 'error.main', + tip: 'Nœuds avec plus de 5 particules en file d\'attente — signale une accumulation.', + }, + // Phase 5 — drops surface only when the DES simulator is in + // charge. Heuristic mode (no generators) has no notion of drop. + // Phase 6 — File max + Débit instantané (DES-only too). + ...(hasGenerators && simulatorStats + ? [ + { + k: 'Drops', + v: simulatorStats.totalDropped, + color: 'error.main', + tip: 'Cumul des particules droppées (queue pleine, failure_rate, no_outlet).', + }, + { + k: 'File max', + v: simulationStats.maxQueueSize, + color: 'warning.main', + tip: 'Plus grande file constatée depuis le démarrage de la simulation.', + }, + { + k: 'Débit', + v: `${simulationStats.throughputPerSec}/s`, + color: 'info.main', + tip: 'Débit instantané (particules/seconde) sur les 2 dernières secondes.', + }, + ] + : []), ].map((s) => ( - - - {s.k} - - - {s.v} - - + + + + {s.k} + + + {s.v} + + + ))} )} diff --git a/frontend/src/components/graphs/GraphViewer.tsx b/frontend/src/components/graphs/GraphViewer.tsx index d5e931f..8b122c9 100644 --- a/frontend/src/components/graphs/GraphViewer.tsx +++ b/frontend/src/components/graphs/GraphViewer.tsx @@ -59,14 +59,6 @@ const GraphViewer: React.FC = () => { await stopSimulation(); }; - const handleToggleSimulation = async () => { - if (simulationState && simulationState.config.isRunning) { - await handleStopSimulation(); - } else { - await handleStartSimulation(); - } - }; - const handlePauseSimulation = async () => { await pauseSimulation(); }; @@ -160,12 +152,7 @@ const GraphViewer: React.FC = () => { - + ); diff --git a/frontend/src/hooks/useParticleSimulator.test.ts b/frontend/src/hooks/useParticleSimulator.test.ts new file mode 100644 index 0000000..88b861c --- /dev/null +++ b/frontend/src/hooks/useParticleSimulator.test.ts @@ -0,0 +1,203 @@ +/** + * Tests for the useParticleSimulator React hook. + * + * The hook wires a ParticleSimulator instance to React state + the rAF loop. + * The simulator itself is heavily tested elsewhere — these tests focus on the + * binding: lifecycle, stats surfacing, hasGenerators flag, and callback + * forwarding. + */ + +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +import { useParticleSimulator } from './useParticleSimulator'; + +// Minimal stable rAF stub: each requestAnimationFrame call is queued, and +// we drain the queue manually via `flushFrames`. Keeps tests deterministic. +let frameCallbacks: Array<(t: number) => void> = []; +let simulatedTime = 0; + +beforeEach(() => { + frameCallbacks = []; + simulatedTime = 0; + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => { + frameCallbacks.push(cb); + return frameCallbacks.length; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + frameCallbacks[id - 1] = () => {}; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function flushFrames(count: number, dtPerFrame = 16.67) { + for (let i = 0; i < count; i++) { + simulatedTime += dtPerFrame; + const cbs = frameCallbacks; + frameCallbacks = []; + for (const cb of cbs) cb(simulatedTime); + } +} + +const trivialGraph = () => ({ + nodes: [ + { id: 'A', nodeRole: 'generator' as const, particleGeneration: 100 }, + { id: 'B', nodeRole: 'sink' as const }, + ], + links: [{ source: 'A', target: 'B', particleSpeed: 6 }], +}); + +describe('useParticleSimulator', () => { + test('returns null stats and hasGenerators=false on an empty graph', () => { + const { result } = renderHook(() => + useParticleSimulator({ + graphData: { nodes: [], links: [] }, + isRunning: false, + }) + ); + expect(result.current.stats).toBeNull(); + expect(result.current.hasGenerators).toBe(false); + }); + + test('detects generators in the graph', () => { + const graphData = trivialGraph(); + const { result } = renderHook(() => + useParticleSimulator({ + graphData, + isRunning: false, + }) + ); + expect(result.current.hasGenerators).toBe(true); + }); + + test('detects absence of generators (only relays / sinks)', () => { + const { result } = renderHook(() => + useParticleSimulator({ + graphData: { + nodes: [ + { id: 'A', nodeRole: 'relay' }, + { id: 'B', nodeRole: 'sink' }, + ], + links: [{ source: 'A', target: 'B', particleSpeed: 6 }], + }, + isRunning: false, + }) + ); + expect(result.current.hasGenerators).toBe(false); + }); + + test('does not run frames when isRunning is false', () => { + const onReleased = vi.fn(); + const graphData = trivialGraph(); + renderHook(() => + useParticleSimulator({ + graphData, + isRunning: false, + onParticleReleased: onReleased, + }) + ); + act(() => flushFrames(30)); + expect(onReleased).not.toHaveBeenCalled(); + }); + + test('drives the simulator and fires onParticleReleased when isRunning', () => { + const onReleased = vi.fn(); + const graphData = trivialGraph(); + renderHook(() => + useParticleSimulator({ + graphData, + isRunning: true, + onParticleReleased: onReleased, + }) + ); + // particleGeneration=100/s → 1 every 10ms. ~60 frames of 16.67ms = 1000ms + // simulated, but dt is clamped to 33ms by default per the simulator, so + // we still expect at least a handful of emissions. + act(() => flushFrames(120)); + expect(onReleased).toHaveBeenCalled(); + expect(onReleased.mock.calls[0][0]).toMatch(/^A->B/); + }); + + test('surfaces stats via React state after a tick', () => { + // graphData must be referentially stable (documented contract). If we + // pass `trivialGraph()` inline, each setStats-driven rerender would + // construct a new object → infinite recreate loop. + const graphData = trivialGraph(); + const { result } = renderHook(() => + useParticleSimulator({ + graphData, + isRunning: true, + }) + ); + act(() => flushFrames(60)); + expect(result.current.stats).not.toBeNull(); + expect(result.current.stats!.totalEmitted).toBeGreaterThan(0); + }); + + test('resets stats when isRunning flips from true to false to true', () => { + const graphData = trivialGraph(); + const { result, rerender } = renderHook( + (props: { isRunning: boolean }) => + useParticleSimulator({ graphData, isRunning: props.isRunning }), + { initialProps: { isRunning: true } } + ); + act(() => flushFrames(60)); + const firstRunEmitted = result.current.stats!.totalEmitted; + expect(firstRunEmitted).toBeGreaterThan(0); + + rerender({ isRunning: false }); + // pause keeps state; resume calls start() which resets + rerender({ isRunning: true }); + act(() => flushFrames(5)); + // After fresh start, totalEmitted should be less than (or equal to a + // single tick's worth of) the previous run. + expect(result.current.stats!.totalEmitted).toBeLessThan(firstRunEmitted); + }); + + test('disposes the simulator on unmount', () => { + const onReleased = vi.fn(); + const graphData = trivialGraph(); + const { unmount } = renderHook(() => + useParticleSimulator({ + graphData, + isRunning: true, + onParticleReleased: onReleased, + }) + ); + act(() => flushFrames(60)); + const callsBeforeUnmount = onReleased.mock.calls.length; + expect(callsBeforeUnmount).toBeGreaterThan(0); + + unmount(); + act(() => flushFrames(60)); + // No more callbacks after unmount + expect(onReleased.mock.calls.length).toBe(callsBeforeUnmount); + }); + + test('keeps the latest onParticleReleased callback without recreating the simulator', () => { + const cb1 = vi.fn(); + const cb2 = vi.fn(); + const graphData = trivialGraph(); + const { rerender } = renderHook( + (props: { cb: typeof cb1 }) => + useParticleSimulator({ + graphData, + isRunning: true, + onParticleReleased: props.cb, + }), + { initialProps: { cb: cb1 } } + ); + act(() => flushFrames(30)); + expect(cb1).toHaveBeenCalled(); + const cb1Calls = cb1.mock.calls.length; + + rerender({ cb: cb2 }); + act(() => flushFrames(30)); + expect(cb2).toHaveBeenCalled(); + // cb1 should have stopped receiving calls after the swap + expect(cb1.mock.calls.length).toBe(cb1Calls); + }); +}); diff --git a/frontend/src/hooks/useParticleSimulator.ts b/frontend/src/hooks/useParticleSimulator.ts new file mode 100644 index 0000000..b1d9371 --- /dev/null +++ b/frontend/src/hooks/useParticleSimulator.ts @@ -0,0 +1,164 @@ +import { useEffect, useRef, useState } from 'react'; + +import { + ParticleSimulator, + type GraphInput, + type LinkInput, + type NodeInput, + type SimulatorStats, +} from '../services/particleSimulator'; + +/** + * Subset of the renderer's ForceGraphNode that the simulator cares about. + * Kept structural (not imported from GraphRenderer3D) so the hook stays + * self-contained and unit-testable without pulling Three.js. + */ +interface RendererGraphNode { + id: string; + nodeRole?: 'generator' | 'relay' | 'sink'; + particleGeneration?: number; + maxParticleProcessing?: number; + queue_size?: number; + processing_time?: number; + failure_rate?: number; + dropPolicy?: 'tail' | 'head' | 'reject'; +} + +/** + * Renderer link as exposed by 3d-force-graph: `source` and `target` are + * either strings (before the engine resolves them) or node-object references + * (after the first cooldown tick). Both are tolerated here. + */ +interface RendererGraphLink { + source: string | { id: string }; + target: string | { id: string }; + particleSpeed?: number; + maxParticleFlow?: number; +} + +export interface UseParticleSimulatorOptions { + /** + * The parsed graph. **Must be referentially stable** — only swap the + * reference when the topology (nodes / links / DES attributes) genuinely + * changes. The simulator is recreated each time the reference changes. + */ + graphData: { nodes: RendererGraphNode[]; links: RendererGraphLink[] }; + /** Drives `start()` / `pause()`. */ + isRunning: boolean; + /** + * Wired to `ParticleSimulator.options.onParticleReleased`. Use it to call + * `forceGraphRef.current.emitParticle(link)` so the visual animation + * matches each logical release. The latest reference is read on every + * call — safe to pass a fresh closure each render. + */ + onParticleReleased?: (linkId: string, particleId: string) => void; +} + +export interface UseParticleSimulatorResult { + /** Latest stats snapshot, or null before the first tick / after dispose. */ + stats: SimulatorStats | null; + /** True when at least one node has `nodeRole === 'generator'`. */ + hasGenerators: boolean; +} + +function toGraphInput(data: UseParticleSimulatorOptions['graphData']): GraphInput { + const nodes: NodeInput[] = data.nodes.map((n) => ({ + id: n.id, + nodeRole: n.nodeRole, + particleGeneration: n.particleGeneration, + maxParticleProcessing: n.maxParticleProcessing, + queue_size: n.queue_size, + processing_time: n.processing_time, + failure_rate: n.failure_rate, + dropPolicy: n.dropPolicy, + })); + const links: LinkInput[] = data.links.map((l) => ({ + source: typeof l.source === 'string' ? l.source : l.source.id, + target: typeof l.target === 'string' ? l.target : l.target.id, + particleSpeed: l.particleSpeed, + maxParticleFlow: l.maxParticleFlow, + })); + return { nodes, links }; +} + +/** + * React binding for `ParticleSimulator`. Owns the simulator instance, + * drives it via `requestAnimationFrame`, surfaces stats as React state, + * and forwards `onParticleReleased` to the visual emitter. + * + * Integration contract for the renderer (Phase 4): + * const { stats, hasGenerators } = useParticleSimulator({ + * graphData: currentGraphData, + * isRunning: simulationRunning, + * onParticleReleased: (linkId) => forceGraphRef.current?.emitParticle(link), + * }); + */ +export function useParticleSimulator({ + graphData, + isRunning, + onParticleReleased, +}: UseParticleSimulatorOptions): UseParticleSimulatorResult { + const simulatorRef = useRef(null); + const onReleasedRef = useRef(onParticleReleased); + const [stats, setStats] = useState(null); + + // Keep the callback ref fresh without recreating the simulator. + useEffect(() => { + onReleasedRef.current = onParticleReleased; + }, [onParticleReleased]); + + // (Re)create the simulator whenever the graphData reference changes. + useEffect(() => { + if (!graphData.nodes.length) return undefined; + + const sim = new ParticleSimulator(toGraphInput(graphData), { + onParticleReleased: (linkId, particleId) => { + onReleasedRef.current?.(linkId, particleId); + }, + }); + simulatorRef.current = sim; + const unsubscribe = sim.onTick((snapshot) => { + setStats(snapshot); + }); + + return () => { + unsubscribe(); + sim.dispose(); + simulatorRef.current = null; + setStats(null); + }; + }, [graphData]); + + // Drive the rAF loop based on isRunning + simulator availability. + useEffect(() => { + const sim = simulatorRef.current; + if (!sim) return undefined; + if (!isRunning) { + sim.pause(); + return undefined; + } + sim.start(); + let lastTime = typeof performance !== 'undefined' ? performance.now() : Date.now(); + let rafId = 0; + const tick = (now: number) => { + const dt = now - lastTime; + lastTime = now; + sim.tick(dt); + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(rafId); + // Intentionally do NOT call sim.pause() here. When graphData changes, + // the create-effect's cleanup (which ran first because of effect-order) + // has already disposed this simulator instance, so calling pause() + // would throw. The rAF cancel above is enough to stop the loop, and + // the next mount of this effect will call start() or pause() again + // depending on isRunning. + }; + }, [isRunning, graphData]); + + const hasGenerators = graphData.nodes.some((n) => n.nodeRole === 'generator'); + + return { stats, hasGenerators }; +} diff --git a/frontend/src/services/particleSimulator.integration.test.ts b/frontend/src/services/particleSimulator.integration.test.ts new file mode 100644 index 0000000..1c85d80 --- /dev/null +++ b/frontend/src/services/particleSimulator.integration.test.ts @@ -0,0 +1,232 @@ +/** + * End-to-end scenario tests for ParticleSimulator (Phase 7). + * + * Unit tests in particleSimulator.test.ts cover each branch in isolation. + * The tests below stress the simulator on realistic topologies: + * convergence (M sources → 1 node), divergence with weighted routing, + * cycles (no infinite cascade), and saturation with drops. + * + * A perf smoke test at the bottom ensures the simulator stays cheap + * enough to drive in a browser rAF loop. + */ + +import { describe, test, expect } from 'vitest'; +import { ParticleSimulator, type GraphInput } from './particleSimulator'; + +// Deterministic random source used by every scenario that involves probabilistic +// behaviour (weighted routing, failure_rate). Without seeding, weighted routing +// would be statistical and the assertions would flake. +function cyclicRandom(seed = 0): () => number { + let i = seed; + return () => { + i = (i + 1) % 100; + return i / 100; + }; +} + +const wideDt = (random?: () => number) => ({ + maxDtMs: 10_000, + random: random ?? cyclicRandom(), +}); + +// ─── Scenario 1: Convergence ─────────────────────────────────────────────── + +describe('Scenario: convergence (3 sources → 1 relay → 1 sink)', () => { + test('queue grows when total input rate exceeds processing rate', () => { + // Total input = 3 × 10 = 30 p/s. Processing: 1 slot, processing_time=66ms + // → ~15 p/s out. Sustained 15/s deficit → queue grows. + const graph: GraphInput = { + nodes: [ + { id: 'A1', nodeRole: 'generator', particleGeneration: 10 }, + { id: 'A2', nodeRole: 'generator', particleGeneration: 10 }, + { id: 'A3', nodeRole: 'generator', particleGeneration: 10 }, + { + id: 'B', + nodeRole: 'relay', + maxParticleProcessing: 1, + processing_time: 66, // 1 / 0.015s ≈ 66.7ms + }, + { id: 'C', nodeRole: 'sink' }, + ], + links: [ + { source: 'A1', target: 'B', particleSpeed: 6 }, + { source: 'A2', target: 'B', particleSpeed: 6 }, + { source: 'A3', target: 'B', particleSpeed: 6 }, + { source: 'B', target: 'C', particleSpeed: 6 }, + ], + }; + const sim = new ParticleSimulator(graph, wideDt()); + sim.start(); + // Drive 3 seconds of small ticks. Expect ~90 emissions, ~45 arrived, + // queue around 30+ pending. + for (let i = 0; i < 30; i++) sim.tick(100); + const stats = sim.getStats(); + expect(stats.totalEmitted).toBeGreaterThanOrEqual(85); + expect(stats.totalArrived).toBeLessThan(stats.totalEmitted); + expect(stats.queues.get('B')!.size).toBeGreaterThan(10); + }); +}); + +// ─── Scenario 2: Divergence with weighted routing ────────────────────────── + +describe('Scenario: divergence (1 source → 3 sinks, weighted 50/30/20)', () => { + test('outputs distribute proportionally to maxParticleFlow', () => { + const counts = new Map(); + const graph: GraphInput = { + nodes: [ + { id: 'A', nodeRole: 'generator', particleGeneration: 100 }, + { id: 'X', nodeRole: 'sink' }, + { id: 'Y', nodeRole: 'sink' }, + { id: 'Z', nodeRole: 'sink' }, + ], + links: [ + { source: 'A', target: 'X', particleSpeed: 6, maxParticleFlow: 50 }, + { source: 'A', target: 'Y', particleSpeed: 6, maxParticleFlow: 30 }, + { source: 'A', target: 'Z', particleSpeed: 6, maxParticleFlow: 20 }, + ], + }; + const sim = new ParticleSimulator(graph, { + ...wideDt(), + onParticleReleased: (linkId) => counts.set(linkId, (counts.get(linkId) ?? 0) + 1), + }); + sim.start(); + sim.tick(1000); // 100 emissions + const x = Array.from(counts).find(([id]) => id.startsWith('A->X'))?.[1] ?? 0; + const y = Array.from(counts).find(([id]) => id.startsWith('A->Y'))?.[1] ?? 0; + const z = Array.from(counts).find(([id]) => id.startsWith('A->Z'))?.[1] ?? 0; + expect(x + y + z).toBe(100); + // Cyclic seed: r=i/100 for i=1..100. cumul [0,50)→X (50), [50,80)→Y (30), [80,100)→Z (20) + expect(x).toBe(50); + expect(y).toBe(30); + expect(z).toBe(20); + }); +}); + +// ─── Scenario 3: Cycle ───────────────────────────────────────────────────── + +describe('Scenario: cycle (A → B → C → B)', () => { + test('handles a directed cycle without crashing or runaway state', () => { + const graph: GraphInput = { + nodes: [ + { id: 'A', nodeRole: 'generator', particleGeneration: 1 }, + { id: 'B', nodeRole: 'relay', maxParticleProcessing: 10, processing_time: 0 }, + { id: 'C', nodeRole: 'relay', maxParticleProcessing: 10, processing_time: 0 }, + ], + links: [ + { source: 'A', target: 'B', particleSpeed: 6 }, + { source: 'B', target: 'C', particleSpeed: 6 }, + { source: 'C', target: 'B', particleSpeed: 6 }, // cycle back into B + ], + }; + const sim = new ParticleSimulator(graph, wideDt()); + sim.start(); + // Drive a few seconds; particles will start cycling. The key assertion is + // that tick() never throws and getStats() stays consistent. + expect(() => { + for (let i = 0; i < 200; i++) sim.tick(50); + }).not.toThrow(); + const stats = sim.getStats(); + // At least one emission should have happened. + expect(stats.totalEmitted).toBeGreaterThan(0); + // With cycles, particles keep moving — particlesInFlight can be high but finite. + expect(stats.particlesInFlight).toBeGreaterThanOrEqual(0); + expect(Number.isFinite(stats.particlesInFlight)).toBe(true); + }); +}); + +// ─── Scenario 4: Saturation with drops ───────────────────────────────────── + +describe('Scenario: saturation (queue_size=5, dropPolicy=tail)', () => { + test('queue caps at queue_size and excess arrivals are dropped', () => { + const graph: GraphInput = { + nodes: [ + { id: 'A', nodeRole: 'generator', particleGeneration: 100 }, + { + id: 'B', + nodeRole: 'relay', + queue_size: 5, + dropPolicy: 'tail', + maxParticleProcessing: 1, + processing_time: 200, // 5 p/s out → 95 p/s deficit + }, + { id: 'C', nodeRole: 'sink' }, + ], + links: [ + { source: 'A', target: 'B', particleSpeed: 6 }, + { source: 'B', target: 'C', particleSpeed: 6 }, + ], + }; + const sim = new ParticleSimulator(graph, wideDt()); + sim.start(); + for (let i = 0; i < 30; i++) sim.tick(100); // 3 seconds + const stats = sim.getStats(); + expect(stats.totalDropped).toBeGreaterThan(50); + expect(stats.queues.get('B')!.size).toBeLessThanOrEqual(5); + expect(stats.queues.get('B')!.droppedCount).toBeGreaterThan(50); + }); + + test('dropPolicy=head preserves queue size but drops the oldest', () => { + const graph: GraphInput = { + nodes: [ + { id: 'A', nodeRole: 'generator', particleGeneration: 50 }, + { + id: 'B', + nodeRole: 'relay', + queue_size: 3, + dropPolicy: 'head', + maxParticleProcessing: 0, // no outflow at all + }, + ], + links: [{ source: 'A', target: 'B', particleSpeed: 6 }], + }; + const sim = new ParticleSimulator(graph, wideDt()); + sim.start(); + for (let i = 0; i < 20; i++) sim.tick(100); // 2 seconds + const stats = sim.getStats(); + expect(stats.queues.get('B')!.size).toBe(3); + expect(stats.queues.get('B')!.droppedCount).toBeGreaterThan(50); + }); +}); + +// ─── Perf smoke test ─────────────────────────────────────────────────────── + +describe('Perf: 100 nodes / ~200 links / 100 ticks', () => { + test('100 ticks of a 100-node graph stay under 500 ms', () => { + const nodes: GraphInput['nodes'] = []; + const links: GraphInput['links'] = []; + + // 10 generators + for (let i = 0; i < 10; i++) { + nodes.push({ id: `N${i}`, nodeRole: 'generator', particleGeneration: 5 }); + } + // 80 relays + for (let i = 10; i < 90; i++) { + nodes.push({ id: `N${i}`, nodeRole: 'relay', processing_time: 0 }); + } + // 10 sinks + for (let i = 90; i < 100; i++) { + nodes.push({ id: `N${i}`, nodeRole: 'sink' }); + } + // ~200 links — each non-sink fans out to 2 deterministic successors. + for (let i = 0; i < 90; i++) { + const t1 = (i + 1) % 100; + const t2 = (i + 7) % 100; + if (t1 !== i) links.push({ source: `N${i}`, target: `N${t1}`, particleSpeed: 6 }); + if (t2 !== i) links.push({ source: `N${i}`, target: `N${t2}`, particleSpeed: 6 }); + } + + const sim = new ParticleSimulator({ nodes, links }, { maxDtMs: 33 }); + sim.start(); + // Warm-up tick so JIT settles before measurement. + sim.tick(16.67); + + const start = performance.now(); + for (let i = 0; i < 100; i++) sim.tick(16.67); + const elapsed = performance.now() - start; + + // 5 ms per tick on average — comfortable margin for a 60 fps rAF loop. + expect(elapsed).toBeLessThan(500); + // Sanity: the simulator should have produced visible work. + expect(sim.getStats().totalEmitted).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/services/particleSimulator.test.ts b/frontend/src/services/particleSimulator.test.ts new file mode 100644 index 0000000..06485f3 --- /dev/null +++ b/frontend/src/services/particleSimulator.test.ts @@ -0,0 +1,634 @@ +/** + * Behaviour tests for ParticleSimulator (Phase 3). + * + * The simulator is pure data — these tests drive it with explicit `tick(dt)` + * calls (no rAF), with a seeded random source where stochastic behaviour + * matters, and a generous maxDtMs so a few ticks cover seconds of simulated + * time. + */ + +import { describe, test, expect, vi } from 'vitest'; +import { + ParticleSimulator, + type GraphInput, + type NodeRole, + type DropPolicy, +} from './particleSimulator'; + +// Default options reused by most tests. +const wideDt = (extra: Partial<{ random: () => number }> = {}) => ({ + maxDtMs: 10_000, + random: extra.random ?? (() => 0.5), +}); + +/** + * Build a simple linear graph: generator A → relay B → sink C. + * Caller can override per-node and per-link attributes. + */ +function linearGraph(overrides: { + A?: Partial; + B?: Partial; + C?: Partial; + AB?: Partial; + BC?: Partial; +} = {}): GraphInput { + return { + nodes: [ + { id: 'A', nodeRole: 'generator' as NodeRole, particleGeneration: 1, ...overrides.A }, + { id: 'B', nodeRole: 'relay' as NodeRole, ...overrides.B }, + { id: 'C', nodeRole: 'sink' as NodeRole, ...overrides.C }, + ], + links: [ + { source: 'A', target: 'B', particleSpeed: 6, ...overrides.AB }, + { source: 'B', target: 'C', particleSpeed: 6, ...overrides.BC }, + ], + }; +} + +// ─── Construction & defaults ─────────────────────────────────────────────── + +describe('ParticleSimulator — construction & defaults', () => { + test('instantiates with a minimal graph', () => { + const sim = new ParticleSimulator(linearGraph(), wideDt()); + expect(sim).toBeInstanceOf(ParticleSimulator); + }); + + test('start() is idempotent and resets stats', () => { + const sim = new ParticleSimulator(linearGraph(), wideDt()); + sim.start(); + sim.tick(1000); + const before = sim.getStats().totalEmitted; + expect(before).toBeGreaterThan(0); + sim.start(); + expect(sim.getStats().totalEmitted).toBe(0); + }); + + test('generates link ids from source->target when not provided', () => { + const sim = new ParticleSimulator(linearGraph(), wideDt()); + const released: string[] = []; + sim.dispose(); + const sim2 = new ParticleSimulator(linearGraph(), { + ...wideDt(), + onParticleReleased: (linkId) => released.push(linkId), + }); + sim2.start(); + sim2.tick(1100); // 1.1s → at least 1 emission + expect(released[0]).toMatch(/^A->B/); + }); +}); + +// ─── Phase 3a: emission ──────────────────────────────────────────────────── + +describe('ParticleSimulator — emission (3a)', () => { + test('only generators emit', () => { + const sim = new ParticleSimulator( + { + nodes: [ + // No generators here — only a relay and a sink. + { id: 'A', nodeRole: 'relay' }, + { id: 'B', nodeRole: 'sink' }, + ], + links: [{ source: 'A', target: 'B', particleSpeed: 6 }], + }, + wideDt() + ); + sim.start(); + sim.tick(5000); + expect(sim.getStats().totalEmitted).toBe(0); + }); + + test('generator emits at particleGeneration rate', () => { + const sim = new ParticleSimulator( + linearGraph({ A: { particleGeneration: 10 } }), // 10 p/s + wideDt() + ); + sim.start(); + sim.tick(1000); // 1 second simulated + expect(sim.getStats().totalEmitted).toBe(10); + }); + + test('emission is regular (deterministic) — exactly 1 every 100ms at 10p/s', () => { + const sim = new ParticleSimulator(linearGraph({ A: { particleGeneration: 10 } }), wideDt()); + sim.start(); + for (let i = 0; i < 10; i++) sim.tick(100); + expect(sim.getStats().totalEmitted).toBe(10); + }); + + test('multiple small ticks accumulate properly', () => { + const sim = new ParticleSimulator(linearGraph({ A: { particleGeneration: 5 } }), wideDt()); + sim.start(); + // 5 p/s = 1 every 200ms. After 1s of cumulative ticking we expect 5. + for (let i = 0; i < 100; i++) sim.tick(10); + expect(sim.getStats().totalEmitted).toBe(5); + }); + + test('generator without outgoing link drops with reason no_outlet', () => { + const sim = new ParticleSimulator( + { + nodes: [{ id: 'A', nodeRole: 'generator', particleGeneration: 5 }], + links: [], + }, + wideDt() + ); + sim.start(); + sim.tick(1000); + const stats = sim.getStats(); + expect(stats.totalEmitted).toBe(0); + expect(stats.totalDropped).toBe(5); + expect(stats.queues.get('A')?.droppedCount).toBe(5); + }); + + test('default generation rate of 1/s applies when particleGeneration is omitted on a generator', () => { + const sim = new ParticleSimulator( + { + nodes: [ + { id: 'A', nodeRole: 'generator' }, // no particleGeneration + { id: 'C', nodeRole: 'sink' }, + ], + links: [{ source: 'A', target: 'C', particleSpeed: 6 }], + }, + wideDt() + ); + sim.start(); + sim.tick(3000); + expect(sim.getStats().totalEmitted).toBe(3); + }); + + test('particleGeneration on relay/sink is ignored', () => { + const sim = new ParticleSimulator( + { + nodes: [ + { id: 'A', nodeRole: 'relay', particleGeneration: 100 }, + { id: 'B', nodeRole: 'sink', particleGeneration: 100 }, + ], + links: [{ source: 'A', target: 'B', particleSpeed: 6 }], + }, + wideDt() + ); + sim.start(); + sim.tick(2000); + expect(sim.getStats().totalEmitted).toBe(0); + }); + + test('onParticleReleased callback fires with linkId and particleId', () => { + const onReleased = vi.fn(); + const sim = new ParticleSimulator(linearGraph({ A: { particleGeneration: 3 } }), { + ...wideDt(), + onParticleReleased: onReleased, + }); + sim.start(); + sim.tick(1000); + expect(onReleased).toHaveBeenCalledTimes(3); + expect(onReleased.mock.calls[0][0]).toMatch(/^A->B/); + expect(onReleased.mock.calls[0][1]).toMatch(/^p\d+$/); + }); +}); + +// ─── Phase 3b: transit & arrival ─────────────────────────────────────────── + +describe('ParticleSimulator — transit & arrival (3b)', () => { + test('particle does not arrive before its expected transit time', () => { + // A (gen 1/s) → C (sink), particleSpeed=6 → internal speed 0.018 → arrival ~926ms + // after emission. With small ticks we can observe both states. + const sim = new ParticleSimulator( + { + nodes: [ + { id: 'A', nodeRole: 'generator', particleGeneration: 1 }, + { id: 'C', nodeRole: 'sink' }, + ], + links: [{ source: 'A', target: 'C', particleSpeed: 6 }], + }, + wideDt() + ); + sim.start(); + // Sim time = 1500ms after 15×100ms. Emission was at t=1000ms; transit needs + // 926ms more, so arrival expected around t≈1926ms — not yet at t=1500ms. + for (let i = 0; i < 15; i++) sim.tick(100); + expect(sim.getStats().totalEmitted).toBe(1); + expect(sim.getStats().totalArrived).toBe(0); + + // Continue past the expected arrival time. + for (let i = 0; i < 10; i++) sim.tick(100); // sim time = 2500ms + expect(sim.getStats().totalArrived).toBe(1); + }); + + test('latency is tracked end-to-end across multiple hops', () => { + const sim = new ParticleSimulator(linearGraph({ A: { particleGeneration: 1 } }), wideDt()); + sim.start(); + // Drive long enough for at least one particle to traverse A→B→C + for (let i = 0; i < 50; i++) sim.tick(100); + const stats = sim.getStats(); + expect(stats.totalArrived).toBeGreaterThan(0); + expect(stats.averageLatencyMs).toBeGreaterThan(0); + expect(Number.isNaN(stats.averageLatencyMs)).toBe(false); + }); + + test('averageLatencyMs is NaN before any arrival', () => { + const sim = new ParticleSimulator(linearGraph(), wideDt()); + sim.start(); + sim.tick(100); // not enough to reach the sink yet + expect(Number.isNaN(sim.getStats().averageLatencyMs)).toBe(true); + }); + + test('particlesInFlight reflects active transit', () => { + const sim = new ParticleSimulator( + linearGraph({ A: { particleGeneration: 10 } }), // burst + wideDt() + ); + sim.start(); + sim.tick(500); // emit some, none arrived yet + const stats = sim.getStats(); + expect(stats.particlesInFlight).toBeGreaterThan(0); + }); + + test('arriving at a relay enqueues into its pending list', () => { + // particleSpeed high enough to reach B during the test + // Slow processing so the queue actually builds up. + const sim = new ParticleSimulator( + linearGraph({ + A: { particleGeneration: 5 }, + B: { processing_time: 5000, maxParticleProcessing: 0 }, // no slots → everything queues + }), + wideDt() + ); + sim.start(); + sim.tick(2000); + const stats = sim.getStats(); + expect(stats.queues.get('B')!.size).toBeGreaterThan(0); + }); +}); + +// ─── Lifecycle ───────────────────────────────────────────────────────────── + +describe('ParticleSimulator — lifecycle', () => { + test('tick is a no-op when not running', () => { + const sim = new ParticleSimulator(linearGraph(), wideDt()); + // start has NOT been called + sim.tick(5000); + expect(sim.getStats().totalEmitted).toBe(0); + }); + + test('pause suspends advancement but keeps state', () => { + const sim = new ParticleSimulator(linearGraph({ A: { particleGeneration: 2 } }), wideDt()); + sim.start(); + sim.tick(1000); // 2 emissions + const before = sim.getStats().totalEmitted; + sim.pause(); + sim.tick(5000); // should not progress + expect(sim.getStats().totalEmitted).toBe(before); + }); + + test('stop resets stats and queues', () => { + const sim = new ParticleSimulator(linearGraph({ A: { particleGeneration: 5 } }), wideDt()); + sim.start(); + sim.tick(1000); + expect(sim.getStats().totalEmitted).toBeGreaterThan(0); + sim.stop(); + expect(sim.getStats().totalEmitted).toBe(0); + expect(sim.getStats().particlesInFlight).toBe(0); + }); + + test('dispose makes the instance unusable', () => { + const sim = new ParticleSimulator(linearGraph(), wideDt()); + sim.dispose(); + expect(() => sim.start()).toThrow(/disposed/); + expect(() => sim.tick(100)).toThrow(/disposed/); + }); + + test('onTick fires after each tick and returns an unsubscribe function', () => { + const sim = new ParticleSimulator(linearGraph(), wideDt()); + const cb = vi.fn(); + const unsubscribe = sim.onTick(cb); + sim.start(); + sim.tick(1000); + sim.tick(1000); + expect(cb).toHaveBeenCalledTimes(2); + unsubscribe(); + sim.tick(1000); + expect(cb).toHaveBeenCalledTimes(2); + }); + + test('dt is clamped to maxDtMs', () => { + const sim = new ParticleSimulator(linearGraph({ A: { particleGeneration: 10 } }), { + maxDtMs: 100, + }); + sim.start(); + sim.tick(100_000); // would emit 1000 particles unclamped; clamped → 1 emission (100ms * 10/s) + expect(sim.getStats().totalEmitted).toBe(1); + }); + + test('dt of 0 or negative is a no-op (does not advance time)', () => { + const sim = new ParticleSimulator(linearGraph({ A: { particleGeneration: 1 } }), wideDt()); + sim.start(); + sim.tick(0); + sim.tick(-100); + expect(sim.getStats().totalEmitted).toBe(0); + }); +}); + +// ─── Phase 3c: queue, drop, processing slots ─────────────────────────────── + +describe('ParticleSimulator — queue, dropPolicy, processing (3c)', () => { + test('queue grows when generation > processing capacity', () => { + // A generates 10/s, B has 1 slot * processing_time=2000ms = 0.5/s throughput + const sim = new ParticleSimulator( + linearGraph({ + A: { particleGeneration: 10 }, + B: { maxParticleProcessing: 1, processing_time: 2000, queue_size: 100 }, + AB: { particleSpeed: 6 }, + BC: { particleSpeed: 6 }, + }), + wideDt() + ); + sim.start(); + sim.tick(2000); + expect(sim.getStats().queues.get('B')!.size).toBeGreaterThan(0); + }); + + test('dropPolicy=tail drops the incoming particle when queue is full', () => { + // Burst arrivals at a relay with queue_size=2, dropPolicy=tail. + // No outlet processing → all particles past the 2nd get dropped. + const sim = new ParticleSimulator( + { + nodes: [ + { id: 'A', nodeRole: 'generator', particleGeneration: 10 }, + { + id: 'B', + nodeRole: 'relay', + queue_size: 2, + dropPolicy: 'tail', + maxParticleProcessing: 0, + }, + ], + links: [{ source: 'A', target: 'B', particleSpeed: 6 }], + }, + wideDt() + ); + sim.start(); + sim.tick(2000); // generate + transit + const stats = sim.getStats(); + expect(stats.queues.get('B')!.size).toBeLessThanOrEqual(2); + expect(stats.queues.get('B')!.droppedCount).toBeGreaterThan(0); + }); + + test('dropPolicy=head drops the oldest queued particle', () => { + const sim = new ParticleSimulator( + { + nodes: [ + { id: 'A', nodeRole: 'generator', particleGeneration: 10 }, + { + id: 'B', + nodeRole: 'relay', + queue_size: 1, + dropPolicy: 'head', + maxParticleProcessing: 0, + }, + ], + links: [{ source: 'A', target: 'B', particleSpeed: 6 }], + }, + wideDt() + ); + sim.start(); + sim.tick(2000); + const stats = sim.getStats(); + expect(stats.queues.get('B')!.size).toBe(1); + expect(stats.queues.get('B')!.droppedCount).toBeGreaterThan(0); + }); + + test('queue_size undefined → no drops by queue full', () => { + const sim = new ParticleSimulator( + { + nodes: [ + { id: 'A', nodeRole: 'generator', particleGeneration: 10 }, + { + id: 'B', + nodeRole: 'relay', + // queue_size undefined → unbounded + maxParticleProcessing: 0, + }, + ], + links: [{ source: 'A', target: 'B', particleSpeed: 6 }], + }, + wideDt() + ); + sim.start(); + sim.tick(2000); + expect(sim.getStats().queues.get('B')!.droppedCount).toBe(0); + expect(sim.getStats().queues.get('B')!.size).toBeGreaterThan(5); + }); + + test('processing_time delays release from a slot', () => { + const sim = new ParticleSimulator( + linearGraph({ + A: { particleGeneration: 1 }, + B: { maxParticleProcessing: 10, processing_time: 5000 }, + AB: { particleSpeed: 6 }, + BC: { particleSpeed: 6 }, + }), + wideDt() + ); + sim.start(); + // Drive in small ticks for a fluid pipeline. With 5000ms processing_time, + // even the first emitted particle (at t=1000ms) won't reach C before + // t ≈ 1000 + 926 (transit) + 5000 (slot) + 926 (transit) ≈ 7852ms. + for (let i = 0; i < 70; i++) sim.tick(100); // sim time = 7000ms + expect(sim.getStats().totalArrived).toBe(0); + + // Continue past the expected arrival. + for (let i = 0; i < 30; i++) sim.tick(100); // sim time = 10000ms + expect(sim.getStats().totalArrived).toBeGreaterThan(0); + }); + + test('maxParticleProcessing caps parallel slots', () => { + // Burst of arrivals — only 2 should be in slots at once + const sim = new ParticleSimulator( + { + nodes: [ + { id: 'A', nodeRole: 'generator', particleGeneration: 100 }, + { + id: 'B', + nodeRole: 'relay', + maxParticleProcessing: 2, + processing_time: 10000, // long enough to keep slots busy + queue_size: 100, + }, + { id: 'C', nodeRole: 'sink' }, + ], + links: [ + { source: 'A', target: 'B', particleSpeed: 6 }, + { source: 'B', target: 'C', particleSpeed: 6 }, + ], + }, + wideDt() + ); + sim.start(); + sim.tick(2000); + // None has finished processing yet (processing_time=10000) + expect(sim.getStats().totalArrived).toBe(0); + // But queue should have plenty of pending particles (more than 2) + expect(sim.getStats().queues.get('B')!.size).toBeGreaterThan(2); + }); + + test('failure_rate=1.0 drops every particle at output', () => { + const sim = new ParticleSimulator( + linearGraph({ + A: { particleGeneration: 5 }, + B: { failure_rate: 1.0, processing_time: 0 }, + AB: { particleSpeed: 6 }, + BC: { particleSpeed: 6 }, + }), + { ...wideDt(), random: () => 0 } // always less than 1.0 + ); + sim.start(); + for (let i = 0; i < 30; i++) sim.tick(100); + const stats = sim.getStats(); + expect(stats.totalArrived).toBe(0); + expect(stats.totalDropped).toBeGreaterThan(0); + }); + + test('failure_rate=0 means no drop at output', () => { + const sim = new ParticleSimulator( + linearGraph({ + A: { particleGeneration: 5 }, + B: { failure_rate: 0, processing_time: 0 }, + }), + wideDt() + ); + sim.start(); + for (let i = 0; i < 30; i++) sim.tick(100); + expect(sim.getStats().totalDropped).toBe(0); + }); + + test('relay with no outgoing link drops at output with no_outlet', () => { + const sim = new ParticleSimulator( + { + nodes: [ + { id: 'A', nodeRole: 'generator', particleGeneration: 2 }, + { id: 'B', nodeRole: 'relay', processing_time: 0 }, // no outgoing + ], + links: [{ source: 'A', target: 'B', particleSpeed: 6 }], + }, + wideDt() + ); + sim.start(); + for (let i = 0; i < 30; i++) sim.tick(100); + const stats = sim.getStats(); + expect(stats.totalArrived).toBe(0); + expect(stats.queues.get('B')!.droppedCount).toBeGreaterThan(0); + }); +}); + +// ─── Phase 3d: routing ───────────────────────────────────────────────────── + +describe('ParticleSimulator — routing (3d)', () => { + test('round-robin when no weights are defined', () => { + // Generator with 3 outgoing links to 3 sinks, no maxParticleFlow defined. + // 6 emissions → exactly 2 on each outgoing link. + const released = new Map(); + const sim = new ParticleSimulator( + { + nodes: [ + { id: 'A', nodeRole: 'generator', particleGeneration: 6 }, + { id: 'X', nodeRole: 'sink' }, + { id: 'Y', nodeRole: 'sink' }, + { id: 'Z', nodeRole: 'sink' }, + ], + links: [ + { source: 'A', target: 'X', particleSpeed: 6 }, + { source: 'A', target: 'Y', particleSpeed: 6 }, + { source: 'A', target: 'Z', particleSpeed: 6 }, + ], + }, + { + ...wideDt(), + onParticleReleased: (linkId) => released.set(linkId, (released.get(linkId) ?? 0) + 1), + } + ); + sim.start(); + sim.tick(1000); // 6 particles + const counts = Array.from(released.values()).sort(); + expect(counts).toEqual([2, 2, 2]); + }); + + test('weighted routing by maxParticleFlow distributes proportionally', () => { + // 100 emissions, weights 80/20 → ~80/20 split. Use seeded random for determinism. + const released = new Map(); + let i = 0; + const seededRandom = () => { + // Pseudo-random: returns 0.0, 0.01, 0.02, ... 0.99 cyclically + i = (i + 1) % 100; + return i / 100; + }; + const sim = new ParticleSimulator( + { + nodes: [ + { id: 'A', nodeRole: 'generator', particleGeneration: 100 }, + { id: 'X', nodeRole: 'sink' }, + { id: 'Y', nodeRole: 'sink' }, + ], + links: [ + { source: 'A', target: 'X', particleSpeed: 6, maxParticleFlow: 80 }, + { source: 'A', target: 'Y', particleSpeed: 6, maxParticleFlow: 20 }, + ], + }, + { + ...wideDt(), + random: seededRandom, + onParticleReleased: (linkId) => released.set(linkId, (released.get(linkId) ?? 0) + 1), + } + ); + sim.start(); + sim.tick(1000); + const xCount = Array.from(released).find(([id]) => id.startsWith('A->X'))?.[1] ?? 0; + const yCount = Array.from(released).find(([id]) => id.startsWith('A->Y'))?.[1] ?? 0; + expect(xCount + yCount).toBe(100); + // With the cyclic seed: r < 80 → X (80 cases), r < 100 → Y (20 cases) + expect(xCount).toBe(80); + expect(yCount).toBe(20); + }); + + test('seeded random produces deterministic routing', () => { + const run = () => { + const released: string[] = []; + const sim = new ParticleSimulator( + { + nodes: [ + { id: 'A', nodeRole: 'generator', particleGeneration: 4 }, + { id: 'X', nodeRole: 'sink' }, + { id: 'Y', nodeRole: 'sink' }, + ], + links: [ + { source: 'A', target: 'X', particleSpeed: 6, maxParticleFlow: 50 }, + { source: 'A', target: 'Y', particleSpeed: 6, maxParticleFlow: 50 }, + ], + }, + { + ...wideDt(), + random: () => 0.3, // always X (cumul=50; 0.3*100=30 < 50) + onParticleReleased: (linkId) => released.push(linkId), + } + ); + sim.start(); + sim.tick(1000); + return released; + }; + const a = run(); + const b = run(); + expect(a).toEqual(b); + expect(a.every((id) => id.startsWith('A->X'))).toBe(true); + }); +}); + +// ─── Type smoke tests ────────────────────────────────────────────────────── + +describe('exported types are usable', () => { + test('NodeRole enum values', () => { + const roles: NodeRole[] = ['generator', 'relay', 'sink']; + expect(roles).toHaveLength(3); + }); + + test('DropPolicy enum values', () => { + const policies: DropPolicy[] = ['tail', 'head', 'reject']; + expect(policies).toHaveLength(3); + }); +}); diff --git a/frontend/src/services/particleSimulator.ts b/frontend/src/services/particleSimulator.ts new file mode 100644 index 0000000..2a82c2f --- /dev/null +++ b/frontend/src/services/particleSimulator.ts @@ -0,0 +1,560 @@ +/** + * Particle Simulator — Discrete Event Simulation (DES) for VortexFlow. + * + * Owns the *logical* state of all particles in transit, all per-node queues, + * and all parallel processing slots. Pure TypeScript, no React or Three.js + * dependency — testable in isolation. + * + * Design rationale: ADR-006. + * + * Locked decisions applied below: + * - V1 strict on nodeRole — no fallback "everyone emits". + * - Routing at a node with M outgoing links: weighted by maxParticleFlow, + * round-robin fallback when no weights are defined. + * - dt clamped to options.maxDtMs (default 33 ms). + * - Stats reset on `start()`. + * - dropPolicy without queue_size is meaningless — validated/warned at parse. + * - particleGeneration on relay/sink is ignored (zeroed at construction). + * - Emission is regular deterministic: 1 particle every (1000 / rate) ms, + * accumulator-based (no Poisson jitter — predictable tests + visuals). + * - Processing model: parallel slots — maxParticleProcessing = number of + * concurrent workers, processing_time = ms each worker stays busy. + * - failure_rate is sampled at the *output* of a node (after processing). + * - Speed calibration matches the existing one-shot `handleEmitTrace`: + * speed_internal = particleSpeed × 0.003 (fraction-of-link per 16.67-ms tick) + * arrival_ms = (1 / speed_internal) × 16.67 + * + * Integration (Phase 4): a thin React hook `useParticleSimulator` will own + * a `ParticleSimulator` instance, drive it via rAF, and wire + * `onParticleReleased` to `forceGraphRef.current.emitParticle(link)` so the + * visual animation matches each logical release. The simulator itself does + * NOT touch 3d-force-graph. + */ + +// ─── Domain types ────────────────────────────────────────────────────────── + +export type NodeRole = 'generator' | 'relay' | 'sink'; +export type DropPolicy = 'tail' | 'head' | 'reject'; + +/** Why a particle was dropped — surfaced in per-node stats for diagnostics. */ +export type DropReason = 'queue_full' | 'failure_rate' | 'no_outlet'; + +export type ParticleId = string; +export type NodeId = string; +export type LinkId = string; + +/** + * A single particle being tracked by the simulator. + * + * State is implicit from `linkId`: + * - linkId set, t ∈ [0, 1) → in transit on a link + * - linkId === null → queued or being processed on a node + */ +export interface Particle { + id: ParticleId; + linkId: LinkId | null; + /** Position along the current link, 0 → 1. Ignored when linkId is null. */ + t: number; + /** Speed in link-fraction per 16.67-ms tick (already normalised). */ + speed: number; + /** Simulator time (ms) at which the particle was emitted — for latency. */ + bornAt: number; +} + +export interface NodeQueue { + nodeId: NodeId; + pending: Particle[]; + droppedCount: number; + droppedReasons: Map; + /** Cursor used by the round-robin fallback when no maxParticleFlow weights. */ + roundRobinCursor: number; +} + +export interface SimulatorStats { + /** Particles currently advancing on a link. */ + particlesInFlight: number; + /** Cumulative emissions since the last `start()`. */ + totalEmitted: number; + /** Cumulative arrivals at a sink (or end-of-chain absorption). */ + totalArrived: number; + /** Cumulative drops, all reasons combined. */ + totalDropped: number; + /** Average end-to-end latency, in ms. NaN before any arrival. */ + averageLatencyMs: number; + /** Per-node snapshot. */ + queues: Map; +} + +// ─── Inputs ──────────────────────────────────────────────────────────────── + +export interface NodeInput { + id: NodeId; + nodeRole?: NodeRole; + /** Particles per second (only meaningful for generators). */ + particleGeneration?: number; + /** Maximum parallel processing slots (default Infinity = unbounded throughput). */ + maxParticleProcessing?: number; + /** Maximum FIFO queue size before dropPolicy kicks in (default unbounded). */ + queue_size?: number; + /** Time (ms) a processing slot stays busy per particle (default 0 = instant). */ + processing_time?: number; + /** Probability [0, 1] that a particle is dropped at the output (default 0). */ + failure_rate?: number; + dropPolicy?: DropPolicy; +} + +export interface LinkInput { + id?: LinkId; + source: NodeId; + target: NodeId; + /** Multiplier; default 1.0. Internal speed = particleSpeed × 0.003 per tick. */ + particleSpeed?: number; + /** Weight for output routing on the source node. */ + maxParticleFlow?: number; +} + +export interface GraphInput { + nodes: NodeInput[]; + links: LinkInput[]; +} + +// ─── Simulator options ───────────────────────────────────────────────────── + +export interface SimulatorOptions { + maxDtMs?: number; + random?: () => number; + defaultGenerationPerSecond?: number; + onParticleReleased?: (linkId: LinkId, particleId: ParticleId) => void; +} + +export type StatsListener = (stats: SimulatorStats) => void; + +// ─── Constants ───────────────────────────────────────────────────────────── + +/** Minimum/maximum internal speed (fraction-of-link per tick) — clamp range. */ +const MIN_INTERNAL_SPEED = 0.001; +const MAX_INTERNAL_SPEED = 0.02; +/** Particle-speed multiplier to internal-speed (matches `handleEmitTrace`). */ +const SPEED_SCALE = 0.003; +/** Tick duration in ms at 60 fps — used to convert internal speed to ms/tick. */ +const TICK_MS = 16.67; + +const DEFAULT_OPTIONS: Required> = { + maxDtMs: 33, + random: Math.random, + defaultGenerationPerSecond: 1, +}; + +// ─── Internal state ──────────────────────────────────────────────────────── + +interface ResolvedNode { + id: NodeId; + nodeRole: NodeRole; + /** Particles per second. 0 for relay/sink. */ + particleGeneration: number; + /** Number of parallel processing slots (Infinity = unbounded). */ + maxParticleProcessing: number; + /** Maximum FIFO queue size (undefined = unbounded). */ + queue_size: number | undefined; + processing_time: number; + failure_rate: number; + dropPolicy: DropPolicy; +} + +interface ResolvedLink { + id: LinkId; + source: NodeId; + target: NodeId; + particleSpeed: number; + maxParticleFlow: number; +} + +interface ProcessingSlot { + particleId: ParticleId; + releaseAt: number; +} + +interface InternalStats { + totalEmitted: number; + totalArrived: number; + totalDropped: number; + latencySumMs: number; +} + +// ─── Class ───────────────────────────────────────────────────────────────── + +export class ParticleSimulator { + private readonly options: Required> & { + onParticleReleased?: SimulatorOptions['onParticleReleased']; + }; + + private readonly nodes = new Map(); + private readonly links = new Map(); + private readonly outgoing = new Map(); + private readonly queues = new Map(); + private readonly slots = new Map(); + private readonly generatorAccumulators = new Map(); + private readonly particles = new Map(); + private readonly listeners = new Set(); + + private running = false; + private disposed = false; + private now = 0; + private particleIdCounter = 0; + private stats: InternalStats = { totalEmitted: 0, totalArrived: 0, totalDropped: 0, latencySumMs: 0 }; + + constructor(graph: GraphInput, options: SimulatorOptions = {}) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + + // Resolve nodes with defaults + for (const node of graph.nodes) { + this.nodes.set(node.id, this.resolveNode(node)); + this.queues.set(node.id, this.makeEmptyQueue(node.id)); + this.slots.set(node.id, []); + this.generatorAccumulators.set(node.id, 0); + } + + // Resolve links with defaults + build outgoing adjacency + let counter = 0; + for (const link of graph.links) { + const id = link.id ?? `${link.source}->${link.target}#${counter++}`; + this.links.set(id, { + id, + source: link.source, + target: link.target, + particleSpeed: link.particleSpeed ?? 1.0, + maxParticleFlow: link.maxParticleFlow ?? 0, + }); + if (!this.outgoing.has(link.source)) this.outgoing.set(link.source, []); + this.outgoing.get(link.source)!.push(id); + } + } + + // ─── Public API ──────────────────────────────────────────────────────── + + start(): void { + this.assertNotDisposed(); + this.resetState(); + this.running = true; + } + + pause(): void { + this.assertNotDisposed(); + this.running = false; + } + + stop(): void { + this.assertNotDisposed(); + this.running = false; + this.resetState(); + } + + tick(dt: number): void { + this.assertNotDisposed(); + if (!this.running) return; + const clamped = Math.min(Math.max(dt, 0), this.options.maxDtMs); + if (clamped === 0) return; + this.now += clamped; + + this.tickEmission(clamped); + this.tickTransit(clamped); + this.tickProcessing(); + + if (this.listeners.size > 0) { + const snapshot = this.getStats(); + for (const cb of this.listeners) cb(snapshot); + } + } + + getStats(): SimulatorStats { + this.assertNotDisposed(); + const queues = new Map(); + for (const [nodeId, q] of this.queues) { + queues.set(nodeId, { size: q.pending.length, droppedCount: q.droppedCount }); + } + let inFlight = 0; + for (const p of this.particles.values()) { + if (p.linkId !== null) inFlight++; + } + return { + particlesInFlight: inFlight, + totalEmitted: this.stats.totalEmitted, + totalArrived: this.stats.totalArrived, + totalDropped: this.stats.totalDropped, + averageLatencyMs: + this.stats.totalArrived > 0 ? this.stats.latencySumMs / this.stats.totalArrived : NaN, + queues, + }; + } + + onTick(listener: StatsListener): () => void { + this.assertNotDisposed(); + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + dispose(): void { + this.running = false; + this.disposed = true; + this.listeners.clear(); + this.particles.clear(); + this.queues.clear(); + this.slots.clear(); + this.generatorAccumulators.clear(); + this.nodes.clear(); + this.links.clear(); + this.outgoing.clear(); + } + + // ─── Resolve / construction helpers ──────────────────────────────────── + + private resolveNode(node: NodeInput): ResolvedNode { + const role: NodeRole = node.nodeRole ?? 'relay'; + // particleGeneration only applies to generators; zeroed for relay/sink + // (validator emits a warning when this happens — see dotValidator). + const rawGen = node.particleGeneration; + const particleGeneration = + role === 'generator' + ? rawGen !== undefined && rawGen > 0 + ? rawGen + : this.options.defaultGenerationPerSecond + : 0; + return { + id: node.id, + nodeRole: role, + particleGeneration, + maxParticleProcessing: node.maxParticleProcessing ?? Infinity, + queue_size: node.queue_size, + processing_time: node.processing_time ?? 0, + failure_rate: node.failure_rate ?? 0, + dropPolicy: node.dropPolicy ?? 'tail', + }; + } + + private makeEmptyQueue(nodeId: NodeId): NodeQueue { + return { + nodeId, + pending: [], + droppedCount: 0, + droppedReasons: new Map(), + roundRobinCursor: 0, + }; + } + + private resetState(): void { + this.now = 0; + this.particleIdCounter = 0; + this.particles.clear(); + this.stats = { totalEmitted: 0, totalArrived: 0, totalDropped: 0, latencySumMs: 0 }; + for (const nodeId of this.nodes.keys()) { + this.queues.set(nodeId, this.makeEmptyQueue(nodeId)); + this.slots.set(nodeId, []); + this.generatorAccumulators.set(nodeId, 0); + } + } + + // ─── Tick phases ─────────────────────────────────────────────────────── + + /** Phase 3a: generators emit at their configured rate (regular deterministic). */ + private tickEmission(dt: number): void { + for (const node of this.nodes.values()) { + if (node.nodeRole !== 'generator' || node.particleGeneration <= 0) continue; + const intervalMs = 1000 / node.particleGeneration; + const acc = (this.generatorAccumulators.get(node.id) ?? 0) + dt; + let toEmit = Math.floor(acc / intervalMs); + this.generatorAccumulators.set(node.id, acc - toEmit * intervalMs); + while (toEmit-- > 0) { + this.routeOutFromGenerator(node.id); + } + } + } + + /** Phase 3b: advance all in-transit particles, fire arrivals. */ + private tickTransit(dt: number): void { + const ticks = dt / TICK_MS; + const arrived: Particle[] = []; + for (const p of this.particles.values()) { + if (p.linkId === null) continue; + p.t += p.speed * ticks; + if (p.t >= 1) arrived.push(p); + } + for (const p of arrived) { + const link = this.links.get(p.linkId!); + this.particles.delete(p.id); + if (!link) continue; + this.handleArrival(link.target, p); + } + } + + /** Phase 3c: release finished slots, refill from queues. */ + private tickProcessing(): void { + for (const node of this.nodes.values()) { + if (node.nodeRole === 'sink') continue; + const slots = this.slots.get(node.id)!; + const q = this.queues.get(node.id)!; + + // Step 1: release finished slots + let i = 0; + while (i < slots.length) { + if (slots[i].releaseAt <= this.now) { + const particleId = slots[i].particleId; + slots.splice(i, 1); + this.releaseFromSlot(node, particleId); + } else { + i++; + } + } + + // Step 2: fill empty slots from queue + while (slots.length < node.maxParticleProcessing && q.pending.length > 0) { + const p = q.pending.shift()!; + slots.push({ particleId: p.id, releaseAt: this.now + node.processing_time }); + // The particle stays in this.particles (linkId stays null while processing). + } + } + } + + // ─── Arrival / routing / emission helpers ────────────────────────────── + + private handleArrival(targetId: NodeId, p: Particle): void { + const target = this.nodes.get(targetId); + if (!target) { + // Edge points to an unknown node — treat as drop (no_outlet). + this.recordDrop(targetId, 'no_outlet'); + return; + } + if (target.nodeRole === 'sink') { + // Absorbed at sink. + this.stats.totalArrived++; + this.stats.latencySumMs += this.now - p.bornAt; + return; + } + // Relay or generator (C1): enqueue. + this.enqueue(target, p); + } + + /** Place an arriving particle into the target node's queue (applies dropPolicy). */ + private enqueue(node: ResolvedNode, p: Particle): void { + const q = this.queues.get(node.id)!; + if (node.queue_size !== undefined && q.pending.length >= node.queue_size) { + if (node.dropPolicy === 'head' && q.pending.length > 0) { + const dropped = q.pending.shift()!; + this.particles.delete(dropped.id); + this.recordDrop(node.id, 'queue_full'); + // fall through: push incoming + } else { + // tail / reject: drop incoming + this.particles.delete(p.id); + this.recordDrop(node.id, 'queue_full'); + return; + } + } + p.linkId = null; + p.t = 0; + q.pending.push(p); + // Make sure the particle is tracked (it is, since handleArrival was called + // on a particle already in this.particles via tickTransit's arrival list). + this.particles.set(p.id, p); + } + + /** Move a particle from a processing slot onto an outgoing link (or drop). */ + private releaseFromSlot(node: ResolvedNode, particleId: ParticleId): void { + const p = this.particles.get(particleId); + if (!p) return; + // failure_rate sampled at the output + if (node.failure_rate > 0 && this.options.random() < node.failure_rate) { + this.particles.delete(particleId); + this.recordDrop(node.id, 'failure_rate'); + return; + } + const outgoing = this.outgoing.get(node.id); + if (!outgoing || outgoing.length === 0) { + this.particles.delete(particleId); + this.recordDrop(node.id, 'no_outlet'); + return; + } + const linkId = this.pickOutgoing(node.id, outgoing); + this.sendOnLink(p, linkId); + } + + /** + * Phase 3d — outbound routing. Weighted by maxParticleFlow when at least one + * link has a positive weight, otherwise round-robin. + */ + private pickOutgoing(nodeId: NodeId, outgoing: LinkId[]): LinkId { + if (outgoing.length === 1) return outgoing[0]; + let totalWeight = 0; + const weights: number[] = []; + for (const id of outgoing) { + const w = this.links.get(id)!.maxParticleFlow; + const safe = w > 0 ? w : 0; + weights.push(safe); + totalWeight += safe; + } + if (totalWeight > 0) { + const r = this.options.random() * totalWeight; + let cumul = 0; + for (let i = 0; i < outgoing.length; i++) { + cumul += weights[i]; + if (r < cumul) return outgoing[i]; + } + return outgoing[outgoing.length - 1]; + } + // Round-robin fallback + const q = this.queues.get(nodeId)!; + const idx = q.roundRobinCursor % outgoing.length; + q.roundRobinCursor = (q.roundRobinCursor + 1) % outgoing.length; + return outgoing[idx]; + } + + /** + * Emit a generator's own particle directly onto an outgoing link. + * Bypasses the queue and slots (a generator's emission is push, not pull). + * This is the *only* place `totalEmitted` is incremented — routing of + * relayed traffic uses `sendOnLink` directly which does not bump the counter. + */ + private routeOutFromGenerator(nodeId: NodeId): void { + const outgoing = this.outgoing.get(nodeId); + if (!outgoing || outgoing.length === 0) { + this.recordDrop(nodeId, 'no_outlet'); + return; + } + const linkId = this.pickOutgoing(nodeId, outgoing); + const p: Particle = { + id: `p${++this.particleIdCounter}`, + linkId: null, + t: 0, + speed: 0, + bornAt: this.now, + }; + this.particles.set(p.id, p); + this.stats.totalEmitted++; + this.sendOnLink(p, linkId); + } + + /** Put a particle in transit on a link, set its speed, fire the callback. */ + private sendOnLink(p: Particle, linkId: LinkId): void { + const link = this.links.get(linkId)!; + const speed = Math.max(MIN_INTERNAL_SPEED, Math.min(MAX_INTERNAL_SPEED, link.particleSpeed * SPEED_SCALE)); + p.linkId = linkId; + p.t = 0; + p.speed = speed; + this.options.onParticleReleased?.(linkId, p.id); + } + + private recordDrop(nodeId: NodeId, reason: DropReason): void { + const q = this.queues.get(nodeId); + if (!q) return; + q.droppedCount++; + q.droppedReasons.set(reason, (q.droppedReasons.get(reason) ?? 0) + 1); + this.stats.totalDropped++; + } + + private assertNotDisposed(): void { + if (this.disposed) { + throw new Error('ParticleSimulator has been disposed and is no longer usable.'); + } + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d21dc33..9c44b7f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -56,12 +56,21 @@ export default defineConfig({ coverage: { // Baseline matching the current numbers. Raise these as new tests land // for the under-covered modules (AdminPanel, GraphList, api, websocket). - // Per-module thresholds aren't worth the noise yet — track via reports. + // Per-module thresholds are added selectively for well-covered critical + // modules to prevent regression. thresholds: { lines: 60, branches: 55, functions: 50, statements: 60, + // ParticleSimulator is the DES core (ADR-006). Locking it high to + // protect against drift as Phase 4+ integrates it into the renderer. + 'src/services/particleSimulator.ts': { + lines: 90, + branches: 85, + functions: 95, + statements: 90, + }, }, }, },