Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .ai/rules/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# jsCAT Engineering Rules

Modular, enforceable engineering rules for jsCAT. Rules are both human-readable (for contributors to browse) and machine-readable (for AI coding tools to consume automatically). jsCAT is a psychometrics library used in live assessments, so the rules weight scientific correctness above all else.

## Rules index

| Rule | Impact | Description |
|------|--------|-------------|
| [architecture-extension-points](architecture-extension-points.md) | CRITICAL | Estimators, selectors, and stopping rules are added via registries/interfaces, never by editing `Cat` dispatch |
| [science-validation-required](science-validation-required.md) | CRITICAL | Every new or changed algorithm ships a literature reference, analytic tests, and golden fixtures vs. catR/mirt |
| [science-numerical-correctness](science-numerical-correctness.md) | HIGH | Log-space likelihoods, seeded RNG only, optimizer conventions, documented model scope |
| [testing-expectations](testing-expectations.md) | HIGH | Three test tiers (analytic, golden, behavioral); explicit tolerances; never snapshot floats |
| [quality-typescript-strictness](quality-typescript-strictness.md) | HIGH | Literal unions over raw strings, no `as any`, strict mode conventions |
| [quality-api-stability](quality-api-stability.md) | MEDIUM | Public API is semver-bound; both zeta formats keep working; case-insensitive method names |
| [quality-pr-creation](quality-pr-creation.md) | MEDIUM | Branch naming, draft PRs, the algorithmic checklist, AI disclosure |

## Impact levels

- **CRITICAL**: Violations risk shipping incorrect ability estimates to real assessments. Must always be followed.
- **HIGH**: Violations cause architectural inconsistency or undermine the validation safety net.
- **MEDIUM**: Best practices for consistency. Follow for new code.

## How AI tools discover these rules

- **Claude Code / Cowork**: `CLAUDE.md` at the repo root (symlink to `AGENTS.md`).
- **Cursor / Copilot and others**: `AGENTS.md` at the repo root.

Both point here. Each rule is self-contained with incorrect/correct examples.

## Contributing a rule

Add or update rules in the same PR that introduces or changes the pattern they describe. Keep the set small (under ~10 rules); prune rather than accumulate.
59 changes: 59 additions & 0 deletions .ai/rules/architecture-extension-points.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
title: Algorithm extension points
description: Estimators, item selectors, and stopping criteria are added through their registries and interfaces — never by adding dispatch logic to Cat or Clowder.
impact: CRITICAL
scope: all
tags: architecture, estimators, selectors, registry
---

## Algorithm extension points

jsCAT's algorithms are pluggable. Each algorithm family has an interface, a directory, and a registry that serves as the single source of truth for the family's type union, runtime validation, and dispatch. Adding an algorithm means adding a file and one registry entry — existing code, especially `Cat`, does not change.

| Family | Interface | Directory | Registry |
|--------|-----------|-----------|----------|
| Ability estimators | `AbilityEstimator` | `src/estimators/` | `src/estimators/registry.ts` (`ABILITY_ESTIMATORS`) |
| Item selectors | `ItemSelector` | `src/selectors/` | `src/selectors/registry.ts` (`SELECTORS` + allowlists) |
| Stopping criteria | `EarlyStopping` (abstract class) | `src/stopping.ts` | exported subclasses |

### Incorrect

```typescript
// Adding an estimator by editing Cat's dispatch — every new algorithm
// makes the most safety-critical file in the library grow
// src/cat.ts
if (method === 'eap') {
this._theta = this.estimateAbilityEAP();
} else if (method === 'mle') {
this._theta = this.estimateAbilityMLE();
} else if (method === 'wle') { // ❌ new branch in Cat
this._theta = this.estimateAbilityWLE(); // ❌ new private method in Cat
}

// ❌ and separately maintaining the validation list by hand
const validMethods: Array<string> = ['mle', 'eap', 'wle'];
```

### Correct

```typescript
// 1. New file: src/estimators/wle.ts
export class WLEEstimator implements AbilityEstimator {
estimateAbility(context: EstimationContext): number { /* ... */ }
}

// 2. One entry in src/estimators/registry.ts
export const ABILITY_ESTIMATORS = {
mle: new MLEEstimator(),
eap: new EAPEstimator(),
wle: new WLEEstimator(), // ← the only edit to existing code
} as const;

// 3. Re-export from src/estimators/index.ts
```

The `EstimationMethod` type, `validateEstimationMethod`, and `Cat`'s dispatch all derive from the registry object automatically.

### The principle

