Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
abc8950
docs: design paired rating range evidence
seonghobae Aug 9, 2026
61fbc60
docs: correct paired range evidence citation
seonghobae Aug 9, 2026
6d678c9
docs: plan paired rating range evidence
seonghobae Aug 9, 2026
0e1550b
test: define paired rating range evidence contracts
seonghobae Aug 9, 2026
19b6ee7
feat(validation): add Rust paired rating-range evidence
seonghobae Aug 9, 2026
8b9f485
feat(validation): register rating-range extension
seonghobae Aug 9, 2026
15c4e1d
feat(validation): add rating-range extension loader
seonghobae Aug 9, 2026
5bfd6f0
feat(validation): add typed paired range evidence wrapper
seonghobae Aug 9, 2026
9a52b7d
feat(validation): expose paired range evidence
seonghobae Aug 9, 2026
cff209a
test(validation): require reusable Rust-core rating range API
seonghobae Aug 9, 2026
3e26838
feat(validation): move paired rating range evidence into Rust core
seonghobae Aug 9, 2026
ed7cade
refactor(validation): delegate rating range numerics to Rust core
seonghobae Aug 9, 2026
8e1a17f
feat(validation): export paired rating range core module
seonghobae Aug 9, 2026
64ba3eb
docs(validation): doctor paired rating range evidence
seonghobae Aug 9, 2026
9b043ec
docs(changelog): record paired rating range evidence
seonghobae Aug 9, 2026
d8c06ac
test(validation): prove every rating range field delegates to Rust
seonghobae Aug 9, 2026
f11f46c
Merge d8c06ace4f227e0f0152103de778b649b65220d8 into 7516031df533d1131…
seonghobae Aug 9, 2026
fa5ad82
chore(validation): reconcile rating-range evidence with protected main
seonghobae Aug 9, 2026
c4a290b
chore(validation): reconcile rating-range evidence with protected main
seonghobae Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions crates/fast-mlsirm-py/src/entrypoint.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
//! PyO3 entrypoint preserving `_core` and registering modular extensions.
//!
//! PyO3 permits one shared library to export multiple `PyInit_*` symbols. The
//! historical module remains `_core`; the bifactor and rotation bindings export
//! `_bifactor_core` and `_rotation_core` from the same binary. Keeping the
//! registrations together prevents stacked feature branches from silently
//! replacing one another's extension-module entrypoint.
//! historical module remains `_core`; bounded domain modules export additional
//! initialization symbols from the same binary. Keeping registrations together
//! prevents stacked feature branches from silently replacing one another's
//! extension-module entrypoint.

include!("lib.rs");

mod bifactor_bindings;
mod rating_range_bindings;
mod rotation_bindings;
126 changes: 126 additions & 0 deletions crates/fast-mlsirm-py/src/rating_range_bindings.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//! Rust-owned paired rating-range evidence exposed through a modular PyO3 entrypoint.
//!
//! Numerical statistics live exclusively in `mlsirm-core::rating_range`. This
//! binding validates Python array layout, delegates unchanged rating slices to
//! the core, and marshals the Rust result into a Python dictionary.

use mlsirm_core::rating_range::{paired_rating_range_evidence, PairedRatingRangeEvidence};
use numpy::{PyReadonlyArray1, PyUntypedArrayMethods};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyModule};
use pyo3::wrap_pyfunction;

fn result_dict(py: Python<'_>, result: PairedRatingRangeEvidence) -> PyResult<Py<PyDict>> {
let out = PyDict::new(py);
out.set_item("sample_size", result.sample_size)?;
out.set_item("automated_min", result.automated_min)?;
out.set_item("automated_max", result.automated_max)?;
out.set_item("reference_min", result.reference_min)?;
out.set_item("reference_max", result.reference_max)?;
out.set_item(
"automated_distinct_categories",
result.automated_distinct_categories,
)?;
out.set_item(
"reference_distinct_categories",
result.reference_distinct_categories,
)?;
out.set_item("automated_span", result.automated_span)?;
out.set_item("reference_span", result.reference_span)?;
out.set_item("automated_sd", result.automated_sd)?;
out.set_item("reference_sd", result.reference_sd)?;
out.set_item("span_ratio", result.span_ratio)?;
out.set_item("distinct_category_ratio", result.distinct_category_ratio)?;
out.set_item("sd_ratio", result.sd_ratio)?;
out.set_item("lower_endpoint_gap", result.lower_endpoint_gap)?;
out.set_item("upper_endpoint_gap", result.upper_endpoint_gap)?;
out.set_item("narrower_observed_support", result.narrower_observed_support)?;
out.set_item("central_tendency_signal", result.central_tendency_signal)?;
Ok(out.into())
}

