Skip to content

feat(mokken): Mokken scale analysis with Loevinger H and AISP - #219

Merged
seonghobae merged 2 commits into
mainfrom
seonghobae-mokken-scale-analysis
Jul 25, 2026
Merged

feat(mokken): Mokken scale analysis with Loevinger H and AISP#219
seonghobae merged 2 commits into
mainfrom
seonghobae-mokken-scale-analysis

Conversation

@seonghobae

Copy link
Copy Markdown
Contributor

Mokken scale analysis: Loevinger H, Mokken Z, and AISP

Adds nonparametric Mokken scale analysis (Mokken, 1971, as cited in van der Ark, 2007) to the Rust core with a thin Python wrapper. Stacked on #218.

What

  • mlsirm_core::mokken (new): Loevinger scalability coefficients Hij, Hi, H; Mokken Z statistics; and the automated item selection procedure (AISP, "search normal"). Dichotomous and polytomous complete integer data.
  • PyO3: mokken_coef_h, mokken_aisp.
  • Python: fast_mlsirm.mokken_analysisMokkenResult (hij/hi/h, zij/zi/z, per-item AISP scale labels; 0 = unscalable).
  • For LLM-as-a-Judge item-quality management this flags evaluation items that fail to scale and detects multidimensional item pools before parametric IRT calibration.

Source verification (spec-verify workflow ran before implementation)

  • Read directly: van der Ark (2007, JSS, doi:10.18637/jss.v020.i11); Straat, van der Ark, & Sijtsma (2013, J. Classification, doi:10.1007/s00357-013-9122-y).
  • Sample statistics and AISP mechanics verified line-by-line against the mokken R package source (cran/mokken: internalFunctions.R::coefHTiny, coefZ.R, search.normal.R): Smax = sorted-column (comonotone) covariance; Hi/H = ratios of pairwise sums; per-scale Bonferroni-adjusted Z gates alpha/(K1(K1-1)/2 + sum of step candidate counts); Criterion-1 negative-Hij exclusion; eps tie-break on the larger pair index.
  • Adversarial spec review REFUTED an initially claimed equivalence between the pairwise-sum Hi denominator and a fixed-restscore-marginal reading of van der Ark Eq. 2 (counterexample: X1=X2=[0,0,1,1], X3=[0,1,0,1] → 1/3 vs 2/3); the module doc records the counterexample and implements the R-package form.
  • NOT read: Mokken (1971), Sijtsma & Molenaar (2002) — cited only "as cited in" the read sources. The Z normal approximation has no verified primary derivation; documented as implementation-verified.

Test evidence (every assert reads crate outputs)

Test Kills
brute-force covariance oracle (independent two-pass path) any algebra mutant in pairwise/h_subset
exact Guttman scalogram H = 1 covmax mutants
hand-computed Z fixture (S=0.1, s=0.3, Z=0.1·√5/0.3) √(N−1)/variance-product mutants
Z-gate anchor (Hij=1/3 > c but Z≈0.745 < 1.645) deleting the significance gate — this test caught a real branch-sign bug in the Acklam quantile during development
Acklam anchors (Φ⁻¹(0.95), Φ⁻¹(0.999)) quantile sign/branch flips
hand-built 80×3 Criterion-1 fixture (H01=0.5, H02=−0.05, Hi(2)=0.2, Zi(2)=2.51>zc — only the negative-Hij gate blocks) removing Criterion 1 — mutation-verified FAIL
exact-tie eps fixture (X0=X3 ⊥ X1=X2 → labels [2,1,1,2]) lexicographic tie-break — mutation-verified FAIL
two-cluster AISP recovery (N=1500) selection-logic mutants
translation invariance raw-moment mutants
input rejection (10 cases, wrapper + crate) guard deletion
#[ignore] 500-rep MC (normal + skewed traits): mean-H band + ≥95% one-scale recovery distributional regression

Known limitation (stated, not hidden): the MC test cannot pin exact constants; algebra anchors are the oracle and hand fixtures.

