Skip to main content

sat_score_projection/
lib.rs

1//! # sat-score-projection
2//!
3//! Estimate a **digital SAT** total scaled score (400–1600) from the number of
4//! raw correct answers in the Reading & Writing and Math sections.
5//!
6//! ## Important: this is an estimate
7//!
8//! The real digital SAT is **section-adaptive** — the second module's difficulty
9//! (and therefore its scoring weight) depends on performance in the first module —
10//! and the College Board **does not publish** exact raw-to-scaled conversion
11//! tables. There is therefore no single deterministic function from "questions
12//! correct" to a scaled score; the same raw count can map to different scaled
13//! scores depending on which module a test-taker was routed into.
14//!
15//! This crate provides a deliberately **transparent linear model** that
16//! approximates the published score scale (each section 200–800; total 400–1600)
17//! across the full raw range (0 to the maximum number of questions). It is
18//! intended for orientation and "what-if" planning — **not** as a prediction of
19//! any individual's official score. Treat every output as ± a few tens of points.
20//!
21//! If you need a published conversion rather than an approximation, the
22//! released digital-SAT practice forms each ship their own raw-to-scaled table.
23//! ExamScoreCalc runs the table belonging to the form you sat, prints every row
24//! of that table on the page rather than hiding the lookup, and hands back the
25//! score *range* College Board publishes instead of picking one number inside
26//! it: <https://examscorecalc.com/digital-sat-score-calculator/>
27//!
28//! ## Quick example
29//! ```
30//! use sat_score_projection::{section_score, total_score};
31//!
32//! // Digital SAT: 54 Reading & Writing items, 44 Math items.
33//! let rw = section_score(40, 54);   // ~40 of 54 RW correct
34//! let math = section_score(30, 44); // ~30 of 44 Math correct
35//! let total = total_score(40, 54, 30, 44);
36//!
37//! assert!((200.0..=800.0).contains(&rw));
38//! assert!((200.0..=800.0).contains(&math));
39//! assert!((400.0..=1600.0).contains(&total));
40//! ```
41
42/// Minimum scaled score for a single SAT section (College Board floor).
43pub const MIN_SECTION_SCORE: f64 = 200.0;
44/// Maximum scaled score for a single SAT section (College Board ceiling).
45pub const MAX_SECTION_SCORE: f64 = 800.0;
46/// Minimum total SAT score (two sections combined).
47pub const MIN_TOTAL_SCORE: f64 = 400.0;
48/// Maximum total SAT score (two sections combined).
49pub const MAX_TOTAL_SCORE: f64 = 1600.0;
50
51/// Number of scored items in the digital SAT Reading & Writing section (2 modules × 27).
52pub const RW_ITEMS: u32 = 54;
53/// Number of scored items in the digital SAT Math section (2 modules × 22).
54pub const MATH_ITEMS: u32 = 44;
55
56/// Clamp a value into the inclusive `[min, max]` range.
57#[inline]
58fn clamp(v: f64, min: f64, max: f64) -> f64 {
59    v.max(min).min(max)
60}
61
62/// Estimated **section** scaled score (200–800) from a raw correct count.
63///
64/// `correct` may exceed `max_items` or be negative; both are clamped before the
65/// linear projection. The model maps 0 correct → 200 and a perfect paper → 800
66/// along a straight line. Because the live exam is section-adaptive and the
67/// College Board does not publish exact conversions, **treat this as an
68/// approximation**, typically within a few tens of points of an official score.
69pub fn section_score(correct: i32, max_items: u32) -> f64 {
70    let max = max_items as f64;
71    let c = clamp(correct as f64, 0.0, max);
72    let span = MAX_SECTION_SCORE - MIN_SECTION_SCORE;
73    MIN_SECTION_SCORE + span * (c / max)
74}
75
76/// Estimated **total** SAT score (400–1600) from raw correct counts in both
77/// sections.
78///
79/// Convenience wrapper: projects each section independently (200–800) and sums
80/// them. See [`section_score`] for the accuracy caveats — the result is a
81/// planning estimate, not an official score prediction.
82pub fn total_score(rw_correct: i32, rw_max: u32, math_correct: i32, math_max: u32) -> f64 {
83    let rw = section_score(rw_correct, rw_max);
84    let math = section_score(math_correct, math_max);
85    clamp(rw + math, MIN_TOTAL_SCORE, MAX_TOTAL_SCORE)
86}
87
88/// Total score using the standard **digital SAT** item counts (54 RW, 44 Math).
89///
90/// Equivalent to `total_score(rw, RW_ITEMS, math, MATH_ITEMS)`.
91pub fn digital_sat_total(rw_correct: i32, math_correct: i32) -> f64 {
92    total_score(rw_correct, RW_ITEMS, math_correct, MATH_ITEMS)
93}
94
95/// Fraction of items answered correctly in a section (0.0–1.0), clamped.
96pub fn accuracy(correct: i32, max_items: u32) -> f64 {
97    let max = max_items as f64;
98    clamp(correct as f64, 0.0, max) / max
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn zero_correct_is_floor() {
107        assert!((section_score(0, RW_ITEMS) - 200.0).abs() < 1e-9);
108        assert!((section_score(0, MATH_ITEMS) - 200.0).abs() < 1e-9);
109    }
110
111    #[test]
112    fn perfect_is_ceiling() {
113        assert!((section_score(54, RW_ITEMS) - 800.0).abs() < 1e-9);
114        assert!((section_score(44, MATH_ITEMS) - 800.0).abs() < 1e-9);
115    }
116
117    #[test]
118    fn total_range_is_400_to_1600() {
119        let floor = digital_sat_total(0, 0);
120        let ceil = digital_sat_total(RW_ITEMS as i32, MATH_ITEMS as i32);
121        assert!((floor - 400.0).abs() < 1e-9);
122        assert!((ceil - 1600.0).abs() < 1e-9);
123    }
124
125    #[test]
126    fn clamps_negative_and_overflow() {
127        assert!((section_score(-5, RW_ITEMS) - 200.0).abs() < 1e-9);
128        assert!((section_score(99, RW_ITEMS) - 800.0).abs() < 1e-9);
129    }
130
131    #[test]
132    fn halfway_is_midpoint() {
133        // Half of RW items correct → 200 + 300 = 500
134        let s = section_score(27, RW_ITEMS);
135        assert!((s - 500.0).abs() < 1e-6);
136    }
137
138    #[test]
139    fn accuracy_clamped_and_bounded() {
140        assert!((accuracy(27, 54) - 0.5).abs() < 1e-9);
141        assert_eq!(accuracy(0, 54), 0.0);
142        assert_eq!(accuracy(54, 54), 1.0);
143        // overflow clamps to 1.0
144        assert_eq!(accuracy(99, 54), 1.0);
145    }
146
147    #[test]
148    fn typical_score_lands_in_plausible_band() {
149        // 40/54 RW → 200 + (40/54)*600 = 644.44
150        // 30/44 Math → 200 + (30/44)*600 = 609.09
151        // Total ≈ 1253.53
152        let total = digital_sat_total(40, 30);
153        assert!((total - 1_253.53).abs() < 0.05);
154        assert!((1000.0..=1500.0).contains(&total));
155    }
156}