Skip to content

Add Kingsbury-Zara (1989) CCAT content-balanced item selection - #254

Merged
seonghobae merged 5 commits into
mainfrom
seonghobae-ccat
Jul 26, 2026
Merged

Add Kingsbury-Zara (1989) CCAT content-balanced item selection#254
seonghobae merged 5 commits into
mainfrom
seonghobae-ccat

Conversation

@seonghobae

@seonghobae seonghobae commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Implements Kingsbury & Zara (1989) constrained CAT (CCAT) content-balanced item selection as a single-step ccat_select companion to kl_select, per the autonomous paper-implementation loop (iteration 23).

Rule (verified contract)

  1. Any eligible content group (>= 1 unadministered item) with zero administered items has priority (lowest index).
  2. Otherwise the eligible group with the maximal discrepancy t_g - k_g/k (target minus empirical proportion) wins; ties -> lowest index.
  3. Within the chosen group, the unadministered item with maximal logistic 3PL Fisher information a^2 (Q/P) ((P-c)/(1-c))^2 at theta0 is selected; ties -> lowest index.

Returns {selected, group, discrepancy (per group), info (per item, whole pool)}.

Citation governance

  • Kingsbury & Zara (1989), Applied Measurement in Education, 2(4), 359-375, doi:10.1207/s15324818ame0204_6 — NOT read (paywalled). Stated explicitly in rustdoc/docstrings.
  • Rule implemented as reproduced by the R catR package nextItem.R cbControl branch (READ during adversarial spec review), including the zero-empirical-proportion priority branch.
  • Fisher information formula verified against catR Ii.R/Pi.R.
  • Lowest-index tie-breaks and exhausted-group skip are documented implementation deviations (catR breaks ties randomly).

Adversarial spec-verify (BEFORE implementation): GO-WITH-CHANGES

All changes applied: zero-coverage priority added, gap rule on proportions (not raw counts), k derived from the mask (no separate param), strictly positive targets summing to 1, info computed for the whole pool (masking applies to selection only).

Evidence

  • Pinned oracles (exact arithmetic by the spec reviewer, f64 tol 1e-12): base case -> group 0, item 1 (I_1 = 0.451012779418390198, I_4 = 0.348583393587808097); discriminating case -> group 1, item 3 while the unconstrained max-info item is 1 (kills a "global max-info" mutant).
  • Zero-coverage discriminator: 13-item pool where the gap rule alone would pick group 0 (gap 0.6) but the uncovered group 2 must win.
  • 4 executed mutation kills (restore verified green): M1 gap argmax->argmin (1 fail), M2 info argmax->argmin (3 fail), M3 zero-priority dropped (1 fail), M4 guessing factor dropped (3 fail). Every assert reads CcatSelectResult fields or returned Errs.
  • MC-500 #[ignore] structural-invariant test over 500 random pools/masks: passes.
  • PyO3 binding py_ccat_select; Python wrapper validates groups BEFORE the uintp cast (rejects negatives, non-integers, complex); pytest TestCcatSelect (4 tests, pass).

Adversarial impl-review

Round 1 (1 MAJOR, fixed in 3c27789): with c = 0 and an extreme finite theta0 the logistic underflows to P = 0, making the naive q/p * r^2 info form inf * 0 = NaN, which silently corrupted the within-group argmax (x > NaN is false, so a NaN-info item stayed selected). Repro: pool a=[1,1], b=[0,-1e308], c=[0,0], theta0=-1e308 selected item 0 with info=[NaN, 0.25] instead of item 1. Fix: return the limiting value 0 when p == 0 (with c = 0, I = a^2 q p -> 0). Regression test ccat_underflow_info_is_zero_not_nan reads crate outputs; guard-revert mutant executed and killed (restore green). Round 2 in progress.

