1#![forbid(unsafe_code)]
41#![cfg_attr(not(test), deny(clippy::unwrap_used))]
42#![cfg_attr(not(test), deny(clippy::expect_used))]
43#![warn(missing_docs)]
44pub mod transfer;
45
46pub use transfer::{AffinityMatrix, CapabilityMultiplier, TransferResult};
47
48use nexcore_error::Error;
49use serde::{Deserialize, Serialize};
50
51#[derive(Debug, Error)]
57pub enum AcademyError {
58 #[error("Invalid course code: {0}")]
59 InvalidCode(String),
60 #[error("Unknown subject: {0}")]
61 UnknownSubject(String),
62 #[error("Invalid level: {0}")]
63 InvalidLevel(u8),
64}
65
66pub type AcademyResult<T> = Result<T, AcademyError>;
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
77pub enum Subject {
78 Mathematics,
81 Physics,
83 Chemistry,
85 Biology,
87 ComputerScience,
89 Engineering,
91 Statistics,
93
94 Pharmacology,
97 Nursing,
99 Medicine,
101 PublicHealth,
103
104 English,
107 History,
109 Philosophy,
111 Psychology,
113
114 Business,
117 Economics,
119 Accounting,
121 Finance,
123
124 Unknown(u16),
126}
127
128impl Subject {
129 pub fn from_code(code: &str) -> Self {
131 let upper = code.to_uppercase();
132 match upper.as_str() {
133 "MTH" | "MAT" | "MATH" => Self::Mathematics,
134 "PHY" | "PHS" | "PHYS" => Self::Physics,
135 "CHM" | "CHE" | "CHEM" => Self::Chemistry,
136 "BIO" | "BIOL" => Self::Biology,
137 "CSC" | "CIS" | "CS" | "CSCI" => Self::ComputerScience,
138 "EGR" | "ENR" | "ENGR" => Self::Engineering,
139 "STA" | "STT" | "STAT" => Self::Statistics,
140 "PHR" | "PHA" | "PHAR" => Self::Pharmacology,
141 "NUR" | "NSG" | "NURS" => Self::Nursing,
142 "MED" | "MEDI" => Self::Medicine,
143 "PUH" | "PBH" | "PUBH" => Self::PublicHealth,
144 "ENG" | "ENL" | "ENGL" => Self::English,
145 "HIS" | "HST" | "HIST" => Self::History,
146 "PHI" | "PHL" | "PHIL" => Self::Philosophy,
147 "PSY" | "PSYC" => Self::Psychology,
148 "BUS" | "BUA" | "BUSI" => Self::Business,
149 "ECO" | "ECN" | "ECON" => Self::Economics,
150 "ACC" | "ACT" | "ACCT" => Self::Accounting,
151 "FIN" | "FINA" => Self::Finance,
152 _ => {
153 let hash = code.bytes().fold(0u16, |acc, b| acc.wrapping_add(b as u16));
155 Self::Unknown(hash)
156 }
157 }
158 }
159
160 #[must_use]
162 pub fn code(&self) -> &'static str {
163 match self {
164 Self::Mathematics => "MTH",
165 Self::Physics => "PHY",
166 Self::Chemistry => "CHM",
167 Self::Biology => "BIO",
168 Self::ComputerScience => "CSC",
169 Self::Engineering => "EGR",
170 Self::Statistics => "STA",
171 Self::Pharmacology => "PHR",
172 Self::Nursing => "NUR",
173 Self::Medicine => "MED",
174 Self::PublicHealth => "PUH",
175 Self::English => "ENG",
176 Self::History => "HIS",
177 Self::Philosophy => "PHI",
178 Self::Psychology => "PSY",
179 Self::Business => "BUS",
180 Self::Economics => "ECO",
181 Self::Accounting => "ACC",
182 Self::Finance => "FIN",
183 Self::Unknown(_) => "UNK",
184 }
185 }
186
187 #[must_use]
189 pub fn is_stem(&self) -> bool {
190 matches!(
191 self,
192 Self::Mathematics
193 | Self::Physics
194 | Self::Chemistry
195 | Self::Biology
196 | Self::ComputerScience
197 | Self::Engineering
198 | Self::Statistics
199 )
200 }
201
202 #[must_use]
204 pub fn is_healthcare(&self) -> bool {
205 matches!(
206 self,
207 Self::Pharmacology | Self::Nursing | Self::Medicine | Self::PublicHealth
208 )
209 }
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
220pub enum CourseLevel {
221 Introductory = 1,
223 Intermediate = 2,
225 Advanced = 3,
227 Capstone = 4,
229 Graduate = 5,
231 Doctoral = 6,
233 PostDoctoral = 7,
235}
236
237impl CourseLevel {
238 pub fn from_digit(d: u8) -> AcademyResult<Self> {
240 match d {
241 0 | 1 => Ok(Self::Introductory),
242 2 => Ok(Self::Intermediate),
243 3 => Ok(Self::Advanced),
244 4 => Ok(Self::Capstone),
245 5 => Ok(Self::Graduate),
246 6 => Ok(Self::Doctoral),
247 7..=9 => Ok(Self::PostDoctoral),
248 _ => Err(AcademyError::InvalidLevel(d)),
249 }
250 }
251
252 #[must_use]
258 pub fn to_prima_tier(&self) -> PrimaTier {
259 match self {
260 Self::Introductory | Self::Intermediate => PrimaTier::T3,
261 Self::Advanced | Self::Capstone => PrimaTier::T2C,
262 Self::Graduate => PrimaTier::T2P,
263 Self::Doctoral | Self::PostDoctoral => PrimaTier::T1,
264 }
265 }
266
267 #[must_use]
269 pub fn as_number(&self) -> u8 {
270 *self as u8
271 }
272
273 #[must_use]
275 pub fn year_name(&self) -> &'static str {
276 match self {
277 Self::Introductory => "Freshman",
278 Self::Intermediate => "Sophomore",
279 Self::Advanced => "Junior",
280 Self::Capstone => "Senior",
281 Self::Graduate => "Graduate",
282 Self::Doctoral => "Doctoral",
283 Self::PostDoctoral => "Post-Doctoral",
284 }
285 }
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
296pub enum PrimaTier {
297 T1 = 1,
299 T2P = 2,
301 T2C = 3,
303 T3 = 4,
305}
306
307impl PrimaTier {
308 #[must_use]
310 pub fn transfer_confidence(&self) -> f64 {
311 match self {
312 Self::T1 => 1.0, Self::T2P => 0.9, Self::T2C => 0.7, Self::T3 => 0.4, }
317 }
318
319 #[must_use]
321 pub fn symbol(&self) -> &'static str {
322 match self {
323 Self::T1 => "T1",
324 Self::T2P => "T2-P",
325 Self::T2C => "T2-C",
326 Self::T3 => "T3",
327 }
328 }
329}
330
331#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
341pub struct Course {
342 pub subject: Subject,
344 pub number: u16,
346 pub title: Option<String>,
348 pub credits: u8,
350}
351
352impl Course {
353 #[must_use]
355 pub fn new(subject: Subject, number: u16) -> Self {
356 Self {
357 subject,
358 number,
359 title: None,
360 credits: 3, }
362 }
363
364 pub fn parse(s: &str) -> AcademyResult<Self> {
366 let s = s.trim().to_uppercase();
367
368 let letter_end = s.chars().take_while(|c| c.is_alphabetic()).count();
370 if letter_end == 0 {
371 return Err(AcademyError::InvalidCode(s));
372 }
373
374 let subject_str = &s[..letter_end];
375 let number_str: String = s[letter_end..]
376 .chars()
377 .filter(|c| c.is_ascii_digit())
378 .collect();
379
380 if number_str.is_empty() {
381 return Err(AcademyError::InvalidCode(s));
382 }
383
384 let number: u16 = number_str
385 .parse()
386 .map_err(|_| AcademyError::InvalidCode(s.clone()))?;
387
388 let subject = Subject::from_code(subject_str);
389
390 Ok(Self::new(subject, number))
391 }
392
393 #[must_use]
395 pub fn level(&self) -> CourseLevel {
396 let first_digit = (self.number / 100) as u8;
397 CourseLevel::from_digit(first_digit).unwrap_or(CourseLevel::Introductory)
398 }
399
400 #[must_use]
402 pub fn prima_tier(&self) -> PrimaTier {
403 self.level().to_prima_tier()
404 }
405
406 #[must_use]
408 pub fn code(&self) -> String {
409 format!("{} {}", self.subject.code(), self.number)
410 }
411
412 #[must_use]
414 pub fn is_graduate(&self) -> bool {
415 self.number >= 500
416 }
417
418 #[must_use]
420 pub fn can_prereq(&self, other: &Course) -> bool {
421 self.subject == other.subject && self.number < other.number
423 }
424
425 #[must_use]
427 pub fn with_title(mut self, title: &str) -> Self {
428 self.title = Some(title.to_string());
429 self
430 }
431
432 #[must_use]
434 pub fn with_credits(mut self, credits: u8) -> Self {
435 self.credits = credits;
436 self
437 }
438}
439
440impl std::fmt::Display for Course {
441 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442 write!(f, "{}", self.code())
443 }
444}
445
446#[derive(Debug, Clone, Default)]
454pub struct Curriculum {
455 pub courses: Vec<Course>,
457 pub prerequisites: Vec<Vec<usize>>,
459}
460
461impl Curriculum {
462 #[must_use]
464 pub fn new() -> Self {
465 Self::default()
466 }
467
468 pub fn add_course(&mut self, course: Course) -> usize {
470 let idx = self.courses.len();
471 self.courses.push(course);
472 self.prerequisites.push(Vec::new());
473 idx
474 }
475
476 pub fn add_prereq(&mut self, course_idx: usize, prereq_idx: usize) {
478 if course_idx < self.prerequisites.len() && prereq_idx < self.courses.len() {
479 self.prerequisites[course_idx].push(prereq_idx);
480 }
481 }
482
483 #[must_use]
485 pub fn courses_at_level(&self, level: CourseLevel) -> Vec<&Course> {
486 self.courses.iter().filter(|c| c.level() == level).collect()
487 }
488
489 #[must_use]
491 pub fn courses_in_subject(&self, subject: Subject) -> Vec<&Course> {
492 self.courses
493 .iter()
494 .filter(|c| c.subject == subject)
495 .collect()
496 }
497
498 #[must_use]
500 pub fn credits_by_tier(&self) -> [(PrimaTier, u32); 4] {
501 let mut counts = [
502 (PrimaTier::T1, 0),
503 (PrimaTier::T2P, 0),
504 (PrimaTier::T2C, 0),
505 (PrimaTier::T3, 0),
506 ];
507
508 for course in &self.courses {
509 let tier = course.prima_tier();
510 match tier {
511 PrimaTier::T1 => counts[0].1 += course.credits as u32,
512 PrimaTier::T2P => counts[1].1 += course.credits as u32,
513 PrimaTier::T2C => counts[2].1 += course.credits as u32,
514 PrimaTier::T3 => counts[3].1 += course.credits as u32,
515 }
516 }
517
518 counts
519 }
520}
521
522#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
535 fn test_subject_from_code() {
536 assert_eq!(Subject::from_code("MTH"), Subject::Mathematics);
537 assert_eq!(Subject::from_code("mth"), Subject::Mathematics);
538 assert_eq!(Subject::from_code("MATH"), Subject::Mathematics);
539 assert_eq!(Subject::from_code("PHY"), Subject::Physics);
540 assert_eq!(Subject::from_code("CSC"), Subject::ComputerScience);
541 assert_eq!(Subject::from_code("CS"), Subject::ComputerScience);
542 }
543
544 #[test]
545 fn test_subject_is_stem() {
546 assert!(Subject::Mathematics.is_stem());
547 assert!(Subject::Physics.is_stem());
548 assert!(Subject::ComputerScience.is_stem());
549 assert!(!Subject::English.is_stem());
550 assert!(!Subject::Philosophy.is_stem());
551 }
552
553 #[test]
554 fn test_subject_is_healthcare() {
555 assert!(Subject::Pharmacology.is_healthcare());
556 assert!(Subject::Nursing.is_healthcare());
557 assert!(Subject::Medicine.is_healthcare());
558 assert!(!Subject::Mathematics.is_healthcare());
559 }
560
561 #[test]
562 fn test_subject_code_roundtrip() {
563 let subjects = [
564 Subject::Mathematics,
565 Subject::Physics,
566 Subject::Chemistry,
567 Subject::Biology,
568 Subject::ComputerScience,
569 ];
570 for subj in subjects {
571 let code = subj.code();
572 let parsed = Subject::from_code(code);
573 assert_eq!(parsed, subj);
574 }
575 }
576
577 #[test]
582 fn test_level_from_digit() {
583 assert_eq!(
584 CourseLevel::from_digit(1).ok(),
585 Some(CourseLevel::Introductory)
586 );
587 assert_eq!(
588 CourseLevel::from_digit(2).ok(),
589 Some(CourseLevel::Intermediate)
590 );
591 assert_eq!(CourseLevel::from_digit(3).ok(), Some(CourseLevel::Advanced));
592 assert_eq!(CourseLevel::from_digit(4).ok(), Some(CourseLevel::Capstone));
593 assert_eq!(CourseLevel::from_digit(5).ok(), Some(CourseLevel::Graduate));
594 assert_eq!(CourseLevel::from_digit(6).ok(), Some(CourseLevel::Doctoral));
595 }
596
597 #[test]
598 fn test_level_to_prima_tier() {
599 assert_eq!(CourseLevel::Introductory.to_prima_tier(), PrimaTier::T3);
601 assert_eq!(CourseLevel::Intermediate.to_prima_tier(), PrimaTier::T3);
602 assert_eq!(CourseLevel::Advanced.to_prima_tier(), PrimaTier::T2C);
603 assert_eq!(CourseLevel::Capstone.to_prima_tier(), PrimaTier::T2C);
604 assert_eq!(CourseLevel::Graduate.to_prima_tier(), PrimaTier::T2P);
605 assert_eq!(CourseLevel::Doctoral.to_prima_tier(), PrimaTier::T1);
606 }
607
608 #[test]
609 fn test_level_ordering() {
610 assert!(CourseLevel::Introductory < CourseLevel::Intermediate);
611 assert!(CourseLevel::Intermediate < CourseLevel::Advanced);
612 assert!(CourseLevel::Advanced < CourseLevel::Capstone);
613 assert!(CourseLevel::Capstone < CourseLevel::Graduate);
614 assert!(CourseLevel::Graduate < CourseLevel::Doctoral);
615 }
616
617 #[test]
622 fn test_tier_transfer_confidence() {
623 assert!((PrimaTier::T1.transfer_confidence() - 1.0).abs() < f64::EPSILON);
624 assert!((PrimaTier::T2P.transfer_confidence() - 0.9).abs() < f64::EPSILON);
625 assert!((PrimaTier::T2C.transfer_confidence() - 0.7).abs() < f64::EPSILON);
626 assert!((PrimaTier::T3.transfer_confidence() - 0.4).abs() < f64::EPSILON);
627 }
628
629 #[test]
630 fn test_tier_ordering() {
631 assert!(PrimaTier::T1 < PrimaTier::T2P);
633 assert!(PrimaTier::T2P < PrimaTier::T2C);
634 assert!(PrimaTier::T2C < PrimaTier::T3);
635 }
636
637 #[test]
642 fn test_course_parse_with_space() {
643 let course = Course::parse("MTH 101")
644 .ok()
645 .unwrap_or_else(|| Course::new(Subject::Mathematics, 0));
646 assert_eq!(course.subject, Subject::Mathematics);
647 assert_eq!(course.number, 101);
648 }
649
650 #[test]
651 fn test_course_parse_without_space() {
652 let course = Course::parse("PHY201")
653 .ok()
654 .unwrap_or_else(|| Course::new(Subject::Physics, 0));
655 assert_eq!(course.subject, Subject::Physics);
656 assert_eq!(course.number, 201);
657 }
658
659 #[test]
660 fn test_course_parse_lowercase() {
661 let course = Course::parse("csc 350")
662 .ok()
663 .unwrap_or_else(|| Course::new(Subject::ComputerScience, 0));
664 assert_eq!(course.subject, Subject::ComputerScience);
665 assert_eq!(course.number, 350);
666 }
667
668 #[test]
669 fn test_course_level() {
670 assert_eq!(
671 Course::new(Subject::Mathematics, 101).level(),
672 CourseLevel::Introductory
673 );
674 assert_eq!(
675 Course::new(Subject::Mathematics, 201).level(),
676 CourseLevel::Intermediate
677 );
678 assert_eq!(
679 Course::new(Subject::Mathematics, 350).level(),
680 CourseLevel::Advanced
681 );
682 assert_eq!(
683 Course::new(Subject::Mathematics, 450).level(),
684 CourseLevel::Capstone
685 );
686 assert_eq!(
687 Course::new(Subject::Mathematics, 501).level(),
688 CourseLevel::Graduate
689 );
690 assert_eq!(
691 Course::new(Subject::Mathematics, 650).level(),
692 CourseLevel::Doctoral
693 );
694 }
695
696 #[test]
697 fn test_course_prima_tier() {
698 let intro = Course::new(Subject::Mathematics, 101);
699 let grad = Course::new(Subject::Mathematics, 550);
700
701 assert_eq!(intro.prima_tier(), PrimaTier::T3);
702 assert_eq!(grad.prima_tier(), PrimaTier::T2P);
703 }
704
705 #[test]
706 fn test_course_is_graduate() {
707 assert!(!Course::new(Subject::Physics, 450).is_graduate());
708 assert!(Course::new(Subject::Physics, 500).is_graduate());
709 assert!(Course::new(Subject::Physics, 650).is_graduate());
710 }
711
712 #[test]
713 fn test_course_can_prereq() {
714 let calc1 = Course::new(Subject::Mathematics, 151);
715 let calc2 = Course::new(Subject::Mathematics, 152);
716 let physics = Course::new(Subject::Physics, 151);
717
718 assert!(calc1.can_prereq(&calc2)); assert!(!calc2.can_prereq(&calc1)); assert!(!calc1.can_prereq(&physics)); }
722
723 #[test]
724 fn test_course_code() {
725 let course = Course::new(Subject::Chemistry, 201);
726 assert_eq!(course.code(), "CHM 201");
727 }
728
729 #[test]
730 fn test_course_with_title() {
731 let course = Course::new(Subject::Mathematics, 151).with_title("Calculus I");
732 assert_eq!(course.title, Some("Calculus I".to_string()));
733 }
734
735 #[test]
740 fn test_curriculum_add_courses() {
741 let mut curriculum = Curriculum::new();
742 let idx1 = curriculum.add_course(Course::new(Subject::Mathematics, 101));
743 let idx2 = curriculum.add_course(Course::new(Subject::Mathematics, 102));
744
745 assert_eq!(idx1, 0);
746 assert_eq!(idx2, 1);
747 assert_eq!(curriculum.courses.len(), 2);
748 }
749
750 #[test]
751 fn test_curriculum_courses_at_level() {
752 let mut curriculum = Curriculum::new();
753 curriculum.add_course(Course::new(Subject::Mathematics, 101));
754 curriculum.add_course(Course::new(Subject::Physics, 101));
755 curriculum.add_course(Course::new(Subject::Mathematics, 201));
756
757 let intro = curriculum.courses_at_level(CourseLevel::Introductory);
758 assert_eq!(intro.len(), 2);
759
760 let intermediate = curriculum.courses_at_level(CourseLevel::Intermediate);
761 assert_eq!(intermediate.len(), 1);
762 }
763
764 #[test]
765 fn test_curriculum_courses_in_subject() {
766 let mut curriculum = Curriculum::new();
767 curriculum.add_course(Course::new(Subject::Mathematics, 101));
768 curriculum.add_course(Course::new(Subject::Mathematics, 201));
769 curriculum.add_course(Course::new(Subject::Physics, 101));
770
771 let math = curriculum.courses_in_subject(Subject::Mathematics);
772 assert_eq!(math.len(), 2);
773
774 let physics = curriculum.courses_in_subject(Subject::Physics);
775 assert_eq!(physics.len(), 1);
776 }
777
778 #[test]
779 fn test_curriculum_credits_by_tier() {
780 let mut curriculum = Curriculum::new();
781 curriculum.add_course(Course::new(Subject::Mathematics, 101).with_credits(3)); curriculum.add_course(Course::new(Subject::Mathematics, 201).with_credits(3)); curriculum.add_course(Course::new(Subject::Mathematics, 350).with_credits(3)); curriculum.add_course(Course::new(Subject::Mathematics, 550).with_credits(3)); let credits = curriculum.credits_by_tier();
787 assert_eq!(credits[3].1, 6); assert_eq!(credits[2].1, 3); assert_eq!(credits[1].1, 3); }
791
792 #[test]
797 fn test_knowledge_funnel_inversion() {
798 let courses = [
800 Course::new(Subject::Mathematics, 101), Course::new(Subject::Mathematics, 201), Course::new(Subject::Mathematics, 301), Course::new(Subject::Mathematics, 401), Course::new(Subject::Mathematics, 501), Course::new(Subject::Mathematics, 601), ];
807
808 let tiers: Vec<PrimaTier> = courses.iter().map(|c| c.prima_tier()).collect();
809
810 assert_eq!(tiers[0], PrimaTier::T3);
812 assert_eq!(tiers[1], PrimaTier::T3);
813 assert_eq!(tiers[2], PrimaTier::T2C);
814 assert_eq!(tiers[3], PrimaTier::T2C);
815 assert_eq!(tiers[4], PrimaTier::T2P);
816 assert_eq!(tiers[5], PrimaTier::T1);
817 }
818
819 #[test]
820 fn test_transfer_confidence_gradient() {
821 let freshman = Course::new(Subject::Pharmacology, 101).prima_tier();
823 let graduate = Course::new(Subject::Pharmacology, 550).prima_tier();
824 let doctoral = Course::new(Subject::Pharmacology, 650).prima_tier();
825
826 assert!(doctoral.transfer_confidence() > graduate.transfer_confidence());
827 assert!(graduate.transfer_confidence() > freshman.transfer_confidence());
828 }
829}