feat(psychometric): recover ESEM loadings and refuse reverse DSEM lags - #119
Conversation
Add a standalone psychometric_fit crate that recovers exploratory cross-loadings on a CPU f64 OLS path from admitted log-ratio coordinates and refuses non-forward event-time DSEM lags. Does not recreate psychometric_core or allocate migration 0008.
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (17)
📝 WalkthroughWalkthrough새로운 Changespsychometric_fit 적합 기능
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Caller
participant admit_fit_coordinates
participant recover_esem_loadings
participant loading_recovery_rmse
Caller->>admit_fit_coordinates: FitCoordinateKind 검증
admit_fit_coordinates-->>Caller: 좌표 적합성 결과
Caller->>recover_esem_loadings: factor_scores와 indicators 전달
recover_esem_loadings-->>Caller: 복구된 loading matrix 반환
Caller->>loading_recovery_rmse: truth와 recovered 비교
loading_recovery_rmse-->>Caller: RMSE 반환
✨ 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 |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI 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:
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 |
# Conflicts: # CHANGELOG.md # docs/validation/temporal-event-foundation.md
|
Current HEAD |
|
Current-head review refresh for 2ea08a8:
|
|
Rebased current head 47ab763 onto origin/main. The changelog conflict retains both feature and current-main entries; inherited documentation trailing whitespace was removed. Local merge-tree, git diff --cached --check, and cargo fmt --all -- --check pass. Exact-head hosted checks and required independent approvals remain required before protected merge. |
…em-dsem-fit # Conflicts: # ARCHITECTURE.md # CHANGELOG.md # Cargo.lock # Cargo.toml # README.md # docs/TRACEABILITY.md # docs/adr/0005-posterior-esem-dsem.md # docs/adr/README.md # docs/research/standards-and-literature.md # docs/validation/temporal-event-foundation.md # scripts/check_workspace_contract.py # tests/quality/test_check_docstrings.py
| fn invert_gram(gram: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, PsychometricFitError> { | ||
| match gram.len() { | ||
| 1 => { | ||
| let value = gram[0][0]; | ||
| if value <= 0.0 { | ||
| return Err(PsychometricFitError::SingularDesign); | ||
| } | ||
| Ok(vec![vec![require_finite(1.0 / value)?]]) | ||
| } | ||
| 2 => { | ||
| let a = gram[0][0]; | ||
| let b = gram[0][1]; | ||
| let c = gram[1][0]; | ||
| let d = gram[1][1]; | ||
| let determinant = require_finite(a * d - b * c)?; | ||
| if determinant.abs() <= 0.0 { | ||
| return Err(PsychometricFitError::SingularDesign); | ||
| } | ||
| Ok(vec![ | ||
| vec![ | ||
| require_finite(d / determinant)?, | ||
| require_finite(-b / determinant)?, | ||
| ], | ||
| vec![ | ||
| require_finite(-c / determinant)?, | ||
| require_finite(a / determinant)?, | ||
| ], | ||
| ]) | ||
| } | ||
| _ => Err(PsychometricFitError::InvalidNumericInput), | ||
| } | ||
| } |
There was a problem hiding this comment.
📝 Info: Singular-design guard only rejects exactly-zero determinant, not near-singular
invert_gram rejects a design only when the 1x1 value is <= 0.0 (fit.rs) or the 2x2 determinant is exactly zero (determinant.abs() <= 0.0 at fit.rs). A numerically near-singular (ill-conditioned but nonzero-determinant) Gram matrix passes this gate and yields large but finite loadings that are only rejected if they overflow to non-finite via require_finite. For a crate that advertises a fail-closed SingularDesign error, a relative/condition-number tolerance would be more robust. This is a defensible reference-path design choice (the contract text says "singular") rather than a bug, so I did not flag it, but reviewers may want to confirm the intended tolerance behavior.
Was this helpful? React with 👍 or 👎 to provide feedback.
| pub fn recover_dsem_lagged_path( | ||
| predictor_event_time: i64, | ||
| outcome_event_time: i64, | ||
| predictor: &[f64], | ||
| outcome: &[f64], | ||
| ) -> Result<f64, PsychometricFitError> { | ||
| if outcome_event_time <= predictor_event_time { | ||
| return Err(PsychometricFitError::ReverseEventTimePath); | ||
| } | ||
| let loadings = recover_esem_loadings( | ||
| &[predictor.to_vec()], | ||
| &[outcome.to_vec()], | ||
| FitCoordinateKind::LogisticNormal, | ||
| )?; | ||
| Ok(loadings[0][0]) | ||
| } |
There was a problem hiding this comment.
📝 Info: DSEM lag path hardcodes LogisticNormal and does not gate raw-proportion inputs
recover_dsem_lagged_path always calls recover_esem_loadings with FitCoordinateKind::LogisticNormal (fit.rs). Because the function signature takes no coordinate kind, a caller cannot indicate that the predictor/outcome series are raw simplex proportions, so the RawProportionForbidden gate that protects recover_esem_loadings is effectively bypassed for DSEM lag recovery. This is consistent with the crate's current narrow API (the caller is expected to supply already-transformed coordinates), so I did not flag it as a bug, but if DSEM inputs can ever originate from untransformed proportions this gate should be surfaced in the signature.
Was this helpful? React with 👍 or 👎 to provide feedback.
| pub fn recover_esem_loadings( | ||
| factor_scores: &[Vec<f64>], | ||
| indicators: &[Vec<f64>], | ||
| kind: FitCoordinateKind, | ||
| ) -> Result<Vec<Vec<f64>>, PsychometricFitError> { | ||
| admit_fit_coordinates(kind)?; | ||
| if factor_scores.len() > 2 { | ||
| return Err(PsychometricFitError::InvalidNumericInput); | ||
| } | ||
| let observation_count = factor_scores.first().map_or(0, Vec::len); | ||
| let mut centered_factors = Vec::new(); | ||
| for values in factor_scores { | ||
| if observation_count < 2 || values.len() != observation_count { | ||
| return Err(PsychometricFitError::InvalidNumericInput); | ||
| } | ||
| centered_factors.push(center(values)?); | ||
| } | ||
| if centered_factors.is_empty() { | ||
| return Err(PsychometricFitError::InvalidNumericInput); | ||
| } | ||
| let mut loadings = Vec::new(); | ||
| for values in indicators { | ||
| if values.len() != observation_count { | ||
| return Err(PsychometricFitError::InvalidNumericInput); | ||
| } | ||
| let centered_indicator = center(values)?; | ||
| loadings.push(ordinary_least_squares_loadings( | ||
| ¢ered_factors, | ||
| ¢ered_indicator, | ||
| )?); | ||
| } | ||
| if loadings.is_empty() { | ||
| return Err(PsychometricFitError::InvalidNumericInput); | ||
| } | ||
| Ok(loadings) | ||
| } |
There was a problem hiding this comment.
📝 Info: OLS regression recovers loadings only for exact/noiseless linear combinations
recover_esem_loadings recovers the loading matrix as OLS regression coefficients of each indicator on the factor scores (fit.rs). This exactly recovers the true loadings only when the indicator is an exact linear combination of the provided factors (the noiseless test setup). With measurement error or omitted factors, the OLS coefficients are the best linear predictor, not necessarily the structural ESEM loadings — and the crate does no rotation or posterior propagation. The docstrings and esem-dsem-fit.md explicitly scope this out, so it is not a bug, but consumers should not treat these coefficients as full ESEM loadings on noisy data.
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
f64ESEM/DSEM fit path that does not treat raw topic proportions as Euclidean indicators.psychometric_fit: OLS recovers known exploratory cross-loadings from admitted ALR/ILR/logistic-normal coordinates; recovered RMSE is below a zero-loading collapse.psychometric_core(feat(psychometric): posterior ESEM input gates with true-parameter RMSE #49 owns input gates) and does not allocate migration0007/0008(#45still owns0007).Claim boundary
Test plan
esem_dsem_fit_contractfailed before the crate existedcargo test -p psychometric_fit --offline --lib --testscargo clippy -p psychometric_fit --all-targets --offline -- -D warningscargo llvm-cov -p psychometric_fitauthored lines 108/108; nightly-2026-08-01 branches 30/30Summary by CodeRabbit
새 기능
문서
테스트