feat(irt): expose Rust polytomous predictions - #1279
Conversation
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe change adds public GRM and GPCM polytomous prediction APIs. Rust computes batched probabilities and expected scores. Python validates and normalizes evidence, enforces resource and rank limits, exposes the functions, and adds regression coverage and documentation. ChangesPolytomous prediction flow
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: 🟡 Moderate · up to The new prediction APIs can currently change large integer inputs during normalization and briefly materialize oversized broadcast grids before rejecting them, risking incorrect results and avoidable memory use. Merge should wait for these admission checks to be corrected. Sequence Diagram(s)sequenceDiagram
participant PythonCaller
participant PredictionAdmission
participant PolytomousAPI
participant PyO3Binding
participant RustCore
PythonCaller->>PredictionAdmission: Submit fit and theta evidence
PredictionAdmission->>PredictionAdmission: Preflight rank, callbacks, cycles, and size
PredictionAdmission->>PredictionAdmission: Create contiguous float64 arrays
PredictionAdmission->>PolytomousAPI: Invoke guarded prediction
PolytomousAPI->>PyO3Binding: Call polytomous_predictions
PyO3Binding->>RustCore: Compute GRM/GPCM predictions
RustCore-->>PyO3Binding: Return probabilities and expected scores
PyO3Binding-->>PolytomousAPI: Return flattened outputs
PolytomousAPI-->>PythonCaller: Return reshaped arrays
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Scheduled review-feedback autofix for this PR head.
|
|
Merge-forward: resolved |
|
@coderabbitai review |
|
Pin the baseline to protected main@9c12eab1 (v0.9.0). The open-PR queue collapsed from 74 to 6 since the previous snapshot; replace the stale PR table with the current six lanes (#1363, #1345, #1279, #1029, #1005, #998) including their merge-forward state. Update gap-matrix evidence columns for landed work: automatic Rust backend (#951/#1070), crossed multiple-membership (#1014), the Figma design-boundary ADR now at docs/adr/0016 via merged #1130, and cross-engine conformance (#1082) after #1077 closed as completed. No production code, formula, estimator, or release surface changed.
|
Merge-forward 2: resolved the package-surface conflict and changelog against |
|
Scheduled review-feedback autofix for this PR head.
|
…s-predictions # Conflicts: # python/fast_mlsirm/__init__.py
* fix(release): resync CHANGELOG fragments after v0.8.0 cut PRs merged after the v0.8.0 release-cut PR (#966) added new docs/changelog.d fragments without re-running the renderer, and the already-released release-0.8.0-cut.md fragment was left in place, duplicating the 0.8.0 section under Unreleased. Delete the stale fragment and re-render so render_changelog_fragments.py --check passes again, unblocking the v0.8.0 release-tag dispatch. * fix(release): re-render CHANGELOG fragments for v0.8.0 aggregate Ten more PRs merged into main after this fix's original commit, each adding docs/changelog.d fragments without re-running the renderer. Re-run render_changelog_fragments.py --update so --check passes again and the v0.8.0 release-tag dispatch is no longer blocked. * fix(dif): harden logistic and purification controls (#959) * test(dif): expose executable logistic controls * fix(dif): normalize controls before data and Rust * fix(dif): install hardened public DIF adapters * docs(changelog): record logistic DIF control boundary * docs(doctoring): trace logistic DIF control trust boundary * fix(changelog): classify logistic DIF control boundary --------- Co-authored-by: Claude <noreply@anthropic.com> * test(dif): preserve native zero-iteration and NumPy bool domains * fix(dif): preserve native control compatibility * test(dif): align regressions with native control domains * test(dif): prove hardened package aliases on landing lane * fix(dif): reject max_iter=0 before native discovery in logistic DIF wrappers safe_logistic_dif and safe_logistic_dif_purified normalized max_iter with minimum=0, admitting zero at the Python boundary even though the native logistic_sweep (crates/mlsirm-core/src/dif.rs) rejects max_iter == 0 with "max_iter must be >= 1", and the module's own doctoring already documented max_iter as a positive usize control. A zero cap therefore cleared control validation, materialized caller response/group arrays, and dispatched to the compiled core before failing there instead of being rejected up front (Devin AI review finding on PR #998). Raise the minimum to 1 for both wrappers so the rejection happens before data materialization or core discovery, matching the documented and native domains. Updated the compatibility regressions accordingly: the former zero-iteration dispatch proofs now use the true minimum (1), and new regressions prove max_iter=0 is rejected before core discovery for both logistic_dif and logistic_dif_purified. * fix(release): resync changelog after current main * fix(dif): make control installer idempotent * feat(interaction-map): own residual factorization in Rust * test(interaction-map): seal evidence and resource admission * fix(interaction-map): seal evidence before Rust * test(interaction-map): bound native resource requests * fix(interaction-map): bound native workspaces * fix(interaction-map): reject oversized axis controls before data * test(interaction-map): reject maximal axis request before data * docs(interaction-map): record bounded evidence admission * test(interaction-map): require shape-consistent empty map * test(interaction-map): require coherent empty rectangle * fix(interaction-map): normalize empty complete-case rectangle * docs(interaction-map): record empty complete-case contract * feat(interaction-map): expose coverage and cell evidence * test(interaction-map): reject NaN model expectations * test(interaction-map): reject nonfinite expected evidence in Rust * fix(interaction-map): reject NaN model expectations * fix(interaction-map): require finite expected evidence * docs(changelog): record finite interaction-map expectations * test(interaction-map): preserve observed infinity rejection in Rust * fix(interaction-map): reject infinite observed evidence in Rust * docs(changelog): align interaction-map missingness parity * chore: normalize composed public exports --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
| let center = residual.iter().sum::<f64>() / residual.len() as f64; | ||
| let centered: Vec<f64> = residual.iter().map(|value| value - center).collect(); |
There was a problem hiding this comment.
🔍 Reconstruction excludes the residual grand mean
The residual is grand-mean-centered (center at interaction_map.rs) before factorization, so reconstruction reconstructs the centered residual while unexplained = raw - fitted uses the raw residual. A full-rank map therefore leaves the entire grand mean inside unexplained. The cross-term identity still holds and ADR-0021 documents centering, but only grand-mean (not row/column) centering is applied, and public tests exercise only zero-mean residuals.
Was this helpful? React with 👍 or 👎 to provide feedback.
| coordinate_cells = (rows + columns) * axis_count_value | ||
| if coordinate_cells > _MAX_INTERACTION_MAP_COORDINATE_CELLS: | ||
| raise ValueError( | ||
| "interaction map coordinate request exceeds " | ||
| f"{_MAX_INTERACTION_MAP_COORDINATE_CELLS} cells" | ||
| ) |
There was a problem hiding this comment.
📝 Info: Python coordinate budget uses full, not complete-case, dimensions
The Python coordinate budget (rows + columns) * axis_count_value uses the full matrix shape, while the Rust validate_factorization_workspace charges only retained complete-case dimensions. The Python guard is strictly more conservative and can reject a request the core would accept. Not a correctness bug, but the two ceilings bound different quantities.
Was this helpful? React with 👍 or 👎 to provide feedback.
…ns' into feat/public-polytomous-predictions
| from .interaction_map import ResidualInteractionMap as ResidualInteractionMap | ||
| from .interaction_map import residual_interaction_map as residual_interaction_map |
There was a problem hiding this comment.
📝 Info: Interaction-map API missing from all
residual_interaction_map and ResidualInteractionMap are re-exported (init.py) but never added to __all__, unlike the polytomous prediction functions that arrive via _legacy_init.__all__. Direct imports work, but from fast_mlsirm import * omits them. If a fully public surface is intended, add them to __all__ for consistency.
Was this helpful? React with 👍 or 👎 to provide feedback.
Closes #1281.
Closes #1291.
Closes #1296.
Closes #1297.
Closes #1307.
Addresses #1280.
Addresses #1292.
Summary
Expose public GRM/GPCM category probabilities and expected-response predictions while keeping probability and expected-score arithmetic in the Rust numerical owner. Python validates, bounds, normalizes, and marshals only.
The prediction path now enforces one bounded scientific contract across the installed public Python boundary, the package-private raw Python helper, and the direct Rust boundary: trusted/lossless/finite evidence, a 20,000,000 probability-cell request ceiling, the fitter-supported
2..=64category domain, and preservation of package-ownedPolytomousFitmetadata. GRM fitting also preserves strict threshold ordering for sparse/collapsed observed-category patterns so returned fits remain inside the scoring/prediction parameter domain.Test-first lineage
Public prediction and allocation envelope
ddf3cd33df8f2606882619c14cc1c5b57b332fdc: Rust-owned GRM/GPCM prediction surface plus public Python exports and ADR-0016;a6cfbe67be331bcb0d1b3c29e51ba65ce203ad0dand allocation-order RED10dc8015a20239ea21db4647be05078ea8a00dab;425e310b5e185fabdffd9a743d4016ca579c9876-> GREENae48db32c6b6c2ed00d0f14afd2b1fc4fa805fce: Rust enforcesPOLY_MAX_PREDICTION_CELLS = 20_000_000before allocation/parameter-dependent work.Callback-free/lossless evidence admission — #1281
8d6b62abb10ea5981561bd4be84caa3077ddb48e-> GREEN2a7cf5565a8ac3b468a3a8f575599760454b8260: reject hostile array providers, complex/non-real storage, and lossy float64 conversion while preserving trusted NumPy/built-in evidence;941843a9db775f5e18030c76d73669ef86686afckeeps public/legacy exports on the same guard;a0f57302be0f929cbe2ffbd439fe0c90b721e702-> GREENb51a0720c9a6014656d8f6d07001dc05df4b722a: reject trustedNaN/Infevidence before raw prediction/native work.Rank/resource ordering — #1291
4cb3117e4dda30d5eec7de326a5aac2f3c486368-> GREEN854c3dfd89ac49b714096e7cf393a108b611450f: reject provably over-rank evidence before NumPy materialization;df2922df968200e6a78eedda211cfb6f91ed444fpreflights theta, slope, and category parameters before any field reaches NumPy;c41c9a5de5194ae032b8b4eaf1bdc0cd25a0f173->e8d8335536dd66e1f1d4ed1a3bf2f9a94a76efe5: reject lossy integer promotion and oversized joint grids before float64 conversion.Category-domain parity — #1292 / #1307
f69b11c0c982e158de0c7fbe0c816e197a828531-> GREEN2ee70059f198bc4186f57e90dfc8c5825410ae6e: manually constructed fits implyingn_cat > 64fail from trusted shape metadata before NumPy/core discovery;fc198cf9579175c02bee2822a0dbf461c35e67d5-> GREEN0f4c78fe46a02b21e143b80cf4dfad858cfe9b7d: direct Rust prediction enforces2..=64before allocation/item-parameter validation;fdce0d6fe45a6ff89f5ba6362435df7821d1516f-> GREENb9ecf432bc257a9d99e2b3e1358331d72b971295: package-private_polytomous_predictions()now replaysMAX_POLYTOMOUS_CATEGORIESafter shape validation and before resource/core work;1dc38914439833b180983cc825e5340034c311b3: the raw helper still delegates the valid 64-category boundary with canonicaln_cat=64payload and output shape;84ffcb5bf407aaad79a3ebd89aa1100ebf6e7a54records the raw-helper defense-in-depth contract.Fit metadata and GRM domain preservation — #1296 / #1297
f1d417a0bba572da5a6cc3dff082589fea241b44->0664286a425620faba8c551c778127ece2856b81: admission usesdataclasses.replaceso normalization preserves convergence, trace, stopping and threshold metadata;602c8dd7750d032214c6f3130941d5c12da13c6d->2eeb3d42479779edcb58890b121ce73f4f321982: smoothed GRM initialization preserves strict cumulative-threshold order without changing Samejima probability arithmetic;9bf2a850a167950d96a5db288eb7a3054fbe233c: sparse-category post-M-step proof keeps returned GRM thresholds inside the native prediction domain and yields finite nonnegative normalized probabilities.Scientific ownership
GRM/GPCM probability kernels, expected-score arithmetic, parameterization, fit likelihood/EM updates, item-parameter validation inside the numerical core, and extreme-theta behavior remain Rust-owned. Python changes are trust/resource/domain validation and marshalling only; no second production numerical engine is introduced.
Verification boundary
Protected base tip at this body refresh:
main@9ef9e1a86f18743323f15da64bba7a80aa0698a4.Exact source/test/evidence head at the immediately preceding lifecycle refetch:
84ffcb5bf407aaad79a3ebd89aa1100ebf6e7a54, open, non-Draft, mergeable. Relative to the current protected tip the branch is 58 commits ahead and 1 commit behind; that moving-main delta is unrelated report work and must be reconciled non-destructively before landing if it remains outstanding.All predecessor-head checks and reviews are historical. Exact-current-head CI, Security Scan, SAST Semgrep, CodeQL, ClusterFuzzLite, every applicable protected central coverage/review/package/SBOM/provenance/dependency context, and independent approval must be freshly satisfied before merge. No self-approval, gate weakening, force update, or predecessor-head evidence reuse is authorized.
Consumer
LineageWeave can consume
polytomous_expected_response(fit, theta)andpolytomous_category_probabilities(fit, theta)without importingfast_mlsirm._coreor reproducing GRM/GPCM arithmetic.