Skip to content

refactor(inference): own observed information and second-order tests in Rust - #758

Merged
seonghobae merged 1 commit into
mainfrom
fix/second-order-rust-green
Aug 11, 2026
Merged

refactor(inference): own observed information and second-order tests in Rust#758
seonghobae merged 1 commit into
mainfrom
fix/second-order-rust-green

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Why

Draft #734 is a RED ownership contract (test-only). Production Hessian assembly and second-order diagnostics still ran fully in Python.

What

  • Rust finite_difference_hessian owns FD coefficients + symmetrisation
  • Rust second_order_test owns Jacobi eigenvalue diagnostics
  • PyO3 bindings observed_information / second_order_test
  • Python evaluates scalar objective at FD offsets and transports Rust results
  • Ownership sentinels + quadratic recovery unit tests + APA doctoring

Verification

  • cargo test -p mlsirm-core --lib inference::tests (7 pass)
  • pytest tests/test_observed_information_rust_ownership.py + related suite (24 pass)

Supersedes #734 once product gates are green.

Summary by CodeRabbit

  • New Features

    • Added Rust-backed Hessian construction for observed-information calculations.
    • Added second-order diagnostics for assessing matrix positive definiteness, including minimum eigenvalue results.
    • Added validation for matrix dimensions and finite numerical inputs.
  • Documentation

    • Documented the updated calculation and diagnostic behavior.
  • Tests

    • Added coverage for Hessian recovery, definiteness checks, and delegation behavior.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ca7ff1c1-6224-4749-9f7a-8b20e80faa4e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

observed_information now delegates finite-difference Hessian assembly to Rust. second_order_test delegates eigenvalue diagnostics to Rust. PyO3 bindings expose both operations, and tests verify delegation and result preservation.

Changes

Observed-information Rust ownership

Layer / File(s) Summary
Rust numerical kernels
crates/mlsirm-core/src/inference.rs
Adds validated finite-difference Hessian construction and positive-definiteness diagnostics. Tests cover quadratic Hessian recovery and definite or indefinite matrices.
Python-to-Rust delegation
python/fast_mlsirm/inference.py, crates/fast-mlsirm-py/src/lib.rs
Python collects objective evaluations, delegates Hessian and eigenvalue work to Rust, and exposes both functions through _core.
Ownership contract validation
tests/test_observed_information_rust_ownership.py, docs/doctoring/observed_information_rust_ownership.md, docs/changelog.d/734-observed-information-rust-ownership.md, CHANGELOG.md
Tests verify Rust delegation and exact sentinel results. Documentation and changelogs record the ownership boundary.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PythonInference
  participant PyO3Core
  participant RustInference
  PythonInference->>PythonInference: Collect objective evaluations
  PythonInference->>PyO3Core: Pass finite-difference samples
  PyO3Core->>RustInference: Build symmetric Hessian
  RustInference-->>PyO3Core: Return Hessian
  PyO3Core-->>PythonInference: Return Hessian
  PythonInference->>PyO3Core: Pass Hessian and tolerance
  PyO3Core->>RustInference: Compute eigenvalue diagnostics
  RustInference-->>PyO3Core: Return status and eigenvalues
  PyO3Core-->>PythonInference: Return diagnostic result
Loading

Possibly related issues

  • Issue 625: The pull request implements the observed-information ownership change described by moving Hessian construction and second-order diagnostics into Rust with PyO3 bindings.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: moving observed-information and second-order test ownership to Rust.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch fix/second-order-rust-green
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/second-order-rust-green

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.

❤️ Share

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

@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.

Actionable comments posted: 5

🤖 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.