Adversarial impl-review ran post-implementation: confirmed all math OK by independent computation; its two findings (tie-break fidelity vs R's eps rule; a mismatched unread-source citation) are fixed in this PR, with the tie-break now pinned by its own mutation-verified test.

cargo test -p mlsirm-core: 437 passed. Python suite targeted run: 2 passed.

Base automatically changed from seonghobae-pr-213-completion-loop to seonghobae-validate-commit-160 July 24, 2026 07:26
@seonghobae
seonghobae requested a review from Copilot July 24, 2026 11:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds two new psychometric item-quality / calibration capabilities to fast-mlsirm: (1) nonparametric Mokken scale analysis (Loevinger H, Mokken Z, AISP “search normal”) and (2) Many-Facet Rasch Model (MFRM) rater-severity calibration, both implemented in the Rust core with thin Python wrappers and backed by Rust + Python test coverage.

Changes:

  • Add mlsirm_core::mokken (coefficients + Z + AISP) and expose it via PyO3 + fast_mlsirm.mokken_analysis.
  • Add mlsirm_core::facets (MFRM via marginal-ML EM + connectedness flag) and expose it via PyO3 + fast_mlsirm.fit_facets.
  • Add extensive Rust unit tests and Python “paper features” tests for both features; update public exports and changelog.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
crates/mlsirm-core/src/mokken.rs New Rust Mokken implementation (H, Z, AISP) and internal tests hook.
crates/mlsirm-core/src/facets.rs New Rust MFRM EM implementation with connectedness diagnostic and tests hook.
crates/mlsirm-core/src/lib.rs Exposes new core modules.
crates/fast-mlsirm-py/src/lib.rs Adds PyO3 bindings for fit_facets, mokken_coef_h, mokken_aisp.
python/fast_mlsirm/mokken.py Python validation + marshaling wrapper returning MokkenResult.
python/fast_mlsirm/facets.py Python validation + marshaling wrapper returning FacetsFit.
python/fast_mlsirm/__init__.py Exports new public API symbols.
tests/unit/mokken_tests.rs Rust unit tests for Mokken (oracle, anchors, AISP fixtures).
tests/unit/facets_tests.rs Rust unit tests for facets (FD anchors, recovery, sparse/disconnected).
tests/test_paper_features.py Python integration/regression tests for facets + mokken wrappers.
CHANGELOG.md Documents both new features and their scope/verification summary.
Comments suppressed due to low confidence (2)

crates/mlsirm-core/src/mokken.rs:214

  • normal_upper_quantile only has a debug_assert!(q > 0 && q < 1) after computing q = 1 - p. For very small p, q can round to exactly 1.0 (even when p > 0), tripping the debug assert and/or sending later code down an invalid path. Add explicit runtime bounds handling for p and avoid asserting on q.
fn normal_upper_quantile(p: f64) -> f64 {
    // invert the CDF at 1 - p using Peter Acklam's approximation
    let q = 1.0 - p;
    debug_assert!(q > 0.0 && q < 1.0);

crates/mlsirm-core/src/mokken.rs:260

  • In the upper-tail branch, r is computed from (1.0 - q).ln() where q = 1 - p. When p is small enough that q rounds to 1.0, this becomes ln(0) and can yield inf/NaN quantiles. Since (1 - q) is mathematically p here, compute r from p.ln() directly to avoid the cancellation/rounding problem.
    } else {
        let r = (-2.0 * (1.0 - q).ln()).sqrt();
        -((((((C[0] * r + C[1]) * r + C[2]) * r + C[3]) * r + C[4]) * r + C[5])
            / ((((D[0] * r + D[1]) * r + D[2]) * r + D[3]) * r + 1.0))
    }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/mlsirm-core/src/mokken.rs Outdated
Comment thread tests/unit/mokken_tests.rs Outdated
@seonghobae

Copy link
Copy Markdown
Contributor Author

@copilot Fix the code for all comments in this review thread.

When a review comment includes a suggested change, apply the suggestion exactly.

Do not make changes beyond what is described in the linked review thread.

Copilot AI commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@copilot Fix the code for all comments in this review thread.

When a review comment includes a suggested ch...

Fixed in commit fix(mokken): use checked_mul for buffer-length validation; clarify test comment ranges:

  • mokken.rs validate (lines 96–102 and its only usage of the unchecked product): replaced n_persons * n_items with crate::checked_mul_usize(n_persons, n_items, "n_persons * n_items overflows usize")? so an overflow returns an error rather than silently wrapping and allowing a mismatched buffer through.
  • mokken_tests.rs line 243: changed the comment from items 0..4 … items 4..8 (ambiguous — could be read as 5-item inclusive ranges) to items 0..=3 … items 4..=7 (explicit inclusive Rust notation, matching the actual 4 items written per cluster).

All 11 mokken tests pass; CodeQL scan is clean.

@seonghobae

Copy link
Copy Markdown
Contributor Author

@copilot resolve the merge conflicts on this branch.

Copilot AI commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts on this branch.

Merge conflicts resolved in commit d0eb420. The branch now includes commits from seonghobae-validate-commit-160 (poly guards, facets negative-sentinel fix) merged with the mokken changes:

  • CHANGELOG.md: kept both the Mokken scale analysis entry and the MFRM entry
  • crates/fast-mlsirm-py/src/lib.rs: kept all three mokken additions (import, function implementations, module registrations)
  • python/fast_mlsirm/__init__.py: kept the mokken import and __all__ exports
  • python/fast_mlsirm/facets.py: took the base branch version (docstring updated to mention negative sentinels; validation uses np.isfinite(y) & (y >= 0) to treat both NaN and negatives as missing)
  • tests/test_paper_features.py: kept both the new neg-sentinel parity tests from the base branch and the two mokken tests from this branch

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment thread crates/mlsirm-core/src/mokken.rs
Comment thread crates/mlsirm-core/src/mokken.rs
Base automatically changed from seonghobae-validate-commit-160 to main July 25, 2026 09:10
seonghobae and others added 2 commits July 25, 2026 18:21
Implements sample Loevinger scalability coefficients H_ij, H_i, H,
their Mokken Z statistics, and the automated item selection procedure
(AISP, 'search normal') in Rust (mlsirm_core::mokken) and Python
(fast_mlsirm.mokken_analysis, MokkenResult).

Formula verified line-by-line against the mokken R package
(van der Ark, 2007, doi:10.18637/jss.v020.i11):
  H_ij = S_ij / Smax_ij
where Smax uses the comonotone (sorted-column) coupling.

References:
- Mokken, R. J. (1971). A Theory and Procedure of Scale Analysis.
- van der Ark, L. A. (2007). doi:10.18637/jss.v020.i11
- Straat, J. H. et al. (2013). doi:10.1007/s00357-013-9133-6

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@seonghobae
seonghobae force-pushed the seonghobae-mokken-scale-analysis branch from d0eb420 to a7ef4db Compare July 25, 2026 09:21
@seonghobae
seonghobae merged commit 1d63c14 into main Jul 25, 2026
32 checks passed
@seonghobae
seonghobae deleted the seonghobae-mokken-scale-analysis branch July 25, 2026 09:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants