Skip to main content

prima_academy/
lib.rs

1// Copyright © 2026 NexVigilant LLC. All Rights Reserved.
2// Intellectual Property of Matthew Alexander Campion, PharmD
3
4//! # Prima Academy — Academic Course Classification Primitives
5//!
6//! Maps university course numbering to Prima's tier system.
7//!
8//! ## Primitive Grounding
9//!
10//! | Component | Primitive | Meaning |
11//! |-----------|-----------|---------|
12//! | Subject code | N | Domain identifier (3-letter) |
13//! | Level digit | λ | Year/position in curriculum |
14//! | Course number | σ | Sequence within level |
15//! | Full code | κ | Enables comparison/prerequisites |
16//! | Curriculum | μ | Subject → courses mapping |
17//!
18//! ## Course Number Format
19//!
20//! ```text
21//! MTH 101
22//!  │   │││
23//!  │   ││└── Specifier (1-9): Specific course variant
24//!  │   │└─── Subcategory (0-9): Track within level
25//!  │   └──── Level (1-6+): Year/advancement
26//!  └──────── Subject: 3-letter domain code
27//! ```
28//!
29//! ## Tier Mapping (Inverted)
30//!
31//! | Course Level | Prima Tier | Abstraction |
32//! |--------------|------------|-------------|
33//! | 100-level | T3 | Domain-specific intro |
34//! | 200-level | T3 | Domain intermediate |
35//! | 300-level | T2-C | Cross-topic synthesis |
36//! | 400-level | T2-C | Capstone integration |
37//! | 500-level | T2-P | Graduate research |
38//! | 600+ level | T1 | Foundational theory |
39
40#![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// ═══════════════════════════════════════════════════════════════════════════════
52// ERRORS
53// ═══════════════════════════════════════════════════════════════════════════════
54
55/// Academy errors.
56#[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
66/// Result type for academy operations.
67pub type AcademyResult<T> = Result<T, AcademyError>;
68
69// ═══════════════════════════════════════════════════════════════════════════════
70// SUBJECT DOMAINS (T3)
71// ═══════════════════════════════════════════════════════════════════════════════
72
73/// Academic subject domain.
74///
75/// ## Tier: T3 (Domain-specific)
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
77pub enum Subject {
78    // STEM
79    /// Mathematics (MTH/MAT)
80    Mathematics,
81    /// Physics (PHY/PHS)
82    Physics,
83    /// Chemistry (CHM/CHE)
84    Chemistry,
85    /// Biology (BIO)
86    Biology,
87    /// Computer Science (CSC/CIS/CS)
88    ComputerScience,
89    /// Engineering (ENG/ENR)
90    Engineering,
91    /// Statistics (STA/STT)
92    Statistics,
93
94    // Healthcare
95    /// Pharmacology (PHR/PHA)
96    Pharmacology,
97    /// Nursing (NUR/NSG)
98    Nursing,
99    /// Medicine (MED)
100    Medicine,
101    /// PublicHealth (PUH/PBH)
102    PublicHealth,
103
104    // Humanities
105    /// English (ENG/ENL)
106    English,
107    /// History (HIS/HST)
108    History,
109    /// Philosophy (PHI/PHL)
110    Philosophy,
111    /// Psychology (PSY)
112    Psychology,
113
114    // Business
115    /// Business (BUS/BUA)
116    Business,
117    /// Economics (ECO/ECN)
118    Economics,
119    /// Accounting (ACC/ACT)
120    Accounting,
121    /// Finance (FIN)
122    Finance,
123
124    /// Unknown subject with raw code
125    Unknown(u16),
126}
127
128impl Subject {
129    /// Parse subject from 3-letter code.
130    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                // Encode unknown as numeric hash
154                let hash = code.bytes().fold(0u16, |acc, b| acc.wrapping_add(b as u16));
155                Self::Unknown(hash)
156            }
157        }
158    }
159
160    /// Get canonical 3-letter code.
161    #[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    /// Check if STEM discipline.
188    #[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    /// Check if healthcare discipline.
203    #[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// ═══════════════════════════════════════════════════════════════════════════════
213// COURSE LEVEL (λ Position)
214// ═══════════════════════════════════════════════════════════════════════════════
215
216/// Course level (year/advancement).
217///
218/// ## Tier: T2-P (λ primitive)
219#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
220pub enum CourseLevel {
221    /// 100-level: Freshman introductory
222    Introductory = 1,
223    /// 200-level: Sophomore intermediate
224    Intermediate = 2,
225    /// 300-level: Junior advanced
226    Advanced = 3,
227    /// 400-level: Senior capstone
228    Capstone = 4,
229    /// 500-level: Graduate/Masters
230    Graduate = 5,
231    /// 600-level: Doctoral/Research
232    Doctoral = 6,
233    /// 700+: Post-doctoral/Seminar
234    PostDoctoral = 7,
235}
236
237impl CourseLevel {
238    /// Parse from first digit of course number.
239    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    /// Map to Prima tier (inverted — higher level = lower tier).
253    ///
254    /// Graduate+ research → T1 foundational
255    /// Senior synthesis → T2-C composite
256    /// Intro domain → T3 specific
257    #[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    /// Get numeric level (1-7).
268    #[must_use]
269    pub fn as_number(&self) -> u8 {
270        *self as u8
271    }
272
273    /// Get typical year designation.
274    #[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// ═══════════════════════════════════════════════════════════════════════════════
289// PRIMA TIER (Knowledge Abstraction)
290// ═══════════════════════════════════════════════════════════════════════════════
291
292/// Prima knowledge tier.
293///
294/// ## Grounding: κ (Comparison) — tiers are ordered
295#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
296pub enum PrimaTier {
297    /// T1: Universal primitives (foundational theory)
298    T1 = 1,
299    /// T2-P: Cross-domain primitives (research methodology)
300    T2P = 2,
301    /// T2-C: Cross-domain composites (synthesis)
302    T2C = 3,
303    /// T3: Domain-specific (applications)
304    T3 = 4,
305}
306
307impl PrimaTier {
308    /// Transfer confidence for cross-domain mapping.
309    #[must_use]
310    pub fn transfer_confidence(&self) -> f64 {
311        match self {
312            Self::T1 => 1.0,  // Universal — transfers perfectly
313            Self::T2P => 0.9, // Cross-domain primitive
314            Self::T2C => 0.7, // Cross-domain composite
315            Self::T3 => 0.4,  // Domain-specific — limited transfer
316        }
317    }
318
319    /// Symbol representation.
320    #[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// ═══════════════════════════════════════════════════════════════════════════════
332// COURSE (Full Identifier)
333// ═══════════════════════════════════════════════════════════════════════════════
334
335/// A complete course identifier.
336///
337/// ## Tier: T2-C (N + λ + σ + κ)
338///
339/// Combines subject (T3), level (λ), and number (σ).
340#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
341pub struct Course {
342    /// Subject domain.
343    pub subject: Subject,
344    /// Course number (e.g., 101, 350, 501).
345    pub number: u16,
346    /// Optional course title.
347    pub title: Option<String>,
348    /// Credit hours.
349    pub credits: u8,
350}
351
352impl Course {
353    /// Create a new course.
354    #[must_use]
355    pub fn new(subject: Subject, number: u16) -> Self {
356        Self {
357            subject,
358            number,
359            title: None,
360            credits: 3, // Default
361        }
362    }
363
364    /// Parse from string like "MTH 101" or "MTH101".
365    pub fn parse(s: &str) -> AcademyResult<Self> {
366        let s = s.trim().to_uppercase();
367
368        // Find where letters end and digits begin
369        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    /// Get course level.
394    #[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    /// Get Prima tier (inverted mapping).
401    #[must_use]
402    pub fn prima_tier(&self) -> PrimaTier {
403        self.level().to_prima_tier()
404    }
405
406    /// Get canonical string representation.
407    #[must_use]
408    pub fn code(&self) -> String {
409        format!("{} {}", self.subject.code(), self.number)
410    }
411
412    /// Check if graduate level.
413    #[must_use]
414    pub fn is_graduate(&self) -> bool {
415        self.number >= 500
416    }
417
418    /// Check if can be prerequisite for another course.
419    #[must_use]
420    pub fn can_prereq(&self, other: &Course) -> bool {
421        // Same subject, lower number
422        self.subject == other.subject && self.number < other.number
423    }
424
425    /// Set title.
426    #[must_use]
427    pub fn with_title(mut self, title: &str) -> Self {
428        self.title = Some(title.to_string());
429        self
430    }
431
432    /// Set credits.
433    #[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// ═══════════════════════════════════════════════════════════════════════════════
447// CURRICULUM (μ Mapping)
448// ═══════════════════════════════════════════════════════════════════════════════
449
450/// A curriculum — collection of courses with prerequisites.
451///
452/// ## Tier: T2-C (μ + σ + →)
453#[derive(Debug, Clone, Default)]
454pub struct Curriculum {
455    /// All courses in the curriculum.
456    pub courses: Vec<Course>,
457    /// Prerequisites: course index → required course indices.
458    pub prerequisites: Vec<Vec<usize>>,
459}
460
461impl Curriculum {
462    /// Create empty curriculum.
463    #[must_use]
464    pub fn new() -> Self {
465        Self::default()
466    }
467
468    /// Add a course.
469    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    /// Add prerequisite relationship.
477    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    /// Get courses at a specific level.
484    #[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    /// Get courses in a subject.
490    #[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    /// Calculate total credits at each tier.
499    #[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// ═══════════════════════════════════════════════════════════════════════════════
523// TESTS
524// ═══════════════════════════════════════════════════════════════════════════════
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    // ─────────────────────────────────────────────────────────────────────────
531    // Subject Tests
532    // ─────────────────────────────────────────────────────────────────────────
533
534    #[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    // ─────────────────────────────────────────────────────────────────────────
578    // CourseLevel Tests
579    // ─────────────────────────────────────────────────────────────────────────
580
581    #[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        // Higher course level → lower (more abstract) Prima tier
600        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    // ─────────────────────────────────────────────────────────────────────────
618    // PrimaTier Tests
619    // ─────────────────────────────────────────────────────────────────────────
620
621    #[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        // Lower tier number = more abstract = should be "less" in ordering
632        assert!(PrimaTier::T1 < PrimaTier::T2P);
633        assert!(PrimaTier::T2P < PrimaTier::T2C);
634        assert!(PrimaTier::T2C < PrimaTier::T3);
635    }
636
637    // ─────────────────────────────────────────────────────────────────────────
638    // Course Tests
639    // ─────────────────────────────────────────────────────────────────────────
640
641    #[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)); // Same subject, lower number
719        assert!(!calc2.can_prereq(&calc1)); // Higher can't be prereq of lower
720        assert!(!calc1.can_prereq(&physics)); // Different subject
721    }
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    // ─────────────────────────────────────────────────────────────────────────
736    // Curriculum Tests
737    // ─────────────────────────────────────────────────────────────────────────
738
739    #[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)); // T3
782        curriculum.add_course(Course::new(Subject::Mathematics, 201).with_credits(3)); // T3
783        curriculum.add_course(Course::new(Subject::Mathematics, 350).with_credits(3)); // T2-C
784        curriculum.add_course(Course::new(Subject::Mathematics, 550).with_credits(3)); // T2-P
785
786        let credits = curriculum.credits_by_tier();
787        assert_eq!(credits[3].1, 6); // T3: 3+3=6
788        assert_eq!(credits[2].1, 3); // T2-C: 3
789        assert_eq!(credits[1].1, 3); // T2-P: 3
790    }
791
792    // ─────────────────────────────────────────────────────────────────────────
793    // Knowledge Funnel Tests (Integration)
794    // ─────────────────────────────────────────────────────────────────────────
795
796    #[test]
797    fn test_knowledge_funnel_inversion() {
798        // Verify the knowledge funnel: higher academic level → lower Prima tier
799        let courses = [
800            Course::new(Subject::Mathematics, 101), // Freshman intro
801            Course::new(Subject::Mathematics, 201), // Sophomore
802            Course::new(Subject::Mathematics, 301), // Junior
803            Course::new(Subject::Mathematics, 401), // Senior
804            Course::new(Subject::Mathematics, 501), // Graduate
805            Course::new(Subject::Mathematics, 601), // Doctoral
806        ];
807
808        let tiers: Vec<PrimaTier> = courses.iter().map(|c| c.prima_tier()).collect();
809
810        // T3 → T3 → T2-C → T2-C → T2-P → T1 (descending abstraction)
811        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        // As you go up in academic level, transfer confidence increases
822        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}