Inline comments:
In `@crates/mlsirm-core/src/inference.rs`:
- Around line 116-124: In the Hessian construction flow, validate the
finite-difference denominators immediately after computing h2 and before
diagonal or mixed-partial division: reject h2 when it is zero or non-finite, and
reject 4.0 * h2 when it overflows or is non-finite, returning the documented
benign error. Also reject any non-finite assembled Hessian entries, and add
coverage for positive step values whose square underflows or overflows.
- Line 65: Replace or guard the jacobi_symmetric_eigen call in the inference
path so dimensions accepted by the public Python API cannot trigger unbounded
cubic diagnostic work. Prefer a scalable symmetric eigensolver; otherwise reject
oversized n before the call with a documented benign exception, and align the
Rust dimension limit with the Python API limit while preserving valid-input
behavior.
- Around line 48-49: Use checked arithmetic for all dimension-derived sizes in
vcov_from_hessian, second_order_test, finite_difference_hessian, and
standard_errors_from_vcov, including n * n, n * (n - 1) / 2, k * k, and p * p.
Return each function’s existing benign error before validation or allocation
when checked multiplication or subtraction overflows; update both the anchor at
crates/mlsirm-core/src/inference.rs:48-49 and the sibling at
crates/mlsirm-core/src/inference.rs:101-117.

In `@docs/doctoring/observed_information_rust_ownership.md`:
- Around line 10-19: Expand the documentation around the “Standards and
literature (APA 7th)” section to explain that this implementation computes
penalized-objective curvature using central finite differences, rather than
Oakes’s direct EM observed-information identity or the covariance estimators
compared by Pritikin. State the supported interpretation and document numerical
limitations caused by the chosen finite-difference step size, while retaining
the full citations and links required by the coding guidelines.

In `@python/fast_mlsirm/inference.py`:
- Around line 60-78: Remove the `np.eye(n)` dependency from the
finite-difference loop and replace it with one mutable parameter workspace
initialized from `x0`. For each evaluation in the loop around `diag_plus`,
`diag_minus`, and the off-diagonal arrays, adjust only coordinates `i` and `j`
by ±`h`, call `objective`, then reset those coordinates before the next
evaluation; preserve the existing result ordering and values without allocating
a dense identity matrix.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c88aa4a0-9170-44e2-a9e3-3f14da55fc80

📥 Commits

Reviewing files that changed from the base of the PR and between d6ff8b1 and 30e3cff.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • crates/fast-mlsirm-py/src/lib.rs
  • crates/mlsirm-core/src/inference.rs
  • docs/changelog.d/734-observed-information-rust-ownership.md
  • docs/doctoring/observed_information_rust_ownership.md
  • python/fast_mlsirm/inference.py
  • tests/test_observed_information_rust_ownership.py