`Cat` orchestrates; it does not implement algorithms. When dispatch lives in one registry, a reviewer of an algorithmic PR reads exactly one new file plus one-line diffs — there is no opportunity to silently alter MLE while adding WLE. This is what makes AI-generated algorithm contributions reviewable: the blast radius of a new estimator is structurally confined.
31 changes: 31 additions & 0 deletions .ai/rules/quality-api-stability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
title: Public API stability
description: The npm package is semver-bound; preserve both zeta formats, case-insensitive method names, and existing error messages.
impact: MEDIUM
scope: all
tags: api, semver, compatibility
---

## Public API stability

`@bdelab/jscat` is consumed by ROAR assessments in production and by external users. Everything exported from `src/index.ts` is public API and changes to it are semver events.

### Invariants to preserve

- **Both zeta formats work everywhere**: symbolic (`a, b, c, d`) and semantic (`discrimination, difficulty, guessing, slipping`) item parameters are interchangeable inputs. New code must handle both (use `fillZetaDefaults` / `convertZeta`); tests loop over both formats.
- **Method names are case-insensitive**: `'MLE'`, `'mle'`, and `'Mle'` are all valid and normalize to lowercase. Constructor inputs stay loosely typed (`EstimationMethodInput`) for this reason.
- **Error messages are contract**: existing throw messages (e.g., `'The abilityEstimator you provided is not in the list of valid methods'`) are asserted by downstream tests. Don't reword them casually.
- **Getters keep returning live state**: `theta`, `seMeasurement`, `nItems`, `resps`, `zetas`, `prior` on `Cat`; the corresponding aggregates on `Clowder`.
- **Mutation semantics of `findNextItem`**: `deepCopy = true` by default; `deepCopy = false` is documented to mutate the caller's array. Don't change either default.

### Changes that require a major version

Removing or renaming exports, narrowing accepted input types, changing default parameter values (`minTheta`, `maxTheta`, prior defaults, `startSelect`), or changing numerical behavior of an existing estimator (also see `science-validation-required.md` — baseline regeneration plus justification).

### Changes that are minor

Adding estimators/selectors/stopping criteria via the registries, adding optional fields to context interfaces, adding new exports.

### The principle

Assessment results must be comparable across time. A silent change to a default prior or an estimator's behavior changes children's scores between sessions of the same study. Additive evolution through the registries is cheap; behavioral change is expensive and must be deliberate, versioned, and announced.
33 changes: 33 additions & 0 deletions .ai/rules/quality-pr-creation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
title: PR creation
description: Branch naming, draft mode, the algorithmic-contribution checklist, and AI disclosure.
impact: MEDIUM
scope: all
tags: process, git, prs
---

## PR creation

### Branch naming

`<prefix>/<short-description>` with prefixes: `enh/` (features), `fix/` (bug fixes), `refactor/`, `maint/`, `dep/` (dependency bumps). Example: `enh/wle-estimation`.

### Commits and titles

Imperative verb phrase, sentence-cased, no trailing period: `Add WLE ability estimator`, `Fix seeded RNG usage in Clowder item selection`. PR titles follow the same convention.

### Draft mode and checks

Open PRs as drafts. Before marking ready: `npm test && npm run lint` locally, and fill in the PR template checklist — the algorithmic checklist is mandatory for any change under `src/estimators/`, `src/selectors/`, `src/stopping.ts`, or to `itemResponseFunction`/`fisherInformation`/`logLikelihood`.

### AI disclosure

State in the PR description whether and how AI tools were used. This is not a gate — AI-assisted PRs are welcome — it tells reviewers where to spend attention: generated structure gets normal review; generated numerics get verified through the golden tests, not by trust.

### Scope discipline

One concern per PR. If you notice a small adjacent improvement (a misleading name, a missing JSDoc), fix it in the same PR rather than leaving a TODO — but split genuinely unrelated changes. A new estimator PR should contain: the estimator file, the registry entry, the export, tests, regenerated fixtures, and docs — and nothing else.

### The principle

The PR is the unit of scientific review here. A tightly-scoped PR with disclosed provenance and machine-checkable validation can be reviewed in minutes with high confidence; a sprawling one can only be skimmed and trusted.
38 changes: 38 additions & 0 deletions .ai/rules/quality-typescript-strictness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
title: TypeScript strictness
description: Strict mode conventions — literal unions derived from registries, no `as any`, narrow escape hatches.
impact: HIGH
scope: all
tags: typescript, types
---

## TypeScript strictness

The package compiles with `strict: true`. Work with the type system, not around it.

### Literal unions derive from the registries

Method names are typed as unions derived from the registry objects — never as free-standing string lists that can drift from the implementation:

