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}