#[pyfunction(name = "paired_rating_range_evidence")]
fn py_paired_rating_range_evidence(
py: Python<'_>,
automated: PyReadonlyArray1<'_, u32>,
reference: PyReadonlyArray1<'_, u32>,
category_count: usize,
) -> PyResult<Py<PyDict>> {
if automated.ndim() != 1 || reference.ndim() != 1 {
return Err(PyValueError::new_err("ratings must be 1-D arrays"));
}
let result = paired_rating_range_evidence(
automated.as_slice()?,
reference.as_slice()?,
category_count,
)
.map_err(PyValueError::new_err)?;
result_dict(py, result)
}

#[pymodule]
#[pyo3(name = "_rating_range_core")]
fn fast_mlsirm_rating_range_core(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(py_paired_rating_range_evidence, m)?)?;
Ok(())
}

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

#[test]
fn binding_uses_core_hand_calculation() {
let result = paired_rating_range_evidence(&[1, 1, 2, 3, 3], &[0, 1, 2, 3, 4], 5)
.expect("valid paired ratings");
assert_eq!(result.sample_size, 5);
assert_eq!((result.automated_min, result.automated_max), (1, 3));
assert_eq!((result.reference_min, result.reference_max), (0, 4));
assert_eq!(result.automated_distinct_categories, 3);
assert_eq!(result.reference_distinct_categories, 5);
assert_eq!(result.automated_span, 2);
assert_eq!(result.reference_span, 4);
assert!((result.automated_sd - 0.8_f64.sqrt()).abs() < 1e-12);
assert!((result.reference_sd - 2.0_f64.sqrt()).abs() < 1e-12);
assert_eq!(result.span_ratio, Some(0.5));
assert!((result.distinct_category_ratio - 0.6).abs() < 1e-12);
assert!((result.sd_ratio.expect("identified") - 0.4_f64.sqrt()).abs() < 1e-12);
assert_eq!(result.lower_endpoint_gap, 1);
assert_eq!(result.upper_endpoint_gap, 1);
assert!(result.narrower_observed_support);
assert!(result.central_tendency_signal);
}

#[test]
fn binding_preserves_core_degenerate_reference_behavior() {
let result = paired_rating_range_evidence(&[1, 2, 2, 3], &[2, 2, 2, 2], 5)
.expect("valid paired ratings");
assert_eq!(result.reference_span, 0);
assert_eq!(result.reference_sd, 0.0);
assert_eq!(result.span_ratio, None);
assert_eq!(result.sd_ratio, None);
assert_eq!(result.distinct_category_ratio, 3.0);
assert!(!result.narrower_observed_support);
assert!(!result.central_tendency_signal);
}

#[test]
fn binding_preserves_core_validation_errors() {
assert!(paired_rating_range_evidence(&[0], &[0], 2)
.expect_err("too short")
.contains("at least two"));
assert!(paired_rating_range_evidence(&[0, 1], &[0], 2)
.expect_err("length mismatch")
.contains("length"));
assert!(paired_rating_range_evidence(&[0, 2], &[0, 1], 2)
.expect_err("out of range")
.contains("0..category_count-1"));
assert!(paired_rating_range_evidence(&[0, 1], &[0, 1], 1)
.expect_err("bad category count")
.contains("category_count"));
assert!(paired_rating_range_evidence(&[0, 1], &[0, 1], 1_001)
.expect_err("bad category count")
.contains("category_count"));
}
}
1 change: 1 addition & 0 deletions crates/mlsirm-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub mod poly;
pub mod poly_marginal;
pub(crate) mod quadrature;
pub mod rasch_cml;
pub mod rating_range;
pub mod reliability;
pub mod rsm;
pub mod rt;
Expand Down
161 changes: 161 additions & 0 deletions crates/mlsirm-core/src/rating_range.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//! Descriptive paired rating-range evidence for automated scoring validation.
//!
//! This module summarizes how an automated scorer uses an ordinal category
//! scale relative to paired reference ratings. It is intentionally descriptive:
//! the returned statistics are not a generalized many-facet range-restriction
//! parameter and they do not define universal pass/fail thresholds.

const MAX_CATEGORY_COUNT: usize = 1_000;