```typescript
// ✅ src/estimators/registry.ts — the object is the source of truth
export const ABILITY_ESTIMATORS = { mle: ..., eap: ... } as const;
export type EstimationMethod = keyof typeof ABILITY_ESTIMATORS;

// ❌ a hand-maintained list that can disagree with the registry
const validMethods: Array<string> = ['mle', 'eap'];
```

Public inputs use the `Input` variants (e.g., `EstimationMethodInput`), which add `(string & Record<never, never>)` so editors autocomplete the canonical names while arbitrary-case strings ('MLE', 'miDdle') keep compiling and are normalized at runtime. Don't narrow public inputs to the bare unions — that's a breaking change for JS consumers and case-variant callers.

### No `as any`

Use `as const` for literal types, generics for reusable helpers, and `// eslint-disable-next-line @typescript-eslint/no-non-null-assertion` scoped to a single line where a value is guaranteed by a prior check (the codebase uses `difficulty!` after `fillZetaDefaults`). If a third-party module lacks types, add a minimal `.d.ts` (see `src/optimization-js.d.ts`) rather than casting call sites.

### Interfaces over structural drift

New estimators/selectors implement the published interfaces (`AbilityEstimator`, `ItemSelector`). Don't add untyped extra parameters to `estimateAbility`/`select` — extend `EstimationContext`/`SelectorContext` instead, with optional fields and JSDoc, so all implementations stay call-compatible.

### The principle

In a library consumed by both TS and JS users, types are API documentation that the compiler enforces. Deriving them from runtime objects means the docs cannot lie; an AI or human contributor who invents a method name gets a compile error instead of a silent fallthrough.
51 changes: 51 additions & 0 deletions .ai/rules/science-numerical-correctness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
title: Numerical correctness conventions
description: Likelihood, optimization, and randomness conventions that keep estimates correct and simulations reproducible.
impact: HIGH
scope: all
tags: numerics, likelihood, rng, optimization
---

## Numerical correctness conventions

### Likelihoods live in log space, computed once

All likelihood-based estimators use `logLikelihood` from `src/estimators/log-likelihood.ts`. Do not reimplement it per estimator, and do not work with raw likelihood products (they underflow beyond a few dozen items).

```typescript
// ❌ Incorrect: private likelihood with a nonzero reduce seed (returns ln L + 1)
private likelihood(theta: number) {
return this._zetas.reduce((acc, zeta, i) => /* ... */, 1);
}

// ✅ Correct: the shared helper, seeded at 0
import { logLikelihood } from './log-likelihood';
```

### Optimization goes through the shared scaffold

Estimators that maximize an objective use `maximizeOverTheta` from `src/estimators/optimize.ts`. It owns the optimizer choice (Powell), the start value, and the negation convention. Powell searches unbounded; `Cat` clamps the result to `[minTheta, maxTheta]` afterward — document, don't duplicate, this behavior. Guard objectives against domain errors (e.g., `Math.log` of a non-positive information sum).

### Randomness is always seeded

Every random draw goes through the Cat/Clowder seeded RNG (`this._rng` or the `randomInteger` provided in `SelectorContext`). `Math.random()` is forbidden in `src/` — it silently breaks the reproducibility that `randomSeed` promises to simulation users.

```typescript
// ❌ Incorrect
const random = Math.random();

// ✅ Correct
const random = this._rng();
```

### Model scope is explicit

jsCAT accepts 4PL parameters (a, b, c, d) everywhere. If an algorithm's theory only covers a subset (e.g., a bias correction exact for 1PL/2PL but approximate under 3PL/4PL), say so in the JSDoc and validate at least the exact scope against the R reference.

### Magic constants get derivations

Numerical constants carry a comment deriving them (see `CLOSEST_SELECTION_OFFSET` in `src/selectors/closest.ts`: 0.481 = ln((1+√5)/2), the information-maximizing offset for c = 0.5). An underived constant is unreviewable.

### The principle

Numerical bugs don't throw — they return plausible wrong numbers. Conventions that centralize the likelihood, the optimizer, and the RNG mean there is exactly one place for each class of bug to live, and the golden tests watch that place.
49 changes: 49 additions & 0 deletions .ai/rules/science-validation-required.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
title: Scientific validation required
description: Every new or changed psychometric algorithm must ship a literature reference, analytic unit tests, and golden fixture tests against an independent reference implementation (catR/mirt) that run in CI.
impact: CRITICAL
scope: all
tags: validation, golden-tests, psychometrics
---

## Scientific validation required

jsCAT scores real assessments. An algorithm that "looks right" and passes structural tests can still be numerically wrong. Every PR that adds or changes an estimator, selector, information function, or likelihood must carry three kinds of evidence, and the evidence must be executable — a validation plot in the README is documentation, not proof.

