nu_analytics/core/models/
course.rs

1//! Course model
2
3use serde::{Deserialize, Serialize};
4
5/// Represents a course in a curriculum
6///
7/// # Note on Complex Prerequisites
8/// Currently, prerequisites are stored as a flat list with implicit AND semantics.
9/// However, real curricula often have complex boolean expressions like:
10/// - "CS101 OR CS102"
11/// - "(CS101 AND MATH156) OR CS200"
12/// - "CS101 OR (CS102 AND MATH156)"
13///
14/// Several approaches to handle complex prerequisites:
15///
16/// 1. **Disjunctive Normal Form (DNF)**: Store as `Vec<Vec<String>>` where
17///    outer Vec is OR, inner Vec is AND. Example: `[[\"CS101\"], [\"CS102\", \"MATH156\"]]`
18///    means CS101 OR (CS102 AND MATH156). This is a standard form in logic.
19///
20/// 2. **Prerequisite Expression Trees**: Use a recursive enum:
21///    ```ignore
22///    enum PrereqExpr {
23///        Course(String),
24///        And(Vec<PrereqExpr>),
25///        Or(Vec<PrereqExpr>),
26///    }
27///    ```
28///    This can represent any boolean expression and is most flexible.
29///
30/// 3. **Virtual Courses**: Create synthetic course keys like `\"CS101_OR_CS102\"` or
31///    `\"CS101_AND_MATH156\"` in the DAG. Each virtual course represents a requirement
32///    that can be satisfied by its components.
33///
34/// 4. **Hypergraph Representation**: Extend DAG to support hyperedges where a single
35///    edge can connect to multiple prerequisite sets with boolean operators.
36///
37/// 5. **Choice Resolution at Plan Build Time** (Recommended for Plan Analysis): The Course
38///    struct stores the full prerequisite expression (using one of the above approaches),
39///    but when building a DAG from a Plan, the plan specifies which alternative was chosen.
40///    This keeps plan DAGs simple while preserving the full requirement information in courses.
41///    Plans represent actual student selections where boolean logic has been resolved.
42///
43/// **Note**: Regardless of approach, the Course struct must be able to represent the full
44/// prerequisite expression. The choice resolution approach means that when analyzing a
45/// specific plan, we only include the paths the student actually took, not all possibilities.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct Course {
48    /// Original course ID from the curriculum file
49    pub csv_id: Option<String>,
50
51    /// Unique course identifier (optional, used for deduplication when multiple courses have same key)
52    pub id: Option<String>,
53    /// Course name (e.g., "Calculus for Physical Scientists I")
54    pub name: String,
55
56    /// Course prefix (e.g., "MATH", "CS")
57    pub prefix: String,
58
59    /// Course number (e.g., "1342", "2510")
60    pub number: String,
61
62    /// Prerequisites - stored as "PREFIX NUMBER" keys (e.g., "MATH 1341")
63    /// Currently assumes ALL prerequisites must be satisfied (AND semantics)
64    pub prerequisites: Vec<String>,
65
66    /// Co-requisites - stored as "PREFIX NUMBER" keys
67    pub corequisites: Vec<String>,
68
69    /// Strict co-requisites - stored as "PREFIX NUMBER" keys (must be taken together)
70    pub strict_corequisites: Vec<String>,
71
72    /// Credit hours (can be fractional)
73    pub credit_hours: f32,
74
75    /// Canonical name for cross-institution lookup (e.g., "Calculus I")
76    pub canonical_name: Option<String>,
77}
78
79impl Course {
80    /// Create a new course
81    ///
82    /// # Arguments
83    /// * `name` - Full course name
84    /// * `prefix` - Course prefix
85    /// * `number` - Course number
86    /// * `credit_hours` - Credit hours (can be fractional)
87    #[must_use]
88    pub const fn new(name: String, prefix: String, number: String, credit_hours: f32) -> Self {
89        Self {
90            csv_id: None,
91            id: None,
92            name,
93            prefix,
94            number,
95            prerequisites: Vec::new(),
96            corequisites: Vec::new(),
97            strict_corequisites: Vec::new(),
98            credit_hours,
99            canonical_name: None,
100        }
101    }
102
103    /// Get the course key for lookups (prefix + number)
104    ///
105    /// # Returns
106    /// A string in the format "PREFIXNUMBER" (e.g., "CS2510")
107    #[must_use]
108    pub fn key(&self) -> String {
109        format!("{}{}", self.prefix, self.number)
110    }
111
112    /// Add a prerequisite by course key
113    pub fn add_prerequisite(&mut self, prereq_key: String) {
114        if !self.prerequisites.contains(&prereq_key) {
115            self.prerequisites.push(prereq_key);
116        }
117    }
118
119    /// Add a co-requisite by course key
120    pub fn add_corequisite(&mut self, coreq_key: String) {
121        if !self.corequisites.contains(&coreq_key) {
122            self.corequisites.push(coreq_key);
123        }
124    }
125
126    /// Add a strict co-requisite by course key
127    pub fn add_strict_corequisite(&mut self, coreq_key: String) {
128        if !self.strict_corequisites.contains(&coreq_key) {
129            self.strict_corequisites.push(coreq_key);
130        }
131    }
132
133    /// Set the canonical name
134    pub fn set_canonical_name(&mut self, name: String) {
135        self.canonical_name = Some(name);
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_course_creation() {
145        let course = Course::new(
146            "Discrete Structures".to_string(),
147            "CS".to_string(),
148            "1800".to_string(),
149            4.0,
150        );
151
152        assert_eq!(course.name, "Discrete Structures");
153        assert_eq!(course.prefix, "CS");
154        assert_eq!(course.number, "1800");
155        assert!((course.credit_hours - 4.0).abs() < f32::EPSILON);
156        assert!(course.prerequisites.is_empty());
157        assert!(course.corequisites.is_empty());
158        assert!(course.canonical_name.is_none());
159    }
160
161    #[test]
162    fn test_course_key() {
163        let course = Course::new(
164            "Data Structures".to_string(),
165            "CS".to_string(),
166            "2510".to_string(),
167            4.0,
168        );
169
170        assert_eq!(course.key(), "CS2510");
171    }
172
173    #[test]
174    fn test_fractional_credits() {
175        let course = Course::new(
176            "Lab".to_string(),
177            "PHYS".to_string(),
178            "1151".to_string(),
179            1.5,
180        );
181
182        assert!((course.credit_hours - 1.5).abs() < f32::EPSILON);
183    }
184
185    #[test]
186    fn test_add_prerequisite() {
187        let mut course = Course::new(
188            "Data Structures".to_string(),
189            "CS".to_string(),
190            "2510".to_string(),
191            4.0,
192        );
193
194        course.add_prerequisite("CS1800".to_string());
195        assert_eq!(course.prerequisites.len(), 1);
196        assert_eq!(course.prerequisites[0], "CS1800");
197
198        // Adding duplicate should not duplicate
199        course.add_prerequisite("CS1800".to_string());
200        assert_eq!(course.prerequisites.len(), 1);
201    }
202
203    #[test]
204    fn test_add_corequisite() {
205        let mut course = Course::new(
206            "Physics I".to_string(),
207            "PHYS".to_string(),
208            "1151".to_string(),
209            4.0,
210        );
211
212        course.add_corequisite("PHYS1152".to_string());
213        assert_eq!(course.corequisites.len(), 1);
214        assert_eq!(course.corequisites[0], "PHYS1152");
215    }
216
217    #[test]
218    fn test_canonical_name() {
219        let mut course = Course::new(
220            "Calculus for Physical Scientists I".to_string(),
221            "MATH".to_string(),
222            "1342".to_string(),
223            4.0,
224        );
225
226        assert!(course.canonical_name.is_none());
227
228        course.set_canonical_name("Calculus I".to_string());
229        assert_eq!(course.canonical_name, Some("Calculus I".to_string()));
230    }
231}