/// Descriptive category-support and dispersion evidence for paired ratings.
#[derive(Clone, Debug, PartialEq)]
pub struct PairedRatingRangeEvidence {
/// Number of paired rating observations.
pub sample_size: usize,
/// Smallest observed automated-score category.
pub automated_min: usize,
/// Largest observed automated-score category.
pub automated_max: usize,
/// Smallest observed reference-score category.
pub reference_min: usize,
/// Largest observed reference-score category.
pub reference_max: usize,
/// Number of distinct categories used by the automated scorer.
pub automated_distinct_categories: usize,
/// Number of distinct categories used by the reference scorer.
pub reference_distinct_categories: usize,
/// Observed automated-score range, `automated_max - automated_min`.
pub automated_span: usize,
/// Observed reference-score range, `reference_max - reference_min`.
pub reference_span: usize,
/// Population-divisor empirical standard deviation of automated ratings.
pub automated_sd: f64,
/// Population-divisor empirical standard deviation of reference ratings.
pub reference_sd: f64,
/// Automated/reference observed-span ratio, unavailable for zero reference span.
pub span_ratio: Option<f64>,
/// Automated/reference distinct-category-count ratio.
pub distinct_category_ratio: f64,
/// Automated/reference empirical-SD ratio, unavailable for zero reference SD.
pub sd_ratio: Option<f64>,
/// Signed lower-end gap, `automated_min - reference_min`.
pub lower_endpoint_gap: i64,
/// Signed upper-end gap, `reference_max - automated_max`.
pub upper_endpoint_gap: i64,
/// Whether automated support is narrower in both span and category count.
pub narrower_observed_support: bool,
/// Whether narrower support also truncates both reference endpoints inward.
pub central_tendency_signal: bool,
}

#[derive(Clone, Copy, Debug)]
struct RatingSummary {
minimum: usize,
maximum: usize,
distinct_categories: usize,
sd: f64,
}

fn summarize_ratings(labels: &[u32], category_count: usize) -> Result<RatingSummary, String> {
let mut present = vec![false; category_count];
let mut minimum = usize::MAX;
let mut maximum = 0usize;
let mut mean = 0.0;
let mut m2 = 0.0;

for (index, &raw) in labels.iter().enumerate() {
let label = usize::try_from(raw).map_err(|_| "rating label does not fit usize")?;
if label >= category_count {
return Err(format!(
"rating label at paired index {index} must be in 0..category_count-1"
));
}
present[label] = true;
minimum = minimum.min(label);
maximum = maximum.max(label);

let x = f64::from(raw);
let n = (index + 1) as f64;
let delta = x - mean;
mean += delta / n;
m2 += delta * (x - mean);
}

let distinct_categories = present.into_iter().filter(|seen| *seen).count();
let variance = m2 / labels.len() as f64;
Ok(RatingSummary {
minimum,
maximum,
distinct_categories,
sd: variance.max(0.0).sqrt(),
})
}

/// Compute descriptive rating-range evidence over the same paired cases.
///
/// `automated` and `reference` must have equal lengths of at least two. Every
/// label must be in `0..category_count`, and `category_count` must be between 2
/// and 1,000 inclusive. Dispersion uses the population divisor (`n`) because
/// these paired observations are the complete validation cases supplied to this
/// diagnostic rather than a sample-SD estimator.
///
/// A zero reference span or reference standard deviation makes the corresponding
/// relative ratio unidentified; that ratio is returned as [`None`] rather than
/// NaN or infinity.
///
/// # Errors
///
/// Returns a bounded descriptive error when lengths, category count, or labels
/// violate the public contract.
pub fn paired_rating_range_evidence(
automated: &[u32],
reference: &[u32],
category_count: usize,
) -> Result<PairedRatingRangeEvidence, String> {
if !(2..=MAX_CATEGORY_COUNT).contains(&category_count) {
return Err(format!(
"category_count must be between 2 and {MAX_CATEGORY_COUNT}"
));
}
if automated.len() != reference.len() {
return Err("automated and reference rating lengths must match".to_owned());
}
if automated.len() < 2 {
return Err("paired ratings require at least two observations".to_owned());
}

let automated_summary = summarize_ratings(automated, category_count)?;
let reference_summary = summarize_ratings(reference, category_count)?;
let automated_span = automated_summary.maximum - automated_summary.minimum;
let reference_span = reference_summary.maximum - reference_summary.minimum;
let lower_endpoint_gap = automated_summary.minimum as i64 - reference_summary.minimum as i64;
let upper_endpoint_gap = reference_summary.maximum as i64 - automated_summary.maximum as i64;
let narrower_observed_support = automated_span < reference_span
&& automated_summary.distinct_categories < reference_summary.distinct_categories;
let central_tendency_signal =
narrower_observed_support && lower_endpoint_gap > 0 && upper_endpoint_gap > 0;

Ok(PairedRatingRangeEvidence {
sample_size: automated.len(),
automated_min: automated_summary.minimum,
automated_max: automated_summary.maximum,
reference_min: reference_summary.minimum,
reference_max: reference_summary.maximum,
automated_distinct_categories: automated_summary.distinct_categories,
reference_distinct_categories: reference_summary.distinct_categories,
automated_span,
reference_span,
automated_sd: automated_summary.sd,
reference_sd: reference_summary.sd,
span_ratio: (reference_span > 0)
.then_some(automated_span as f64 / reference_span as f64),
distinct_category_ratio: automated_summary.distinct_categories as f64
/ reference_summary.distinct_categories as f64,
sd_ratio: (reference_summary.sd > 0.0)
.then_some(automated_summary.sd / reference_summary.sd),
lower_endpoint_gap,
upper_endpoint_gap,
narrower_observed_support,
central_tendency_signal,
})
}
50 changes: 50 additions & 0 deletions crates/mlsirm-core/tests/rating_range_evidence.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//! Public Rust-core contract tests for paired rating-range evidence.