Round 2 (3 MAJOR, fixed in 4c81ea9): all against the naive q/p * r^2 info form / interim p == 0 guard on extreme-but-valid inputs: (1) subnormal c = 5e-324 with underflowed L still gave inf * 0 = NaN; (2) a = 1e154 overflowed info to spurious +inf via multiplication order and won the argmax (true value 1.2167807506233457); (3) the guard masked a genuinely informative extreme item (a = 1e162, true info 2.8223507304721003 > 2.25). Fix: log-space computation using the identity r = (P-c)/(1-c) = L = sigmoid(a(theta0-b)), so I = exp(2 ln a + ln(1-c) + ln(1-L) + 2 ln L - ln p) with ln L = -softplus(-z); c = 0 reduces exactly to I = a^2 L (1-L). Regression test pins 80-digit Decimal references at 1e-9 relative; pinned 1e-12 oracles unchanged; two more executed mutation kills (c==0 branch dropped, naive softplus). Round 3 in progress.

Round 3: CLEAN. Reviewer re-ran all three round-2 repros through the Python wrapper (selections 1, 1, 0; finite info), swept 10k ordinary parameter combos against the naive form (max relative deviation 1.48e-10; 1.8e-12 in the well-conditioned central region), found no reachable NaN in a pathology sweep (only a documented genuine +inf for a = 1e200 at theta0 = b), and confirmed all Rust (7 + MC-500) and Python (4) tests pass. Final mutation-kill ledger: 7 executed kills, restore green after each.

Summary by CodeRabbit

  • 신규 기능

    • 콘텐츠 제약 CAT(CCAT)에서 콘텐츠 균형을 고려해 다음 문항을 선택하는 ccat_select API를 추가했습니다.
    • Python에서 선택 문항, 콘텐츠 그룹, 그룹별 불일치도, 문항별 정보를 확인할 수 있습니다.
    • 그룹 우선순위와 Fisher 정보, 동률 처리 규칙을 적용합니다.
  • 문서

    • CCAT 콘텐츠 균형 문서와 변경 내역을 추가했습니다.
  • 테스트

    • 균형 선택, 입력 오류, 소진된 그룹 처리 및 극단값에서의 안정성을 검증하는 테스트를 추가했습니다.

@seonghobae
seonghobae force-pushed the seonghobae-owen-cat branch 2 times, most recently from c3892dd to af8f0ae Compare July 25, 2026 10:17
@seonghobae
seonghobae force-pushed the seonghobae-owen-cat branch from af8f0ae to 7c8fd15 Compare July 25, 2026 13:03
Base automatically changed from seonghobae-owen-cat to main July 25, 2026 13:09
seonghobae and others added 3 commits July 25, 2026 23:16
Rust core ccat_select in mlsirm_core::exposure: zero-coverage group
priority, then max target-minus-empirical-proportion discrepancy among
eligible groups; within-group max logistic 3PL Fisher information
a^2(Q/P)((P-c)/(1-c))^2; lowest-index ties (documented deviation from
catR's random tie-break). Primary source NOT read (paywalled,
doi:10.1207/s15324818ame0204_6); rule reproduced from R catR nextItem.R
cbControl (READ), info formula verified against catR Ii.R/Pi.R.

Tests: pinned exact-arithmetic oracles (1e-12), balancing-overrides-
global-max-info discriminator, zero-coverage priority discriminator,
exhausted-group skip, error paths, MC-500 structural invariants
(#[ignore]); 4 executed mutation kills (gap argmax->argmin, info
argmax->argmin, zero-priority dropped, guessing factor dropped).
PyO3 binding py_ccat_select + Python wrapper with pre-cast group
validation (rejects negatives/non-integers/complex) + pytest.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Impl-review MAJOR: with c = 0 and extreme finite theta0 the logistic
underflows to P = 0, making the naive q/p * r^2 info form inf * 0 = NaN,
which silently corrupted the within-group argmax (x > NaN is false, so a
NaN-info item stayed selected). The true limit is 0 (c = 0 gives
I = a^2 q p -> 0 as p -> 0); return the limiting value directly.
Regression test reads crate outputs; guard-revert mutant executed and
killed (1 failed), restore verified green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Impl-review round 2 (3 MAJOR): the naive q/p * r^2 form (and the interim
p == 0 guard) failed on extreme-but-valid inputs: (1) subnormal c > 0
with underflowed L gave inf * 0 = NaN; (2) a = 1e154 overflowed to
spurious +inf via multiplication order and won the argmax; (3) the p == 0
guard masked a genuinely informative extreme item (a = 1e162, true
I = 2.822 > 2.25). Fix: with z = a(theta0 - b), L = sigmoid(z), r =
(P - c)/(1 - c) = L exactly, so I = a^2 (1-c)(1-L) L^2 / (c + (1-c)L)
(c = 0: a^2 L (1-L)) computed via ln L = -softplus(-z),
ln(1-L) = -softplus(z). Regression test pins 80-digit Decimal reference
values (1e-9); two executed mutation kills (c==0 branch dropped: 2
failed; naive softplus: 1 failed), restore green; pinned 1e-12 oracles
unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 25, 2026 14:22

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