### Required evidence

1. **Literature reference** in the class JSDoc (`@remarks` + Reference line: author, year, journal), plus documented model scope — which IRT models (1PL/2PL/3PL/4PL) the implementation is exact for and what happens outside that scope.
2. **Analytic unit tests**: at least one closed-form case with the derivation in a comment (e.g., a single Rasch item answered correctly gives WLE θ = ln 3 ≈ 1.0986).
3. **Golden fixture tests**: regenerate `src/__tests__/__fixtures__/golden/` via `npm run fixtures:generate`, regenerate the independent reference via `Rscript validation/generate-r-reference.R` (catR), and extend `src/__tests__/golden.test.ts` with the new columns. The committed CSVs make the validation run in CI on every future PR, with no R at test time.

### Incorrect

```typescript
// ❌ New estimator validated only by a plot generated once and committed as a PNG
// ❌ Tests that assert structure, not values:
it('estimates ability', () => {
cat.updateAbilityEstimate(zetas, resps);
expect(typeof cat.theta).toBe('number'); // passes for any wrong answer
});
// ❌ Changing the characterization baseline (expected-jscat.csv) without
// explaining the numerical change in the PR description
```

### Correct

```typescript
// Analytic test with derivation
it('correctly updates ability estimate through WLE', () => {
// Single Rasch item (a=1, b=0), correct response.
// WLE score equation: (1−σ) + 0.5·(1−2σ) = 0 ⟹ σ = 0.75 ⟹ θ = logit(0.75) = ln 3
const cat = new Cat({ method: 'WLE' });
cat.updateAbilityEstimate({ a: 1, b: 0, c: 0, d: 1 }, 1);
expect(cat.theta).toBeCloseTo(Math.log(3), 2);
});
```

Plus regenerated golden fixtures with the catR `method = "WL"` reference column, asserted within the tolerances defined in `golden.test.ts`.

### The principle

This is how SciPy-class projects stay correct: reference values from an independent implementation, committed to the repo, asserted on every CI run. A reviewer cannot referee numerical code by reading it — but a passing golden test against catR cannot be faked by plausible-looking code, human- or AI-written. Validation that doesn't run automatically will silently rot the first time someone touches the algorithm.
44 changes: 44 additions & 0 deletions .ai/rules/testing-expectations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
title: Testing expectations
description: Three tiers of tests for psychometric code — analytic, golden, and behavioral — with explicit numeric tolerances.
impact: HIGH
scope: all
tags: testing, tolerances, golden-tests
---

## Testing expectations

### The three tiers

1. **Analytic tests** — closed-form cases with the derivation in a comment. The strongest evidence: no reference software needed, exact expected value known.
2. **Golden tests** — `src/__tests__/golden.test.ts` compares every estimator against (a) a committed characterization baseline (exact reproduction) and (b) an independent catR reference (within tolerance). New estimators must be wired in; see `validation/README.md`.
3. **Behavioral/property tests** — invariants that hold regardless of exact values: estimates respect `[minTheta, maxTheta]`; `seMeasurement` is finite and positive after informative items; symbolic (`a,b,c,d`) and semantic (`discrimination,difficulty,...`) parameter formats produce identical results (the existing test suites loop over both formats — keep that pattern); WLE is less extreme than MLE for extreme response patterns; etc.

### Numeric assertions use explicit tolerances

```typescript
// ✅ tolerance is visible and justified
expect(cat.theta).toBeCloseTo(Math.log(3), 2);
expect(maxDiff).toBeLessThan(R_REFERENCE_MAX_ABS_DIFF); // named constant with comment

// ❌ snapshot of floating point output — breaks on any platform/optimizer noise,
// and a careless `--update` silently rewrites the expected science
expect(cat.theta).toMatchSnapshot();

// ❌ structure-only assertion — passes for any wrong number
expect(typeof cat.theta).toBe('number');
```

### Test data conventions

- Fixtures live in `src/__tests__/__fixtures__/` (excluded from test discovery by jest config).
- Fixture generation is deterministic: fixed seeds via `seedrandom`, generation scripts committed (`scripts/generate-golden-fixtures.js`).
- Don't hand-edit generated fixture CSVs; regenerate them.

### When behavior intentionally changes

Regenerating `expected-jscat.csv` is allowed only with an intentional, explained algorithm change. The PR description must state which values moved and why; the catR reference comparison must still pass.

### The principle

Tests for scientific code answer two different questions: "is it right?" (analytic + golden) and "did it change?" (characterization). Keep both. A test suite that only checks shapes and types will happily certify an estimator that's off by a sign.
Loading
Loading