use mlsirm_core::rating_range::{paired_rating_range_evidence, PairedRatingRangeEvidence};

#[test]
fn public_core_recovers_hand_calculated_range_evidence() {
let result: PairedRatingRangeEvidence =
paired_rating_range_evidence(&[1, 1, 2, 3, 3], &[0, 1, 2, 3, 4], 5)
.expect("valid paired ratings");

assert_eq!(result.sample_size, 5);
assert_eq!((result.automated_min, result.automated_max), (1, 3));
assert_eq!((result.reference_min, result.reference_max), (0, 4));
assert_eq!(result.automated_distinct_categories, 3);
assert_eq!(result.reference_distinct_categories, 5);
assert_eq!(result.automated_span, 2);
assert_eq!(result.reference_span, 4);
assert!((result.automated_sd - 0.8_f64.sqrt()).abs() < 1e-12);
assert!((result.reference_sd - 2.0_f64.sqrt()).abs() < 1e-12);
assert_eq!(result.span_ratio, Some(0.5));
assert!((result.distinct_category_ratio - 0.6).abs() < 1e-12);
assert!((result.sd_ratio.expect("identified") - 0.4_f64.sqrt()).abs() < 1e-12);
assert_eq!(result.lower_endpoint_gap, 1);
assert_eq!(result.upper_endpoint_gap, 1);
assert!(result.narrower_observed_support);
assert!(result.central_tendency_signal);
}

#[test]
fn public_core_omits_unidentified_relative_ratios() {
let result = paired_rating_range_evidence(&[1, 2, 2, 3], &[2, 2, 2, 2], 5)
.expect("valid paired ratings");

assert_eq!(result.reference_span, 0);
assert_eq!(result.reference_sd, 0.0);
assert_eq!(result.span_ratio, None);
assert_eq!(result.sd_ratio, None);
assert_eq!(result.distinct_category_ratio, 3.0);
assert!(!result.narrower_observed_support);
assert!(!result.central_tendency_signal);
}

#[test]
fn public_core_rejects_invalid_inputs() {
assert!(paired_rating_range_evidence(&[0], &[0], 2).is_err());
assert!(paired_rating_range_evidence(&[0, 1], &[0], 2).is_err());
assert!(paired_rating_range_evidence(&[0, 2], &[0, 1], 2).is_err());
assert!(paired_rating_range_evidence(&[0, 1], &[0, 1], 1).is_err());
assert!(paired_rating_range_evidence(&[0, 1], &[0, 1], 1_001).is_err());
}
7 changes: 7 additions & 0 deletions docs/changelog.d/397-paired-rating-range-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Paired rating-range evidence

## Added

- Added a Rust-owned paired automated/reference rating diagnostic for observed category endpoints, distinct category use, span, empirical dispersion, relative ratios, endpoint gaps, narrower-support evidence, and a conservative central-tendency signal.
- Added a thin PyO3/Python product path that delegates numerical statistics to `mlsirm-core` and keeps descriptive range-use evidence separate from agreement, rater severity, and future generalized many-facet range-restriction parameters.
- Added fail-closed input/degenerate-reference behavior and APA 7 doctoring for automated essay-scoring validation.
Loading
Loading