Skip to main content

simular/demos/
tsp_instance.rs

1//! TSP Instance YAML Configuration
2//!
3//! YAML-first architecture for TSP instances. Users can download, modify, and re-run
4//! experiments without touching code.
5//!
6//! # Example YAML
7//!
8//! ```yaml
9//! meta:
10//!   id: "TSP-BAY-006"
11//!   version: "1.0.0"
12//!   description: "6-city Bay Area ground truth instance"
13//!   source: "Google Maps (Dec 2024)"
14//!   units: "miles"
15//!   optimal_known: 115
16//!
17//! cities:
18//!   - id: 0
19//!     name: "San Francisco"
20//!     alias: "SF"
21//!     coords: { lat: 37.7749, lon: -122.4194 }
22//!
23//! matrix:
24//!   - [0, 12, 48]
25//!   - [12, 0, 42]
26//!   - [48, 42, 0]
27//!
28//! algorithm:
29//!   method: "grasp"
30//!   params:
31//!     rcl_size: 3
32//!     restarts: 10
33//!     two_opt: true
34//!     seed: 42
35//! ```
36
37use serde::{Deserialize, Serialize};
38
39/// Geographic coordinates (lat/lon).
40#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
41pub struct Coords {
42    /// Latitude in degrees.
43    pub lat: f64,
44    /// Longitude in degrees.
45    pub lon: f64,
46}
47
48impl Default for Coords {
49    fn default() -> Self {
50        Self { lat: 0.0, lon: 0.0 }
51    }
52}
53
54/// A city in the TSP instance.
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub struct TspCity {
57    /// Unique city identifier (0-indexed).
58    pub id: usize,
59    /// Full city name.
60    pub name: String,
61    /// Short alias (2-4 chars).
62    pub alias: String,
63    /// Geographic coordinates.
64    pub coords: Coords,
65}
66
67impl TspCity {
68    /// Create a new city.
69    #[must_use]
70    pub fn new(
71        id: usize,
72        name: impl Into<String>,
73        alias: impl Into<String>,
74        lat: f64,
75        lon: f64,
76    ) -> Self {
77        Self {
78            id,
79            name: name.into(),
80            alias: alias.into(),
81            coords: Coords { lat, lon },
82        }
83    }
84}
85
86/// Metadata about the TSP instance.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct TspMeta {
89    /// Unique instance identifier.
90    pub id: String,
91    /// Version string (semver).
92    #[serde(default = "default_version")]
93    pub version: String,
94    /// Human-readable description.
95    pub description: String,
96    /// Data source (e.g., "Google Maps").
97    #[serde(default)]
98    pub source: String,
99    /// Distance units (e.g., "miles", "km").
100    #[serde(default = "default_units")]
101    pub units: String,
102    /// Known optimal solution (for verification).
103    #[serde(default)]
104    pub optimal_known: Option<u32>,
105}
106
107fn default_version() -> String {
108    "1.0.0".to_string()
109}
110
111fn default_units() -> String {
112    "miles".to_string()
113}
114
115impl Default for TspMeta {
116    fn default() -> Self {
117        Self {
118            id: "TSP-UNNAMED".to_string(),
119            version: default_version(),
120            description: "Unnamed TSP instance".to_string(),
121            source: String::new(),
122            units: default_units(),
123            optimal_known: None,
124        }
125    }
126}
127
128/// Algorithm parameters for TSP solving.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct TspParams {
131    /// RCL size for GRASP (restricted candidate list).
132    #[serde(default = "default_rcl_size")]
133    pub rcl_size: usize,
134    /// Number of GRASP restarts.
135    #[serde(default = "default_restarts")]
136    pub restarts: usize,
137    /// Enable 2-opt local search.
138    #[serde(default = "default_two_opt")]
139    pub two_opt: bool,
140    /// Random seed for reproducibility.
141    #[serde(default = "default_seed")]
142    pub seed: u64,
143}
144
145fn default_rcl_size() -> usize {
146    3
147}
148
149fn default_restarts() -> usize {
150    10
151}
152
153fn default_two_opt() -> bool {
154    true
155}
156
157fn default_seed() -> u64 {
158    42
159}
160
161impl Default for TspParams {
162    fn default() -> Self {
163        Self {
164            rcl_size: default_rcl_size(),
165            restarts: default_restarts(),
166            two_opt: default_two_opt(),
167            seed: default_seed(),
168        }
169    }
170}
171
172/// Algorithm configuration.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct TspAlgorithmConfig {
175    /// Algorithm method: `greedy`, `grasp`, or `brute_force`.
176    #[serde(default = "default_method")]
177    pub method: String,
178    /// Algorithm-specific parameters.
179    #[serde(default)]
180    pub params: TspParams,
181}
182
183fn default_method() -> String {
184    "grasp".to_string()
185}
186
187impl Default for TspAlgorithmConfig {
188    fn default() -> Self {
189        Self {
190            method: default_method(),
191            params: TspParams::default(),
192        }
193    }
194}
195
196/// Complete TSP instance configuration (YAML-first).
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198pub struct TspInstanceYaml {
199    /// Instance metadata.
200    pub meta: TspMeta,
201    /// List of cities.
202    pub cities: Vec<TspCity>,
203    /// Distance matrix (n x n).
204    pub matrix: Vec<Vec<u32>>,
205    /// Algorithm configuration.
206    #[serde(default)]
207    pub algorithm: TspAlgorithmConfig,
208}
209
210/// Error types for TSP instance parsing/validation.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum TspInstanceError {
213    /// YAML parsing failed.
214    ParseError(String),
215    /// Matrix dimensions don't match city count.
216    MatrixDimensionMismatch { expected: usize, got_rows: usize },
217    /// Matrix row has wrong number of columns.
218    MatrixRowMismatch {
219        row: usize,
220        expected: usize,
221        got: usize,
222    },
223    /// Triangle inequality violated.
224    TriangleInequalityViolation { i: usize, j: usize, k: usize },
225    /// Matrix is not symmetric.
226    AsymmetricMatrix {
227        i: usize,
228        j: usize,
229        forward: u32,
230        backward: u32,
231    },
232    /// Invalid city ID.
233    InvalidCityId { id: usize, max: usize },
234    /// IO error.
235    IoError(String),
236}
237
238impl std::fmt::Display for TspInstanceError {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        match self {
241            Self::ParseError(msg) => write!(f, "YAML parse error: {msg}"),
242            Self::MatrixDimensionMismatch { expected, got_rows } => {
243                write!(f, "Matrix dimension mismatch: expected {expected}x{expected}, got {got_rows} rows")
244            }
245            Self::MatrixRowMismatch { row, expected, got } => {
246                write!(f, "Matrix row {row} has {got} columns, expected {expected}")
247            }
248            Self::TriangleInequalityViolation { i, j, k } => {
249                write!(
250                    f,
251                    "Triangle inequality violated: d({i},{k}) > d({i},{j}) + d({j},{k})"
252                )
253            }
254            Self::AsymmetricMatrix {
255                i,
256                j,
257                forward,
258                backward,
259            } => {
260                write!(
261                    f,
262                    "Asymmetric matrix: d({i},{j})={forward} != d({j},{i})={backward}"
263                )
264            }
265            Self::InvalidCityId { id, max } => {
266                write!(f, "Invalid city ID {id}, max is {max}")
267            }
268            Self::IoError(msg) => write!(f, "IO error: {msg}"),
269        }
270    }
271}
272
273impl std::error::Error for TspInstanceError {}
274
275impl TspInstanceYaml {
276    /// Parse TSP instance from YAML string.
277    ///
278    /// # Errors
279    ///
280    /// Returns `TspInstanceError::ParseError` if YAML is invalid.
281    pub fn from_yaml(yaml: &str) -> Result<Self, TspInstanceError> {
282        serde_yaml::from_str(yaml).map_err(|e| TspInstanceError::ParseError(e.to_string()))
283    }
284
285    /// Load TSP instance from a YAML file.
286    ///
287    /// # Errors
288    ///
289    /// Returns error if file cannot be read or YAML is invalid.
290    pub fn from_yaml_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self, TspInstanceError> {
291        let content =
292            std::fs::read_to_string(path).map_err(|e| TspInstanceError::IoError(e.to_string()))?;
293        Self::from_yaml(&content)
294    }
295
296    /// Serialize to YAML string.
297    ///
298    /// # Errors
299    ///
300    /// Returns error if serialization fails.
301    pub fn to_yaml(&self) -> Result<String, TspInstanceError> {
302        serde_yaml::to_string(self).map_err(|e| TspInstanceError::ParseError(e.to_string()))
303    }
304
305    /// Number of cities in the instance.
306    #[must_use]
307    pub fn city_count(&self) -> usize {
308        self.cities.len()
309    }
310
311    /// Get distance between two cities.
312    ///
313    /// # Panics
314    ///
315    /// Panics if city indices are out of bounds.
316    #[must_use]
317    pub fn distance(&self, from: usize, to: usize) -> u32 {
318        self.matrix[from][to]
319    }
320
321    /// Validate the instance (Jidoka).
322    ///
323    /// # Errors
324    ///
325    /// Returns error if validation fails.
326    pub fn validate(&self) -> Result<(), TspInstanceError> {
327        let n = self.cities.len();
328
329        // Check matrix dimensions
330        if self.matrix.len() != n {
331            return Err(TspInstanceError::MatrixDimensionMismatch {
332                expected: n,
333                got_rows: self.matrix.len(),
334            });
335        }
336
337        // Check each row has correct length
338        for (i, row) in self.matrix.iter().enumerate() {
339            if row.len() != n {
340                return Err(TspInstanceError::MatrixRowMismatch {
341                    row: i,
342                    expected: n,
343                    got: row.len(),
344                });
345            }
346        }
347
348        // Check city IDs are valid
349        for city in &self.cities {
350            if city.id >= n {
351                return Err(TspInstanceError::InvalidCityId {
352                    id: city.id,
353                    max: n - 1,
354                });
355            }
356        }
357
358        Ok(())
359    }
360
361    /// Check if matrix is symmetric.
362    ///
363    /// # Errors
364    ///
365    /// Returns error with first asymmetry found.
366    pub fn check_symmetry(&self) -> Result<(), TspInstanceError> {
367        let n = self.cities.len();
368        for i in 0..n {
369            for j in (i + 1)..n {
370                let forward = self.matrix[i][j];
371                let backward = self.matrix[j][i];
372                if forward != backward {
373                    return Err(TspInstanceError::AsymmetricMatrix {
374                        i,
375                        j,
376                        forward,
377                        backward,
378                    });
379                }
380            }
381        }
382        Ok(())
383    }
384
385    /// Check triangle inequality for all city triples.
386    ///
387    /// # Errors
388    ///
389    /// Returns error with first violation found.
390    pub fn check_triangle_inequality(&self) -> Result<(), TspInstanceError> {
391        let n = self.cities.len();
392        for i in 0..n {
393            for j in 0..n {
394                for k in 0..n {
395                    if i != j && j != k && i != k {
396                        let direct = self.matrix[i][k];
397                        let via_j = self.matrix[i][j].saturating_add(self.matrix[j][k]);
398                        if direct > via_j {
399                            return Err(TspInstanceError::TriangleInequalityViolation { i, j, k });
400                        }
401                    }
402                }
403            }
404        }
405        Ok(())
406    }
407
408    /// Compute tour length for given city sequence.
409    ///
410    /// # Arguments
411    ///
412    /// * `tour` - Sequence of city indices (must visit each city once)
413    ///
414    /// # Returns
415    ///
416    /// Total tour length including return to start.
417    #[must_use]
418    pub fn tour_length(&self, tour: &[usize]) -> u32 {
419        if tour.is_empty() {
420            return 0;
421        }
422        let mut total = 0u32;
423        for i in 0..tour.len() {
424            let from = tour[i];
425            let to = tour[(i + 1) % tour.len()];
426            total = total.saturating_add(self.matrix[from][to]);
427        }
428        total
429    }
430
431    /// Compute tour length with step-by-step verification (Genchi Genbutsu).
432    ///
433    /// # Returns
434    ///
435    /// Tuple of (`total_length`, `step_descriptions`).
436    #[must_use]
437    pub fn tour_length_verified(&self, tour: &[usize]) -> (u32, Vec<String>) {
438        let mut total = 0u32;
439        let mut steps = Vec::new();
440
441        for i in 0..tour.len() {
442            let from = tour[i];
443            let to = tour[(i + 1) % tour.len()];
444            let dist = self.matrix[from][to];
445            total = total.saturating_add(dist);
446
447            let from_name = self.cities.get(from).map_or("?", |c| &c.name);
448            let to_name = self.cities.get(to).map_or("?", |c| &c.name);
449            steps.push(format!(
450                "Step {}: {} → {} = {} {} (total: {})",
451                i + 1,
452                from_name,
453                to_name,
454                dist,
455                self.meta.units,
456                total
457            ));
458        }
459
460        (total, steps)
461    }
462}
463
464// =============================================================================
465// WASM Bindings (OR-001-06)
466// =============================================================================
467
468#[cfg(feature = "wasm")]
469mod wasm {
470    use super::TspInstanceYaml;
471    use wasm_bindgen::prelude::*;
472
473    /// WASM-exported TSP instance for JavaScript/TypeScript.
474    ///
475    /// Provides YAML-first configuration for TSP instances accessible from
476    /// web browsers via WebAssembly.
477    ///
478    /// # JavaScript Example
479    ///
480    /// ```javascript
481    /// import { TspWasmInstance } from 'simular';
482    ///
483    /// const yaml = `
484    /// meta:
485    ///   id: "MY-TSP"
486    ///   description: "Custom instance"
487    /// cities:
488    ///   - id: 0
489    ///     name: "A"
490    ///     alias: "A"
491    ///     coords: { lat: 0.0, lon: 0.0 }
492    /// matrix:
493    ///   - [0]
494    /// `;
495    ///
496    /// const instance = TspWasmInstance.from_yaml(yaml);
497    /// console.log(instance.city_count());
498    /// ```
499    #[wasm_bindgen]
500    pub struct TspWasmInstance {
501        inner: TspInstanceYaml,
502    }
503
504    // WASM exports don't need #[must_use] - values returned to JS
505    #[allow(clippy::must_use_candidate)]
506    #[wasm_bindgen]
507    impl TspWasmInstance {
508        /// Parse a TSP instance from YAML string.
509        ///
510        /// # Errors
511        ///
512        /// Returns error string if YAML is invalid or fails validation.
513        #[wasm_bindgen(js_name = fromYaml)]
514        pub fn from_yaml(yaml: &str) -> Result<Self, String> {
515            let inner = TspInstanceYaml::from_yaml(yaml).map_err(|e| e.to_string())?;
516            Ok(Self { inner })
517        }
518
519        /// Serialize the instance back to YAML.
520        #[wasm_bindgen(js_name = toYaml)]
521        pub fn to_yaml(&self) -> Result<String, String> {
522            self.inner.to_yaml().map_err(|e| e.to_string())
523        }
524
525        /// Validate the instance (Jidoka).
526        ///
527        /// Checks matrix dimensions, city IDs, symmetry, and triangle inequality.
528        #[wasm_bindgen]
529        pub fn validate(&self) -> Result<(), String> {
530            self.inner.validate().map_err(|e| e.to_string())
531        }
532
533        /// Check matrix symmetry.
534        #[wasm_bindgen(js_name = checkSymmetry)]
535        pub fn check_symmetry(&self) -> Result<(), String> {
536            self.inner.check_symmetry().map_err(|e| e.to_string())
537        }
538
539        /// Check triangle inequality.
540        #[wasm_bindgen(js_name = checkTriangleInequality)]
541        pub fn check_triangle_inequality(&self) -> Result<(), String> {
542            self.inner
543                .check_triangle_inequality()
544                .map_err(|e| e.to_string())
545        }
546
547        /// Get the number of cities.
548        #[wasm_bindgen(js_name = cityCount)]
549        pub fn city_count(&self) -> usize {
550            self.inner.city_count()
551        }
552
553        /// Get distance between two cities.
554        #[wasm_bindgen]
555        pub fn distance(&self, from: usize, to: usize) -> u32 {
556            self.inner.distance(from, to)
557        }
558
559        /// Compute tour length for a given tour (city indices).
560        ///
561        /// Tour is specified as a JavaScript array of city indices.
562        #[wasm_bindgen(js_name = tourLength)]
563        pub fn tour_length(&self, tour: &[usize]) -> u32 {
564            self.inner.tour_length(tour)
565        }
566
567        /// Get instance ID.
568        #[wasm_bindgen(js_name = getId)]
569        pub fn get_id(&self) -> String {
570            self.inner.meta.id.clone()
571        }
572
573        /// Get instance description.
574        #[wasm_bindgen(js_name = getDescription)]
575        pub fn get_description(&self) -> String {
576            self.inner.meta.description.clone()
577        }
578
579        /// Get units (e.g., "miles", "km").
580        #[wasm_bindgen(js_name = getUnits)]
581        pub fn get_units(&self) -> String {
582            self.inner.meta.units.clone()
583        }
584
585        /// Get known optimal value (if any).
586        #[wasm_bindgen(js_name = getOptimalKnown)]
587        pub fn get_optimal_known(&self) -> Option<u32> {
588            self.inner.meta.optimal_known
589        }
590
591        /// Get algorithm method.
592        #[wasm_bindgen(js_name = getAlgorithmMethod)]
593        pub fn get_algorithm_method(&self) -> String {
594            self.inner.algorithm.method.clone()
595        }
596
597        /// Get algorithm seed.
598        #[wasm_bindgen(js_name = getSeed)]
599        pub fn get_seed(&self) -> u64 {
600            self.inner.algorithm.params.seed
601        }
602
603        /// Get RCL size parameter.
604        #[wasm_bindgen(js_name = getRclSize)]
605        pub fn get_rcl_size(&self) -> usize {
606            self.inner.algorithm.params.rcl_size
607        }
608
609        /// Get number of restarts.
610        #[wasm_bindgen(js_name = getRestarts)]
611        pub fn get_restarts(&self) -> usize {
612            self.inner.algorithm.params.restarts
613        }
614
615        /// Check if 2-opt is enabled.
616        #[wasm_bindgen(js_name = getTwoOptEnabled)]
617        pub fn get_two_opt_enabled(&self) -> bool {
618            self.inner.algorithm.params.two_opt
619        }
620
621        /// Get city names as JSON array.
622        #[wasm_bindgen(js_name = getCityNamesJson)]
623        pub fn get_city_names_json(&self) -> String {
624            let names: Vec<&str> = self.inner.cities.iter().map(|c| c.name.as_str()).collect();
625            serde_json::to_string(&names).unwrap_or_else(|_| "[]".to_string())
626        }
627
628        /// Get city aliases as JSON array.
629        #[wasm_bindgen(js_name = getCityAliasesJson)]
630        pub fn get_city_aliases_json(&self) -> String {
631            let aliases: Vec<&str> = self.inner.cities.iter().map(|c| c.alias.as_str()).collect();
632            serde_json::to_string(&aliases).unwrap_or_else(|_| "[]".to_string())
633        }
634
635        /// Get distance matrix as JSON (2D array).
636        #[wasm_bindgen(js_name = getMatrixJson)]
637        pub fn get_matrix_json(&self) -> String {
638            serde_json::to_string(&self.inner.matrix).unwrap_or_else(|_| "[]".to_string())
639        }
640
641        /// Get city coordinates as JSON array of {lat, lon} objects.
642        #[wasm_bindgen(js_name = getCityCoordsJson)]
643        pub fn get_city_coords_json(&self) -> String {
644            let coords: Vec<_> = self.inner.cities.iter().map(|c| &c.coords).collect();
645            serde_json::to_string(&coords).unwrap_or_else(|_| "[]".to_string())
646        }
647
648        /// Compute tour length with step-by-step verification.
649        ///
650        /// Returns JSON: `{"length": 115, "steps": ["Step 1: SF → OAK = 12 miles (total: 12)", ...]}`
651        #[wasm_bindgen(js_name = tourLengthVerifiedJson)]
652        pub fn tour_length_verified_json(&self, tour: &[usize]) -> String {
653            let (length, steps) = self.inner.tour_length_verified(tour);
654            serde_json::json!({
655                "length": length,
656                "steps": steps
657            })
658            .to_string()
659        }
660
661        /// Get full instance as JSON.
662        #[wasm_bindgen(js_name = toJson)]
663        pub fn to_json(&self) -> String {
664            serde_json::to_string(&self.inner).unwrap_or_else(|_| "{}".to_string())
665        }
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    const MINIMAL_YAML: &str = r#"
674meta:
675  id: "TEST-001"
676  description: "Minimal test instance"
677cities:
678  - id: 0
679    name: "City A"
680    alias: "A"
681    coords: { lat: 0.0, lon: 0.0 }
682  - id: 1
683    name: "City B"
684    alias: "B"
685    coords: { lat: 1.0, lon: 1.0 }
686matrix:
687  - [0, 10]
688  - [10, 0]
689"#;
690
691    const BAY_AREA_YAML: &str = r#"
692meta:
693  id: "TSP-BAY-006"
694  version: "1.0.0"
695  description: "6-city Bay Area ground truth instance"
696  source: "Google Maps (Dec 2024)"
697  units: "miles"
698  optimal_known: 115
699cities:
700  - id: 0
701    name: "San Francisco"
702    alias: "SF"
703    coords: { lat: 37.7749, lon: -122.4194 }
704  - id: 1
705    name: "Oakland"
706    alias: "OAK"
707    coords: { lat: 37.8044, lon: -122.2712 }
708  - id: 2
709    name: "San Jose"
710    alias: "SJ"
711    coords: { lat: 37.3382, lon: -121.8863 }
712  - id: 3
713    name: "Palo Alto"
714    alias: "PA"
715    coords: { lat: 37.4419, lon: -122.1430 }
716  - id: 4
717    name: "Berkeley"
718    alias: "BRK"
719    coords: { lat: 37.8716, lon: -122.2727 }
720  - id: 5
721    name: "Fremont"
722    alias: "FRE"
723    coords: { lat: 37.5485, lon: -121.9886 }
724matrix:
725  - [ 0, 12, 48, 35, 14, 42]
726  - [12,  0, 42, 30,  4, 30]
727  - [48, 42,  0, 15, 46, 17]
728  - [35, 30, 15,  0, 32, 18]
729  - [14,  4, 46, 32,  0, 32]
730  - [42, 30, 17, 18, 32,  0]
731algorithm:
732  method: "grasp"
733  params:
734    rcl_size: 3
735    restarts: 10
736    two_opt: true
737    seed: 42
738"#;
739
740    // =========================================================================
741    // OR-001-01: TspInstanceYaml struct + serde tests
742    // =========================================================================
743
744    #[test]
745    fn test_deserialize_valid_yaml() {
746        let instance = TspInstanceYaml::from_yaml(BAY_AREA_YAML).expect("Should parse valid YAML");
747        assert_eq!(instance.meta.id, "TSP-BAY-006");
748        assert_eq!(instance.cities.len(), 6);
749        assert_eq!(instance.matrix.len(), 6);
750        assert_eq!(instance.algorithm.method, "grasp");
751    }
752
753    #[test]
754    fn test_deserialize_minimal_yaml() {
755        let instance = TspInstanceYaml::from_yaml(MINIMAL_YAML).expect("Should parse minimal YAML");
756        assert_eq!(instance.meta.id, "TEST-001");
757        assert_eq!(instance.cities.len(), 2);
758        assert_eq!(instance.matrix.len(), 2);
759        // Defaults should apply
760        assert_eq!(instance.algorithm.method, "grasp");
761        assert_eq!(instance.algorithm.params.rcl_size, 3);
762    }
763
764    #[test]
765    fn test_deserialize_invalid_yaml() {
766        let invalid = "this is not valid yaml: [[[";
767        let result = TspInstanceYaml::from_yaml(invalid);
768        assert!(result.is_err());
769        if let Err(TspInstanceError::ParseError(msg)) = result {
770            assert!(!msg.is_empty());
771        } else {
772            panic!("Expected ParseError");
773        }
774    }
775
776    #[test]
777    fn test_deserialize_missing_required_fields() {
778        let missing_cities = r#"
779meta:
780  id: "TEST"
781  description: "No cities"
782matrix: []
783"#;
784        let result = TspInstanceYaml::from_yaml(missing_cities);
785        assert!(result.is_err());
786    }
787
788    #[test]
789    fn test_serialize_roundtrip() {
790        let original = TspInstanceYaml::from_yaml(BAY_AREA_YAML).expect("Parse");
791        let yaml = original.to_yaml().expect("Serialize");
792        let restored = TspInstanceYaml::from_yaml(&yaml).expect("Reparse");
793        assert_eq!(original.meta.id, restored.meta.id);
794        assert_eq!(original.cities.len(), restored.cities.len());
795        assert_eq!(original.matrix, restored.matrix);
796    }
797
798    #[test]
799    fn test_default_algorithm_params() {
800        let params = TspParams::default();
801        assert_eq!(params.rcl_size, 3);
802        assert_eq!(params.restarts, 10);
803        assert!(params.two_opt);
804        assert_eq!(params.seed, 42);
805    }
806
807    #[test]
808    fn test_default_meta() {
809        let meta = TspMeta::default();
810        assert_eq!(meta.id, "TSP-UNNAMED");
811        assert_eq!(meta.version, "1.0.0");
812        assert_eq!(meta.units, "miles");
813        assert!(meta.optimal_known.is_none());
814    }
815
816    #[test]
817    fn test_default_algorithm_config() {
818        let config = TspAlgorithmConfig::default();
819        assert_eq!(config.method, "grasp");
820        assert_eq!(config.params.rcl_size, 3);
821    }
822
823    #[test]
824    fn test_coords_default() {
825        let coords = Coords::default();
826        assert_eq!(coords.lat, 0.0);
827        assert_eq!(coords.lon, 0.0);
828    }
829
830    #[test]
831    fn test_tsp_city_new() {
832        let city = TspCity::new(0, "San Francisco", "SF", 37.7749, -122.4194);
833        assert_eq!(city.id, 0);
834        assert_eq!(city.name, "San Francisco");
835        assert_eq!(city.alias, "SF");
836        assert!((city.coords.lat - 37.7749).abs() < 0.0001);
837    }
838
839    // =========================================================================
840    // Validation tests (Jidoka)
841    // =========================================================================
842
843    #[test]
844    fn test_validate_valid_instance() {
845        let instance = TspInstanceYaml::from_yaml(BAY_AREA_YAML).expect("Parse");
846        assert!(instance.validate().is_ok());
847    }
848
849    #[test]
850    fn test_validate_matrix_dimension_mismatch() {
851        let yaml = r#"
852meta:
853  id: "TEST"
854  description: "Bad matrix"
855cities:
856  - id: 0
857    name: "A"
858    alias: "A"
859    coords: { lat: 0.0, lon: 0.0 }
860  - id: 1
861    name: "B"
862    alias: "B"
863    coords: { lat: 1.0, lon: 1.0 }
864matrix:
865  - [0, 10, 20]
866  - [10, 0, 30]
867  - [20, 30, 0]
868"#;
869        let instance = TspInstanceYaml::from_yaml(yaml).expect("Parse");
870        let result = instance.validate();
871        assert!(matches!(
872            result,
873            Err(TspInstanceError::MatrixDimensionMismatch { .. })
874        ));
875    }
876
877    #[test]
878    fn test_validate_matrix_row_mismatch() {
879        let yaml = r#"
880meta:
881  id: "TEST"
882  description: "Bad row"
883cities:
884  - id: 0
885    name: "A"
886    alias: "A"
887    coords: { lat: 0.0, lon: 0.0 }
888  - id: 1
889    name: "B"
890    alias: "B"
891    coords: { lat: 1.0, lon: 1.0 }
892matrix:
893  - [0, 10]
894  - [10]
895"#;
896        let instance = TspInstanceYaml::from_yaml(yaml).expect("Parse");
897        let result = instance.validate();
898        assert!(matches!(
899            result,
900            Err(TspInstanceError::MatrixRowMismatch { .. })
901        ));
902    }
903
904    #[test]
905    fn test_validate_invalid_city_id() {
906        let yaml = r#"
907meta:
908  id: "TEST"
909  description: "Bad city ID"
910cities:
911  - id: 5
912    name: "A"
913    alias: "A"
914    coords: { lat: 0.0, lon: 0.0 }
915  - id: 1
916    name: "B"
917    alias: "B"
918    coords: { lat: 1.0, lon: 1.0 }
919matrix:
920  - [0, 10]
921  - [10, 0]
922"#;
923        let instance = TspInstanceYaml::from_yaml(yaml).expect("Parse");
924        let result = instance.validate();
925        assert!(matches!(
926            result,
927            Err(TspInstanceError::InvalidCityId { .. })
928        ));
929    }
930
931    #[test]
932    fn test_check_symmetry_valid() {
933        let instance = TspInstanceYaml::from_yaml(BAY_AREA_YAML).expect("Parse");
934        assert!(instance.check_symmetry().is_ok());
935    }
936
937    #[test]
938    fn test_check_symmetry_invalid() {
939        let yaml = r#"
940meta:
941  id: "TEST"
942  description: "Asymmetric"
943cities:
944  - id: 0
945    name: "A"
946    alias: "A"
947    coords: { lat: 0.0, lon: 0.0 }
948  - id: 1
949    name: "B"
950    alias: "B"
951    coords: { lat: 1.0, lon: 1.0 }
952matrix:
953  - [0, 10]
954  - [20, 0]
955"#;
956        let instance = TspInstanceYaml::from_yaml(yaml).expect("Parse");
957        let result = instance.check_symmetry();
958        assert!(matches!(
959            result,
960            Err(TspInstanceError::AsymmetricMatrix { .. })
961        ));
962    }
963
964    #[test]
965    fn test_check_triangle_inequality_valid() {
966        let instance = TspInstanceYaml::from_yaml(BAY_AREA_YAML).expect("Parse");
967        assert!(instance.check_triangle_inequality().is_ok());
968    }
969
970    #[test]
971    fn test_check_triangle_inequality_violation() {
972        // d(0,2) = 100 > d(0,1) + d(1,2) = 10 + 10 = 20
973        let yaml = r#"
974meta:
975  id: "TEST"
976  description: "Triangle violation"
977cities:
978  - id: 0
979    name: "A"
980    alias: "A"
981    coords: { lat: 0.0, lon: 0.0 }
982  - id: 1
983    name: "B"
984    alias: "B"
985    coords: { lat: 1.0, lon: 1.0 }
986  - id: 2
987    name: "C"
988    alias: "C"
989    coords: { lat: 2.0, lon: 2.0 }
990matrix:
991  - [0, 10, 100]
992  - [10, 0, 10]
993  - [100, 10, 0]
994"#;
995        let instance = TspInstanceYaml::from_yaml(yaml).expect("Parse");
996        let result = instance.check_triangle_inequality();
997        assert!(matches!(
998            result,
999            Err(TspInstanceError::TriangleInequalityViolation { .. })
1000        ));
1001    }
1002
1003    // =========================================================================
1004    // Tour length tests
1005    // =========================================================================
1006
1007    #[test]
1008    fn test_tour_length_bay_area_optimal() {
1009        let instance = TspInstanceYaml::from_yaml(BAY_AREA_YAML).expect("Parse");
1010        // Optimal tour: SF(0) → OAK(1) → BRK(4) → FRE(5) → SJ(2) → PA(3) → SF(0)
1011        let tour = vec![0, 1, 4, 5, 2, 3];
1012        let length = instance.tour_length(&tour);
1013        // 12 + 4 + 32 + 17 + 15 + 35 = 115
1014        assert_eq!(length, 115);
1015    }
1016
1017    #[test]
1018    fn test_tour_length_empty() {
1019        let instance = TspInstanceYaml::from_yaml(MINIMAL_YAML).expect("Parse");
1020        let length = instance.tour_length(&[]);
1021        assert_eq!(length, 0);
1022    }
1023
1024    #[test]
1025    fn test_tour_length_single_city() {
1026        let instance = TspInstanceYaml::from_yaml(MINIMAL_YAML).expect("Parse");
1027        let length = instance.tour_length(&[0]);
1028        assert_eq!(length, 0); // d(0,0) = 0
1029    }
1030
1031    #[test]
1032    fn test_tour_length_verified() {
1033        let instance = TspInstanceYaml::from_yaml(BAY_AREA_YAML).expect("Parse");
1034        let tour = vec![0, 1, 4, 5, 2, 3];
1035        let (length, steps) = instance.tour_length_verified(&tour);
1036        assert_eq!(length, 115);
1037        assert_eq!(steps.len(), 6);
1038        assert!(steps[0].contains("San Francisco"));
1039        assert!(steps[0].contains("Oakland"));
1040        assert!(steps[0].contains("12"));
1041    }
1042
1043    // =========================================================================
1044    // Accessor tests
1045    // =========================================================================
1046
1047    #[test]
1048    fn test_city_count() {
1049        let instance = TspInstanceYaml::from_yaml(BAY_AREA_YAML).expect("Parse");
1050        assert_eq!(instance.city_count(), 6);
1051    }
1052
1053    #[test]
1054    fn test_distance() {
1055        let instance = TspInstanceYaml::from_yaml(BAY_AREA_YAML).expect("Parse");
1056        assert_eq!(instance.distance(0, 1), 12); // SF to Oakland
1057        assert_eq!(instance.distance(1, 4), 4); // Oakland to Berkeley
1058    }
1059
1060    // =========================================================================
1061    // Error display tests
1062    // =========================================================================
1063
1064    #[test]
1065    fn test_error_display_parse() {
1066        let err = TspInstanceError::ParseError("test error".to_string());
1067        assert!(err.to_string().contains("YAML parse error"));
1068    }
1069
1070    #[test]
1071    fn test_error_display_matrix_dimension() {
1072        let err = TspInstanceError::MatrixDimensionMismatch {
1073            expected: 6,
1074            got_rows: 4,
1075        };
1076        let msg = err.to_string();
1077        assert!(msg.contains("6x6"));
1078        assert!(msg.contains("4 rows"));
1079    }
1080
1081    #[test]
1082    fn test_error_display_matrix_row() {
1083        let err = TspInstanceError::MatrixRowMismatch {
1084            row: 2,
1085            expected: 6,
1086            got: 4,
1087        };
1088        let msg = err.to_string();
1089        assert!(msg.contains("row 2"));
1090        assert!(msg.contains("4 columns"));
1091    }
1092
1093    #[test]
1094    fn test_error_display_triangle() {
1095        let err = TspInstanceError::TriangleInequalityViolation { i: 0, j: 1, k: 2 };
1096        assert!(err.to_string().contains("Triangle inequality"));
1097    }
1098
1099    #[test]
1100    fn test_error_display_asymmetric() {
1101        let err = TspInstanceError::AsymmetricMatrix {
1102            i: 0,
1103            j: 1,
1104            forward: 10,
1105            backward: 20,
1106        };
1107        let msg = err.to_string();
1108        assert!(msg.contains("Asymmetric"));
1109        assert!(msg.contains("10"));
1110        assert!(msg.contains("20"));
1111    }
1112
1113    #[test]
1114    fn test_error_display_invalid_city() {
1115        let err = TspInstanceError::InvalidCityId { id: 10, max: 5 };
1116        assert!(err.to_string().contains("Invalid city ID"));
1117    }
1118
1119    #[test]
1120    fn test_error_display_io() {
1121        let err = TspInstanceError::IoError("file not found".to_string());
1122        assert!(err.to_string().contains("IO error"));
1123    }
1124
1125    // =========================================================================
1126    // File loading tests
1127    // =========================================================================
1128
1129    #[test]
1130    fn test_from_yaml_file_not_found() {
1131        let result = TspInstanceYaml::from_yaml_file("/nonexistent/path/file.yaml");
1132        assert!(matches!(result, Err(TspInstanceError::IoError(_))));
1133    }
1134
1135    #[test]
1136    fn test_from_yaml_file_success() {
1137        // Use the actual bay_area_tsp.yaml file (now 20-city California instance)
1138        let result = TspInstanceYaml::from_yaml_file("examples/experiments/bay_area_tsp.yaml");
1139        assert!(result.is_ok());
1140        let instance = result.unwrap();
1141        assert_eq!(instance.meta.id, "TSP-CA-020");
1142        assert_eq!(instance.city_count(), 20);
1143    }
1144
1145    // =========================================================================
1146    // Trait implementations
1147    // =========================================================================
1148
1149    #[test]
1150    fn test_coords_clone_and_copy() {
1151        let coords = Coords {
1152            lat: 37.0,
1153            lon: -122.0,
1154        };
1155        let cloned = coords.clone();
1156        let copied = coords;
1157        assert_eq!(coords, cloned);
1158        assert_eq!(coords, copied);
1159    }
1160
1161    #[test]
1162    fn test_coords_partial_eq() {
1163        let c1 = Coords {
1164            lat: 37.0,
1165            lon: -122.0,
1166        };
1167        let c2 = Coords {
1168            lat: 37.0,
1169            lon: -122.0,
1170        };
1171        let c3 = Coords {
1172            lat: 38.0,
1173            lon: -122.0,
1174        };
1175        assert_eq!(c1, c2);
1176        assert_ne!(c1, c3);
1177    }
1178
1179    #[test]
1180    fn test_tsp_city_clone() {
1181        let city = TspCity::new(0, "SF", "SF", 37.0, -122.0);
1182        let cloned = city.clone();
1183        assert_eq!(city, cloned);
1184    }
1185
1186    #[test]
1187    fn test_tsp_meta_clone() {
1188        let meta = TspMeta::default();
1189        let cloned = meta.clone();
1190        assert_eq!(meta, cloned);
1191    }
1192
1193    #[test]
1194    fn test_tsp_params_clone() {
1195        let params = TspParams::default();
1196        let cloned = params.clone();
1197        assert_eq!(params, cloned);
1198    }
1199
1200    #[test]
1201    fn test_tsp_algorithm_config_clone() {
1202        let config = TspAlgorithmConfig::default();
1203        let cloned = config.clone();
1204        assert_eq!(config, cloned);
1205    }
1206
1207    #[test]
1208    fn test_tsp_instance_yaml_clone() {
1209        let instance = TspInstanceYaml::from_yaml(MINIMAL_YAML).expect("Parse");
1210        let cloned = instance.clone();
1211        assert_eq!(instance, cloned);
1212    }
1213
1214    #[test]
1215    fn test_error_is_error_trait() {
1216        let err: Box<dyn std::error::Error> =
1217            Box::new(TspInstanceError::ParseError("test".to_string()));
1218        assert!(!err.to_string().is_empty());
1219    }
1220
1221    // =========================================================================
1222    // Debug trait tests
1223    // =========================================================================
1224
1225    #[test]
1226    fn test_coords_debug() {
1227        let coords = Coords {
1228            lat: 37.0,
1229            lon: -122.0,
1230        };
1231        let debug = format!("{:?}", coords);
1232        assert!(debug.contains("Coords"));
1233        assert!(debug.contains("37"));
1234    }
1235
1236    #[test]
1237    fn test_tsp_instance_error_debug() {
1238        let err = TspInstanceError::ParseError("test".to_string());
1239        let debug = format!("{:?}", err);
1240        assert!(debug.contains("ParseError"));
1241    }
1242}
1243
1244// =============================================================================
1245// WASM Binding Tests (OR-001-06)
1246// =============================================================================
1247
1248#[cfg(all(test, feature = "wasm"))]
1249mod wasm_tests {
1250    use super::wasm::TspWasmInstance;
1251
1252    const BAY_AREA_YAML: &str = include_str!("../../examples/experiments/bay_area_tsp.yaml");
1253
1254    const MINIMAL_YAML: &str = r#"
1255meta:
1256  id: "TEST-001"
1257  description: "Minimal test instance"
1258cities:
1259  - id: 0
1260    name: "A"
1261    alias: "A"
1262    coords: { lat: 0.0, lon: 0.0 }
1263  - id: 1
1264    name: "B"
1265    alias: "B"
1266    coords: { lat: 1.0, lon: 1.0 }
1267matrix:
1268  - [0, 10]
1269  - [10, 0]
1270"#;
1271
1272    #[test]
1273    fn test_wasm_from_yaml() {
1274        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML);
1275        assert!(instance.is_ok());
1276    }
1277
1278    #[test]
1279    fn test_wasm_from_yaml_invalid() {
1280        let result = TspWasmInstance::from_yaml("invalid yaml: [[[");
1281        assert!(result.is_err());
1282    }
1283
1284    #[test]
1285    fn test_wasm_to_yaml() {
1286        let instance = TspWasmInstance::from_yaml(MINIMAL_YAML).expect("parse");
1287        let yaml = instance.to_yaml();
1288        assert!(yaml.is_ok());
1289        assert!(yaml.unwrap().contains("TEST-001"));
1290    }
1291
1292    #[test]
1293    fn test_wasm_validate() {
1294        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1295        assert!(instance.validate().is_ok());
1296    }
1297
1298    #[test]
1299    fn test_wasm_check_symmetry() {
1300        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1301        assert!(instance.check_symmetry().is_ok());
1302    }
1303
1304    #[test]
1305    fn test_wasm_check_triangle_inequality() {
1306        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1307        // Real-world driving distances may violate triangle inequality
1308        // (roads don't always follow straight lines). This is expected.
1309        let result = instance.check_triangle_inequality();
1310        // Just verify the check runs without panic - violation is acceptable for real data
1311        assert!(result.is_ok() || result.is_err());
1312    }
1313
1314    #[test]
1315    fn test_wasm_city_count() {
1316        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1317        assert_eq!(instance.city_count(), 20); // 20-city California instance
1318    }
1319
1320    #[test]
1321    fn test_wasm_distance() {
1322        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1323        assert_eq!(instance.distance(0, 1), 12); // SF to Oakland
1324    }
1325
1326    #[test]
1327    fn test_wasm_tour_length() {
1328        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1329        let tour = [0, 1, 4, 5, 2, 3]; // Optimal tour
1330        assert_eq!(instance.tour_length(&tour), 115);
1331    }
1332
1333    #[test]
1334    fn test_wasm_get_id() {
1335        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1336        assert_eq!(instance.get_id(), "TSP-CA-020"); // Updated to 20-city California instance
1337    }
1338
1339    #[test]
1340    fn test_wasm_get_description() {
1341        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1342        assert!(instance.get_description().contains("Bay Area"));
1343    }
1344
1345    #[test]
1346    fn test_wasm_get_units() {
1347        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1348        assert_eq!(instance.get_units(), "miles");
1349    }
1350
1351    #[test]
1352    fn test_wasm_get_optimal_known() {
1353        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1354        assert_eq!(instance.get_optimal_known(), None); // Unknown for 20-city instance
1355    }
1356
1357    #[test]
1358    fn test_wasm_get_algorithm_method() {
1359        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1360        assert_eq!(instance.get_algorithm_method(), "grasp");
1361    }
1362
1363    #[test]
1364    fn test_wasm_get_seed() {
1365        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1366        assert_eq!(instance.get_seed(), 42);
1367    }
1368
1369    #[test]
1370    fn test_wasm_get_rcl_size() {
1371        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1372        assert_eq!(instance.get_rcl_size(), 3);
1373    }
1374
1375    #[test]
1376    fn test_wasm_get_restarts() {
1377        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1378        assert_eq!(instance.get_restarts(), 100); // More restarts for 20-city instance
1379    }
1380
1381    #[test]
1382    fn test_wasm_get_two_opt_enabled() {
1383        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1384        assert!(instance.get_two_opt_enabled());
1385    }
1386
1387    #[test]
1388    fn test_wasm_get_city_names_json() {
1389        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1390        let json = instance.get_city_names_json();
1391        assert!(json.contains("San Francisco"));
1392        assert!(json.contains("Oakland"));
1393    }
1394
1395    #[test]
1396    fn test_wasm_get_city_aliases_json() {
1397        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1398        let json = instance.get_city_aliases_json();
1399        assert!(json.contains("SF"));
1400        assert!(json.contains("OAK"));
1401    }
1402
1403    #[test]
1404    fn test_wasm_get_matrix_json() {
1405        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1406        let json = instance.get_matrix_json();
1407        assert!(json.contains("[0,12")); // First row starts with 0,12
1408    }
1409
1410    #[test]
1411    fn test_wasm_get_city_coords_json() {
1412        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1413        let json = instance.get_city_coords_json();
1414        assert!(json.contains("37.7749")); // SF latitude
1415    }
1416
1417    #[test]
1418    fn test_wasm_tour_length_verified_json() {
1419        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1420        let tour = [0, 1, 4, 5, 2, 3];
1421        let json = instance.tour_length_verified_json(&tour);
1422        assert!(json.contains("\"length\":115"));
1423        assert!(json.contains("steps"));
1424        assert!(json.contains("San Francisco"));
1425    }
1426
1427    #[test]
1428    fn test_wasm_to_json() {
1429        let instance = TspWasmInstance::from_yaml(BAY_AREA_YAML).expect("parse");
1430        let json = instance.to_json();
1431        assert!(json.contains("TSP-CA-020")); // Updated to 20-city California instance
1432        assert!(json.contains("cities"));
1433        assert!(json.contains("matrix"));
1434    }
1435
1436    #[test]
1437    fn test_wasm_check_symmetry_fails() {
1438        let asymmetric_yaml = r#"
1439meta:
1440  id: "ASYM"
1441  description: "Asymmetric"
1442cities:
1443  - id: 0
1444    name: "A"
1445    alias: "A"
1446    coords: { lat: 0.0, lon: 0.0 }
1447  - id: 1
1448    name: "B"
1449    alias: "B"
1450    coords: { lat: 1.0, lon: 1.0 }
1451matrix:
1452  - [0, 10]
1453  - [99, 0]
1454"#;
1455        let instance = TspWasmInstance::from_yaml(asymmetric_yaml).expect("parse");
1456        assert!(instance.check_symmetry().is_err());
1457    }
1458
1459    #[test]
1460    fn test_wasm_validate_fails() {
1461        let bad_yaml = r#"
1462meta:
1463  id: "BAD"
1464  description: "Bad matrix"
1465cities:
1466  - id: 0
1467    name: "A"
1468    alias: "A"
1469    coords: { lat: 0.0, lon: 0.0 }
1470matrix:
1471  - [0, 10, 20]
1472  - [10, 0, 30]
1473  - [20, 30, 0]
1474"#;
1475        let instance = TspWasmInstance::from_yaml(bad_yaml).expect("parse");
1476        assert!(instance.validate().is_err());
1477    }
1478}