Comment on lines +48 to +49
if n == 0 || hessian.len() != n * n {
return Err("hessian must be a square matrix".into());

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline crates/mlsirm-core/src/inference.rs --items all \
  --match 'second_order_test|finite_difference_hessian'

rg -n -C 3 'n \* n|n \* \(n - 1\)|checked_mul|checked_sub' \
  crates/mlsirm-core/src/inference.rs

Repository: ContextualWisdomLab/fast-mlsirm

Length of output: 1786


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline crates/mlsirm-core/src/inference.rs --items all

sed -n '1,190p' crates/mlsirm-core/src/inference.rs

rg -n -C 4 'vcov_from_hessian|second_order_test|finite_difference_hessian|standard_errors_from_vcov' crates tests

Repository: ContextualWisdomLab/fast-mlsirm

Length of output: 44373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '155,289p' crates/mlsirm-core/src/inference.rs

python3 - <<'PY'
U = 2**64
MASK = U - 1

def release_mul(a, b):
    return (a * b) & MASK

def release_sub(a, b):
    return (a - b) & MASK

for n in [2**32, 2**32 + 1, 2**63, 2**64 - 1]:
    square = release_mul(n, n)
    off_n = release_mul(n, release_sub(n, 1)) // 2
    print(f"n={n}: n*n wraps to {square}, n*(n-1)/2 evaluates to {off_n}")

print("debug overflow thresholds:")
print("largest n with n*n <= usize::MAX:", 2**32 - 1)
print("largest n with n*(n-1) <= usize::MAX:", 2**32)
PY

Repository: ContextualWisdomLab/fast-mlsirm

Length of output: 4633


Use checked arithmetic for every dimension-derived size.

If n * n or n * (n - 1) / 2 overflows, return the function's existing benign error before validation or allocation. Apply this to vcov_from_hessian, second_order_test, finite_difference_hessian, and standard_errors_from_vcov. This must also protect the helper k * k and p * p allocations.

📍 Affects 1 file
  • crates/mlsirm-core/src/inference.rs#L48-L49 (this comment)
  • crates/mlsirm-core/src/inference.rs#L101-L117
🤖 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/inference.rs` around lines 48 - 49, Use checked
arithmetic for all dimension-derived sizes in vcov_from_hessian,
second_order_test, finite_difference_hessian, and standard_errors_from_vcov,
including n * n, n * (n - 1) / 2, k * k, and p * p. Return each function’s
existing benign error before validation or allocation when checked
multiplication or subtraction overflows; update both the anchor at
crates/mlsirm-core/src/inference.rs:48-49 and the sibling at
crates/mlsirm-core/src/inference.rs:101-117.

Source: Coding guidelines

symmetric[j * n + i] = mean;
}
}
let (mut evals, _) = jacobi_symmetric_eigen(&symmetric, n)?;

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.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Bound or replace the dense Jacobi diagnostic.

jacobi_symmetric_eigen uses up to 64 sweeps of cubic work. The public Python API admits n == 5_000. That input can require about 12 trillion coordinate updates before this call returns or reports non-convergence.

Use a scalable symmetric eigensolver, or reject dimensions that the fixed-sweep Jacobi method cannot serve before calling it. Keep the Python and Rust limits aligned. As per coding guidelines, “For fuzzed arbitrary input, code must either succeed or raise a documented benign exception; panics, hangs, AssertionError, KeyError, IndexError, and TypeError are bugs.”

🤖 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/inference.rs` at line 65, Replace or guard the
jacobi_symmetric_eigen call in the inference path so dimensions accepted by the
public Python API cannot trigger unbounded cubic diagnostic work. Prefer a
scalable symmetric eigensolver; otherwise reject oversized n before the call
with a documented benign exception, and align the Rust dimension limit with the
Python API limit while preserving valid-input behavior.

Source: Coding guidelines

Comment on lines +116 to +124
let h2 = step * step;
let mut hessian = vec![0.0_f64; n * n];
for i in 0..n {
hessian[i * n + i] = (diag_plus[i] - 2.0 * base + diag_minus[i]) / h2;
}
let mut k = 0usize;
for i in 0..n {
for j in (i + 1)..n {
let value = (off_pp[k] - off_pm[k] - off_mp[k] + off_mm[k]) / (4.0 * h2);

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject unusable finite-difference denominators.

A finite positive step can still make step * step equal 0.0 or inf. Also, 4.0 * h2 can overflow. The current code then returns zero, infinite, or NaN Hessian entries instead of a documented error.

Validate h2 and the mixed-partial denominator before division. Reject non-finite assembled entries. Add tests for underflow and overflow step values. As per coding guidelines, “For fuzzed arbitrary input, code must either succeed or raise a documented benign exception; panics, hangs, AssertionError, KeyError, IndexError, and TypeError are bugs.”

🤖 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/inference.rs` around lines 116 - 124, In the Hessian
construction flow, validate the finite-difference denominators immediately after
computing h2 and before diagonal or mixed-partial division: reject h2 when it is
zero or non-finite, and reject 4.0 * h2 when it overflows or is non-finite,
returning the documented benign error. Also reject any non-finite assembled
Hessian entries, and add coverage for positive step values whose square
underflows or overflows.

Source: Coding guidelines

Comment on lines +10 to +19
## Standards and literature (APA 7th)

Pritikin, J. N. (2017). A comparison of parameter covariance estimation methods
for item response models in an expectation-maximization framework. *Cogent
Psychology, 4*(1), Article 1279435.
https://doi.org/10.1080/23311908.2017.1279435

Oakes, D. (1999). Direct calculation of the information matrix via the EM
algorithm. *Journal of the Royal Statistical Society Series B: Statistical
Methodology, 61*(2), 479–482. https://doi.org/10.1111/1467-9868.00188

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add the required literature basis and scope statement.

The citations alone do not explain the supported method. Oakes derives a direct observed-information identity for EM. Pritikin compares covariance estimators for EM-based IRT models. This implementation instead uses central finite differences of the penalized objective. State that distinction, the supported interpretation, and the numerical limitations of step-size-based curvature diagnostics. (ideas.repec.org)

As per coding guidelines, “For substantive feature or process PRs, include permissible research PDFs with full citations, or cite, link, and summarize them when redistribution is not permissible.”

🤖 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 `@docs/doctoring/observed_information_rust_ownership.md` around lines 10 - 19,
Expand the documentation around the “Standards and literature (APA 7th)” section
to explain that this implementation computes penalized-objective curvature using
central finite differences, rather than Oakes’s direct EM observed-information
identity or the covariance estimators compared by Pritikin. State the supported
interpretation and document numerical limitations caused by the chosen
finite-difference step size, while retaining the full citations and links
required by the coding guidelines.

Source: Coding guidelines

Comment on lines +60 to +78
# Python evaluates the scalar objective at FD offsets; Rust owns the
# finite-difference coefficients and symmetrised matrix assembly.
diag_plus = np.empty(n, dtype=np.float64)
diag_minus = np.empty(n, dtype=np.float64)
off_n = n * (n - 1) // 2
off_pp = np.empty(off_n, dtype=np.float64)
off_pm = np.empty(off_n, dtype=np.float64)
off_mp = np.empty(off_n, dtype=np.float64)
off_mm = np.empty(off_n, dtype=np.float64)
k = 0
for i in range(n):
x_plus = x0 + h * eye[i]
x_minus = x0 - h * eye[i]
hessian[i, i] = (objective(x_plus) - 2.0 * base + objective(x_minus)) / (h * h)
diag_plus[i] = objective(x0 + h * eye[i])
diag_minus[i] = objective(x0 - h * eye[i])
for j in range(i + 1, n):
f_pp = objective(x0 + h * eye[i] + h * eye[j])
f_pm = objective(x0 + h * eye[i] - h * eye[j])
f_mp = objective(x0 - h * eye[i] + h * eye[j])
f_mm = objective(x0 - h * eye[i] - h * eye[j])
value = (f_pp - f_pm - f_mp + f_mm) / (4.0 * h * h)
hessian[i, j] = value
hessian[j, i] = value
off_pp[k] = objective(x0 + h * eye[i] + h * eye[j])
off_pm[k] = objective(x0 + h * eye[i] - h * eye[j])
off_mp[k] = objective(x0 - h * eye[i] + h * eye[j])
off_mm[k] = objective(x0 - h * eye[i] - h * eye[j])
k += 1

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.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Remove the dense identity workspace.

The offset loop depends on np.eye(n) from Line 57. At the admitted 5,000-parameter limit, that workspace adds 200 MB to an already dense diagnostic path.

Use one mutable parameter workspace. Adjust and reset only coordinates i and j for each objective evaluation. As per coding guidelines, “Avoid unnecessary intermediate NumPy allocations and prefer einsum or BLAS-backed forms where appropriate.”

🤖 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 `@python/fast_mlsirm/inference.py` around lines 60 - 78, Remove the `np.eye(n)`
dependency from the finite-difference loop and replace it with one mutable
parameter workspace initialized from `x0`. For each evaluation in the loop
around `diag_plus`, `diag_minus`, and the off-diagonal arrays, adjust only
coordinates `i` and `j` by ±`h`, call `objective`, then reset those coordinates
before the next evaluation; preserve the existing result ordering and values
without allocating a dense identity matrix.

Source: Coding guidelines

seonghobae added a commit that referenced this pull request Aug 11, 2026
…in Rust

Public observed_information assembles finite-difference Hessians in the Rust
core from evaluated objective samples; second_order_test eigenvalue diagnostics
are Rust-owned. Ownership sentinels, quadratic recovery unit tests, changelog,
and APA doctoring included.

Supersedes draft #734 once green.
@seonghobae
seonghobae force-pushed the fix/second-order-rust-green branch from 671290f to 207e259 Compare August 11, 2026 19:11
@seonghobae
seonghobae merged commit 7a5a457 into main Aug 11, 2026
9 checks passed
@seonghobae
seonghobae deleted the fix/second-order-rust-green branch August 11, 2026 19:11
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.

1 participant