Refactor algorithm dispatch into registries and add a scientific validation harness#54
Open
richford wants to merge 11 commits into
Open
Refactor algorithm dispatch into registries and add a scientific validation harness#54richford wants to merge 11 commits into
richford wants to merge 11 commits into
Conversation
Estimators now implement the AbilityEstimator interface in src/estimators/ and item selectors implement ItemSelector in src/selectors/. Each family is registered in a registry that serves as the single source of truth for the method type unions, runtime validation, and dispatch. Cat delegates to the registries, so adding an algorithm no longer requires editing Cat. Also fixes a latent bug: the private likelihood method seeded its reduce with 1 instead of 0, returning ln L + 1. The shared logLikelihood helper seeds at 0. (No observable behavior change: the offset cancelled in both the MLE argmax and the EAP posterior ratio.)
Clowder._selectNextItem used Math.random() for the validated-vs-unvalidated coin flip while every other draw used the seeded RNG, breaking the reproducibility that the randomSeed parameter promises to simulation users.
Adds deterministic fixture generation (50-item bank x 100 examinees, fixed seed), a characterization baseline of jsCAT's own MLE/EAP estimates, and a Jest suite that exactly reproduces the baseline on every run. A companion R script (validation/generate-r-reference.R) scores the same fixtures with catR; once its output CSV is committed, the suite also asserts agreement with the independent reference within tolerance on every CI run, with no R dependency at test time. See validation/README.md for the workflow.
CONTRIBUTING.md documents the registry architecture and walks through adding a new ability estimator end to end, including the validation requirements (literature reference, analytic tests, golden fixtures vs. catR). The PR template encodes the algorithmic-contribution checklist and an AI-disclosure section. .ai/rules/ provides machine-readable rules for AI coding agents, surfaced via AGENTS.md and a CLAUDE.md symlink.
Removes .idea/ and validation/.DS_Store from version control and ignores .idea/, .DS_Store, .Rproj.user/, and .Rhistory going forward.
Two changes to make the coverage report trustworthy: 1. Switch coverageProvider from v8 to babel (Istanbul). The v8 provider under Jest 28 merged per-worker coverage nondeterministically — corpus.ts and utils.ts oscillated between 100% and ~94% across identical runs. Istanbul instrumentation is deterministic. 2. Exclude barrel files (src/**/index.ts) from coverage, matching the existing exclusion of the root src/index.ts. Re-export bindings were counted as uncovered functions. Also adds two micro-tests for previously unexercised default-parameter branches (bare uniform() and fillZetaDefaults() without a format), now visible under deterministic reporting. Remaining branch misses are two pre-existing Clowder branches (clowder.ts:86,311).
…ility Regenerating the baseline on a different OS perturbed the Powell optimizer path at the ~1e-9 level (different libm/V8 builds), churning every line of expected-jscat.csv with meaningless diffs. Rounding to 8 decimals makes regeneration byte-stable across machines while staying three orders of magnitude above the golden test's 1e-6 comparison tolerance.
lower/upper/nqp are arguments of catR's standalone eapEst(); thetaEst() takes the EAP integration grid as parInt = c(lower, upper, nqp).
Commits the catR reference estimates (generated locally with validation/generate-r-reference.R) and fixes the test's CSV parser to strip the surrounding quotes that R's write.csv puts on headers — unquoted lookup made every reference value parse as NaN. With the comparison live, tightens the provisional tolerances based on observed agreement (MLE max |diff| = 0.0036 from optimizer differences; EAP max |diff| = 2.4e-6 from shared rectangular quadrature): max 0.02, mean 0.002. All estimators now validate against catR on every test run.
Coverage Report for CI Build 27384496497Coverage decreased (-0.3%) to 99.713%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
The tutorial's penalized objective (max ln L + 0.5 ln I) equals Warm's WLE only for 1PL/2PL, where Warm's J(theta) coincides with I'(theta). Under 3PL they diverge (verified against catR source: method = 'WL' solves S + J/2I = 0 via Ji(), while BM + Jeffreys prior uses I'/2I), producing theta differences up to ~0.2 on an all-3PL bank and ~0.04 on the golden fixture bank — beyond the golden test tolerance. The warning spells out the two valid resolutions so contributors hit this as a documented decision, not a surprise test failure.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR makes jsCAT's algorithm families (ability estimators, item selectors) pluggable through registries, adds an automated golden-test harness that validates every estimator against catR on each CI run, and establishes the contribution infrastructure (CONTRIBUTING.md, PR template,
.ai/rules/) needed to review algorithmic PRs, including AI-generated ones, on machine-checkable evidence rather than trust.Motivation
A recent community PR (WLE estimation, #53) was psychometrically sound and also completely AI-generated. I suspect these kinds of contributions will become more and more common. I haven't merged #53 yet because I want to make sure we have a better architecture for these kinds of contributions. To add his method, @ben-domingue had to edit
Catin three places (thevalidMethodsarray, the if/else dispatch, and duplicated optimizer scaffolding), and its validation evidence was a one-off plot. This PR restructures the library so that the same contribution becomes: one new file, one registry line, tests, and regenerated fixtures that CI re-validates forever. After this lands, I'll ask @ben-domingue to re-submit the WLE PR against this architecture as its first test case.Architecture
graph LR Cat[Cat] -->|"getEstimator(method)"| ER[estimators/registry.ts] Cat -->|"getSelector(method)"| SR[selectors/registry.ts] ER --> MLE[mle.ts] ER --> EAP[eap.ts] ER -.->|"future: one file + one line"| WLE[wle.ts] SR --> MFI[mfi.ts] SR --> CL[closest.ts] SR --> RND[random.ts] SR --> FX[fixed.ts] SR --> MID[middle.ts] MLE & EAP --> LL[log-likelihood.ts<br/>optimize.ts]AbilityEstimatorinsrc/estimators/; selectors implementItemSelectorinsrc/selectors/. The registry objects are the single source of truth — the method type unions, runtime validation, and dispatch all derive from them.logLikelihoodandmaximizeOverTheta(Powell scaffolding).Bug fixes
Cat's private likelihood seeded its reduce with1, returning ln L + 1. Benign today (the offset cancelled in the MLE argmax and EAP posterior ratio) but a landmine for any future consumer; the shared helper seeds at 0.Clowder._selectNextItemusedMath.random()for the validated/unvalidated coin flip while everything else used the seeded RNG, breaking the reproducibility thatrandomSeedpromises. Now usesthis._rng().Golden validation harness
npm run fixtures:generatedeterministically builds a 50-item × 100-examinee fixture set (fixed seed; 2PL plus c = 0.15 items to exercise the 3PL path).src/__tests__/golden.test.tsasserts on every run:validation/generate-r-reference.R) within tolerance — catches algorithmic error. No R needed at test time.Observed agreement on the committed fixtures:
Tolerances are set at max 0.02 / mean 0.002 (~5× headroom) with observed values documented in the test.
Contribution infrastructure
.ai/rules/(7 rules) surfaced viaAGENTS.md+CLAUDE.mdsymlink, so AI coding agents pick up the architecture and validation requirements automatically..idea/andvalidation/.DS_Store; validation workflow documented invalidation/README.md.Test plan
npm test: 233/233 passing, including the live catR comparisonnpm run lint: clean (one pre-existing warning incorpus.ts)npx tsc --noEmit: cleanFollow-ups (separate PRs)
method = "WL"implements Warm's exact correction, which differs from the penalized-ML form under 3PL — the golden test may legitimately surface a scoping discussion on the c > 0 fixture items.optimization-jsGitHub-fork dependency; move@types/nodeto devDependencies.AI assistance disclosure
Architecture, implementation, tests, and documentation drafted with Claude (Cowork); design direction, R reference generation, and review by @richford. All numerical changes are covered by the characterization baseline and the catR reference comparison described above.