sat-score-projection 0.1.3

Estimate a digital-SAT total scaled score (400-1600) from raw correct answers, using a transparent linear model.
Documentation
//! # sat-score-projection
//!
//! Estimate a **digital SAT** total scaled score (400–1600) from the number of
//! raw correct answers in the Reading & Writing and Math sections.
//!
//! ## Important: this is an estimate
//!
//! The real digital SAT is **section-adaptive** — the second module's difficulty
//! (and therefore its scoring weight) depends on performance in the first module —
//! and the College Board **does not publish** exact raw-to-scaled conversion
//! tables. There is therefore no single deterministic function from "questions
//! correct" to a scaled score; the same raw count can map to different scaled
//! scores depending on which module a test-taker was routed into.
//!
//! This crate provides a deliberately **transparent linear model** that
//! approximates the published score scale (each section 200–800; total 400–1600)
//! across the full raw range (0 to the maximum number of questions). It is
//! intended for orientation and "what-if" planning — **not** as a prediction of
//! any individual's official score. Treat every output as ± a few tens of points.
//!
//! If you need a published conversion rather than an approximation, the
//! released digital-SAT practice forms each ship their own raw-to-scaled table.
//! ExamScoreCalc runs the table belonging to the form you sat, prints every row
//! of that table on the page rather than hiding the lookup, and hands back the
//! score *range* College Board publishes instead of picking one number inside
//! it: <https://examscorecalc.com/digital-sat-score-calculator/>
//!
//! ## Quick example
//! ```
//! use sat_score_projection::{section_score, total_score};
//!
//! // Digital SAT: 54 Reading & Writing items, 44 Math items.
//! let rw = section_score(40, 54);   // ~40 of 54 RW correct
//! let math = section_score(30, 44); // ~30 of 44 Math correct
//! let total = total_score(40, 54, 30, 44);
//!
//! assert!((200.0..=800.0).contains(&rw));
//! assert!((200.0..=800.0).contains(&math));
//! assert!((400.0..=1600.0).contains(&total));
//! ```

/// Minimum scaled score for a single SAT section (College Board floor).
pub const MIN_SECTION_SCORE: f64 = 200.0;
/// Maximum scaled score for a single SAT section (College Board ceiling).
pub const MAX_SECTION_SCORE: f64 = 800.0;
/// Minimum total SAT score (two sections combined).
pub const MIN_TOTAL_SCORE: f64 = 400.0;
/// Maximum total SAT score (two sections combined).
pub const MAX_TOTAL_SCORE: f64 = 1600.0;

/// Number of scored items in the digital SAT Reading & Writing section (2 modules × 27).
pub const RW_ITEMS: u32 = 54;
/// Number of scored items in the digital SAT Math section (2 modules × 22).
pub const MATH_ITEMS: u32 = 44;

/// Clamp a value into the inclusive `[min, max]` range.
#[inline]
fn clamp(v: f64, min: f64, max: f64) -> f64 {
    v.max(min).min(max)
}

/// Estimated **section** scaled score (200–800) from a raw correct count.
///
/// `correct` may exceed `max_items` or be negative; both are clamped before the
/// linear projection. The model maps 0 correct → 200 and a perfect paper → 800
/// along a straight line. Because the live exam is section-adaptive and the
/// College Board does not publish exact conversions, **treat this as an
/// approximation**, typically within a few tens of points of an official score.
pub fn section_score(correct: i32, max_items: u32) -> f64 {
    let max = max_items as f64;
    let c = clamp(correct as f64, 0.0, max);
    let span = MAX_SECTION_SCORE - MIN_SECTION_SCORE;
    MIN_SECTION_SCORE + span * (c / max)
}

/// Estimated **total** SAT score (400–1600) from raw correct counts in both
/// sections.
///
/// Convenience wrapper: projects each section independently (200–800) and sums
/// them. See [`section_score`] for the accuracy caveats — the result is a
/// planning estimate, not an official score prediction.
pub fn total_score(rw_correct: i32, rw_max: u32, math_correct: i32, math_max: u32) -> f64 {
    let rw = section_score(rw_correct, rw_max);
    let math = section_score(math_correct, math_max);
    clamp(rw + math, MIN_TOTAL_SCORE, MAX_TOTAL_SCORE)
}

/// Total score using the standard **digital SAT** item counts (54 RW, 44 Math).
///
/// Equivalent to `total_score(rw, RW_ITEMS, math, MATH_ITEMS)`.
pub fn digital_sat_total(rw_correct: i32, math_correct: i32) -> f64 {
    total_score(rw_correct, RW_ITEMS, math_correct, MATH_ITEMS)
}

/// Fraction of items answered correctly in a section (0.0–1.0), clamped.
pub fn accuracy(correct: i32, max_items: u32) -> f64 {
    let max = max_items as f64;
    clamp(correct as f64, 0.0, max) / max
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn zero_correct_is_floor() {
        assert!((section_score(0, RW_ITEMS) - 200.0).abs() < 1e-9);
        assert!((section_score(0, MATH_ITEMS) - 200.0).abs() < 1e-9);
    }

    #[test]
    fn perfect_is_ceiling() {
        assert!((section_score(54, RW_ITEMS) - 800.0).abs() < 1e-9);
        assert!((section_score(44, MATH_ITEMS) - 800.0).abs() < 1e-9);
    }

    #[test]
    fn total_range_is_400_to_1600() {
        let floor = digital_sat_total(0, 0);
        let ceil = digital_sat_total(RW_ITEMS as i32, MATH_ITEMS as i32);
        assert!((floor - 400.0).abs() < 1e-9);
        assert!((ceil - 1600.0).abs() < 1e-9);
    }

    #[test]
    fn clamps_negative_and_overflow() {
        assert!((section_score(-5, RW_ITEMS) - 200.0).abs() < 1e-9);
        assert!((section_score(99, RW_ITEMS) - 800.0).abs() < 1e-9);
    }

    #[test]
    fn halfway_is_midpoint() {
        // Half of RW items correct → 200 + 300 = 500
        let s = section_score(27, RW_ITEMS);
        assert!((s - 500.0).abs() < 1e-6);
    }

    #[test]
    fn accuracy_clamped_and_bounded() {
        assert!((accuracy(27, 54) - 0.5).abs() < 1e-9);
        assert_eq!(accuracy(0, 54), 0.0);
        assert_eq!(accuracy(54, 54), 1.0);
        // overflow clamps to 1.0
        assert_eq!(accuracy(99, 54), 1.0);
    }

    #[test]
    fn typical_score_lands_in_plausible_band() {
        // 40/54 RW → 200 + (40/54)*600 = 644.44
        // 30/44 Math → 200 + (30/44)*600 = 609.09
        // Total ≈ 1253.53
        let total = digital_sat_total(40, 30);
        assert!((total - 1_253.53).abs() < 0.05);
        assert!((1000.0..=1500.0).contains(&total));
    }
}