Adds Kingsbury–Zara (1989) constrained CAT (CCAT) content-balanced item selection to the exposure/CAT utilities, implemented in the Rust core and surfaced through the PyO3 binding and Python API.

Changes:

  • Implement mlsirm_core::exposure::ccat_select with content-group discrepancy selection + within-group 3PL Fisher-information maximization (numerically stabilized via log-space computation).
  • Expose the function to Python via py_ccat_select and a fast_mlsirm.ccat_select wrapper/export.
  • Add Rust + Python tests (pinned oracles, edge/regression cases) and document the feature in CHANGELOG.md.

Reviewed changes

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

Show a summary per file
File Description
tests/unit/exposure_tests.rs Adds Rust unit tests for CCAT selection (oracles, invariants, error paths, numeric regressions).
tests/test_paper_features.py Adds Python tests validating the CCAT binding behavior and wrapper-level group validation.
python/fast_mlsirm/exposure.py Adds ccat_select Python wrapper + docstring describing the CCAT rule and citation governance.
python/fast_mlsirm/init.py Re-exports ccat_select and adds it to __all__.
crates/mlsirm-core/src/exposure.rs Implements ccat_select and CcatSelectResult in the Rust core with robust info computation.
crates/fast-mlsirm-py/src/lib.rs Adds PyO3 binding py_ccat_select wiring to the Rust core.
CHANGELOG.md Documents the newly added CCAT selection feature and its provenance/constraints.

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

Comment thread python/fast_mlsirm/exposure.py Outdated
Comment thread crates/mlsirm-core/src/exposure.rs Outdated
Copilot AI review requested due to automatic review settings July 26, 2026 08:38
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Rust 코어에 CCAT 콘텐츠 밸런싱 선택 로직을 추가하고, PyO3 및 Python 공개 API로 노출했습니다. 그룹 discrepancy, 3PL Fisher 정보, 입력 검증, 타이브레이크와 극단 입력 안정성에 대한 테스트도 추가했습니다.

Changes

CCAT 콘텐츠 밸런싱

Layer / File(s) Summary
CCAT 선택 알고리즘
crates/mlsirm-core/src/exposure.rs
입력 검증, 그룹별 discrepancy 계산, 제로 커버리지 및 고갈 그룹 처리, 그룹 내 Fisher 정보 기반 문항 선택을 구현했습니다.
Python API 연결
crates/fast-mlsirm-py/src/lib.rs, python/fast_mlsirm/exposure.py, python/fast_mlsirm/__init__.py
ccat_select를 Python 공개 API로 추가하고, 입력 검증 및 Rust 결과의 dictionary 변환을 연결했습니다.
선택 규칙 및 안정성 검증
tests/unit/exposure_tests.rs, tests/test_paper_features.py, CHANGELOG.md
고정 오라클, 밸런싱 우선순위, 오류 경로, 랜덤 불변식, 극단 입력의 유한한 정보값을 검증하고 변경 사항을 기록했습니다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PythonCaller
  participant ccat_select
  participant py_ccat_select
  participant core_ccat_select
  PythonCaller->>ccat_select: 입력 배열과 theta0 전달
  ccat_select->>py_ccat_select: 검증된 입력 전달
  py_ccat_select->>core_ccat_select: CCAT 선택 요청
  core_ccat_select-->>py_ccat_select: 선택 결과 반환
  py_ccat_select-->>ccat_select: dictionary 변환
  ccat_select-->>PythonCaller: selected, group, discrepancy, info 반환
