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}