diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..651db36
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+__pycache__/
+*.pyc
+.DS_Store
+output/
+.pytest_cache/
diff --git a/README.md b/README.md
index 79818e7..f307cd6 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,48 @@
# Smart Delivery Dispatch System
## Team Information
-- **Team Name**: [Team Name]
-- **Year**: [Year]
-- **All-Female Team**: [Yes/No]
+- **Team Name**: TOP-G
+- **Year**: 2026
+- **All-Female Team**: No
## Architecture Overview
-#### Describe your approach here. Keep it short and clear.
-
- - What is your dispatch strategy?
- - How do you score agents for incoming orders?
- - How do you manage SLA deadlines, priority orders, and agent capacity?
- - What are the main steps in your pipeline?
+Our system implements a **greedy multi-objective dispatch pipeline** designed for high-throughput real-time operations.
+- **Dispatch Strategy**: We use a simulation-clock-driven approach that processes order arrivals in 1-minute ticks. Assignments are made using a priority-first greedy selection.
+- **Scoring Logic**: Agents are scored for each order based on a weighted linear combination of five factors: **Delivery Time** (distance + prep), **SLA Risk** (urgency based on deadline), **Workload Fairness** (cumulative assignments), **Priority Boost** (weighted multipliers), and **Agent Rating**. The system selects the candidate that minimizes this composite cost.
+- **Management**:
+ - **SLA/Priority**: A min-heap keyed by `(priority, timestamp)` ensures strict tiered processing.
+ - **Capacity**: A strict concurrency limit of **2 active orders** per agent is enforced via the Agent Registry.
+- **Pipeline**:
+ 1. **Ingest**: Orders are loaded into a priority heap upon arrival.
+ 2. **Candidate Generation**: Available agents are filtered by graph connectivity.
+ 3. **Optimization**: Shortest paths are resolved via **Floyd-Warshall** precomputation for O(1) lookups.
+ 4. **Execution**: Atomic state updates move orders from `PENDING` to `DELIVERED`.
**Note:** Please do not change the format or spelling of anything in this README. The fields are extracted using a script, so any changes to the structure or formatting may break the extraction process.
+
+## Issue Resolution Summary
+
+| Issue | Resolution Description |
+| :--- | :--- |
+| **Issue 1** | Implemented `load_orders` in `src/data_loader.py` with full CSV validation and skip-on-error logic. |
+| **Issue 2** | Implemented `load_agents` with rating clamping and graph-location referential integrity checks in `main.py`. |
+| **Issue 3** | Built `EnvironmentGraph` in `src/graph.py` supporting Floyd-Warshall precomputation and Dijkstra for routing. |
+| **Issue 4** | Created `PriorityOrderQueue` using a min-heap keyed by `(priority, timestamp)` for strict tiered dispatch. |
+| **Issue 5** | Developed `AgentRegistry` to track location, workload (max 2), and availability state in O(1). |
+| **Issue 6** | Implemented `generate_candidates` in `src/scorer.py` to filter agents by path connectivity and capacity. |
+| **Issue 7** | Developed a multi-objective `score_candidates` function using weighted normalization of 5 key factors. |
+| **Issue 8** | Added support for tuning objective weights via `constraints.csv` and interactive UI sliders. |
+| **Issue 9** | Orchestrated atomic state updates between queue and registry within `Dispatcher._attempt_assignments`. |
+| **Issue 10** | Implemented `_complete_delivery` to handle flight removal, agent relocation, and SLA verification. |
+| **Issue 11** | Designed `Dispatcher` to auto-retry assignments upon every delivery completion to clear backlogs. |
+| **Issue 12** | Integrated Welford's algorithm in `src/metrics.py` for numerically stable online mean tracking. |
+| **Issue 13** | Implemented SLA violation tracking and average margin calculation reported by priority tier. |
+| **Issue 14** | Added calculation of assignment variance and standard deviation to monitor workload fairness. |
+| **Issue 15** | Developed `MetricsCollector.to_json()` for structured exports and a human-readable summary method. |
+| **Issue 16** | Robust error handling in `data_loader.py` for missing columns, file errors, and malformed numeric fields. |
+| **Issue 17** | Handled disconnected graphs, full agents, and stale deadlines with descriptive logging warnings. |
+| **Issue 18** | Achieved sub-millisecond query time via $O(1)$ routing lookups and implemented latency monitoring. |
+| **Issue 19** | Optimized processing pipeline to exceed 100 orders/min throughput, tracked via wall-clock time. |
+| **Issue 20** | Completed comprehensive documentation of the greedy dispatch strategy and project architecture. |
diff --git a/data/raw/constraints.csv b/data/raw/constraints.csv
index c420519..90a8eba 100644
--- a/data/raw/constraints.csv
+++ b/data/raw/constraints.csv
@@ -5,4 +5,8 @@ default_sla_minutes,50
priority_weight_high,1.5
priority_weight_normal,1.0
priority_weight_low,0.8
-
+weight_delivery_time,0.35
+weight_sla_risk,0.30
+weight_workload,0.20
+weight_priority,0.10
+weight_rating,0.05
diff --git a/frontend/app.js b/frontend/app.js
new file mode 100644
index 0000000..8539733
--- /dev/null
+++ b/frontend/app.js
@@ -0,0 +1,209 @@
+// app.js — UI controller, charts, canvas grid
+
+// ── Data (embedded from CSVs) ─────────────────────────────────────────────
+const ORDERS_RAW = `O001,2026-05-03 09:00:00,2,3,12,high,45
+O002,2026-05-03 09:04:00,5,1,8,normal,50
+O003,2026-05-03 09:08:00,1,6,15,high,40
+O004,2026-05-03 09:12:00,7,4,10,normal,55
+O005,2026-05-03 09:18:00,3,8,6,low,60
+O006,2026-05-03 09:22:00,9,2,14,high,42
+O007,2026-05-03 09:25:00,4,5,9,normal,52
+O008,2026-05-03 09:28:00,6,7,11,low,58
+O009,2026-05-03 09:32:00,1,1,7,high,38
+O010,2026-05-03 09:35:00,8,3,13,normal,54
+O011,2026-05-03 09:38:00,2,9,10,low,62
+O012,2026-05-03 09:42:00,5,4,8,high,44
+O013,2026-05-03 09:45:00,7,8,12,normal,50
+O014,2026-05-03 09:48:00,3,2,6,low,60
+O015,2026-05-03 09:52:00,9,6,15,high,40
+O016,2026-05-03 09:55:00,1,4,9,normal,53
+O017,2026-05-03 09:58:00,6,1,11,low,59
+O018,2026-05-03 10:02:00,4,9,7,high,43
+O019,2026-05-03 10:05:00,8,5,14,normal,51
+O020,2026-05-03 10:08:00,2,7,10,low,61
+O021,2026-05-03 10:12:00,5,3,8,high,45
+O022,2026-05-03 10:15:00,9,9,13,normal,55
+O023,2026-05-03 10:18:00,3,6,6,low,58
+O024,2026-05-03 10:22:00,7,2,12,high,41
+O025,2026-05-03 10:25:00,1,8,9,normal,52
+O026,2026-05-03 10:28:00,6,4,11,low,60
+O027,2026-05-03 10:32:00,4,1,7,high,44
+O028,2026-05-03 10:35:00,8,7,15,normal,50
+O029,2026-05-03 10:38:00,2,5,10,low,62
+O030,2026-05-03 10:42:00,9,3,8,high,42
+O031,2026-05-03 10:45:00,5,9,14,normal,54
+O032,2026-05-03 10:48:00,3,4,6,low,59
+O033,2026-05-03 10:52:00,7,6,12,high,43
+O034,2026-05-03 10:55:00,1,2,9,normal,51
+O035,2026-05-03 10:58:00,6,8,11,low,61
+O036,2026-05-03 11:02:00,4,3,7,high,45
+O037,2026-05-03 11:05:00,8,1,13,normal,53
+O038,2026-05-03 11:08:00,2,6,10,low,58
+O039,2026-05-03 11:12:00,9,4,8,high,44
+O040,2026-05-03 11:15:00,5,7,15,normal,50
+O041,2026-05-03 11:18:00,3,1,6,low,60
+O042,2026-05-03 11:22:00,7,9,12,high,41
+O043,2026-05-03 11:25:00,1,5,9,normal,52
+O044,2026-05-03 11:28:00,6,3,11,low,62
+O045,2026-05-03 11:32:00,4,8,7,high,43
+O046,2026-05-03 11:35:00,8,2,14,normal,54
+O047,2026-05-03 11:38:00,2,4,10,low,59
+O048,2026-05-03 11:42:00,9,7,8,high,42
+O049,2026-05-03 11:45:00,5,5,13,normal,51
+O050,2026-05-03 11:48:00,3,9,6,low,61
+O051,2026-05-03 11:52:00,7,1,12,high,45
+O052,2026-05-03 11:55:00,1,3,9,normal,53
+O053,2026-05-03 11:58:00,6,6,11,low,58
+O054,2026-05-03 12:02:00,4,4,7,high,44
+O055,2026-05-03 12:05:00,8,9,15,normal,50
+O056,2026-05-03 12:08:00,2,2,10,low,60
+O057,2026-05-03 12:12:00,9,5,8,high,42
+O058,2026-05-03 12:15:00,5,8,14,normal,52
+O059,2026-05-03 12:18:00,3,3,6,low,62
+O060,2026-05-03 12:22:00,7,7,12,high,41
+O061,2026-05-03 12:25:00,1,9,9,normal,54
+O062,2026-05-03 12:28:00,6,2,11,low,59
+O063,2026-05-03 12:32:00,4,6,7,high,43
+O064,2026-05-03 12:35:00,8,4,13,normal,51
+O065,2026-05-03 12:38:00,2,8,10,low,61
+O066,2026-05-03 12:42:00,9,1,8,high,45
+O067,2026-05-03 12:45:00,5,6,15,normal,53
+O068,2026-05-03 12:48:00,3,5,6,low,58
+O069,2026-05-03 12:52:00,7,3,12,high,44
+O070,2026-05-03 12:55:00,1,7,9,normal,50
+O071,2026-05-03 12:58:00,6,9,11,low,60
+O072,2026-05-03 13:02:00,4,2,7,high,42
+O073,2026-05-03 13:05:00,8,6,14,normal,52
+O074,2026-05-03 13:08:00,2,1,10,low,62
+O075,2026-05-03 13:12:00,9,8,8,high,41
+O076,2026-05-03 13:15:00,5,4,13,normal,54
+O077,2026-05-03 13:18:00,3,7,6,low,59
+O078,2026-05-03 13:22:00,7,5,12,high,43
+O079,2026-05-03 13:25:00,1,1,9,normal,51
+O080,2026-05-03 13:28:00,6,5,11,low,61
+O081,2026-05-03 13:32:00,4,7,7,high,45
+O082,2026-05-03 13:35:00,8,8,15,normal,53
+O083,2026-05-03 13:38:00,2,3,10,low,58
+O084,2026-05-03 13:42:00,9,2,8,high,44
+O085,2026-05-03 13:45:00,5,2,14,normal,50
+O086,2026-05-03 13:48:00,3,1,6,low,60
+O087,2026-05-03 13:52:00,7,8,12,high,42
+O088,2026-05-03 13:55:00,1,6,9,normal,52
+O089,2026-05-03 13:58:00,6,3,11,low,62
+O090,2026-05-03 14:02:00,4,9,7,high,41
+O091,2026-05-03 14:05:00,8,1,13,normal,54
+O092,2026-05-03 14:08:00,2,4,10,low,59
+O093,2026-05-03 14:12:00,9,6,8,high,43
+O094,2026-05-03 14:15:00,5,9,15,normal,51
+O095,2026-05-03 14:18:00,3,2,6,low,61
+O096,2026-05-03 14:22:00,7,4,12,high,45
+O097,2026-05-03 14:25:00,1,8,9,normal,53
+O098,2026-05-03 14:28:00,6,7,11,low,58
+O099,2026-05-03 14:32:00,4,5,7,high,44
+O100,2026-05-03 14:35:00,8,3,14,normal,50
+O101,2026-05-03 14:38:00,2,9,10,low,60
+O102,2026-05-03 14:42:00,9,4,8,high,42
+O103,2026-05-03 14:45:00,5,1,13,normal,52
+O104,2026-05-03 14:48:00,3,6,6,low,62
+O105,2026-05-03 14:52:00,7,2,12,high,41
+O106,2026-05-03 14:55:00,1,4,9,normal,54
+O107,2026-05-03 14:58:00,6,8,11,low,59
+O108,2026-05-03 15:02:00,4,1,7,high,43
+O109,2026-05-03 15:05:00,8,7,15,normal,51
+O110,2026-05-03 15:08:00,2,5,10,low,61
+O111,2026-05-03 15:12:00,9,9,8,high,45
+O112,2026-05-03 15:15:00,5,3,14,normal,53
+O113,2026-05-03 15:18:00,3,8,6,low,58
+O114,2026-05-03 15:22:00,7,6,12,high,44
+O115,2026-05-03 15:25:00,1,2,9,normal,50
+O116,2026-05-03 15:28:00,6,4,11,low,60
+O117,2026-05-03 15:32:00,4,3,7,high,42
+O118,2026-05-03 15:35:00,8,5,13,normal,52
+O119,2026-05-03 15:38:00,2,7,10,low,62
+O120,2026-05-03 15:42:00,9,1,8,high,41
+O121,2026-05-03 15:45:00,5,7,15,normal,54
+O122,2026-05-03 15:48:00,3,4,6,low,59
+O123,2026-05-03 15:52:00,7,9,12,high,43
+O124,2026-05-03 15:55:00,1,5,9,normal,51
+O125,2026-05-03 15:58:00,6,1,11,low,61
+O126,2026-05-03 16:02:00,4,8,7,high,45
+O127,2026-05-03 16:05:00,8,2,14,normal,53
+O128,2026-05-03 16:08:00,2,6,10,low,58
+O129,2026-05-03 16:12:00,9,3,8,high,44
+O130,2026-05-03 16:15:00,5,5,13,normal,50
+O131,2026-05-03 16:18:00,3,9,6,low,60
+O132,2026-05-03 16:22:00,7,7,12,high,42
+O133,2026-05-03 16:25:00,1,3,9,normal,52
+O134,2026-05-03 16:28:00,6,6,11,low,62
+O135,2026-05-03 16:32:00,4,4,7,high,41
+O136,2026-05-03 16:35:00,8,9,15,normal,54
+O137,2026-05-03 16:38:00,2,2,10,low,59
+O138,2026-05-03 16:42:00,9,7,8,high,43
+O139,2026-05-03 16:45:00,5,8,14,normal,51
+O140,2026-05-03 16:48:00,3,3,6,low,61
+O141,2026-05-03 16:52:00,7,1,12,high,45
+O142,2026-05-03 16:55:00,1,9,9,normal,53
+O143,2026-05-03 16:58:00,6,2,11,low,58
+O144,2026-05-03 17:02:00,4,6,7,high,44
+O145,2026-05-03 17:05:00,8,4,13,normal,50
+O146,2026-05-03 17:08:00,2,8,10,low,60
+O147,2026-05-03 17:12:00,9,5,8,high,42
+O148,2026-05-03 17:15:00,5,6,15,normal,52
+O149,2026-05-03 17:18:00,3,5,6,low,62
+O150,2026-05-03 17:22:00,7,3,12,high,41`;
+
+const AGENTS_RAW = `A001,0,0,4.8
+A002,1,1,4.5
+A003,2,2,4.9
+A004,3,3,4.2
+A005,4,4,4.7
+A006,5,5,4.6
+A007,6,6,4.3
+A008,7,7,4.8
+A009,8,8,4.4
+A010,9,9,4.5
+A011,0,5,4.7
+A012,1,6,4.9
+A013,2,7,4.1
+A014,3,8,4.6
+A015,4,9,4.8
+A016,5,0,4.3
+A017,6,1,4.5
+A018,7,2,4.7
+A019,8,3,4.2
+A020,9,4,4.9
+A021,1,4,4.6
+A022,3,5,4.4
+A023,5,7,4.8
+A024,7,9,4.5
+A025,9,1,4.7`;
+
+// parse
+function parseOrders() {
+ return ORDERS_RAW.trim().split('\n').map(line => {
+ const [id, ts, x, y, prep, priority, sla] = line.split(',');
+ return { id, ts, x: +x, y: +y, prep: +prep, priority, sla: +sla };
+ });
+}
+function parseAgents() {
+ return AGENTS_RAW.trim().split('\n').map(line => {
+ const [id, x, y, rating] = line.split(',');
+ return { id, loc: [+x, +y], rating: +rating };
+ });
+}
+function buildEdges() {
+ const edges = [];
+ for (let x = 0; x <= 9; x++) for (let y = 0; y <= 9; y++) {
+ if (x + 1 <= 9) edges.push({ from: [x, y], to: [x+1, y], dist: 3, delay: 1.0 });
+ if (y + 1 <= 9) edges.push({ from: [x, y], to: [x, y+1], dist: 3, delay: 1.0 });
+ }
+ return edges;
+}
+const CONSTRAINTS = {
+ maxActive: 2,
+ latencyTarget: 5,
+ defaultSLA: 50,
+ priHigh: 1.5,
+ priNormal: 1.0,
+ priLow: 0.8
+};
diff --git a/frontend/graph.html b/frontend/graph.html
new file mode 100644
index 0000000..b54991e
--- /dev/null
+++ b/frontend/graph.html
@@ -0,0 +1,463 @@
+
+
+
+
+Graph Analysis — Floyd-Warshall
+
+
+
+
+
+ 📊 Graph Analysis — Floyd-Warshall & Dijkstra
+
+
Graph size:
+
+
+
Floyd-Warshall active
+
+
+
+
+
+
+
+
+
🔗 Environment Graph (10×10 Grid)
+
+
+
+
+
+
+
🛤 Shortest Path Finder
FW precomputed — O(1) query
+
+
+
+
+
+
+
+
+
Enter coordinates and click Find Path
+
+
+
STEP-BY-STEP DIJKSTRA TRACE
+
+
+
+
+
+
+
+
+
+
+
📐 Distance Matrix (sample nodes)
+ Heatmap: darker = farther
+
+
+
+
+
+
⚙ Floyd-Warshall Steps (k-iteration)
Active for ≤100 nodes
+
+
+
+
+ k = — / —
+ Updates: 0
+
+
+
+
+
+
+
+
+
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..06208d8
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,243 @@
+
+
+
+
+
+ Smart Delivery Dispatch — Live Dashboard
+
+
+
+
+
+
+
+
+
+
+
⚡
+
TOP-G
+
Smart Delivery Dispatch System
+
+
+
🗺 Map Sim
+
📊 Graph
+
+
+ Ready
+
+
T+00:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📦
+
0
+
Delivered
+
of 150 orders
+
+
+
✅
+
—
+
SLA Compliance
+
0 violations
+
+
+
⏱
+
—
+
Avg Delivery
+
minutes
+
+
+
⚖
+
—
+
Workload Std
+
assignment spread
+
+
+
⚡
+
—
+
Avg Latency
+
ms per decision
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ On time
+ Violated
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/map.html b/frontend/map.html
new file mode 100644
index 0000000..636bcf2
--- /dev/null
+++ b/frontend/map.html
@@ -0,0 +1,419 @@
+
+
+
+
+
+ Map Simulation — Step-by-Step Analysis
+
+
+
+
+
+
+
+
+
⚡
+
STEP-BY-STEP MAP SIM
+
+
+
+
+
+
+
+
+
+
+
+ T+0m | Tick 0
+
+
+
+
+
+
+
+ ● Agent
+ ■ High
+ ■ Normal
+ ■ Low
+ → In-Transit
+
+
+
+
+
Assignment Analysis (Numerical)
+
+
Run simulation to see candidate scores...
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/simulation.js b/frontend/simulation.js
new file mode 100644
index 0000000..954e694
--- /dev/null
+++ b/frontend/simulation.js
@@ -0,0 +1,192 @@
+// simulation.js — pure data engine (no DOM)
+
+const PRIORITY_RANK = { high: 0, normal: 1, low: 2 };
+const INF = Infinity;
+
+// ── Graph ────────────────────────────────────────────────────────────────────
+class Graph {
+ constructor(edges) {
+ this.nodes = new Set();
+ this.adj = {};
+ this.dist = {};
+ for (const e of edges) {
+ const w = e.dist * e.delay;
+ this._addEdge(e.from, e.to, w);
+ this._addEdge(e.to, e.from, w);
+ }
+ this._floydWarshall();
+ }
+ _key(loc) { return `${loc[0]},${loc[1]}`; }
+ _addEdge(a, b, w) {
+ const ka = this._key(a), kb = this._key(b);
+ this.nodes.add(ka);
+ this.nodes.add(kb);
+ if (!this.adj[ka]) this.adj[ka] = [];
+ this.adj[ka].push({ to: kb, w });
+ }
+ _floydWarshall() {
+ const nodes = [...this.nodes];
+ const n = nodes.length;
+ const idx = {};
+ nodes.forEach((k, i) => idx[k] = i);
+ const d = Array.from({ length: n }, () => Array(n).fill(INF));
+ for (let i = 0; i < n; i++) d[i][i] = 0;
+ for (const [src, nbrs] of Object.entries(this.adj))
+ for (const { to, w } of nbrs)
+ if (w < d[idx[src]][idx[to]]) d[idx[src]][idx[to]] = w;
+ for (let k = 0; k < n; k++)
+ for (let i = 0; i < n; i++)
+ for (let j = 0; j < n; j++)
+ if (d[i][k] + d[k][j] < d[i][j]) d[i][j] = d[i][k] + d[k][j];
+ for (let i = 0; i < n; i++)
+ for (let j = 0; j < n; j++)
+ this.dist[`${nodes[i]}|${nodes[j]}`] = d[i][j];
+ }
+ travel(a, b) {
+ if (a[0] === b[0] && a[1] === b[1]) return 0;
+ return this.dist[`${this._key(a)}|${this._key(b)}`] ?? INF;
+ }
+}
+
+// ── Scoring ──────────────────────────────────────────────────────────────────
+function scoreCandidate(agent, order, travelTime, agents, constraints, weights, elapsed) {
+ const delivery = travelTime + order.prep;
+ const maxDelivery = 30;
+ const maxAssign = Math.max(...agents.map(a => a.assignments), 1);
+ const pw = { high: constraints.priHigh, normal: constraints.priNormal, low: constraints.priLow };
+ const slaRemain = order.sla - (elapsed + delivery);
+ const slaRatio = Math.max(-1, Math.min(1, slaRemain / order.sla));
+ return (
+ weights.time * (delivery / maxDelivery) -
+ weights.sla * slaRatio +
+ weights.load * (agent.assignments / maxAssign) -
+ weights.priority * (pw[order.priority] / constraints.priHigh) -
+ weights.rating * (agent.rating / 5)
+ );
+}
+
+// ── Simulator ────────────────────────────────────────────────────────────────
+class Simulator {
+ constructor(orders, agents, edges, constraints) {
+ this.allOrders = orders.map(o => ({ ...o, state: 'PENDING', agentId: null }));
+ this.agents = agents.map(a => ({ ...a, active: [], assignments: 0 }));
+ this.graph = new Graph(edges);
+ this.constraints = constraints;
+ this.weights = { time: .35, sla: .30, load: .20, priority: .10, rating: .05 };
+ this.inFlight = [];
+ this.tick = 0;
+ this.elapsed = 0;
+ this.events = [];
+ this.metrics = { delivered: 0, violations: 0, totalTime: 0, totalSLA: 0, latencies: [] };
+ this.throughput = []; // [{tick, count}]
+ this._tickDelivered = 0;
+
+ const sorted = [...this.allOrders].sort((a, b) => new Date(a.ts) - new Date(b.ts));
+ this._arrivals = sorted;
+ this._startTs = new Date(sorted[0].ts).getTime();
+ this._pendingArrivals = [...sorted];
+ }
+
+ setWeights(w) { this.weights = { ...this.weights, ...w }; }
+
+ step() {
+ this.tick++;
+ this.elapsed++;
+ this._tickDelivered = 0;
+
+ // Ingest arrivals
+ const simMs = this._startTs + this.elapsed * 60000;
+ while (this._pendingArrivals.length && new Date(this._pendingArrivals[0].ts).getTime() <= simMs) {
+ const o = this._pendingArrivals.shift();
+ const found = this.allOrders.find(x => x.id === o.id);
+ if (found) found.state = 'PENDING';
+ }
+
+ // Assign
+ this._assign();
+
+ // Advance flights
+ for (const f of [...this.inFlight]) {
+ f.remaining -= 1;
+ if (f.remaining <= 0) this._complete(f);
+ }
+ this.inFlight = this.inFlight.filter(f => f.remaining > 0);
+
+ this.throughput.push({ tick: this.tick, count: this._tickDelivered });
+ return this._isDone();
+ }
+
+ _pendingOrders() {
+ return this.allOrders.filter(o => o.state === 'PENDING')
+ .sort((a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] || new Date(a.ts) - new Date(b.ts));
+ }
+
+ _availAgents() { return this.agents.filter(a => a.active.length < this.constraints.maxActive); }
+
+ _assign() {
+ for (const order of this._pendingOrders()) {
+ const avail = this._availAgents();
+ if (!avail.length) break;
+
+ const t0 = performance.now();
+ let best = null, bestScore = INF;
+ for (const agent of avail) {
+ const t = this.graph.travel(agent.loc, [order.x, order.y]);
+ if (t === INF) continue;
+ const s = scoreCandidate(agent, order, t, this.agents, this.constraints, this.weights, this.elapsed);
+ if (s < bestScore || (s === bestScore && agent.id < (best?.agent.id ?? ''))) {
+ bestScore = s; best = { agent, t };
+ }
+ }
+ const latMs = performance.now() - t0;
+ this.metrics.latencies.push(latMs);
+
+ if (!best) { this._log('warn', `${order.id}: no path — queued`); continue; }
+
+ order.state = 'ASSIGNED'; order.agentId = best.agent.id;
+ best.agent.active.push(order.id); best.agent.assignments++;
+ const delivery = Math.ceil(best.t + order.prep);
+ this.inFlight.push({ orderId: order.id, agentId: best.agent.id, remaining: delivery, dest: [order.x, order.y], sla: order.sla, priority: order.priority, startElapsed: this.elapsed });
+ order.state = 'IN_TRANSIT';
+ this._log('assign', `${order.id} → ${best.agent.id} (${best.t.toFixed(1)}m travel, ${order.priority})`);
+ }
+ }
+
+ _complete(f) {
+ const order = this.allOrders.find(o => o.id === f.orderId);
+ const agent = this.agents.find(a => a.id === f.agentId);
+ const actual = this.elapsed - f.startElapsed;
+ const violated = actual > f.sla;
+ if (order) order.state = 'DELIVERED';
+ if (agent) { agent.active = agent.active.filter(id => id !== f.orderId); agent.loc = f.dest; }
+ this.metrics.delivered++;
+ this.metrics.totalTime += actual;
+ this.metrics.totalSLA += f.sla;
+ if (violated) { this.metrics.violations++; this._log('warn', `${f.orderId}: SLA violated (${actual}m > ${f.sla}m)`); }
+ else this._log('deliver', `${f.orderId} delivered in ${actual}m ✓`);
+ this._tickDelivered++;
+ }
+
+ _log(type, msg) { this.events.push({ type, msg, tick: this.tick }); }
+
+ _isDone() {
+ return this._pendingArrivals.length === 0 && this.allOrders.every(o => o.state === 'DELIVERED');
+ }
+
+ exportMetrics() {
+ const lats = this.metrics.latencies;
+ const avgLat = lats.length ? lats.reduce((a, b) => a + b, 0) / lats.length : 0;
+ const assignments = {};
+ this.agents.forEach(a => assignments[a.id] = a.assignments);
+ const vals = Object.values(assignments);
+ const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
+ const variance = vals.reduce((s, v) => s + (v - mean) ** 2, 0) / vals.length;
+ return {
+ metadata: { timestamp: new Date().toISOString(), total_delivered: this.metrics.delivered, total_violations: this.metrics.violations },
+ delivery_time: { overall_mean: +(this.metrics.totalTime / (this.metrics.delivered || 1)).toFixed(2) },
+ sla_compliance: { rate: +(1 - this.metrics.violations / (this.metrics.delivered || 1)).toFixed(4), violations: this.metrics.violations },
+ workload_fairness: { load_variance: +variance.toFixed(3), load_std: +Math.sqrt(variance).toFixed(3), min: Math.min(...vals), max: Math.max(...vals) },
+ decision_latency: { mean_ms: +avgLat.toFixed(3), count: lats.length }
+ };
+ }
+}
diff --git a/frontend/style.css b/frontend/style.css
new file mode 100644
index 0000000..0cf3e82
--- /dev/null
+++ b/frontend/style.css
@@ -0,0 +1,412 @@
+/* ── Reset & Tokens ─────────────────────────────────── */
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+:root {
+ --bg: #07090f; --bg2: #0e1117; --bg3: #141824; --bg4: #1a2030;
+ --border: #ffffff14; --border2: #ffffff22;
+ --text: #f0f2f8; --text2: #9aa3b8; --text3: #5a6278;
+ --accent: #7c6fff; --accent2: #a89fff;
+ --green: #20d45e; --red: #f04f4f; --orange: #f98c2f; --blue: #4a9eff; --purple: #b06bff;
+ --high: #f04f4f; --normal: #f98c2f; --low: #20d45e;
+ --agent: #7c6fff; --delivered: #20d45e;
+ --radius: 14px; --radius-sm: 8px;
+ --font: 'Inter', sans-serif; --mono: 'JetBrains Mono', monospace;
+ --shadow: 0 6px 32px #00000055;
+ --glass: #ffffff04; --glass-hover: #ffffff0a;
+}
+
+[data-theme="light"] {
+ --bg: #f5f6f9; --bg2: #ffffff; --bg3: #e8ecf3; --bg4: #dfe4ec;
+ --border: #00000010; --border2: #00000020;
+ --text: #1c2235; --text2: #545c75; --text3: #8b92a8;
+ --shadow: 0 6px 32px #00000011;
+ --glass: #00000005; --glass-hover: #0000000a;
+}
+
+html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;overflow:hidden}
+
+/* ── Scrollbar ──────────────────────────────────────── */
+::-webkit-scrollbar{width:4px;height:4px}
+::-webkit-scrollbar-track{background:transparent}
+::-webkit-scrollbar-thumb{background:var(--border2);border-radius:4px}
+::-webkit-scrollbar-thumb:hover{background:#ffffff33}
+
+/* ── Topbar ─────────────────────────────────────────── */
+.topbar{
+ display:flex;align-items:center;justify-content:space-between;
+ height:56px;padding:0 24px;
+ background:var(--bg2);
+ border-bottom:1px solid var(--border);
+ position:sticky;top:0;z-index:100;
+ gap:16px;
+}
+.topbar__brand{display:flex;align-items:center;gap:12px;min-width:0}
+.topbar__icon{font-size:22px;flex-shrink:0}
+.topbar__title{
+ font-size:17px;font-weight:800;letter-spacing:-.5px;
+ background:linear-gradient(90deg,#7c6fff,#b06bff);
+ -webkit-background-clip:text;-webkit-text-fill-color:transparent;
+ white-space:nowrap;
+}
+.topbar__sub{
+ color:var(--text3);font-size:12px;
+ border-left:1px solid var(--border2);padding-left:12px;
+ white-space:nowrap;
+}
+.topbar__controls{display:flex;align-items:center;gap:8px;flex-wrap:nowrap}
+
+/* Nav links in topbar */
+.topbar__nav-link{
+ font-size:12px;font-weight:600;padding:6px 12px;
+ background:var(--bg3);border:1px solid var(--border2);
+ border-radius:var(--radius-sm);text-decoration:none;
+ transition:.2s;white-space:nowrap;
+}
+.topbar__nav-link:hover{border-color:var(--accent);color:#fff}
+
+.sim-status{
+ display:flex;align-items:center;gap:7px;
+ padding:5px 14px;background:var(--bg3);
+ border:1px solid var(--border);border-radius:99px;
+}
+.sim-status__dot{width:8px;height:8px;border-radius:50%;background:var(--text3);transition:.3s}
+.sim-status.running .sim-status__dot{background:var(--green);box-shadow:0 0 8px var(--green);animation:pulse 1.2s infinite}
+.sim-status.done .sim-status__dot{background:var(--accent)}
+@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
+.sim-status__label{font-size:12px;font-weight:600;color:var(--text2)}
+
+.sim-time{
+ font-family:var(--mono);font-size:13px;font-weight:600;
+ color:var(--accent2);padding:6px 12px;
+ background:var(--bg3);border:1px solid var(--border);
+ border-radius:var(--radius-sm);letter-spacing:.5px;
+}
+
+/* ── Buttons ─────────────────────────────────────────── */
+.btn{
+ border:none;cursor:pointer;border-radius:var(--radius-sm);
+ font-family:var(--font);font-size:13px;font-weight:600;
+ padding:8px 18px;transition:.18s;letter-spacing:.2px;
+ white-space:nowrap;
+}
+.btn--primary{
+ background:linear-gradient(135deg,var(--accent),#9d7fff);
+ color:#fff;box-shadow:0 2px 14px #7c6fff33;
+}
+.btn--primary:hover{transform:translateY(-1px);box-shadow:0 4px 22px #7c6fff55}
+.btn--primary:disabled{opacity:.35;cursor:not-allowed;transform:none}
+.btn--ghost{
+ background:var(--bg3);color:var(--text2);
+ border:1px solid var(--border2);
+}
+.btn--ghost:hover{border-color:var(--accent);color:var(--text)}
+.btn--sm{padding:5px 11px;font-size:11px}
+
+/* ── Layout ─────────────────────────────────────────── */
+.layout{
+ display:grid;grid-template-columns:400px 1fr 300px;
+ height:calc(100vh - 56px);overflow:hidden;
+}
+.panel{
+ display:flex;flex-direction:column;gap:10px;
+ padding:12px;overflow-y:auto;overflow-x:hidden;
+}
+.panel--left{border-right:1px solid var(--border);background:var(--bg)}
+.panel--centre{background:var(--bg);padding:12px 14px}
+.panel--right{border-left:1px solid var(--border);background:var(--bg)}
+
+/* ── Cards ───────────────────────────────────────────── */
+.card{
+ background:var(--bg2);border:1px solid var(--border);
+ border-radius:var(--radius);overflow:hidden;
+}
+.card__header{
+ display:flex;align-items:center;justify-content:space-between;
+ padding:11px 16px;border-bottom:1px solid var(--border);
+ background:var(--bg3);
+}
+.card__title{
+ font-size:13px;font-weight:700;color:var(--text);
+ letter-spacing:.1px;display:flex;align-items:center;gap:6px;
+}
+.card__badge{
+ font-size:11px;font-weight:700;padding:3px 10px;
+ background:var(--bg);border:1px solid var(--border2);
+ border-radius:99px;color:var(--text2);
+}
+
+/* ── Map Card ────────────────────────────────────────── */
+.card--map{flex-shrink:0}
+.map-wrapper{display:flex;justify-content:center;padding:10px;background:var(--bg)}
+#gridCanvas{border-radius:8px;border:1px solid var(--border)}
+.map-legend{
+ display:flex;align-items:center;gap:10px;
+ font-size:11px;color:var(--text2);font-weight:500;
+}
+.legend-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex-shrink:0}
+.legend-dot--agent{background:var(--agent)}
+.legend-dot--order-high{background:var(--high)}
+.legend-dot--order-normal{background:var(--normal)}
+.legend-dot--order-low{background:var(--low)}
+.legend-dot--delivered{background:var(--delivered)}
+.map-tick-info{
+ display:flex;gap:16px;padding:8px 16px;
+ border-top:1px solid var(--border);
+ font-size:11px;color:var(--text3);font-family:var(--mono);
+ background:var(--bg3);
+}
+.map-tick-info span{display:flex;align-items:center;gap:4px}
+
+/* ── Order Queue ─────────────────────────────────────── */
+.card--queue{display:flex;flex-direction:column;min-height:0;flex:1}
+.queue-filters{display:flex;gap:4px;flex-wrap:wrap}
+.filter-btn{
+ border:none;cursor:pointer;padding:4px 10px;border-radius:99px;
+ font-size:11px;font-weight:600;background:var(--bg);
+ color:var(--text3);transition:.2s;border:1px solid transparent;
+}
+.filter-btn:hover{color:var(--text2);border-color:var(--border2)}
+.filter-btn.active{background:var(--accent);color:#fff;border-color:transparent}
+.queue-list{flex:1;overflow-y:auto;padding:8px}
+
+.order-item{
+ display:flex;align-items:center;gap:8px;
+ padding:8px 10px;border-radius:var(--radius-sm);
+ border:1px solid transparent;margin-bottom:5px;
+ transition:.2s;cursor:default;
+}
+.order-item:hover{background:var(--bg3)}
+.order-item--PENDING{border-color:var(--border)}
+.order-item--ASSIGNED{border-color:#4a9eff28;background:#4a9eff08}
+.order-item--IN_TRANSIT{
+ border-color:#f98c2f44;background:#f98c2f08;
+ animation:glow-transit 2s infinite;
+}
+.order-item--DELIVERED{border-color:#20d45e22;background:#20d45e08;opacity:.55}
+@keyframes glow-transit{0%,100%{box-shadow:none}50%{box-shadow:0 0 8px #f98c2f22}}
+
+.order-badge{font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;white-space:nowrap}
+.order-badge--high{background:#f04f4f18;color:var(--high);border:1px solid #f04f4f33}
+.order-badge--normal{background:#f98c2f18;color:var(--orange);border:1px solid #f98c2f33}
+.order-badge--low{background:#20d45e18;color:var(--green);border:1px solid #20d45e33}
+
+.order-id{font-family:var(--mono);font-size:11px;font-weight:700;color:var(--accent2);min-width:46px}
+.order-loc{font-size:11px;color:var(--text3)}
+.order-state{font-size:10px;font-weight:700;padding:2px 7px;border-radius:5px;margin-left:auto}
+.state--PENDING{background:#ffffff0e;color:var(--text2)}
+.state--ASSIGNED{background:#4a9eff1a;color:var(--blue)}
+.state--IN_TRANSIT{background:#f98c2f1a;color:var(--orange)}
+.state--DELIVERED{background:#20d45e1a;color:var(--green)}
+
+/* ── KPI Row ─────────────────────────────────────────── */
+.kpi-row{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}
+.kpi-card{
+ background:var(--bg2);border:1px solid var(--border);
+ border-radius:var(--radius);padding:14px 10px;
+ text-align:center;position:relative;overflow:hidden;
+ transition:.2s;
+}
+.kpi-card::after{
+ content:'';position:absolute;bottom:0;left:0;right:0;
+ height:2px;background:var(--accent);opacity:.4;
+}
+.kpi-card--green::after{background:var(--green)}
+.kpi-card--blue::after{background:var(--blue)}
+.kpi-card--purple::after{background:var(--purple)}
+.kpi-card--orange::after{background:var(--orange)}
+.kpi-card__icon{font-size:20px;margin-bottom:6px;line-height:1}
+.kpi-card__value{
+ font-size:24px;font-weight:800;font-family:var(--mono);
+ letter-spacing:-1px;line-height:1;color:var(--text);
+}
+.kpi-card--green .kpi-card__value{color:var(--green)}
+.kpi-card--blue .kpi-card__value{color:var(--blue)}
+.kpi-card--purple .kpi-card__value{color:var(--purple)}
+.kpi-card--orange .kpi-card__value{color:var(--orange)}
+.kpi-card__label{font-size:11px;color:var(--text2);margin-top:5px;font-weight:600}
+.kpi-card__sub{font-size:10px;color:var(--text3);margin-top:2px}
+
+/* ── Charts ─────────────────────────────────────────── */
+.charts-row{display:grid;grid-template-columns:1fr 190px;gap:10px}
+.card--chart{padding:0}
+.card--chart .card__header{margin-bottom:0}
+.card--chart canvas{display:block;padding:10px 14px 12px}
+.card--full{padding:0}
+.card--full canvas{display:block;padding:8px 14px 12px}
+.card--ring{display:flex;flex-direction:column;align-items:center}
+.ring-wrapper{
+ position:relative;display:flex;
+ justify-content:center;align-items:center;margin:8px 0;
+}
+.ring-centre{
+ position:absolute;font-size:20px;font-weight:800;
+ font-family:var(--mono);color:var(--text);
+}
+.ring-legend{
+ display:flex;gap:12px;font-size:11px;
+ color:var(--text2);padding-bottom:8px;font-weight:500;
+}
+.ring-legend__dot{
+ width:9px;height:9px;border-radius:50%;
+ display:inline-block;margin-right:3px;
+}
+.ring-legend__dot--ok{background:var(--green)}
+.ring-legend__dot--bad{background:var(--red)}
+
+/* ── Weights ─────────────────────────────────────────── */
+.card--weights{}
+.weights-grid{display:flex;flex-direction:column;gap:10px;padding:12px 16px}
+.weight-item{display:grid;grid-template-columns:110px 1fr 42px;align-items:center;gap:12px}
+.weight-item label{font-size:12px;color:var(--text2);font-weight:600}
+.weight-item input[type=range]{
+ -webkit-appearance:none;appearance:none;
+ height:4px;border-radius:4px;background:var(--bg3);
+ outline:none;cursor:pointer;
+}
+.weight-item input[type=range]::-webkit-slider-thumb{
+ -webkit-appearance:none;width:15px;height:15px;
+ border-radius:50%;background:var(--accent);
+ cursor:pointer;box-shadow:0 0 8px var(--accent)66;
+}
+.weight-item span{
+ font-family:var(--mono);font-size:12px;font-weight:700;
+ color:var(--accent2);text-align:right;
+}
+
+/* ── Agent Grid ─────────────────────────────────────── */
+.card--agents{flex-shrink:0}
+.agents-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;padding:10px}
+.agent-cell{
+ border-radius:var(--radius-sm);padding:8px 4px;
+ text-align:center;border:1px solid transparent;
+ transition:.25s;cursor:pointer;position:relative;
+}
+.agent-cell:hover{transform:scale(1.1);z-index:1;box-shadow:0 0 14px #7c6fff44}
+.agent-cell--available{background:var(--bg3);border-color:var(--border)}
+.agent-cell--busy{background:#7c6fff14;border-color:#7c6fff44}
+.agent-cell--full{background:#f04f4f14;border-color:#f04f4f44}
+.agent-cell--has-log{box-shadow:inset 0 0 0 1px #7c6fff55}
+.agent-cell__id{font-size:9px;font-family:var(--mono);font-weight:700;color:var(--text3)}
+.agent-cell__load{font-size:13px;font-weight:800;color:var(--text);margin:2px 0}
+.agent-cell--available .agent-cell__load{color:var(--green)}
+.agent-cell--busy .agent-cell__load{color:var(--accent2)}
+.agent-cell--full .agent-cell__load{color:var(--red)}
+.agent-cell__rating{font-size:9px;color:var(--text3)}
+.agent-cell__dot{
+ width:5px;height:5px;border-radius:50%;
+ background:var(--accent);margin:3px auto 0;
+ animation:pulse 1.5s infinite;
+}
+
+/* ── Workload ────────────────────────────────────────── */
+.card--workload{padding:0}
+.card--workload canvas{display:block;padding:8px 14px 12px}
+
+/* ── Log ─────────────────────────────────────────────── */
+.card--log{display:flex;flex-direction:column;flex:1;min-height:120px}
+.log-list{
+ flex:1;overflow-y:auto;padding:8px;
+ display:flex;flex-direction:column;gap:3px;
+}
+.log-item{
+ font-size:11px;font-family:var(--mono);
+ padding:4px 9px;border-radius:5px;
+ border-left:2px solid transparent;
+ line-height:1.5;word-break:break-word;
+}
+.log-item--assign{border-color:var(--accent);background:#7c6fff0d;color:var(--accent2)}
+.log-item--deliver{border-color:var(--green);background:#20d45e0d;color:var(--green)}
+.log-item--warn{border-color:var(--orange);background:#f98c2f0d;color:var(--orange)}
+.log-item--error{border-color:var(--red);background:#f04f4f0d;color:var(--red)}
+.log-item--info{border-color:var(--border);color:var(--text2)}
+
+/* ── Agent Tooltip ───────────────────────────────────── */
+.agent-tooltip{
+ position:fixed;width:280px;
+ background:var(--bg2);border:1px solid var(--border2);
+ border-radius:var(--radius);
+ box-shadow:0 12px 40px #00000088,0 0 0 1px #7c6fff22;
+ z-index:500;overflow:hidden;animation:fadeIn .15s ease;
+}
+@keyframes fadeIn{from{opacity:0;transform:translateY(-5px)}to{opacity:1;transform:translateY(0)}}
+.atp-header{
+ display:flex;align-items:center;gap:8px;
+ padding:11px 14px;border-bottom:1px solid var(--border);
+ background:var(--bg3);
+}
+.atp-id{font-family:var(--mono);font-size:14px;font-weight:800;color:var(--accent2)}
+.atp-status{font-size:11px;font-weight:700;margin-left:auto}
+.atp-close{
+ background:none;border:none;color:var(--text2);
+ cursor:pointer;font-size:15px;padding:0 3px;line-height:1;
+}
+.atp-close:hover{color:var(--text)}
+.atp-meta{
+ display:flex;gap:10px;flex-wrap:wrap;
+ padding:9px 14px;border-bottom:1px solid var(--border);
+ font-size:11px;color:var(--text2);font-weight:500;
+}
+.atp-log-title{
+ padding:7px 14px 4px;font-size:10px;font-weight:700;
+ text-transform:uppercase;letter-spacing:.6px;color:var(--text3);
+}
+.atp-log{max-height:190px;overflow-y:auto;padding:0 10px 10px}
+.atp-empty{font-size:11px;color:var(--text3);padding:8px 4px;display:block}
+.atp-ev{
+ font-family:var(--mono);font-size:9px;padding:3px 5px;
+ border-left:2px solid transparent;line-height:1.5;
+ word-break:break-word;border-radius:0 4px 4px 0;margin-bottom:3px;
+}
+.atp-ev--assign{border-color:var(--accent);color:var(--accent2);background:#7c6fff0a}
+.atp-ev--deliver{border-color:var(--green);color:var(--green);background:#20d45e0a}
+.atp-ev--warn{border-color:var(--orange);color:var(--orange);background:#f98c2f0a}
+.atp-ev--info{border-color:var(--border);color:var(--text2)}
+
+/* ── Export FAB & Modal ─────────────────────────────── */
+.fab{
+ position:fixed;bottom:22px;right:22px;
+ width:50px;height:50px;border-radius:50%;border:none;
+ background:linear-gradient(135deg,var(--accent),#9d7fff);
+ font-size:20px;cursor:pointer;
+ box-shadow:0 4px 20px #7c6fff55;transition:.2s;z-index:200;
+}
+.fab:hover{transform:scale(1.12);box-shadow:0 8px 32px #7c6fff77}
+
+.modal-overlay{
+ position:fixed;inset:0;background:#000a;
+ display:none;align-items:center;justify-content:center;
+ z-index:300;backdrop-filter:blur(6px);
+}
+.modal-overlay.open{display:flex}
+.modal {
+ background: var(--bg2);
+ border: 1px solid var(--border2);
+ border-radius: var(--radius);
+ width: 580px;
+ max-height: 82vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: var(--shadow);
+}
+.modal__header{
+ display:flex;align-items:center;justify-content:space-between;
+ padding:16px 20px;border-bottom:1px solid var(--border);
+}
+.modal__header h3{font-size:14px;font-weight:700}
+.modal__close{
+ background:none;border:none;color:var(--text2);
+ cursor:pointer;font-size:17px;padding:0 4px;
+}
+.modal__code{
+ flex:1;overflow-y:auto;padding:16px 20px;
+ font-family:var(--mono);font-size:12px;color:#7dd3fc;
+ line-height:1.7;white-space:pre-wrap;background:var(--bg);
+}
+.modal__footer{
+ display:flex;gap:10px;padding:14px 20px;
+ border-top:1px solid var(--border);
+}
+
+/* ── Progress bar in KPI ────────────────────────────── */
+.kpi-card__bar{height:3px;border-radius:3px;margin-top:8px;background:var(--bg3);overflow:hidden}
+.kpi-card__bar-fill{height:100%;border-radius:3px;transition:width .5s ease;background:linear-gradient(90deg,var(--accent),var(--accent2))}
+.kpi-card--green .kpi-card__bar-fill{background:linear-gradient(90deg,var(--green),#4ade80)}
diff --git a/frontend/ui.js b/frontend/ui.js
new file mode 100644
index 0000000..fe65074
--- /dev/null
+++ b/frontend/ui.js
@@ -0,0 +1,584 @@
+// ui.js — DOM rendering, canvas, charts, controls
+
+// ── Canvas Grid ───────────────────────────────────────────────────────────────
+const CELL = 36;
+const PAD = 8;
+
+function drawGrid(canvas, sim) {
+ const ctx = canvas.getContext('2d');
+ const size = 10;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ // background
+ ctx.fillStyle = '#0f1320';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ // grid lines
+ ctx.strokeStyle = '#ffffff08';
+ ctx.lineWidth = 1;
+ for (let i = 0; i <= size; i++) {
+ ctx.beginPath();
+ ctx.moveTo(PAD + i * CELL, PAD);
+ ctx.lineTo(PAD + i * CELL, PAD + size * CELL);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(PAD, PAD + i * CELL);
+ ctx.lineTo(PAD + size * CELL, PAD + i * CELL);
+ ctx.stroke();
+ }
+
+ // cell coords
+ ctx.fillStyle = '#ffffff18';
+ ctx.font = '8px JetBrains Mono, monospace';
+ ctx.textAlign = 'center';
+ for (let x = 0; x < size; x++)
+ for (let y = 0; y < size; y++) {
+ const cx = PAD + x * CELL + CELL / 2;
+ const cy = PAD + y * CELL + CELL / 2;
+ ctx.fillText(`${x},${y}`, cx, cy + 3);
+ }
+
+ if (!sim) return;
+
+ // in-transit paths (faint lines agent→order)
+ for (const f of sim.inFlight) {
+ const agent = sim.agents.find(a => a.id === f.agentId);
+ if (!agent) continue;
+ const [ax, ay] = agent.loc;
+ const [ox, oy] = f.dest;
+ ctx.strokeStyle = '#f9731633';
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([3, 3]);
+ ctx.beginPath();
+ ctx.moveTo(PAD + ax * CELL + CELL / 2, PAD + ay * CELL + CELL / 2);
+ ctx.lineTo(PAD + ox * CELL + CELL / 2, PAD + oy * CELL + CELL / 2);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ // orders
+ const priColor = { high: '#ef4444', normal: '#f97316', low: '#22c55e' };
+ for (const o of sim.allOrders) {
+ if (o.state === 'DELIVERED') continue;
+ const cx = PAD + o.x * CELL + CELL / 2;
+ const cy = PAD + o.y * CELL + CELL / 2;
+ const col = priColor[o.priority];
+ // outer glow
+ ctx.beginPath();
+ ctx.arc(cx, cy, 9, 0, Math.PI * 2);
+ ctx.fillStyle = col + '33';
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(cx, cy, 5, 0, Math.PI * 2);
+ ctx.fillStyle = col;
+ ctx.fill();
+ if (o.state === 'IN_TRANSIT') {
+ ctx.strokeStyle = '#f9731699';
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ }
+ }
+
+ // agents
+ for (const agent of sim.agents) {
+ const [ax, ay] = agent.loc;
+ const cx = PAD + ax * CELL + CELL / 2;
+ const cy = PAD + ay * CELL + CELL / 2;
+ const busy = agent.active.length > 0;
+ ctx.beginPath();
+ ctx.arc(cx - 6, cy - 6, 6, 0, Math.PI * 2);
+ ctx.fillStyle = busy ? '#6c63ffcc' : '#6c63ff55';
+ ctx.fill();
+ ctx.strokeStyle = busy ? '#a78bfa' : '#6c63ff88';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ ctx.fillStyle = '#fff';
+ ctx.font = 'bold 7px Inter, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(agent.id.replace('A', ''), cx - 6, cy - 3);
+ }
+}
+
+// ── Mini Bar Chart ────────────────────────────────────────────────────────────
+function drawBarChart(canvas, labels, values, color = '#6c63ff') {
+ const ctx = canvas.getContext('2d');
+ const W = canvas.width, H = canvas.height;
+ ctx.clearRect(0, 0, W, H);
+ ctx.fillStyle = '#0f1320';
+ ctx.fillRect(0, 0, W, H);
+
+ if (!values.length) return;
+ const max = Math.max(...values, 1);
+ const bw = Math.max(2, (W - 20) / values.length - 2);
+
+ values.forEach((v, i) => {
+ const bh = ((v / max) * (H - 30));
+ const bx = 10 + i * ((W - 20) / values.length);
+ const by = H - 20 - bh;
+ const grad = ctx.createLinearGradient(0, by, 0, H - 20);
+ grad.addColorStop(0, color + 'ee');
+ grad.addColorStop(1, color + '33');
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.roundRect(bx, by, bw, bh, 2);
+ ctx.fill();
+ });
+ // x-axis label at start/end
+ ctx.fillStyle = '#8b92a8';
+ ctx.font = '9px Inter, sans-serif';
+ ctx.textAlign = 'left';
+ if (labels.length) ctx.fillText(labels[0], 10, H - 5);
+ ctx.textAlign = 'right';
+ if (labels.length > 1) ctx.fillText(labels[labels.length - 1], W - 10, H - 5);
+}
+
+// ── Donut Chart ───────────────────────────────────────────────────────────────
+function drawDonut(canvas, ok, bad, textEl) {
+ const ctx = canvas.getContext('2d');
+ const W = canvas.width, H = canvas.height;
+ ctx.clearRect(0, 0, W, H);
+ const cx = W / 2, cy = H / 2, r = Math.min(cx, cy) - 10, thick = 22;
+ const total = ok + bad || 1;
+ const okAngle = (ok / total) * Math.PI * 2;
+
+ // track
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.strokeStyle = '#1c2235';
+ ctx.lineWidth = thick;
+ ctx.stroke();
+
+ // ok
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + okAngle);
+ ctx.strokeStyle = '#22c55e';
+ ctx.lineWidth = thick;
+ ctx.lineCap = 'round';
+ ctx.stroke();
+
+ // bad
+ if (bad > 0) {
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, -Math.PI / 2 + okAngle, -Math.PI / 2 + Math.PI * 2);
+ ctx.strokeStyle = '#ef4444';
+ ctx.lineWidth = thick;
+ ctx.lineCap = 'round';
+ ctx.stroke();
+ }
+
+ let pct = (ok / total) * 100;
+ // Never show 100% when there are violations
+ if (bad > 0) pct = Math.min(pct, 99.9);
+ if (textEl) textEl.textContent = `${pct.toFixed(1)}%`;
+}
+
+// ── Workload Bars ─────────────────────────────────────────────────────────────
+function drawWorkload(canvas, agents) {
+ const ctx = canvas.getContext('2d');
+ const W = canvas.width, H = canvas.height;
+ ctx.clearRect(0, 0, W, H);
+ ctx.fillStyle = '#0f1320';
+ ctx.fillRect(0, 0, W, H);
+
+ const max = Math.max(...agents.map(a => a.assignments), 1);
+ const bw = (W - 16) / agents.length - 1;
+
+ agents.forEach((a, i) => {
+ const bh = ((a.assignments / max) * (H - 28));
+ const bx = 8 + i * ((W - 16) / agents.length);
+ const by = H - 22 - bh;
+ const busy = a.active.length > 0;
+ ctx.fillStyle = busy ? '#6c63ffcc' : '#6c63ff55';
+ ctx.beginPath();
+ ctx.roundRect(bx, by, bw, bh, 2);
+ ctx.fill();
+ });
+
+ ctx.fillStyle = '#545c75';
+ ctx.font = '8px Inter, sans-serif';
+ ctx.textAlign = 'center';
+ agents.forEach((a, i) => {
+ const bx = 8 + i * ((W - 16) / agents.length) + bw / 2;
+ ctx.fillText(a.id.replace('A0', '').replace('A', ''), bx, H - 8);
+ });
+}
+
+// ── Order Queue List ──────────────────────────────────────────────────────────
+let _queueFilter = 'all';
+function renderOrderQueue(sim, filter) {
+ if (filter) _queueFilter = filter;
+ const el = document.getElementById('orderQueue');
+ if (!el || !sim) return;
+ const orders = sim.allOrders.filter(o => _queueFilter === 'all' || o.state === _queueFilter);
+ el.innerHTML = orders.map(o => `
+
+ ${o.id}
+ ${o.priority}
+ (${o.x},${o.y})
+ ${o.agentId ? `${o.agentId}` : ''}
+ ${o.state.replace('_', ' ')}
+
+ `).join('');
+}
+
+// ── Per-agent log store ───────────────────────────────────────────────────────
+const agentLogs = {}; // { agentId: [{tick, type, msg}] }
+
+function trackAgentEvents(sim) {
+ // scan new events and bucket by agent id
+ const newEvents = sim.events.slice(_lastAgentEventIdx || 0);
+ _lastAgentEventIdx = sim.events.length;
+ for (const ev of newEvents) {
+ // extract agent id from messages like "O001 → A003 …" or "O001 delivered by A003"
+ const m = ev.msg.match(/→\s*(A\d+)/) || ev.msg.match(/by\s+(A\d+)/);
+ if (m) {
+ const aid = m[1];
+ if (!agentLogs[aid]) agentLogs[aid] = [];
+ agentLogs[aid].push({ tick: ev.tick, type: ev.type, msg: ev.msg });
+ }
+ }
+}
+let _lastAgentEventIdx = 0;
+
+// ── Agent Tooltip ──────────────────────────────────────────────────────────────
+function showAgentTooltip(agentId, cellEl, sim) {
+ // remove existing
+ document.querySelectorAll('.agent-tooltip').forEach(t => t.remove());
+
+ const agent = sim ? sim.agents.find(a => a.id === agentId) : null;
+ const logs = agentLogs[agentId] || [];
+ const status = agent ? (agent.active.length === 0 ? 'available' : agent.active.length < 2 ? 'busy' : 'full') : 'unknown';
+ const statusColor = { available: '#22c55e', busy: '#a78bfa', full: '#ef4444', unknown: '#8b92a8' }[status];
+
+ const tip = document.createElement('div');
+ tip.className = 'agent-tooltip';
+ tip.innerHTML = `
+
+
+ 📍 (${agent ? agent.loc.join(',') : '?'})
+ ⭐ ${agent ? agent.rating : '?'}
+ 📦 ${agent ? agent.assignments : 0} assigned
+ 🔄 ${agent ? agent.active.length : 0} active
+
+ Event History (${logs.length})
+ ${
+ logs.length === 0
+ ? '
No events yet'
+ : logs.slice(-20).reverse().map(e =>
+ `
[T${String(e.tick).padStart(3,'0')}] ${e.msg}
`
+ ).join('')
+ }
`;
+
+ // position near cell
+ const rect = cellEl.getBoundingClientRect();
+ const panelRect = document.querySelector('.panel--right').getBoundingClientRect();
+ tip.style.top = `${rect.top + window.scrollY - 4}px`;
+ tip.style.left = `${panelRect.left - 280}px`;
+ document.body.appendChild(tip);
+
+ // close on outside click
+ setTimeout(() => {
+ document.addEventListener('click', function outside(e) {
+ if (!tip.contains(e.target)) { tip.remove(); document.removeEventListener('click', outside); }
+ });
+ }, 100);
+}
+
+// ── Agent Grid ────────────────────────────────────────────────────────────────
+function renderAgentGrid(sim) {
+ const el = document.getElementById('agentGrid');
+ if (!el || !sim) return;
+ const avail = sim.agents.filter(a => a.active.length === 0).length;
+ document.getElementById('agentAvailBadge').textContent = `${avail} available`;
+ el.innerHTML = sim.agents.map(a => {
+ const cls = a.active.length === 0 ? 'available' : a.active.length < 2 ? 'busy' : 'full';
+ const hasLog = (agentLogs[a.id] || []).length > 0;
+ return `
+
+
${a.id}
+
${a.assignments}
+
★${a.rating}
+ ${hasLog ? '
' : ''}
+
`;
+ }).join('');
+
+ // wire click handlers
+ el.querySelectorAll('.agent-cell').forEach(cell => {
+ cell.addEventListener('click', function(e) {
+ e.stopPropagation();
+ showAgentTooltip(this.dataset.aid, this, sim);
+ });
+ });
+}
+
+// ── Event Log ─────────────────────────────────────────────────────────────────
+let _lastLogIdx = 0;
+function appendLogs(sim) {
+ const el = document.getElementById('eventLog');
+ if (!el || !sim) return;
+ const newEvents = sim.events.slice(_lastLogIdx);
+ _lastLogIdx = sim.events.length;
+ for (const ev of newEvents) {
+ const div = document.createElement('div');
+ div.className = `log-item log-item--${ev.type}`;
+ div.textContent = `[T${String(ev.tick).padStart(3,'0')}] ${ev.msg}`;
+ el.prepend(div);
+ }
+ // cap at 200 items
+ while (el.children.length > 200) el.removeChild(el.lastChild);
+}
+
+// ── KPI Update ────────────────────────────────────────────────────────────────
+function updateKPIs(sim) {
+ if (!sim) return;
+ const m = sim.metrics;
+ const total = sim.allOrders.length;
+
+ setText('kpiDeliveredVal', m.delivered);
+ setText('kpiDeliveredSub', `of ${total} orders`);
+
+ let compliance = m.delivered ? ((m.delivered - m.violations) / m.delivered * 100) : null;
+ if (compliance !== null && m.violations > 0) compliance = Math.min(compliance, 99.9);
+ setText('kpiSLAVal', compliance !== null ? `${compliance.toFixed(1)}%` : '—');
+ setText('kpiSLASub', `${m.violations} violations`);
+
+ const avgT = m.delivered ? (m.totalTime / m.delivered).toFixed(1) : '—';
+ setText('kpiAvgTimeVal', avgT);
+
+ const vals = sim.agents.map(a => a.assignments);
+ const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
+ const std = Math.sqrt(vals.reduce((s, v) => s + (v - mean) ** 2, 0) / vals.length);
+ setText('kpiFairnessVal', std.toFixed(2));
+
+ const lats = m.latencies;
+ const avgLat = lats.length ? (lats.reduce((a, b) => a + b, 0) / lats.length).toFixed(3) : '—';
+ setText('kpiLatencyVal', avgLat === '—' ? '—' : `${avgLat}`);
+
+ // donut
+ const ok = m.delivered - m.violations;
+ drawDonut(document.getElementById('chartSLA'), ok, m.violations, document.getElementById('ringCentreText'));
+
+ // throughput badge
+ const recent = sim.throughput.slice(-60).reduce((s, t) => s + t.count, 0);
+ setText('throughputBadge', `~${recent} orders/min`);
+
+ // map tick info
+ setText('mapTick', `Tick: ${sim.tick}`);
+ setText('mapPending', `Pending: ${sim.allOrders.filter(o => o.state === 'PENDING').length}`);
+ setText('mapInFlight', `In-Transit: ${sim.inFlight.length}`);
+}
+
+function setText(id, val) {
+ const el = document.getElementById(id);
+ if (el) el.textContent = val;
+}
+
+// ── Throughput Chart ──────────────────────────────────────────────────────────
+let _tpHistory = [];
+function updateThroughputChart(sim) {
+ if (!sim || !sim.throughput.length) return;
+ _tpHistory = sim.throughput.slice(-120).map(t => t.count);
+ const canvas = document.getElementById('chartThroughput');
+ drawBarChart(canvas, [], _tpHistory, '#6c63ff');
+}
+
+// ── Delivery Chart ────────────────────────────────────────────────────────────
+function updateDeliveryChart(sim) {
+ if (!sim) return;
+ const canvas = document.getElementById('chartDelivery');
+ const ctx = canvas.getContext('2d');
+ const W = canvas.width, H = canvas.height;
+ ctx.clearRect(0, 0, W, H);
+
+ const priorities = ['high', 'normal', 'low'];
+ const colors = ['#ef4444', '#f97316', '#22c55e'];
+ const delivered = sim.allOrders.filter(o => o.state === 'DELIVERED');
+
+ const byPri = {};
+ for (const p of priorities) {
+ const orders = delivered.filter(o => o.priority === p);
+ byPri[p] = orders.length;
+ }
+
+ const max = Math.max(...Object.values(byPri), 1);
+ const bw = (W - 40) / 3;
+ ctx.fillStyle = '#0f1320';
+ ctx.fillRect(0, 0, W, H);
+
+ priorities.forEach((p, i) => {
+ const bh = ((byPri[p] / max) * (H - 40));
+ const bx = 20 + i * bw + bw * .15;
+ const by = H - 30 - bh;
+ const grad = ctx.createLinearGradient(0, by, 0, H - 30);
+ grad.addColorStop(0, colors[i] + 'dd');
+ grad.addColorStop(1, colors[i] + '22');
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.roundRect(bx, by, bw * .7, bh, 4);
+ ctx.fill();
+ ctx.fillStyle = colors[i];
+ ctx.font = 'bold 12px Inter';
+ ctx.textAlign = 'center';
+ ctx.fillText(byPri[p], bx + bw * .35, by - 6);
+ ctx.fillStyle = '#8b92a8';
+ ctx.font = '10px Inter';
+ ctx.fillText(p, bx + bw * .35, H - 12);
+ });
+}
+
+// ── Main Controller ───────────────────────────────────────────────────────────
+let sim = null;
+let _animId = null;
+let _speed = 1;
+let _speedModes = [1, 5, 20];
+let _speedIdx = 0;
+
+const canvas = document.getElementById('gridCanvas');
+
+function initSim() {
+ _lastLogIdx = 0;
+ _tpHistory = [];
+ sim = new Simulator(parseOrders(), parseAgents(), buildEdges(), CONSTRAINTS);
+ renderOrderQueue(sim);
+ renderAgentGrid(sim);
+ drawGrid(canvas, sim);
+ updateKPIs(sim);
+ setStatus('Ready', '');
+}
+
+function setStatus(label, cls) {
+ const el = document.getElementById('simStatus');
+ const lbl = el.querySelector('.sim-status__label');
+ el.className = 'sim-status ' + cls;
+ lbl.textContent = label;
+}
+
+function fmtTime(ticks) {
+ const h = String(Math.floor(ticks / 60)).padStart(2, '0');
+ const m = String(ticks % 60).padStart(2, '0');
+ return `T+${h}:${m}`;
+}
+
+function tick() {
+ if (!sim) return;
+ let steps = _speed;
+ let done = false;
+ for (let i = 0; i < steps; i++) {
+ done = sim.step();
+ if (done) break;
+ }
+ document.getElementById('simTimeDisplay').textContent = fmtTime(sim.elapsed);
+ drawGrid(canvas, sim);
+ renderOrderQueue(sim);
+ renderAgentGrid(sim);
+ updateKPIs(sim);
+ updateThroughputChart(sim);
+ updateDeliveryChart(sim);
+ drawWorkload(document.getElementById('chartWorkload'), sim.agents);
+ appendLogs(sim);
+ trackAgentEvents(sim);
+
+ if (done) {
+ setStatus('Complete', 'done');
+ document.getElementById('btnRun').disabled = true;
+ return;
+ }
+ _animId = requestAnimationFrame(tick);
+}
+
+function startSim() {
+ if (_animId) cancelAnimationFrame(_animId);
+ setStatus('Running', 'running');
+ document.getElementById('btnRun').disabled = true;
+ _animId = requestAnimationFrame(tick);
+}
+
+// ── Controls ──────────────────────────────────────────────────────────────────
+document.getElementById('btnRun').addEventListener('click', startSim);
+
+document.getElementById('btnReset').addEventListener('click', () => {
+ if (_animId) cancelAnimationFrame(_animId);
+ _animId = null;
+ document.getElementById('btnRun').disabled = false;
+ document.getElementById('eventLog').innerHTML = '';
+ Object.keys(agentLogs).forEach(k => delete agentLogs[k]);
+ _lastAgentEventIdx = 0;
+ document.querySelectorAll('.agent-tooltip').forEach(t => t.remove());
+ initSim();
+});
+
+document.getElementById('btnSpeed').addEventListener('click', function () {
+ _speedIdx = (_speedIdx + 1) % _speedModes.length;
+ _speed = _speedModes[_speedIdx];
+ this.textContent = `${_speed}×`;
+});
+
+document.getElementById('btnClearLog').addEventListener('click', () => {
+ document.getElementById('eventLog').innerHTML = '';
+});
+
+// Theme toggle
+document.getElementById('btnTheme').addEventListener('click', () => {
+ const root = document.documentElement;
+ const theme = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
+ root.setAttribute('data-theme', theme);
+ localStorage.setItem('dispatch-theme', theme);
+});
+const savedTheme = localStorage.getItem('dispatch-theme');
+if (savedTheme) document.documentElement.setAttribute('data-theme', savedTheme);
+
+// Queue filters
+document.querySelectorAll('.filter-btn').forEach(btn => {
+ btn.addEventListener('click', function () {
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
+ this.classList.add('active');
+ renderOrderQueue(sim, this.dataset.filter);
+ });
+});
+
+// Weight sliders
+['Time','SLA','Load','Pri','Rating'].forEach(key => {
+ const input = document.getElementById(`w${key}`);
+ const label = document.getElementById(`w${key}Val`);
+ if (input && label) {
+ input.addEventListener('input', () => { label.textContent = `${input.value}%`; });
+ }
+});
+document.getElementById('btnApplyWeights').addEventListener('click', () => {
+ if (!sim) return;
+ const get = id => parseInt(document.getElementById(id).value) / 100;
+ sim.setWeights({ time: get('wTime'), sla: get('wSLA'), load: get('wLoad'), priority: get('wPri'), rating: get('wRating') });
+ addLog('info', 'Scoring weights updated');
+});
+function addLog(type, msg) {
+ if (!sim) return;
+ sim._log(type, msg);
+ appendLogs(sim);
+}
+
+// Export modal
+document.getElementById('btnExport').addEventListener('click', () => {
+ const modal = document.getElementById('exportModal');
+ modal.classList.add('open');
+ const data = sim ? sim.exportMetrics() : {};
+ document.getElementById('exportJSON').textContent = JSON.stringify(data, null, 2);
+});
+['btnCloseModal','btnCloseModal2'].forEach(id => {
+ document.getElementById(id).addEventListener('click', () => {
+ document.getElementById('exportModal').classList.remove('open');
+ });
+});
+document.getElementById('btnDownloadJSON').addEventListener('click', () => {
+ const data = sim ? sim.exportMetrics() : {};
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a'); a.href = url; a.download = 'metrics.json'; a.click();
+ URL.revokeObjectURL(url);
+});
+
+// ── Boot ──────────────────────────────────────────────────────────────────────
+window.addEventListener('load', initSim);
diff --git a/images/Path-tracing.png b/images/Path-tracing.png
new file mode 100644
index 0000000..fa659c0
Binary files /dev/null and b/images/Path-tracing.png differ
diff --git a/images/algo-map.png b/images/algo-map.png
new file mode 100644
index 0000000..e6353d7
Binary files /dev/null and b/images/algo-map.png differ
diff --git a/images/at-end.png b/images/at-end.png
new file mode 100644
index 0000000..7d3f602
Binary files /dev/null and b/images/at-end.png differ
diff --git a/images/beginning.png b/images/beginning.png
new file mode 100644
index 0000000..6eda4f9
Binary files /dev/null and b/images/beginning.png differ
diff --git a/images/in-midddle.png b/images/in-midddle.png
new file mode 100644
index 0000000..66b428f
Binary files /dev/null and b/images/in-midddle.png differ
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..ce87e78
--- /dev/null
+++ b/main.py
@@ -0,0 +1,124 @@
+"""
+Issue 20, 15: Main entry point — wires everything together and outputs results.
+"""
+
+import json
+import logging
+import os
+import sys
+import time
+from pathlib import Path
+
+# Ensure project root is on sys.path
+ROOT = Path(__file__).resolve().parent
+sys.path.insert(0, str(ROOT))
+
+from src.agent_registry import AgentRegistry
+from src.data_loader import load_agents, load_constraints, load_environment_edges, load_orders
+from src.dispatcher import Dispatcher
+from src.graph import EnvironmentGraph
+from src.metrics import MetricsCollector
+from src.order_queue import PriorityOrderQueue
+
+# ── Logging setup ─────────────────────────────────────────────────────────────
+
+def _setup_logging(level: str = "INFO") -> None:
+ fmt = "%(asctime)s %(levelname)-8s %(name)s — %(message)s"
+ logging.basicConfig(level=getattr(logging, level.upper(), logging.INFO),
+ format=fmt, datefmt="%H:%M:%S")
+
+
+# ── Path configuration ────────────────────────────────────────────────────────
+
+DATA_DIR = ROOT / "data" / "raw"
+OUTPUT_DIR = ROOT / "output"
+
+ORDERS_CSV = DATA_DIR / "orders.csv"
+AGENTS_CSV = DATA_DIR / "agents.csv"
+ENV_EDGES_CSV = DATA_DIR / "environment_edges.csv"
+CONSTRAINTS_CSV = DATA_DIR / "constraints.csv"
+
+METRICS_JSON = OUTPUT_DIR / "metrics.json"
+ASSIGNMENTS_LOG = OUTPUT_DIR / "assignments.log"
+
+
+def main():
+ _setup_logging("INFO")
+ logger = logging.getLogger("main")
+
+ t_start = time.perf_counter()
+
+ # ── 1. Load data ─────────────────────────────────────────────────────────
+ logger.info("Loading data from %s …", DATA_DIR)
+
+ try:
+ orders = load_orders(str(ORDERS_CSV))
+ agents = load_agents(str(AGENTS_CSV))
+ edges = load_environment_edges(str(ENV_EDGES_CSV))
+ constraints = load_constraints(str(CONSTRAINTS_CSV))
+ except (FileNotFoundError, ValueError) as exc:
+ logger.critical("Data loading failed: %s", exc)
+ sys.exit(1)
+
+ if not orders:
+ logger.critical("No valid orders to process, exiting.")
+ sys.exit(1)
+ if not agents:
+ logger.critical("No valid agents, exiting.")
+ sys.exit(1)
+
+ # ── 2. Build graph ───────────────────────────────────────────────────────
+ logger.info("Building environment graph …")
+ graph = EnvironmentGraph(edges)
+
+ # Validate agent locations against graph (Issue 2)
+ for agent in agents:
+ if not graph.validate_location(agent.current_location):
+ logger.warning("Agent %s location %s not in graph — may be isolated",
+ agent.agent_id, agent.current_location)
+
+ # ── 3. Initialise components ─────────────────────────────────────────────
+ queue = PriorityOrderQueue()
+ registry = AgentRegistry(agents, max_active=constraints.max_active_orders_per_agent)
+ metrics = MetricsCollector()
+ metrics.latency_target_ms = constraints.decision_latency_target_seconds * 1000
+
+ # Objective weights (Issue 8)
+ weights = {
+ "delivery_time": constraints.weight_delivery_time,
+ "sla_risk": constraints.weight_sla_risk,
+ "workload": constraints.weight_workload,
+ "priority": constraints.weight_priority,
+ "rating": constraints.weight_rating,
+ }
+
+ # ── 4. Run simulation ────────────────────────────────────────────────────
+ logger.info("Starting dispatch simulation for %d orders …", len(orders))
+ dispatcher = Dispatcher(
+ queue=queue,
+ registry=registry,
+ graph=graph,
+ constraints=constraints,
+ metrics=metrics,
+ scoring_weights=weights,
+ )
+ dispatcher.run(orders, tick_minutes=1.0)
+
+ # ── 5. Output results ─────────────────────────────────────────────────────
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+ # JSON metrics (Issue 15)
+ metrics_dict = metrics.to_dict(dataset_name="orders")
+ with open(METRICS_JSON, "w") as f:
+ json.dump(metrics_dict, f, indent=2)
+ logger.info("Metrics written to %s", METRICS_JSON)
+
+ # Human-readable summary
+ print("\n" + metrics.summary())
+
+ elapsed = time.perf_counter() - t_start
+ logger.info("Total wall-clock time: %.2f s", elapsed)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/output/metrics.json b/output/metrics.json
new file mode 100644
index 0000000..6f5ebe0
--- /dev/null
+++ b/output/metrics.json
@@ -0,0 +1,62 @@
+{
+ "metadata": {
+ "timestamp": "2026-05-06T07:19:51.246314+00:00",
+ "dataset": "orders",
+ "total_orders_delivered": 150,
+ "total_sla_violations": 0
+ },
+ "delivery_time": {
+ "overall": {
+ "count": 150,
+ "mean": 13.74,
+ "std": 4.153
+ },
+ "by_priority": {
+ "high": {
+ "count": 51,
+ "mean": 13.49,
+ "std": 4.96
+ },
+ "normal": {
+ "count": 50,
+ "mean": 15.26,
+ "std": 3.665
+ },
+ "low": {
+ "count": 49,
+ "mean": 12.449,
+ "std": 3.064
+ }
+ }
+ },
+ "sla_compliance": {
+ "compliance_rate": 1.0,
+ "violation_rate": 0.0,
+ "avg_sla_margin_minutes": 37.767,
+ "violations_by_priority": {
+ "high": 0,
+ "normal": 0,
+ "low": 0
+ },
+ "delivered_by_priority": {
+ "high": 51,
+ "normal": 50,
+ "low": 49
+ }
+ },
+ "workload_fairness": {
+ "load_variance": 0.16,
+ "load_std": 0.4,
+ "min_assignments": 5,
+ "max_assignments": 7,
+ "assignment_range": 2,
+ "mean_assignments": 6.0
+ },
+ "decision_latency": {
+ "count": 150,
+ "mean_ms": 0.05,
+ "max_ms": 0.27,
+ "min_ms": 0.03,
+ "violations": 0
+ }
+}
\ No newline at end of file
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..6d8acd5
--- /dev/null
+++ b/src/__init__.py
@@ -0,0 +1 @@
+# Smart Delivery Dispatch System
diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000..74ae9c7
Binary files /dev/null and b/src/__pycache__/__init__.cpython-313.pyc differ
diff --git a/src/__pycache__/agent_registry.cpython-313.pyc b/src/__pycache__/agent_registry.cpython-313.pyc
new file mode 100644
index 0000000..12b670d
Binary files /dev/null and b/src/__pycache__/agent_registry.cpython-313.pyc differ
diff --git a/src/__pycache__/data_loader.cpython-313.pyc b/src/__pycache__/data_loader.cpython-313.pyc
new file mode 100644
index 0000000..614a763
Binary files /dev/null and b/src/__pycache__/data_loader.cpython-313.pyc differ
diff --git a/src/__pycache__/dispatcher.cpython-313.pyc b/src/__pycache__/dispatcher.cpython-313.pyc
new file mode 100644
index 0000000..da3803a
Binary files /dev/null and b/src/__pycache__/dispatcher.cpython-313.pyc differ
diff --git a/src/__pycache__/graph.cpython-313.pyc b/src/__pycache__/graph.cpython-313.pyc
new file mode 100644
index 0000000..f2865af
Binary files /dev/null and b/src/__pycache__/graph.cpython-313.pyc differ
diff --git a/src/__pycache__/metrics.cpython-313.pyc b/src/__pycache__/metrics.cpython-313.pyc
new file mode 100644
index 0000000..8969183
Binary files /dev/null and b/src/__pycache__/metrics.cpython-313.pyc differ
diff --git a/src/__pycache__/order_queue.cpython-313.pyc b/src/__pycache__/order_queue.cpython-313.pyc
new file mode 100644
index 0000000..656127a
Binary files /dev/null and b/src/__pycache__/order_queue.cpython-313.pyc differ
diff --git a/src/__pycache__/scorer.cpython-313.pyc b/src/__pycache__/scorer.cpython-313.pyc
new file mode 100644
index 0000000..74ae9e2
Binary files /dev/null and b/src/__pycache__/scorer.cpython-313.pyc differ
diff --git a/src/agent_registry.py b/src/agent_registry.py
new file mode 100644
index 0000000..83b7893
--- /dev/null
+++ b/src/agent_registry.py
@@ -0,0 +1,72 @@
+"""
+Issue 5: Agent Registry — maintains agent state and provides fast lookups.
+"""
+
+import logging
+from typing import Dict, List, Optional
+
+from src.data_loader import Agent
+
+logger = logging.getLogger(__name__)
+
+
+class AgentRegistry:
+ def __init__(self, agents: List[Agent], max_active: int = 2):
+ self._agents: Dict[str, Agent] = {a.agent_id: a for a in agents}
+ self._max_active = max_active
+ logger.info("Agent registry initialized with %d agents", len(agents))
+
+ # ── Lookups ───────────────────────────────────────────────────────────────
+
+ def get(self, agent_id: str) -> Optional[Agent]:
+ return self._agents.get(agent_id)
+
+ def all_agents(self) -> List[Agent]:
+ return list(self._agents.values())
+
+ def available_agents(self) -> List[Agent]:
+ """Issue 5: agents with active_orders < max_active."""
+ return [a for a in self._agents.values() if len(a.active_orders) < self._max_active]
+
+ # ── Mutations ─────────────────────────────────────────────────────────────
+
+ def assign_order(self, agent_id: str, order_id: str) -> bool:
+ agent = self._agents.get(agent_id)
+ if agent is None:
+ logger.error("assign_order: agent %s not found", agent_id)
+ return False
+ if len(agent.active_orders) >= self._max_active:
+ logger.warning("Agent %s at max capacity (%d)", agent_id, self._max_active)
+ return False
+ agent.active_orders.append(order_id)
+ agent.cumulative_assignments += 1
+ logger.debug("Agent %s assigned order %s (active=%d)", agent_id, order_id, len(agent.active_orders))
+ return True
+
+ def complete_order(self, agent_id: str, order_id: str, new_location=None) -> bool:
+ """Issue 10: Remove order from agent, optionally update location."""
+ agent = self._agents.get(agent_id)
+ if agent is None:
+ logger.error("complete_order: agent %s not found", agent_id)
+ return False
+ if order_id in agent.active_orders:
+ agent.active_orders.remove(order_id)
+ else:
+ logger.warning("complete_order: order %s not in agent %s active_orders", order_id, agent_id)
+ if new_location is not None:
+ agent.current_x, agent.current_y = new_location
+ logger.debug("Agent %s completed order %s, now at %s (active=%d)",
+ agent_id, order_id, agent.current_location, len(agent.active_orders))
+ return True
+
+ def snapshot(self) -> Dict:
+ return {
+ aid: {
+ "location": a.current_location,
+ "rating": a.rating,
+ "active_orders": list(a.active_orders),
+ "cumulative_assignments": a.cumulative_assignments,
+ "available": a.available,
+ }
+ for aid, a in self._agents.items()
+ }
diff --git a/src/data_loader.py b/src/data_loader.py
new file mode 100644
index 0000000..fe1b6a7
--- /dev/null
+++ b/src/data_loader.py
@@ -0,0 +1,290 @@
+"""
+Issue 1, 2, 3, 16: Load and validate CSV data for orders, agents, environment.
+"""
+
+import csv
+import logging
+import os
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+# ─── Data Models ────────────────────────────────────────────────────────────
+
+@dataclass
+class Order:
+ order_id: str
+ timestamp: datetime
+ location_x: int
+ location_y: int
+ prep_time_minutes: float
+ priority: str # "high" | "normal" | "low"
+ sla_minutes: float
+ state: str = "PENDING" # PENDING → ASSIGNED → IN_TRANSIT → DELIVERED
+ assigned_agent_id: Optional[str] = None
+ assigned_at: Optional[datetime] = None
+ delivered_at: Optional[datetime] = None
+ sla_violated: bool = False
+
+ @property
+ def location(self) -> Tuple[int, int]:
+ return (self.location_x, self.location_y)
+
+
+@dataclass
+class Agent:
+ agent_id: str
+ current_x: int
+ current_y: int
+ rating: float
+ active_orders: List[str] = field(default_factory=list)
+ cumulative_assignments: int = 0
+
+ @property
+ def available(self) -> bool:
+ return len(self.active_orders) < 2
+
+ @property
+ def current_location(self) -> Tuple[int, int]:
+ return (self.current_x, self.current_y)
+
+
+@dataclass
+class Constraints:
+ max_active_orders_per_agent: int = 2
+ decision_latency_target_seconds: float = 5.0
+ default_sla_minutes: float = 50.0
+ priority_weight_high: float = 1.5
+ priority_weight_normal: float = 1.0
+ priority_weight_low: float = 0.8
+ # Objective Weights (Issue 8)
+ weight_delivery_time: float = 0.35
+ weight_sla_risk: float = 0.30
+ weight_workload: float = 0.20
+ weight_priority: float = 0.10
+ weight_rating: float = 0.05
+
+
+# ─── Validators ─────────────────────────────────────────────────────────────
+
+VALID_PRIORITIES = {"high", "normal", "low"}
+VALID_STATES = {"PENDING", "ASSIGNED", "IN_TRANSIT", "DELIVERED"}
+
+
+def _parse_int(value: str, field_name: str, record_id: str) -> Optional[int]:
+ try:
+ return int(value)
+ except (ValueError, TypeError):
+ logger.error("Record %s: field '%s' has invalid integer value '%s'", record_id, field_name, value)
+ return None
+
+
+def _parse_float(value: str, field_name: str, record_id: str) -> Optional[float]:
+ try:
+ return float(value)
+ except (ValueError, TypeError):
+ logger.error("Record %s: field '%s' has invalid float value '%s'", record_id, field_name, value)
+ return None
+
+
+def _parse_datetime(value: str, record_id: str) -> Optional[datetime]:
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
+ try:
+ return datetime.strptime(value.strip(), fmt)
+ except ValueError:
+ continue
+ logger.error("Record %s: timestamp '%s' could not be parsed", record_id, value)
+ return None
+
+
+# ─── Loaders ─────────────────────────────────────────────────────────────────
+
+def load_orders(filepath: str) -> List[Order]:
+ """Issue 1: Load and validate orders CSV."""
+ if not os.path.isfile(filepath):
+ raise FileNotFoundError(f"Orders CSV not found: {filepath}")
+
+ required_fields = {"order_id", "timestamp", "location_x", "location_y",
+ "prep_time_minutes", "priority", "sla_minutes"}
+ orders: List[Order] = []
+ skipped = 0
+
+ with open(filepath, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
+ if not required_fields.issubset(set(reader.fieldnames or [])):
+ missing = required_fields - set(reader.fieldnames or [])
+ raise ValueError(f"Orders CSV missing required columns: {missing}")
+
+ for row in reader:
+ oid = row.get("order_id", "UNKNOWN").strip()
+ if not oid:
+ logger.warning("Skipping row with empty order_id")
+ skipped += 1
+ continue
+
+ ts = _parse_datetime(row.get("timestamp", ""), oid)
+ lx = _parse_int(row.get("location_x", ""), "location_x", oid)
+ ly = _parse_int(row.get("location_y", ""), "location_y", oid)
+ pt = _parse_float(row.get("prep_time_minutes", ""), "prep_time_minutes", oid)
+ pr = row.get("priority", "").strip().lower()
+ sl = _parse_float(row.get("sla_minutes", ""), "sla_minutes", oid)
+
+ if any(v is None for v in [ts, lx, ly, pt, sl]):
+ logger.error("Skipping order %s due to invalid numeric fields", oid)
+ skipped += 1
+ continue
+ if pr not in VALID_PRIORITIES:
+ logger.error("Skipping order %s: invalid priority '%s'", oid, pr)
+ skipped += 1
+ continue
+ if pt < 0:
+ logger.warning("Order %s: negative prep_time_minutes (%s), clamping to 0", oid, pt)
+ pt = 0.0
+ if sl <= 0:
+ logger.error("Skipping order %s: sla_minutes must be positive (%s)", oid, sl)
+ skipped += 1
+ continue
+
+ orders.append(Order(
+ order_id=oid,
+ timestamp=ts,
+ location_x=lx,
+ location_y=ly,
+ prep_time_minutes=pt,
+ priority=pr,
+ sla_minutes=sl,
+ ))
+
+ logger.info("Loaded %d orders (%d skipped)", len(orders), skipped)
+ return orders
+
+
+def load_agents(filepath: str) -> List[Agent]:
+ """Issue 2: Load and validate agents CSV."""
+ if not os.path.isfile(filepath):
+ raise FileNotFoundError(f"Agents CSV not found: {filepath}")
+
+ required_fields = {"agent_id", "current_x", "current_y", "rating"}
+ agents: List[Agent] = []
+ skipped = 0
+
+ with open(filepath, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
+ if not required_fields.issubset(set(reader.fieldnames or [])):
+ missing = required_fields - set(reader.fieldnames or [])
+ raise ValueError(f"Agents CSV missing required columns: {missing}")
+
+ for row in reader:
+ aid = row.get("agent_id", "UNKNOWN").strip()
+ if not aid:
+ logger.warning("Skipping row with empty agent_id")
+ skipped += 1
+ continue
+
+ cx = _parse_int(row.get("current_x", ""), "current_x", aid)
+ cy = _parse_int(row.get("current_y", ""), "current_y", aid)
+ rt = _parse_float(row.get("rating", ""), "rating", aid)
+
+ if any(v is None for v in [cx, cy, rt]):
+ logger.error("Skipping agent %s due to invalid fields", aid)
+ skipped += 1
+ continue
+ if not (0.0 <= rt <= 5.0):
+ logger.warning("Agent %s: rating %.2f out of [0,5] range, clamping", aid, rt)
+ rt = max(0.0, min(5.0, rt))
+
+ agents.append(Agent(
+ agent_id=aid,
+ current_x=cx,
+ current_y=cy,
+ rating=rt,
+ ))
+
+ logger.info("Loaded %d agents (%d skipped)", len(agents), skipped)
+ return agents
+
+
+def load_environment_edges(filepath: str) -> List[dict]:
+ """Issue 3: Load environment graph edges CSV."""
+ if not os.path.isfile(filepath):
+ raise FileNotFoundError(f"Environment edges CSV not found: {filepath}")
+
+ required_fields = {"from_x", "from_y", "to_x", "to_y", "distance_minutes", "delay_multiplier"}
+ edges: List[dict] = []
+ skipped = 0
+
+ with open(filepath, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
+ if not required_fields.issubset(set(reader.fieldnames or [])):
+ missing = required_fields - set(reader.fieldnames or [])
+ raise ValueError(f"Environment CSV missing required columns: {missing}")
+
+ for i, row in enumerate(reader, start=2):
+ fx = _parse_int(row.get("from_x", ""), "from_x", f"row{i}")
+ fy = _parse_int(row.get("from_y", ""), "from_y", f"row{i}")
+ tx = _parse_int(row.get("to_x", ""), "to_x", f"row{i}")
+ ty = _parse_int(row.get("to_y", ""), "to_y", f"row{i}")
+ dist = _parse_float(row.get("distance_minutes", ""), "distance_minutes", f"row{i}")
+ delay = _parse_float(row.get("delay_multiplier", ""), "delay_multiplier", f"row{i}")
+
+ if any(v is None for v in [fx, fy, tx, ty, dist, delay]):
+ skipped += 1
+ continue
+ if dist < 0:
+ logger.warning("Row %d: negative distance, skipping", i)
+ skipped += 1
+ continue
+
+ edges.append({
+ "from": (fx, fy), "to": (tx, ty),
+ "distance_minutes": dist, "delay_multiplier": delay,
+ })
+
+ logger.info("Loaded %d environment edges (%d skipped)", len(edges), skipped)
+ return edges
+
+
+def load_constraints(filepath: str) -> Constraints:
+ """Load system constraints from CSV."""
+ if not os.path.isfile(filepath):
+ logger.warning("Constraints CSV not found at %s, using defaults", filepath)
+ return Constraints()
+
+ c = Constraints()
+ with open(filepath, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ key = row.get("constraint", "").strip()
+ val = row.get("value", "").strip()
+ if not key or not val:
+ continue
+ try:
+ if key == "max_active_orders_per_agent":
+ c.max_active_orders_per_agent = int(val)
+ elif key == "decision_latency_target_seconds":
+ c.decision_latency_target_seconds = float(val)
+ elif key == "default_sla_minutes":
+ c.default_sla_minutes = float(val)
+ elif key == "priority_weight_high":
+ c.priority_weight_high = float(val)
+ elif key == "priority_weight_normal":
+ c.priority_weight_normal = float(val)
+ elif key == "priority_weight_low":
+ c.priority_weight_low = float(val)
+ elif key == "weight_delivery_time":
+ c.weight_delivery_time = float(val)
+ elif key == "weight_sla_risk":
+ c.weight_sla_risk = float(val)
+ elif key == "weight_workload":
+ c.weight_workload = float(val)
+ elif key == "weight_priority":
+ c.weight_priority = float(val)
+ elif key == "weight_rating":
+ c.weight_rating = float(val)
+ except ValueError:
+ logger.warning("Constraint '%s': invalid value '%s', using default", key, val)
+
+ logger.info("Loaded constraints: %s", c)
+ return c
diff --git a/src/dispatcher.py b/src/dispatcher.py
new file mode 100644
index 0000000..c78fec8
--- /dev/null
+++ b/src/dispatcher.py
@@ -0,0 +1,208 @@
+"""
+Issues 9, 10, 11, 17, 18, 19: Core dispatcher — orchestrates the full
+assignment + delivery simulation pipeline.
+"""
+
+import logging
+import time
+from datetime import datetime, timedelta
+from typing import List, Optional
+
+from src.agent_registry import AgentRegistry
+from src.data_loader import Constraints, Order
+from src.graph import EnvironmentGraph
+from src.metrics import MetricsCollector
+from src.order_queue import PriorityOrderQueue
+from src.scorer import select_best_candidate
+
+logger = logging.getLogger(__name__)
+
+
+class Dispatcher:
+ """
+ Simulation clock-driven dispatcher.
+
+ Each 'tick' advances the simulation by `tick_minutes`. On each tick:
+ 1. Accept newly arrived orders (by timestamp).
+ 2. Try to assign pending orders to available agents.
+ 3. Advance in-flight deliveries toward completion.
+ 4. Record metrics.
+ """
+
+ def __init__(
+ self,
+ queue: PriorityOrderQueue,
+ registry: AgentRegistry,
+ graph: EnvironmentGraph,
+ constraints: Constraints,
+ metrics: MetricsCollector,
+ scoring_weights: Optional[dict] = None,
+ ):
+ self._queue = queue
+ self._registry = registry
+ self._graph = graph
+ self._constraints = constraints
+ self._metrics = metrics
+ self._weights = scoring_weights
+ self._in_flight: List[dict] = [] # [{order_id, agent_id, remaining_minutes}]
+ self._sim_time: Optional[datetime] = None
+ self._elapsed_minutes: float = 0.0
+ self._orders_per_minute: float = 0.0
+ self._tick_count: int = 0
+
+ # ── Main simulation entry point ─────────────────────────────────────────
+
+ def run(self, all_orders: List[Order], tick_minutes: float = 1.0) -> None:
+ """
+ Issue 19: Process all orders in chronological simulation.
+ tick_minutes: simulated time per step.
+ """
+ if not all_orders:
+ logger.warning("No orders to simulate")
+ return
+
+ wall_start = time.perf_counter()
+
+ # Initialise simulation clock to first order timestamp
+ sorted_orders = sorted(all_orders, key=lambda o: o.timestamp)
+ self._sim_time = sorted_orders[0].timestamp
+ pending_arrivals = list(sorted_orders)
+
+ total_orders = len(all_orders)
+ logger.info("Simulation start: %s | %d orders | tick=%.1f min",
+ self._sim_time.isoformat(), total_orders, tick_minutes)
+
+ while pending_arrivals or self._queue.pending_count() > 0 or self._in_flight:
+ # 1. Ingest arrivals up to current sim_time
+ while pending_arrivals and pending_arrivals[0].timestamp <= self._sim_time:
+ self._queue.push(pending_arrivals.pop(0))
+
+ # 2. Attempt assignments for all pending orders (Issue 11)
+ self._attempt_assignments()
+
+ # 3. Advance in-flight deliveries
+ self._advance_deliveries(tick_minutes)
+
+ # 4. Tick forward
+ self._sim_time += timedelta(minutes=tick_minutes)
+ self._elapsed_minutes += tick_minutes
+ self._tick_count += 1
+
+ # Issue 19: throughput is a processing-performance metric, so track it
+ # against wall-clock runtime rather than simulated business time.
+ wall_elapsed_seconds = max(time.perf_counter() - wall_start, 1e-9)
+ throughput_per_minute = (total_orders / wall_elapsed_seconds) * 60
+ logger.info(
+ "Simulation complete: %.1f simulated min elapsed, %.1f orders/min processing throughput",
+ self._elapsed_minutes,
+ throughput_per_minute,
+ )
+ if throughput_per_minute < 100:
+ logger.warning(
+ "Throughput %.1f orders/min below target 100/min (Issue 19)",
+ throughput_per_minute,
+ )
+
+ # ── Assignment ────────────────────────────────────────────────────────────
+
+ def _attempt_assignments(self) -> None:
+ """Issue 9, 11: Assign highest-priority pending orders to available agents."""
+ while True:
+ order = self._queue.peek_pending()
+ if order is None:
+ break
+ if not self._registry.available_agents():
+ logger.debug("No available agents; %d orders remain pending", self._queue.pending_count())
+ break # Issue 11: keep in queue
+
+ # Check if SLA already violated before assignment (Issue 17)
+ time_since_order = (self._sim_time - order.timestamp).total_seconds() / 60.0
+ if time_since_order > order.sla_minutes:
+ logger.warning("Order %s: SLA deadline already passed (%.1f min late), assigning anyway",
+ order.order_id, time_since_order - order.sla_minutes)
+
+ t0 = time.perf_counter()
+ best = select_best_candidate(
+ order, self._registry, self._graph, self._constraints,
+ weights=self._weights,
+ simulation_elapsed_minutes=self._elapsed_minutes,
+ )
+ latency_ms = (time.perf_counter() - t0) * 1000
+ self._metrics.record_latency_ms(latency_ms) # Issue 18
+
+ if best is None:
+ # No valid path exists for this order (Issue 17: empty candidate list)
+ logger.warning("Order %s: no feasible agent found, will retry next tick", order.order_id)
+ break
+
+ # Issue 9: Atomic state update
+ popped = self._queue.pop_pending()
+ if popped is None or popped.order_id != order.order_id:
+ logger.error("Queue race condition for order %s", order.order_id)
+ break
+
+ ok_q = self._queue.mark_assigned(order.order_id, best.agent.agent_id, self._sim_time)
+ ok_a = self._registry.assign_order(best.agent.agent_id, order.order_id)
+
+ if not (ok_q and ok_a):
+ logger.error("Assignment failed atomically for order %s / agent %s",
+ order.order_id, best.agent.agent_id)
+ continue
+
+ self._metrics.record_assignment(best.agent.agent_id)
+
+ # Schedule delivery completion
+ self._in_flight.append({
+ "order_id": order.order_id,
+ "agent_id": best.agent.agent_id,
+ "remaining_minutes": best.estimated_delivery,
+ "delivery_location": order.location,
+ "sla_minutes": order.sla_minutes,
+ "priority": order.priority,
+ "order_timestamp": order.timestamp,
+ })
+ self._queue.mark_in_transit(order.order_id)
+
+ logger.info("✓ Order %-5s → Agent %-5s travel=%.1fm delivery=%.1fm priority=%s",
+ order.order_id, best.agent.agent_id,
+ best.travel_time, best.estimated_delivery, order.priority)
+
+ # ── Delivery simulation ───────────────────────────────────────────────────
+
+ def _advance_deliveries(self, tick_minutes: float) -> None:
+ """Issue 10: Decrement remaining time; deliver completed orders."""
+ still_flying = []
+ for flight in self._in_flight:
+ flight["remaining_minutes"] -= tick_minutes
+ if flight["remaining_minutes"] <= 0:
+ self._complete_delivery(flight)
+ else:
+ still_flying.append(flight)
+ self._in_flight = still_flying
+
+ def _complete_delivery(self, flight: dict) -> None:
+ """Issue 10, 13: Finalise delivery and check SLA."""
+ oid = flight["order_id"]
+ aid = flight["agent_id"]
+
+ # Actual delivery time in minutes from order timestamp
+ actual_elapsed = (self._sim_time - flight["order_timestamp"]).total_seconds() / 60.0
+ sla_violated = actual_elapsed > flight["sla_minutes"]
+
+ self._queue.mark_delivered(oid, self._sim_time, sla_violated)
+ self._registry.complete_order(aid, oid, new_location=flight["delivery_location"])
+
+ self._metrics.record_delivery(
+ order_id=oid,
+ priority=flight["priority"],
+ delivery_time_minutes=actual_elapsed,
+ sla_minutes=flight["sla_minutes"],
+ sla_violated=sla_violated,
+ )
+
+ status = "⚠ SLA VIOLATED" if sla_violated else "✓ on time"
+ logger.info("Delivered %-5s by Agent %-5s %.1f min [%s]",
+ oid, aid, actual_elapsed, status)
+
+ # Issue 11: After delivery, try to assign queued orders
+ self._attempt_assignments()
diff --git a/src/graph.py b/src/graph.py
new file mode 100644
index 0000000..1d1e643
--- /dev/null
+++ b/src/graph.py
@@ -0,0 +1,128 @@
+"""
+Issue 3, 17: Build environment graph, compute shortest paths (Floyd-Warshall for small
+graphs, Dijkstra for larger ones), handle disconnected locations.
+"""
+
+import heapq
+import logging
+import math
+from typing import Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+Location = Tuple[int, int]
+INF = float("inf")
+
+# Threshold: use Floyd-Warshall below this node count, Dijkstra above
+FLOYD_WARSHALL_THRESHOLD = 200
+
+
+class EnvironmentGraph:
+ """Undirected weighted graph over (x, y) grid locations."""
+
+ def __init__(self, edges: List[dict]):
+ self._adj: Dict[Location, List[Tuple[Location, float]]] = {}
+ self._nodes: set = set()
+ self._dist_cache: Dict[Tuple[Location, Location], float] = {}
+ self._precomputed: bool = False
+
+ for e in edges:
+ src: Location = e["from"]
+ dst: Location = e["to"]
+ weight: float = e["distance_minutes"] * e["delay_multiplier"]
+
+ self._nodes.add(src)
+ self._nodes.add(dst)
+ self._adj.setdefault(src, []).append((dst, weight))
+ # Bidirectional graph
+ self._adj.setdefault(dst, []).append((src, weight))
+
+ logger.info("Graph built: %d nodes, %d directed edges",
+ len(self._nodes), sum(len(v) for v in self._adj.values()))
+
+ if len(self._nodes) <= FLOYD_WARSHALL_THRESHOLD:
+ self._precompute_floyd_warshall()
+ else:
+ logger.info("Graph too large for Floyd-Warshall; will use Dijkstra on demand")
+
+ # ── Shortest path precomputation ─────────────────────────────────────────
+
+ def _precompute_floyd_warshall(self) -> None:
+ """Issue 3: Floyd-Warshall for small graphs; O(V³)."""
+ nodes = sorted(self._nodes)
+ n = len(nodes)
+ idx = {node: i for i, node in enumerate(nodes)}
+
+ # Initialize distance matrix
+ dist = [[INF] * n for _ in range(n)]
+ for i in range(n):
+ dist[i][i] = 0.0
+ for src, neighbours in self._adj.items():
+ for dst, w in neighbours:
+ i, j = idx[src], idx[dst]
+ dist[i][j] = min(dist[i][j], w)
+
+ # Relax
+ for k in range(n):
+ for i in range(n):
+ if dist[i][k] == INF:
+ continue
+ for j in range(n):
+ if dist[i][k] + dist[k][j] < dist[i][j]:
+ dist[i][j] = dist[i][k] + dist[k][j]
+
+ # Store in cache
+ for i, src in enumerate(nodes):
+ for j, dst in enumerate(nodes):
+ self._dist_cache[(src, dst)] = dist[i][j]
+
+ self._precomputed = True
+ logger.info("Floyd-Warshall precomputed for %d nodes", n)
+
+ def _dijkstra(self, src: Location, dst: Location) -> float:
+ """Dijkstra's algorithm for on-demand queries (large graphs). Issue 17: handles disconnected."""
+ if src not in self._nodes:
+ return INF
+ dist: Dict[Location, float] = {src: 0.0}
+ heap = [(0.0, src)]
+ while heap:
+ d, u = heapq.heappop(heap)
+ if u == dst:
+ return d
+ if d > dist.get(u, INF):
+ continue
+ for v, w in self._adj.get(u, []):
+ nd = d + w
+ if nd < dist.get(v, INF):
+ dist[v] = nd
+ heapq.heappush(heap, (nd, v))
+ return INF # disconnected
+
+ # ── Public API ────────────────────────────────────────────────────────────
+
+ def travel_time(self, src: Location, dst: Location) -> float:
+ """Return travel time in minutes between two (x, y) locations.
+ Returns float('inf') if no path exists (Issue 17).
+ """
+ if src == dst:
+ return 0.0
+
+ # Check in-memory cache (populated by Floyd-Warshall or previous Dijkstra)
+ key = (src, dst)
+ if key in self._dist_cache:
+ return self._dist_cache[key]
+
+ # Lazy Dijkstra for large graphs
+ t = self._dijkstra(src, dst)
+ self._dist_cache[key] = t
+ return t
+
+ def has_path(self, src: Location, dst: Location) -> bool:
+ return self.travel_time(src, dst) < INF
+
+ @property
+ def nodes(self) -> set:
+ return self._nodes
+
+ def validate_location(self, loc: Location) -> bool:
+ return loc in self._nodes
diff --git a/src/metrics.py b/src/metrics.py
new file mode 100644
index 0000000..70d4b63
--- /dev/null
+++ b/src/metrics.py
@@ -0,0 +1,213 @@
+"""
+Issues 12, 13, 14, 15: Metrics collection and structured output.
+Uses Welford's algorithm for online mean/variance (Issue 12).
+"""
+
+import json
+import logging
+import math
+import time
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from typing import Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+
+# ─── Welford running statistics ──────────────────────────────────────────────
+
+class RunningStats:
+ """Online mean and variance via Welford's algorithm (Issue 12)."""
+
+ def __init__(self):
+ self.n: int = 0
+ self._mean: float = 0.0
+ self._M2: float = 0.0
+
+ def update(self, value: float) -> None:
+ self.n += 1
+ delta = value - self._mean
+ self._mean += delta / self.n
+ delta2 = value - self._mean
+ self._M2 += delta * delta2
+
+ @property
+ def mean(self) -> float:
+ return self._mean if self.n > 0 else 0.0
+
+ @property
+ def variance(self) -> float:
+ return self._M2 / self.n if self.n > 0 else 0.0
+
+ @property
+ def std(self) -> float:
+ return math.sqrt(self.variance)
+
+ def to_dict(self) -> dict:
+ return {"count": self.n, "mean": round(self.mean, 3), "std": round(self.std, 3)}
+
+
+# ─── Metric accumulators ─────────────────────────────────────────────────────
+
+@dataclass
+class MetricsCollector:
+ # Delivery time (Issue 12)
+ delivery_time_overall: RunningStats = field(default_factory=RunningStats)
+ delivery_time_by_priority: Dict[str, RunningStats] = field(
+ default_factory=lambda: {"high": RunningStats(), "normal": RunningStats(), "low": RunningStats()}
+ )
+
+ # SLA compliance (Issue 13)
+ total_delivered: int = 0
+ sla_violations: int = 0
+ sla_margin_sum: float = 0.0 # sum of (deadline - actual)
+ violations_by_priority: Dict[str, int] = field(
+ default_factory=lambda: {"high": 0, "normal": 0, "low": 0}
+ )
+ delivered_by_priority: Dict[str, int] = field(
+ default_factory=lambda: {"high": 0, "normal": 0, "low": 0}
+ )
+
+ # Workload fairness (Issue 14)
+ agent_assignments: Dict[str, int] = field(default_factory=dict)
+
+ # Latency tracking (Issue 18)
+ decision_latencies_ms: List[float] = field(default_factory=list)
+ latency_target_ms: float = 5000.0
+
+ def record_delivery(
+ self,
+ order_id: str,
+ priority: str,
+ delivery_time_minutes: float,
+ sla_minutes: float,
+ sla_violated: bool,
+ ) -> None:
+ """Record a completed delivery for all metric categories."""
+ self.total_delivered += 1
+ self.delivery_time_overall.update(delivery_time_minutes)
+ self.delivery_time_by_priority[priority].update(delivery_time_minutes)
+
+ self.delivered_by_priority[priority] += 1
+ margin = sla_minutes - delivery_time_minutes
+ self.sla_margin_sum += margin
+ if sla_violated:
+ self.sla_violations += 1
+ self.violations_by_priority[priority] += 1
+
+ def record_assignment(self, agent_id: str) -> None:
+ self.agent_assignments[agent_id] = self.agent_assignments.get(agent_id, 0) + 1
+
+ def record_latency_ms(self, latency_ms: float) -> None:
+ self.decision_latencies_ms.append(latency_ms)
+ if latency_ms > self.latency_target_ms:
+ logger.warning("Decision latency %.1fms exceeds target %.1fms",
+ latency_ms, self.latency_target_ms)
+
+ # ── Derived metrics ───────────────────────────────────────────────────────
+
+ @property
+ def sla_compliance_rate(self) -> float:
+ if self.total_delivered == 0:
+ return 1.0
+ return round(1.0 - self.sla_violations / self.total_delivered, 4)
+
+ @property
+ def sla_violation_rate(self) -> float:
+ if self.total_delivered == 0:
+ return 0.0
+ return round(self.sla_violations / self.total_delivered, 4)
+
+ @property
+ def avg_sla_margin(self) -> float:
+ if self.total_delivered == 0:
+ return 0.0
+ return round(self.sla_margin_sum / self.total_delivered, 3)
+
+ def _fairness_metrics(self) -> dict:
+ """Issue 14: load variance, std-dev, min/max."""
+ if not self.agent_assignments:
+ return {"load_variance": 0.0, "load_std": 0.0, "min_assignments": 0,
+ "max_assignments": 0, "assignment_range": 0}
+ vals = list(self.agent_assignments.values())
+ mn = min(vals)
+ mx = max(vals)
+ mean = sum(vals) / len(vals)
+ variance = sum((v - mean) ** 2 for v in vals) / len(vals)
+ return {
+ "load_variance": round(variance, 3),
+ "load_std": round(math.sqrt(variance), 3),
+ "min_assignments": mn,
+ "max_assignments": mx,
+ "assignment_range": mx - mn,
+ "mean_assignments": round(mean, 3),
+ }
+
+ def _latency_metrics(self) -> dict:
+ if not self.decision_latencies_ms:
+ return {}
+ ls = self.decision_latencies_ms
+ return {
+ "count": len(ls),
+ "mean_ms": round(sum(ls) / len(ls), 2),
+ "max_ms": round(max(ls), 2),
+ "min_ms": round(min(ls), 2),
+ "violations": sum(1 for l in ls if l > self.latency_target_ms),
+ }
+
+ # ── Issue 15: Structured JSON export ─────────────────────────────────────
+
+ def to_dict(self, dataset_name: str = "orders") -> dict:
+ return {
+ "metadata": {
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "dataset": dataset_name,
+ "total_orders_delivered": self.total_delivered,
+ "total_sla_violations": self.sla_violations,
+ },
+ "delivery_time": {
+ "overall": self.delivery_time_overall.to_dict(),
+ "by_priority": {p: s.to_dict() for p, s in self.delivery_time_by_priority.items()},
+ },
+ "sla_compliance": {
+ "compliance_rate": self.sla_compliance_rate,
+ "violation_rate": self.sla_violation_rate,
+ "avg_sla_margin_minutes": self.avg_sla_margin,
+ "violations_by_priority": self.violations_by_priority,
+ "delivered_by_priority": self.delivered_by_priority,
+ },
+ "workload_fairness": self._fairness_metrics(),
+ "decision_latency": self._latency_metrics(),
+ }
+
+ def to_json(self, dataset_name: str = "orders", indent: int = 2) -> str:
+ return json.dumps(self.to_dict(dataset_name), indent=indent)
+
+ def summary(self) -> str:
+ """Issue 15: Human-readable summary."""
+ lines = [
+ "═" * 55,
+ " SMART DELIVERY DISPATCH — METRICS SUMMARY",
+ "═" * 55,
+ f" Orders delivered : {self.total_delivered}",
+ f" SLA compliance : {self.sla_compliance_rate * 100:.1f}%",
+ f" SLA violations : {self.sla_violations}",
+ f" Avg delivery time : {self.delivery_time_overall.mean:.1f} min (±{self.delivery_time_overall.std:.1f})",
+ f" Avg SLA margin : {self.avg_sla_margin:.1f} min",
+ "── By priority ──────────────────────────────────────",
+ ]
+ for p in ("high", "normal", "low"):
+ s = self.delivery_time_by_priority[p]
+ v = self.violations_by_priority[p]
+ d = self.delivered_by_priority[p]
+ lines.append(
+ f" {p:6s} delivered={d:3d} avg={s.mean:.1f}m violations={v}"
+ )
+ fm = self._fairness_metrics()
+ lines += [
+ "── Workload fairness ────────────────────────────────",
+ f" Assignments min={fm['min_assignments']} max={fm['max_assignments']}"
+ f" range={fm['assignment_range']} std={fm['load_std']:.2f}",
+ "═" * 55,
+ ]
+ return "\n".join(lines)
diff --git a/src/order_queue.py b/src/order_queue.py
new file mode 100644
index 0000000..a1851e7
--- /dev/null
+++ b/src/order_queue.py
@@ -0,0 +1,131 @@
+"""
+Issue 4: Priority Order Queue — processes orders by priority (high > normal > low)
+and timestamp (FIFO within the same priority level).
+State machine: PENDING → ASSIGNED → IN_TRANSIT → DELIVERED
+"""
+
+import heapq
+import logging
+from datetime import datetime
+from typing import Dict, List, Optional
+
+from src.data_loader import Order
+
+logger = logging.getLogger(__name__)
+
+# Priority levels: lower integer = higher dispatch priority
+PRIORITY_RANK = {"high": 0, "normal": 1, "low": 2}
+
+
+class PriorityOrderQueue:
+ """Min-heap keyed by (priority_rank, timestamp) for strict ordering."""
+
+ def __init__(self):
+ self._heap: list = [] # (rank, timestamp, order_id) tuples
+ self._orders: Dict[str, Order] = {} # all orders by state
+ self._by_state: Dict[str, List[str]] = {
+ "PENDING": [], "ASSIGNED": [], "IN_TRANSIT": [], "DELIVERED": []
+ }
+
+ # ── Ingestion ────────────────────────────────────────────────────────────
+
+ def push(self, order: Order) -> None:
+ if order.order_id in self._orders:
+ logger.warning("Order %s already in queue, ignoring duplicate", order.order_id)
+ return
+ rank = PRIORITY_RANK.get(order.priority, 1)
+ heapq.heappush(self._heap, (rank, order.timestamp, order.order_id))
+ self._orders[order.order_id] = order
+ self._by_state["PENDING"].append(order.order_id)
+ logger.debug("Queued order %s (priority=%s, ts=%s)", order.order_id, order.priority, order.timestamp)
+
+ def push_many(self, orders: List[Order]) -> None:
+ for o in orders:
+ self.push(o)
+
+ # ── Retrieval ────────────────────────────────────────────────────────────
+
+ def peek_pending(self) -> Optional[Order]:
+ """Return highest-priority pending order without removing from heap."""
+ temp = []
+ result = None
+ while self._heap:
+ item = heapq.heappop(self._heap)
+ rank, ts, oid = item
+ order = self._orders[oid]
+ if order.state == "PENDING":
+ result = order
+ heapq.heappush(self._heap, item)
+ break
+ temp.append(item)
+ for item in temp:
+ heapq.heappush(self._heap, item)
+ return result
+
+ def pop_pending(self) -> Optional[Order]:
+ """Remove and return highest-priority pending order."""
+ while self._heap:
+ rank, ts, oid = heapq.heappop(self._heap)
+ order = self._orders[oid]
+ if order.state == "PENDING":
+ return order
+ # Skip non-pending (stale heap entries)
+ return None
+
+ def get_pending(self) -> List[Order]:
+ """Return all PENDING orders sorted by priority+timestamp."""
+ pending = [self._orders[oid] for oid in self._by_state["PENDING"]]
+ return sorted(pending, key=lambda o: (PRIORITY_RANK[o.priority], o.timestamp))
+
+ def get_by_state(self, state: str) -> List[Order]:
+ return [self._orders[oid] for oid in self._by_state.get(state, [])]
+
+ # ── State transitions ─────────────────────────────────────────────────────
+
+ def _transition(self, order_id: str, new_state: str) -> bool:
+ if order_id not in self._orders:
+ logger.error("Order %s not found in queue", order_id)
+ return False
+ order = self._orders[order_id]
+ valid_transitions = {
+ "PENDING": "ASSIGNED",
+ "ASSIGNED": "IN_TRANSIT",
+ "IN_TRANSIT": "DELIVERED",
+ }
+ if valid_transitions.get(order.state) != new_state:
+ logger.error("Invalid transition %s → %s for order %s", order.state, new_state, order_id)
+ return False
+ self._by_state[order.state].remove(order_id)
+ order.state = new_state
+ self._by_state[new_state].append(order_id)
+ return True
+
+ def mark_assigned(self, order_id: str, agent_id: str, at: datetime) -> bool:
+ if not self._transition(order_id, "ASSIGNED"):
+ return False
+ o = self._orders[order_id]
+ o.assigned_agent_id = agent_id
+ o.assigned_at = at
+ return True
+
+ def mark_in_transit(self, order_id: str) -> bool:
+ return self._transition(order_id, "IN_TRANSIT")
+
+ def mark_delivered(self, order_id: str, at: datetime, sla_violated: bool) -> bool:
+ if not self._transition(order_id, "DELIVERED"):
+ return False
+ o = self._orders[order_id]
+ o.delivered_at = at
+ o.sla_violated = sla_violated
+ return True
+
+ # ── Helpers ───────────────────────────────────────────────────────────────
+
+ def get_order(self, order_id: str) -> Optional[Order]:
+ return self._orders.get(order_id)
+
+ def pending_count(self) -> int:
+ return len(self._by_state["PENDING"])
+
+ def __len__(self) -> int:
+ return len(self._orders)
diff --git a/src/scorer.py b/src/scorer.py
new file mode 100644
index 0000000..e632feb
--- /dev/null
+++ b/src/scorer.py
@@ -0,0 +1,156 @@
+"""
+Issues 6, 7, 8: Candidate generation and assignment scoring.
+
+Scoring formula (lower = better):
+ score = w_time * normalised_delivery_time
+ - w_sla * sla_margin_ratio (negative → urgency bonus)
+ + w_fair * agent_load_ratio
+ - w_pri * priority_weight
+ - w_rating * normalised_rating
+"""
+
+import logging
+from dataclasses import dataclass
+from typing import List, Optional, Tuple
+
+from src.agent_registry import AgentRegistry
+from src.data_loader import Agent, Constraints, Order
+from src.graph import EnvironmentGraph
+
+logger = logging.getLogger(__name__)
+
+# Scoring weight defaults (Issue 8: configurable)
+DEFAULT_WEIGHTS = {
+ "delivery_time": 0.35,
+ "sla_risk": 0.30,
+ "workload": 0.20,
+ "priority": 0.10,
+ "rating": 0.05,
+}
+
+
+@dataclass
+class Candidate:
+ agent: Agent
+ order: Order
+ travel_time: float # agent → order location (minutes)
+ estimated_delivery: float # travel + prep_time (minutes)
+
+ @property
+ def score(self) -> float:
+ return self._score
+
+ def set_score(self, score: float) -> None:
+ self._score = score
+
+
+def generate_candidates(
+ order: Order,
+ registry: AgentRegistry,
+ graph: EnvironmentGraph,
+) -> List[Candidate]:
+ """Issue 6: Find available agents that have a valid path to the order location."""
+ candidates: List[Candidate] = []
+ order_loc = order.location
+
+ for agent in registry.available_agents():
+ t = graph.travel_time(agent.current_location, order_loc)
+ if t == float("inf"):
+ logger.debug("Agent %s has no path to order %s, skipping", agent.agent_id, order.order_id)
+ continue
+ estimated = t + order.prep_time_minutes
+ c = Candidate(agent=agent, order=order,
+ travel_time=t, estimated_delivery=estimated)
+ c.set_score(float("inf")) # placeholder until scored
+ candidates.append(c)
+
+ logger.debug("Order %s: %d feasible candidates", order.order_id, len(candidates))
+ return candidates
+
+
+def score_candidates(
+ candidates: List[Candidate],
+ constraints: Constraints,
+ registry: AgentRegistry,
+ weights: Optional[dict] = None,
+ simulation_elapsed_minutes: float = 0.0,
+) -> List[Candidate]:
+ """Issue 7: Score and rank candidates. Lower score = better match."""
+ if not candidates:
+ return []
+
+ w = {**DEFAULT_WEIGHTS, **(weights or {})}
+
+ # Pre-compute normalisation bounds
+ max_delivery = max(c.estimated_delivery for c in candidates) or 1.0
+ max_assignments = max(
+ a.cumulative_assignments for a in registry.all_agents()
+ ) or 1.0
+
+ priority_weights = {
+ "high": constraints.priority_weight_high,
+ "normal": constraints.priority_weight_normal,
+ "low": constraints.priority_weight_low,
+ }
+
+ scored: List[Candidate] = []
+ for c in candidates:
+ order = c.order
+
+ # 1. Delivery time component (lower → better)
+ time_component = c.estimated_delivery / max_delivery
+
+ # 2. SLA risk component: how close are we to missing the deadline?
+ sla_remaining = order.sla_minutes - (simulation_elapsed_minutes + c.estimated_delivery)
+ sla_ratio = sla_remaining / order.sla_minutes # positive = margin left
+ # Clamp to [-1, 1] so extreme cases don't dominate
+ sla_component = max(-1.0, min(1.0, sla_ratio))
+
+ # 3. Workload fairness (higher load → penalise)
+ load_ratio = c.agent.cumulative_assignments / max_assignments
+ workload_component = load_ratio
+
+ # 4. Priority (higher priority → bonus)
+ priority_component = priority_weights.get(order.priority, 1.0)
+
+ # 5. Agent rating (higher → bonus)
+ rating_component = c.agent.rating / 5.0
+
+ # Composite: minimise score
+ score = (
+ w["delivery_time"] * time_component
+ - w["sla_risk"] * sla_component # bigger margin → lower score
+ + w["workload"] * workload_component
+ - w["priority"] * (priority_component / constraints.priority_weight_high)
+ - w["rating"] * rating_component
+ )
+ c.set_score(score)
+ scored.append(c)
+
+ # Sort ascending (best first); tiebreak by agent_id (Issue 17)
+ scored.sort(key=lambda c: (round(c.score, 8), c.agent.agent_id))
+ return scored
+
+
+def select_best_candidate(
+ order: Order,
+ registry: AgentRegistry,
+ graph: EnvironmentGraph,
+ constraints: Constraints,
+ weights: Optional[dict] = None,
+ simulation_elapsed_minutes: float = 0.0,
+) -> Optional[Candidate]:
+ """Issues 6+7+17: Full pipeline — generate, score, return best."""
+ candidates = generate_candidates(order, registry, graph)
+ if not candidates:
+ logger.info("Order %s: no available candidates (Issue 17 — will queue)", order.order_id)
+ return None
+
+ scored = score_candidates(candidates, constraints, registry,
+ weights=weights,
+ simulation_elapsed_minutes=simulation_elapsed_minutes)
+ best = scored[0]
+ logger.debug("Order %s → Agent %s (score=%.4f, travel=%.1fm, delivery=%.1fm)",
+ order.order_id, best.agent.agent_id, best.score,
+ best.travel_time, best.estimated_delivery)
+ return best
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..65140f2
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+# tests package
diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000..79256af
Binary files /dev/null and b/tests/__pycache__/__init__.cpython-313.pyc differ
diff --git a/tests/__pycache__/test_dispatch.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_dispatch.cpython-313-pytest-9.0.3.pyc
new file mode 100644
index 0000000..32f5157
Binary files /dev/null and b/tests/__pycache__/test_dispatch.cpython-313-pytest-9.0.3.pyc differ
diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py
new file mode 100644
index 0000000..940e8d7
--- /dev/null
+++ b/tests/test_dispatch.py
@@ -0,0 +1,488 @@
+"""
+Tests covering all 20 issues in ISSUES.md.
+Run with: python3 -m pytest tests/ -v
+"""
+
+import json
+import math
+import os
+import sys
+import tempfile
+from datetime import datetime
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+
+from src.agent_registry import AgentRegistry
+from src.data_loader import (
+ Agent,
+ Constraints,
+ Order,
+ load_agents,
+ load_constraints,
+ load_environment_edges,
+ load_orders,
+)
+from src.dispatcher import Dispatcher
+from src.graph import EnvironmentGraph
+from src.metrics import MetricsCollector, RunningStats
+from src.order_queue import PriorityOrderQueue, PRIORITY_RANK
+from src.scorer import generate_candidates, score_candidates, select_best_candidate
+
+
+# ── Fixtures ──────────────────────────────────────────────────────────────────
+
+def make_order(oid="O001", priority="high", prep=10, sla=50,
+ lx=2, ly=3, ts_str="2026-05-03 09:00:00"):
+ return Order(
+ order_id=oid,
+ timestamp=datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S"),
+ location_x=lx, location_y=ly,
+ prep_time_minutes=prep,
+ priority=priority,
+ sla_minutes=sla,
+ )
+
+
+def make_agent(aid="A001", cx=0, cy=0, rating=4.5):
+ return Agent(agent_id=aid, current_x=cx, current_y=cy, rating=rating)
+
+
+def make_grid_edges(size=5):
+ """Build simple grid graph edges for testing."""
+ edges = []
+ for x in range(size):
+ for y in range(size):
+ if x + 1 < size:
+ edges.append({"from": (x, y), "to": (x + 1, y),
+ "distance_minutes": 3, "delay_multiplier": 1.0})
+ if y + 1 < size:
+ edges.append({"from": (x, y), "to": (x, y + 1),
+ "distance_minutes": 3, "delay_multiplier": 1.0})
+ return edges
+
+
+@pytest.fixture
+def small_graph():
+ return EnvironmentGraph(make_grid_edges(size=5))
+
+
+@pytest.fixture
+def constraints():
+ return Constraints(max_active_orders_per_agent=2)
+
+
+@pytest.fixture
+def real_graph():
+ edges_path = ROOT / "data" / "raw" / "environment_edges.csv"
+ edges = load_environment_edges(str(edges_path))
+ return EnvironmentGraph(edges)
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Issue 1: Load and Validate Orders
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestLoadOrders:
+ def test_loads_real_orders(self):
+ path = ROOT / "data" / "raw" / "orders.csv"
+ orders = load_orders(str(path))
+ assert len(orders) == 150
+ for o in orders:
+ assert o.priority in {"high", "normal", "low"}
+ assert o.sla_minutes > 0
+ assert o.prep_time_minutes >= 0
+
+ def test_missing_file_raises(self, tmp_path):
+ with pytest.raises(FileNotFoundError):
+ load_orders(str(tmp_path / "nonexistent.csv"))
+
+ def test_invalid_priority_skipped(self, tmp_path):
+ csv = tmp_path / "orders.csv"
+ csv.write_text(
+ "order_id,timestamp,location_x,location_y,prep_time_minutes,priority,sla_minutes\n"
+ "O001,2026-05-03 09:00:00,1,1,10,INVALID,50\n"
+ "O002,2026-05-03 09:00:00,1,1,10,high,50\n"
+ )
+ orders = load_orders(str(csv))
+ assert len(orders) == 1
+ assert orders[0].order_id == "O002"
+
+ def test_negative_sla_skipped(self, tmp_path):
+ csv = tmp_path / "orders.csv"
+ csv.write_text(
+ "order_id,timestamp,location_x,location_y,prep_time_minutes,priority,sla_minutes\n"
+ "O001,2026-05-03 09:00:00,1,1,10,high,-5\n"
+ )
+ orders = load_orders(str(csv))
+ assert len(orders) == 0
+
+ def test_missing_columns_raises(self, tmp_path):
+ csv = tmp_path / "orders.csv"
+ csv.write_text("order_id,timestamp\nO001,2026-01-01 00:00:00\n")
+ with pytest.raises(ValueError):
+ load_orders(str(csv))
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Issue 2: Load and Validate Agents
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestLoadAgents:
+ def test_loads_real_agents(self):
+ path = ROOT / "data" / "raw" / "agents.csv"
+ agents = load_agents(str(path))
+ assert len(agents) == 25
+ for a in agents:
+ assert 0 <= a.rating <= 5
+ assert a.active_orders == []
+ assert a.cumulative_assignments == 0
+
+ def test_missing_file_raises(self, tmp_path):
+ with pytest.raises(FileNotFoundError):
+ load_agents(str(tmp_path / "missing.csv"))
+
+ def test_bad_rating_clamped(self, tmp_path):
+ csv = tmp_path / "agents.csv"
+ csv.write_text("agent_id,current_x,current_y,rating\nA001,0,0,6.5\n")
+ agents = load_agents(str(csv))
+ assert agents[0].rating == 5.0
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Issue 3: Load Environment Graph
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestEnvironmentGraph:
+ def test_loads_real_edges(self):
+ path = ROOT / "data" / "raw" / "environment_edges.csv"
+ edges = load_environment_edges(str(path))
+ assert len(edges) == 180
+
+ def test_travel_time_adjacent(self, small_graph):
+ t = small_graph.travel_time((0, 0), (1, 0))
+ assert t == pytest.approx(3.0, abs=0.01)
+
+ def test_travel_time_diagonal(self, small_graph):
+ t = small_graph.travel_time((0, 0), (2, 2))
+ assert t == pytest.approx(12.0, abs=0.01) # 4 hops * 3 min
+
+ def test_no_path_returns_inf(self):
+ edges = [{"from": (0, 0), "to": (1, 0), "distance_minutes": 5, "delay_multiplier": 1.0}]
+ graph = EnvironmentGraph(edges)
+ assert graph.travel_time((0, 0), (9, 9)) == float("inf")
+
+ def test_same_location_is_zero(self, small_graph):
+ assert small_graph.travel_time((2, 2), (2, 2)) == 0.0
+
+ def test_has_path(self, small_graph):
+ assert small_graph.has_path((0, 0), (4, 4))
+ assert not small_graph.has_path((0, 0), (99, 99))
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Issue 4: Priority Order Queue
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestPriorityOrderQueue:
+ def test_priority_ordering(self):
+ q = PriorityOrderQueue()
+ q.push(make_order("O1", "low"))
+ q.push(make_order("O2", "high"))
+ q.push(make_order("O3", "normal"))
+ top = q.peek_pending()
+ assert top.order_id == "O2"
+
+ def test_fifo_within_same_priority(self):
+ q = PriorityOrderQueue()
+ q.push(make_order("O1", "high", ts_str="2026-05-03 09:00:00"))
+ q.push(make_order("O2", "high", ts_str="2026-05-03 09:05:00"))
+ first = q.pop_pending()
+ second = q.pop_pending()
+ assert first.order_id == "O1"
+ assert second.order_id == "O2"
+
+ def test_state_transitions(self):
+ q = PriorityOrderQueue()
+ o = make_order("O1")
+ q.push(o)
+ at = datetime.now()
+ assert q.mark_assigned("O1", "A001", at)
+ assert q.get_order("O1").state == "ASSIGNED"
+ assert q.mark_in_transit("O1")
+ assert q.mark_delivered("O1", at, False)
+ assert q.get_order("O1").state == "DELIVERED"
+
+ def test_invalid_transition_blocked(self):
+ q = PriorityOrderQueue()
+ q.push(make_order("O1"))
+ # Cannot go PENDING → IN_TRANSIT directly
+ assert not q.mark_in_transit("O1")
+
+ def test_duplicate_ignored(self):
+ q = PriorityOrderQueue()
+ o = make_order("O1")
+ q.push(o)
+ q.push(o) # duplicate
+ assert len(q) == 1
+
+ def test_pending_count(self):
+ q = PriorityOrderQueue()
+ q.push_many([make_order(f"O{i}") for i in range(5)])
+ assert q.pending_count() == 5
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Issue 5: Agent Registry
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestAgentRegistry:
+ def test_available_filtering(self):
+ agents = [make_agent("A1"), make_agent("A2")]
+ reg = AgentRegistry(agents, max_active=2)
+ reg.assign_order("A1", "O1")
+ reg.assign_order("A1", "O2")
+ avail = [a.agent_id for a in reg.available_agents()]
+ assert "A1" not in avail
+ assert "A2" in avail
+
+ def test_cumulative_increments(self):
+ reg = AgentRegistry([make_agent("A1")], max_active=2)
+ reg.assign_order("A1", "O1")
+ reg.assign_order("A1", "O2")
+ assert reg.get("A1").cumulative_assignments == 2
+
+ def test_complete_order_updates_location(self):
+ reg = AgentRegistry([make_agent("A1")], max_active=2)
+ reg.assign_order("A1", "O1")
+ reg.complete_order("A1", "O1", new_location=(5, 5))
+ assert reg.get("A1").current_location == (5, 5)
+ assert len(reg.get("A1").active_orders) == 0
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Issues 6, 7, 8: Scoring
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestScoring:
+ def test_no_candidates_when_all_full(self, small_graph, constraints):
+ agents = [make_agent("A1"), make_agent("A2")]
+ reg = AgentRegistry(agents, max_active=2)
+ reg.assign_order("A1", "O1"); reg.assign_order("A1", "O2")
+ reg.assign_order("A2", "O3"); reg.assign_order("A2", "O4")
+ order = make_order(lx=0, ly=0)
+ cands = generate_candidates(order, reg, small_graph)
+ assert len(cands) == 0
+
+ def test_unreachable_agents_excluded(self):
+ edges = [{"from": (0, 0), "to": (1, 0), "distance_minutes": 3, "delay_multiplier": 1.0}]
+ graph = EnvironmentGraph(edges)
+ agents = [make_agent("A1", cx=0, cy=0), make_agent("A2", cx=9, cy=9)]
+ reg = AgentRegistry(agents, max_active=2)
+ order = make_order(lx=1, ly=0)
+ cands = generate_candidates(order, reg, graph)
+ assert len(cands) == 1
+ assert cands[0].agent.agent_id == "A1"
+
+ def test_high_priority_gets_better_score(self, small_graph, constraints):
+ agents = [make_agent("A1", cx=0, cy=0), make_agent("A2", cx=0, cy=0)]
+ reg = AgentRegistry(agents, max_active=2)
+ order_h = make_order("OH", "high", lx=1, ly=0)
+ order_l = make_order("OL", "low", lx=1, ly=0)
+ cands_h = generate_candidates(order_h, reg, small_graph)
+ cands_l = generate_candidates(order_l, reg, small_graph)
+ scored_h = score_candidates(cands_h, constraints, reg)[0]
+ scored_l = score_candidates(cands_l, constraints, reg)[0]
+ assert scored_h.score < scored_l.score
+
+ def test_select_best_returns_candidate(self, small_graph, constraints):
+ agents = [make_agent("A1")]
+ reg = AgentRegistry(agents, max_active=2)
+ order = make_order(lx=1, ly=1)
+ best = select_best_candidate(order, reg, small_graph, constraints)
+ assert best is not None
+ assert best.agent.agent_id == "A1"
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Issues 9, 10, 11: Assignment and Delivery Simulation
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestDispatcher:
+ def _make_dispatcher(self, orders, agents, graph, constraints, metrics):
+ q = PriorityOrderQueue()
+ reg = AgentRegistry(agents, max_active=constraints.max_active_orders_per_agent)
+ d = Dispatcher(queue=q, registry=reg, graph=graph,
+ constraints=constraints, metrics=metrics)
+ d.run(orders, tick_minutes=1.0)
+ return d, q, reg
+
+ def test_all_orders_delivered(self, small_graph, constraints):
+ orders = [make_order(f"O{i}", lx=1, ly=1) for i in range(5)]
+ agents = [make_agent(f"A{i}", cx=0, cy=0) for i in range(3)]
+ metrics = MetricsCollector()
+ d, q, reg = self._make_dispatcher(orders, agents, small_graph, constraints, metrics)
+ assert metrics.total_delivered == 5
+ delivered = q.get_by_state("DELIVERED")
+ assert len(delivered) == 5
+
+ def test_no_sla_violations_simple(self, small_graph, constraints):
+ orders = [make_order("O1", priority="high", sla=100, prep=5, lx=1, ly=1)]
+ agents = [make_agent("A1", cx=0, cy=0)]
+ metrics = MetricsCollector()
+ self._make_dispatcher(orders, agents, small_graph, constraints, metrics)
+ assert metrics.sla_violations == 0
+
+ def test_queue_when_no_agents(self, small_graph, constraints):
+ """Issue 11: orders queue when all agents are full."""
+ orders = [make_order(f"O{i}", lx=1, ly=1, prep=30) for i in range(4)]
+ agents = [make_agent("A1", cx=0, cy=0)] # one agent, max 2 active
+ metrics = MetricsCollector()
+ self._make_dispatcher(orders, agents, small_graph, constraints, metrics)
+ # All should eventually get delivered
+ assert metrics.total_delivered == 4
+
+ def test_full_real_dataset(self, real_graph):
+ """Integration test: run against actual CSV data."""
+ orders = load_orders(str(ROOT / "data" / "raw" / "orders.csv"))
+ agents = load_agents(str(ROOT / "data" / "raw" / "agents.csv"))
+ constraints = load_constraints(str(ROOT / "data" / "raw" / "constraints.csv"))
+ metrics = MetricsCollector()
+ q = PriorityOrderQueue()
+ reg = AgentRegistry(agents, max_active=constraints.max_active_orders_per_agent)
+ d = Dispatcher(queue=q, registry=reg, graph=real_graph,
+ constraints=constraints, metrics=metrics)
+ d.run(orders, tick_minutes=1.0)
+ assert metrics.total_delivered == 150
+ assert metrics.sla_compliance_rate == 1.0
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Issues 12, 13, 14: Metrics
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestMetrics:
+ def test_welford_mean(self):
+ rs = RunningStats()
+ for v in [2, 4, 6, 8, 10]:
+ rs.update(v)
+ assert rs.mean == pytest.approx(6.0, abs=1e-9)
+
+ def test_welford_std(self):
+ rs = RunningStats()
+ for v in [2, 4, 4, 4, 5, 5, 7, 9]:
+ rs.update(v)
+ assert rs.std == pytest.approx(math.sqrt(4.0), abs=0.01)
+
+ def test_sla_compliance_rate(self):
+ m = MetricsCollector()
+ m.record_delivery("O1", "high", 10, 50, False)
+ m.record_delivery("O2", "high", 60, 50, True) # violated
+ assert m.sla_compliance_rate == 0.5
+ assert m.sla_violation_rate == 0.5
+
+ def test_fairness_metrics(self):
+ m = MetricsCollector()
+ m.record_assignment("A1")
+ m.record_assignment("A1")
+ m.record_assignment("A2")
+ fm = m._fairness_metrics()
+ assert fm["min_assignments"] == 1
+ assert fm["max_assignments"] == 2
+ assert fm["assignment_range"] == 1
+
+ def test_json_output(self):
+ m = MetricsCollector()
+ m.record_delivery("O1", "high", 12, 50, False)
+ d = m.to_dict()
+ assert "metadata" in d
+ assert "delivery_time" in d
+ assert "sla_compliance" in d
+ assert "workload_fairness" in d
+ # Must be JSON-serialisable
+ json.dumps(d)
+
+ def test_latency_violation_logged(self, caplog):
+ import logging
+ m = MetricsCollector()
+ m.latency_target_ms = 100.0
+ with caplog.at_level(logging.WARNING):
+ m.record_latency_ms(500.0)
+ assert any("latency" in r.message.lower() for r in caplog.records)
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Issues 16, 17: Error Handling / Edge Cases
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestEdgeCases:
+ def test_empty_candidate_list_handled(self, small_graph, constraints):
+ """Issue 17: empty candidate list returns None without crashing."""
+ agents = [make_agent("A1")]
+ reg = AgentRegistry(agents, max_active=2)
+ reg.assign_order("A1", "X1")
+ reg.assign_order("A1", "X2")
+ order = make_order(lx=1, ly=0)
+ result = select_best_candidate(order, reg, small_graph, constraints)
+ assert result is None
+
+ def test_disconnected_location_no_crash(self):
+ """Issue 17: agent at disconnected node handled gracefully."""
+ edges = [{"from": (0, 0), "to": (1, 0), "distance_minutes": 3, "delay_multiplier": 1.0}]
+ graph = EnvironmentGraph(edges)
+ agents = [make_agent("A1", cx=5, cy=5)] # not in graph
+ reg = AgentRegistry(agents, max_active=2)
+ constraints = Constraints()
+ order = make_order(lx=0, ly=0)
+ result = select_best_candidate(order, reg, graph, constraints)
+ assert result is None
+
+ def test_malformed_row_skipped(self, tmp_path):
+ """Issue 16: malformed CSV rows skipped gracefully."""
+ csv = tmp_path / "orders.csv"
+ csv.write_text(
+ "order_id,timestamp,location_x,location_y,prep_time_minutes,priority,sla_minutes\n"
+ "O001,BAD_DATE,1,1,10,high,50\n"
+ "O002,2026-05-03 09:00:00,1,1,10,high,50\n"
+ )
+ orders = load_orders(str(csv))
+ assert len(orders) == 1
+ assert orders[0].order_id == "O002"
+
+ def test_tie_score_deterministic(self, small_graph, constraints):
+ """Issue 17: ties broken deterministically by agent_id."""
+ agents = [make_agent("A2", cx=0, cy=0), make_agent("A1", cx=0, cy=0)]
+ reg = AgentRegistry(agents, max_active=2)
+ order = make_order(lx=1, ly=0)
+ best = select_best_candidate(order, reg, small_graph, constraints)
+ assert best is not None # deterministic selection
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Issue 18: Performance / Latency
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class TestPerformance:
+ def test_graph_query_fast(self, real_graph):
+ """Issue 18: travel_time queries well under 1ms after precompute."""
+ import time
+ t0 = time.perf_counter()
+ for _ in range(1000):
+ real_graph.travel_time((0, 0), (9, 9))
+ elapsed_ms = (time.perf_counter() - t0) * 1000
+ assert elapsed_ms < 50 # 1000 queries in <50ms → <0.05ms each
+
+ def test_assignment_latency(self, real_graph):
+ """Issue 18: single assignment decision under 500ms."""
+ import time
+ agents = load_agents(str(ROOT / "data" / "raw" / "agents.csv"))
+ constraints = load_constraints(str(ROOT / "data" / "raw" / "constraints.csv"))
+ reg = AgentRegistry(agents, max_active=2)
+ order = make_order(lx=5, ly=5)
+ t0 = time.perf_counter()
+ select_best_candidate(order, reg, real_graph, constraints)
+ elapsed_ms = (time.perf_counter() - t0) * 1000
+ assert elapsed_ms < 500