Loading

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed CCAT 콘텐츠 밸런스 문항 선택 기능 추가를 정확히 요약한 제목입니다.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch seonghobae-ccat

Comment @coderabbitai help to get the list of available commands.

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 7 out of 7 changed files in this pull request and generated no new comments.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
crates/mlsirm-core/src/exposure.rs (1)

1203-1214: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

map_or 패턴을 is_none_or로 단순화 가능

Clippy가 지적한 대로, best.map_or(true, |(bd, _)| ...) 형태(Line 1208, Line 1256 동일 패턴)는 Option::is_none_or로 더 간결하게 표현할 수 있습니다. is_none_or는 Rust 1.82에서 안정화되었으므로, 프로젝트 MSRV가 이를 지원하는지 확인이 필요합니다.

♻️ 제안 diff
-                if eligible[g] && best.map_or(true, |(bd, _)| discrepancy[g] > bd) {
+                if eligible[g] && best.is_none_or(|(bd, _)| discrepancy[g] > bd) {

Line 1256도 동일하게:

-        if groups[i] == group && !administered[i] && best.map_or(true, |(bi, _)| info[i] > bi) {
+        if groups[i] == group && !administered[i] && best.is_none_or(|(bi, _)| info[i] > bi) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/mlsirm-core/src/exposure.rs` around lines 1203 - 1214, Update both
discrepancy-selection conditions near the group-selection logic to use
Option::is_none_or instead of best.map_or(true, ...), including the matching
occurrence around the second referenced location. First confirm the project MSRV
supports Rust 1.82; if not, retain the existing compatible pattern.

Source: Linters/SAST tools

tests/unit/exposure_tests.rs (1)

1242-1317: 🩺 Stability & Availability | 🔵 Trivial

#[ignore] 처리된 MC-500 불변식 테스트가 CI에서 실행되는지 확인 필요

ccat_mc500_invariants는 새 CCAT 규칙에 대한 유일한 랜덤화 불변식 검증인데 #[ignore]로 표시되어 있어 기본 cargo test 실행 시 건너뜁니다. CI 파이프라인이 cargo test -- --ignored(또는 동등한 단계)로 이 테스트를 실제로 실행하는지 확인해 주세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/exposure_tests.rs` around lines 1242 - 1317, Ensure the ignored
ccat_mc500_invariants test is executed by CI by adding or updating the relevant
test command to run cargo test with --ignored (or an equivalent dedicated step).
Keep the test’s #[ignore] annotation and verify the CI configuration explicitly
covers this randomized invariant test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/mlsirm-core/src/exposure.rs`:
- Around line 1203-1214: Update both discrepancy-selection conditions near the
group-selection logic to use Option::is_none_or instead of best.map_or(true,
...), including the matching occurrence around the second referenced location.
First confirm the project MSRV supports Rust 1.82; if not, retain the existing
compatible pattern.

In `@tests/unit/exposure_tests.rs`:
- Around line 1242-1317: Ensure the ignored ccat_mc500_invariants test is
executed by CI by adding or updating the relevant test command to run cargo test
with --ignored (or an equivalent dedicated step). Keep the test’s #[ignore]
annotation and verify the CI configuration explicitly covers this randomized
invariant test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3d6a75b3-73ea-417d-9aae-158153609fde

📥 Commits

Reviewing files that changed from the base of the PR and between c2be659 and 11b7eb9.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • crates/fast-mlsirm-py/src/lib.rs
  • crates/mlsirm-core/src/exposure.rs
  • python/fast_mlsirm/__init__.py
  • python/fast_mlsirm/exposure.py
  • tests/test_paper_features.py
  • tests/unit/exposure_tests.rs

Copilot AI review requested due to automatic review settings July 26, 2026 08:46

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 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread python/fast_mlsirm/exposure.py
Comment thread crates/mlsirm-core/src/exposure.rs
@seonghobae
seonghobae merged commit 44a3ecf into main Jul 26, 2026
34 checks passed
@seonghobae
seonghobae deleted the seonghobae-ccat branch July 26, 2026 08:54
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.

2 participants