Skip to main content

simular/demos/
tsp_engine.rs

1//! `TspEngine`: `DemoEngine` Implementation for TSP Demos
2//!
3//! Per specification SIMULAR-DEMO-002: This module provides the unified
4//! `DemoEngine` implementation for Traveling Salesman Problem simulations.
5//!
6//! # Architecture
7//!
8//! ```text
9//! YAML Config → TspEngine → DemoEngine trait
10//!                    ↓
11//!              TspState (serializable, PartialEq)
12//!                    ↓
13//!              TUI / WASM (identical states)
14//! ```
15//!
16//! # Key Invariant
17//!
18//! Given same YAML config and seed, TUI and WASM produce identical state sequences.
19
20use super::engine::{
21    CriterionResult, DemoEngine, DemoError, DemoMeta, FalsificationCriterion, MetamorphicRelation,
22    MrResult, Severity,
23};
24use super::tsp_instance::{TspAlgorithmConfig, TspCity, TspMeta};
25use crate::engine::rng::SimRng;
26use serde::{Deserialize, Serialize};
27use std::collections::hash_map::DefaultHasher;
28use std::hash::{Hash, Hasher};
29
30// =============================================================================
31// Configuration Types (loaded from YAML)
32// =============================================================================
33
34/// Simulation type configuration.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SimulationConfig {
37    #[serde(rename = "type")]
38    pub sim_type: String,
39    pub name: String,
40}
41
42/// Complete TSP configuration from YAML (extends `TspInstanceYaml`).
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TspConfig {
45    /// Simulation header for `DemoEngine`.
46    pub simulation: SimulationConfig,
47    /// Instance metadata.
48    pub meta: TspMeta,
49    /// List of cities.
50    pub cities: Vec<TspCity>,
51    /// Distance matrix (n x n).
52    pub matrix: Vec<Vec<u32>>,
53    /// Algorithm configuration.
54    #[serde(default)]
55    pub algorithm: TspAlgorithmConfig,
56}
57
58// =============================================================================
59// State Types (serializable, comparable)
60// =============================================================================
61
62/// TSP state snapshot.
63///
64/// This is THE state that gets compared for TUI/WASM parity.
65/// It MUST be `PartialEq` for the probar tests.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct TspState {
68    /// Current tour (city indices).
69    pub tour: Vec<usize>,
70    /// Current tour length.
71    pub tour_length: u32,
72    /// Best tour found.
73    pub best_tour: Vec<usize>,
74    /// Best tour length.
75    pub best_tour_length: u32,
76    /// Number of restarts completed.
77    pub restart_count: u32,
78    /// Total 2-opt improvements made.
79    pub two_opt_count: u64,
80    /// Step count.
81    pub step_count: u64,
82    /// Is converged (no improvement in N restarts).
83    pub is_converged: bool,
84}
85
86impl TspState {
87    /// Compute hash for quick comparison.
88    #[must_use]
89    pub fn compute_hash(&self) -> u64 {
90        let mut hasher = DefaultHasher::new();
91        self.tour.hash(&mut hasher);
92        self.tour_length.hash(&mut hasher);
93        self.best_tour.hash(&mut hasher);
94        self.best_tour_length.hash(&mut hasher);
95        self.restart_count.hash(&mut hasher);
96        self.step_count.hash(&mut hasher);
97        hasher.finish()
98    }
99}
100
101/// Step result for TSP simulation.
102#[derive(Debug, Clone)]
103pub struct TspStepResult {
104    /// Whether this step improved the solution.
105    pub improved: bool,
106    /// Current tour length.
107    pub tour_length: u32,
108    /// Gap from best known (if available).
109    pub optimality_gap: Option<f64>,
110}
111
112// =============================================================================
113// TspEngine Implementation
114// =============================================================================
115
116/// Unified TSP engine implementing `DemoEngine`.
117///
118/// This provides a proper `DemoEngine` implementation that:
119/// - Loads from YAML
120/// - Produces deterministic states
121/// - Supports TUI/WASM parity verification
122#[derive(Debug, Clone)]
123pub struct TspEngine {
124    /// Configuration from YAML.
125    config: TspConfig,
126    /// Number of cities.
127    n: usize,
128    /// Distance matrix.
129    distances: Vec<Vec<u32>>,
130    /// Current tour.
131    tour: Vec<usize>,
132    /// Current tour length.
133    tour_length: u32,
134    /// Best tour found.
135    best_tour: Vec<usize>,
136    /// Best tour length.
137    best_tour_length: u32,
138    /// RCL size for randomized greedy.
139    rcl_size: usize,
140    /// Number of restarts completed.
141    restart_count: u32,
142    /// Max restarts before completion.
143    max_restarts: u32,
144    /// 2-opt improvements made.
145    two_opt_count: u64,
146    /// Step count.
147    step_count: u64,
148    /// Restarts without improvement (for convergence).
149    stagnation_count: u32,
150    /// RNG for determinism.
151    rng: SimRng,
152    /// Seed for reproducibility.
153    seed: u64,
154    /// Demo metadata.
155    demo_meta: DemoMeta,
156}
157
158impl TspEngine {
159    /// Calculate tour length.
160    fn calculate_tour_length(&self, tour: &[usize]) -> u32 {
161        if tour.is_empty() {
162            return 0;
163        }
164        let mut total = 0u32;
165        for i in 0..tour.len() {
166            let from = tour[i];
167            let to = tour[(i + 1) % tour.len()];
168            total += self.distances[from][to];
169        }
170        total
171    }
172
173    /// Generate random usize in range [0, max).
174    fn gen_range(&mut self, max: usize) -> usize {
175        if max == 0 {
176            return 0;
177        }
178        (self.rng.gen_u64() as usize) % max
179    }
180
181    /// Construct initial tour using randomized greedy.
182    fn construct_greedy_tour(&mut self) -> Vec<usize> {
183        let mut visited = vec![false; self.n];
184        let mut tour = Vec::with_capacity(self.n);
185
186        // Start from random city
187        let start = self.gen_range(self.n);
188        tour.push(start);
189        visited[start] = true;
190
191        while tour.len() < self.n {
192            // Safe: tour is non-empty due to initial push above
193            let current = tour.last().copied().unwrap_or(0);
194
195            // Build RCL of nearest unvisited cities
196            let mut candidates: Vec<(usize, u32)> = (0..self.n)
197                .filter(|&c| !visited[c])
198                .map(|c| (c, self.distances[current][c]))
199                .collect();
200            candidates.sort_by_key(|&(_, d)| d);
201
202            // Select from RCL
203            let rcl_size = self.rcl_size.min(candidates.len());
204            let idx = self.gen_range(rcl_size);
205            let next = candidates[idx].0;
206
207            tour.push(next);
208            visited[next] = true;
209        }
210
211        tour
212    }
213
214    /// Apply 2-opt local search.
215    fn two_opt(&self, tour: &mut [usize]) -> u64 {
216        let mut improvements = 0u64;
217        let mut improved = true;
218
219        while improved {
220            improved = false;
221            for i in 0..self.n - 1 {
222                for j in i + 2..self.n {
223                    // Skip adjacent edges
224                    if j == i + 1 || (i == 0 && j == self.n - 1) {
225                        continue;
226                    }
227
228                    // Calculate improvement
229                    let a = tour[i];
230                    let b = tour[i + 1];
231                    let c = tour[j];
232                    let d = tour[(j + 1) % self.n];
233
234                    let current = self.distances[a][b] + self.distances[c][d];
235                    let swapped = self.distances[a][c] + self.distances[b][d];
236
237                    if swapped < current {
238                        // Reverse segment [i+1, j]
239                        tour[i + 1..=j].reverse();
240                        improved = true;
241                        improvements += 1;
242                    }
243                }
244            }
245        }
246
247        improvements
248    }
249
250    /// Perform one restart iteration.
251    fn do_restart(&mut self) -> bool {
252        // Construct new tour
253        let mut tour = self.construct_greedy_tour();
254
255        // Apply 2-opt
256        let improvements = self.two_opt(&mut tour);
257        self.two_opt_count += improvements;
258
259        // Calculate length
260        let length = self.calculate_tour_length(&tour);
261
262        // Update current
263        self.tour = tour.clone();
264        self.tour_length = length;
265        self.restart_count += 1;
266
267        // Check if improved best
268        let improved = length < self.best_tour_length;
269        if improved {
270            self.best_tour = tour;
271            self.best_tour_length = length;
272            self.stagnation_count = 0;
273        } else {
274            self.stagnation_count += 1;
275        }
276
277        improved
278    }
279}
280
281impl DemoEngine for TspEngine {
282    type Config = TspConfig;
283    type State = TspState;
284    type StepResult = TspStepResult;
285
286    fn from_yaml(yaml: &str) -> Result<Self, DemoError> {
287        let config: TspConfig = serde_yaml::from_str(yaml)?;
288        Ok(Self::from_config(config))
289    }
290
291    fn from_config(config: Self::Config) -> Self {
292        let n = config.cities.len();
293        let distances = config.matrix.clone();
294        let seed = config.algorithm.params.seed;
295        let rcl_size = config.algorithm.params.rcl_size;
296        let max_restarts = config.algorithm.params.restarts as u32;
297
298        let demo_meta = DemoMeta {
299            id: config.meta.id.clone(),
300            version: config.meta.version.clone(),
301            demo_type: "tsp".to_string(),
302            description: config.meta.description.clone(),
303            author: config.meta.source.clone(),
304            created: String::new(),
305        };
306
307        // Initial tour: sequential
308        let tour: Vec<usize> = (0..n).collect();
309        let tour_length = u32::MAX;
310        let best_tour = tour.clone();
311        let best_tour_length = u32::MAX;
312
313        let mut engine = Self {
314            config,
315            n,
316            distances,
317            tour,
318            tour_length,
319            best_tour,
320            best_tour_length,
321            rcl_size,
322            restart_count: 0,
323            max_restarts,
324            two_opt_count: 0,
325            step_count: 0,
326            stagnation_count: 0,
327            rng: SimRng::new(seed),
328            seed,
329            demo_meta,
330        };
331
332        // Do initial construction
333        engine.tour = engine.construct_greedy_tour();
334        let improvements = engine.two_opt(&mut engine.tour.clone());
335        engine.two_opt_count = improvements;
336        engine.tour_length = engine.calculate_tour_length(&engine.tour);
337        engine.best_tour = engine.tour.clone();
338        engine.best_tour_length = engine.tour_length;
339
340        engine
341    }
342
343    fn config(&self) -> &Self::Config {
344        &self.config
345    }
346
347    fn reset(&mut self) {
348        self.reset_with_seed(self.seed);
349    }
350
351    fn reset_with_seed(&mut self, seed: u64) {
352        self.rng = SimRng::new(seed);
353        self.seed = seed;
354        self.restart_count = 0;
355        self.two_opt_count = 0;
356        self.step_count = 0;
357        self.stagnation_count = 0;
358
359        // Re-initialize
360        self.tour = self.construct_greedy_tour();
361        let improvements = self.two_opt(&mut self.tour.clone());
362        self.two_opt_count = improvements;
363        self.tour_length = self.calculate_tour_length(&self.tour);
364        self.best_tour = self.tour.clone();
365        self.best_tour_length = self.tour_length;
366    }
367
368    fn step(&mut self) -> Self::StepResult {
369        self.step_count += 1;
370
371        // Each step is one restart
372        let improved = self.do_restart();
373
374        TspStepResult {
375            improved,
376            tour_length: self.tour_length,
377            optimality_gap: self
378                .config
379                .meta
380                .optimal_known
381                .map(|opt| (f64::from(self.best_tour_length) - f64::from(opt)) / f64::from(opt)),
382        }
383    }
384
385    fn is_complete(&self) -> bool {
386        // Complete if max restarts reached or stagnated
387        self.restart_count >= self.max_restarts || self.stagnation_count >= 20
388    }
389
390    fn state(&self) -> Self::State {
391        TspState {
392            tour: self.tour.clone(),
393            tour_length: self.tour_length,
394            best_tour: self.best_tour.clone(),
395            best_tour_length: self.best_tour_length,
396            restart_count: self.restart_count,
397            two_opt_count: self.two_opt_count,
398            step_count: self.step_count,
399            is_converged: self.is_complete(),
400        }
401    }
402
403    fn restore(&mut self, state: &Self::State) {
404        self.tour.clone_from(&state.tour);
405        self.tour_length = state.tour_length;
406        self.best_tour.clone_from(&state.best_tour);
407        self.best_tour_length = state.best_tour_length;
408        self.restart_count = state.restart_count;
409        self.two_opt_count = state.two_opt_count;
410        self.step_count = state.step_count;
411    }
412
413    fn step_count(&self) -> u64 {
414        self.step_count
415    }
416
417    fn seed(&self) -> u64 {
418        self.seed
419    }
420
421    fn meta(&self) -> &DemoMeta {
422        &self.demo_meta
423    }
424
425    fn falsification_criteria(&self) -> Vec<FalsificationCriterion> {
426        vec![FalsificationCriterion {
427            id: "TSP-VALID-001".to_string(),
428            name: "Valid tour".to_string(),
429            metric: "tour_validity".to_string(),
430            threshold: 1.0,
431            condition: "all cities visited exactly once".to_string(),
432            tolerance: 0.0,
433            severity: Severity::Critical,
434        }]
435    }
436
437    fn evaluate_criteria(&self) -> Vec<CriterionResult> {
438        // Check tour is valid (visits each city exactly once)
439        let mut visited = vec![false; self.n];
440        let mut valid = self.best_tour.len() == self.n;
441        for &city in &self.best_tour {
442            if city >= self.n || visited[city] {
443                valid = false;
444                break;
445            }
446            visited[city] = true;
447        }
448
449        vec![CriterionResult {
450            id: "TSP-VALID-001".to_string(),
451            passed: valid,
452            actual: if valid { 1.0 } else { 0.0 },
453            expected: 1.0,
454            message: if valid {
455                format!("Valid tour of length {}", self.best_tour_length)
456            } else {
457                "Invalid tour".to_string()
458            },
459            severity: Severity::Critical,
460        }]
461    }
462
463    fn metamorphic_relations(&self) -> Vec<MetamorphicRelation> {
464        vec![MetamorphicRelation {
465            id: "MR-PERMUTATION".to_string(),
466            description: "Relabeling cities preserves tour length".to_string(),
467            source_transform: "permute_city_labels".to_string(),
468            expected_relation: "tour_length_unchanged".to_string(),
469            tolerance: 0.0,
470        }]
471    }
472
473    fn verify_mr(&self, mr: &MetamorphicRelation) -> MrResult {
474        match mr.id.as_str() {
475            "MR-PERMUTATION" => {
476                // Verify tour length calculation is consistent
477                let recalculated = self.calculate_tour_length(&self.best_tour);
478                let passed = recalculated == self.best_tour_length;
479                MrResult {
480                    id: mr.id.clone(),
481                    passed,
482                    message: format!(
483                        "Stored: {}, Recalculated: {}",
484                        self.best_tour_length, recalculated
485                    ),
486                    source_value: Some(f64::from(self.best_tour_length)),
487                    followup_value: Some(f64::from(recalculated)),
488                }
489            }
490            _ => MrResult {
491                id: mr.id.clone(),
492                passed: false,
493                message: format!("Unknown metamorphic relation: {}", mr.id),
494                source_value: None,
495                followup_value: None,
496            },
497        }
498    }
499}
500
501// =============================================================================
502// Tests
503// =============================================================================
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    fn make_test_yaml() -> String {
510        r#"
511simulation:
512  type: tsp
513  name: "Test TSP"
514
515meta:
516  id: "TEST-TSP-001"
517  version: "1.0.0"
518  description: "Test TSP instance"
519
520cities:
521  - id: 0
522    name: "A"
523    alias: "A"
524    coords: { lat: 0.0, lon: 0.0 }
525  - id: 1
526    name: "B"
527    alias: "B"
528    coords: { lat: 0.0, lon: 1.0 }
529  - id: 2
530    name: "C"
531    alias: "C"
532    coords: { lat: 1.0, lon: 0.0 }
533  - id: 3
534    name: "D"
535    alias: "D"
536    coords: { lat: 1.0, lon: 1.0 }
537
538matrix:
539  - [0, 1, 1, 2]
540  - [1, 0, 2, 1]
541  - [1, 2, 0, 1]
542  - [2, 1, 1, 0]
543
544algorithm:
545  method: "grasp"
546  params:
547    rcl_size: 2
548    restarts: 10
549    two_opt: true
550    seed: 42
551"#
552        .to_string()
553    }
554
555    #[test]
556    fn test_from_yaml() {
557        let yaml = make_test_yaml();
558        let engine = TspEngine::from_yaml(&yaml);
559        assert!(engine.is_ok(), "Failed to parse YAML: {:?}", engine.err());
560    }
561
562    #[test]
563    fn test_deterministic_state() {
564        let yaml = make_test_yaml();
565        let mut engine1 = TspEngine::from_yaml(&yaml).unwrap();
566        let mut engine2 = TspEngine::from_yaml(&yaml).unwrap();
567
568        for _ in 0..5 {
569            engine1.step();
570            engine2.step();
571        }
572
573        assert_eq!(
574            engine1.state(),
575            engine2.state(),
576            "State divergence detected"
577        );
578    }
579
580    #[test]
581    fn test_reset_replay() {
582        let yaml = make_test_yaml();
583        let mut engine = TspEngine::from_yaml(&yaml).unwrap();
584
585        // Run 5 steps
586        for _ in 0..5 {
587            engine.step();
588        }
589        let state1 = engine.state();
590
591        // Reset and replay
592        engine.reset();
593        for _ in 0..5 {
594            engine.step();
595        }
596        let state2 = engine.state();
597
598        assert_eq!(state1, state2, "Reset did not produce identical replay");
599    }
600
601    #[test]
602    fn test_valid_tour() {
603        let yaml = make_test_yaml();
604        let mut engine = TspEngine::from_yaml(&yaml).unwrap();
605
606        // Run to completion
607        while !engine.is_complete() {
608            engine.step();
609        }
610
611        let results = engine.evaluate_criteria();
612        assert!(!results.is_empty());
613        assert!(results[0].passed, "Tour should be valid");
614    }
615
616    #[test]
617    fn test_tour_improves() {
618        let yaml = make_test_yaml();
619        let mut engine = TspEngine::from_yaml(&yaml).unwrap();
620
621        let initial_length = engine.best_tour_length;
622
623        // Run multiple steps
624        for _ in 0..10 {
625            engine.step();
626        }
627
628        // With GRASP + 2-opt, we should find the optimal tour
629        // For this 4-city instance, optimal is 4 (square path)
630        assert!(
631            engine.best_tour_length <= initial_length,
632            "Tour should improve or stay same"
633        );
634    }
635
636    #[test]
637    fn test_step_count() {
638        let yaml = make_test_yaml();
639        let mut engine = TspEngine::from_yaml(&yaml).unwrap();
640
641        assert_eq!(engine.step_count(), 0);
642        engine.step();
643        assert_eq!(engine.step_count(), 1);
644    }
645
646    #[test]
647    fn test_seed() {
648        let yaml = make_test_yaml();
649        let engine = TspEngine::from_yaml(&yaml).unwrap();
650        assert_eq!(engine.seed(), 42);
651    }
652
653    #[test]
654    fn test_meta() {
655        let yaml = make_test_yaml();
656        let engine = TspEngine::from_yaml(&yaml).unwrap();
657        assert_eq!(engine.meta().id, "TEST-TSP-001");
658        assert_eq!(engine.meta().demo_type, "tsp");
659    }
660
661    #[test]
662    fn test_state_hash() {
663        let yaml = make_test_yaml();
664        let mut engine = TspEngine::from_yaml(&yaml).unwrap();
665        engine.step();
666
667        let state = engine.state();
668        let hash1 = state.compute_hash();
669        let hash2 = state.compute_hash();
670
671        assert_eq!(hash1, hash2, "Hash should be deterministic");
672    }
673
674    #[test]
675    fn test_is_complete() {
676        let yaml = make_test_yaml();
677        let mut engine = TspEngine::from_yaml(&yaml).unwrap();
678
679        // Run until complete
680        while !engine.is_complete() {
681            engine.step();
682        }
683
684        assert!(engine.is_complete());
685    }
686}