Skip to content

jc: D-KIA-C1b — additive statistics battery (κ, ω, R/η², t-tests, φ) - #887

Merged
AdaWorldAPI merged 1 commit into
mainfrom
claude/x265-x266-plans-review-h9osnl
Aug 4, 2026
Merged

jc: D-KIA-C1b — additive statistics battery (κ, ω, R/η², t-tests, φ)#887
AdaWorldAPI merged 1 commit into
mainfrom
claude/x265-x266-plans-review-h9osnl

Conversation

@AdaWorldAPI

@AdaWorldAPI AdaWorldAPI commented Aug 4, 2026

Copy link
Copy Markdown
Owner

New module crates/jc/src/stats.rs. 107 lib tests + 11 doctests green; stats.rs clippy-clean.

Closes what the C1 audit found: κ was genuinely absent and blocks D3's fusion falsifier, while φ and KR-20 turned out to be computations jc already shipped under other names.

What ships

cohen_kappa Chance-corrected agreement. A different estimator from ICC, not a rename — ICC decomposes variance for interval ratings, κ corrects counts for marginal-expected agreement. This unblocks D3.
omega_total McDonald's ω_t via Spearman's triad identity for congeneric loadings. Where α assumes tau-equivalence, ω does not — so ω ≥ α whenever loadings differ.
phi Named wrapper delegating to reliability::pearson on a 0/1 coding; takes &[bool] so the binary precondition is unforgeable. Marginal-capped ceiling documented at the function.
multiple_r / multiple_r_squared OLS with intercept (normal equations, Gaussian elimination with partial pivoting); singular design → None.
eta_squared / anova_one_way Explained variance (erklärte Varianz) and its F test.
t_test_one_sample / _paired / _welch / _student Each returning t, df, p.

Every p-value comes from one shared regularised-incomplete-beta core (Lanczos ln Γ + modified-Lentz continued fraction), pinned against textbook critical values — never against this code's own output.

The effect-size family is r (φ, R, R², η²). Cohen's d is out by construction; the t-tests are η²/R²'s significance companion, not a d-family back door.

The additive constraint held — tighter than permitted

The entire diff to existing files:

-fn mean(...)          →  +pub(crate) fn mean(...)
-fn all_finite(...)    →  +pub(crate) fn all_finite(...)

plus doc comments and one pub mod line. average_ranks and pop_var were NOT widened — the carve-out allowed it, but this module doesn't consume them. The new sample_var/sample_cov use the unbiased n−1 divisor: a different estimator from pop_var's n, not a duplicate. No existing statistic's arithmetic, signature or semantics moved.

Validation is by cross-identity, not self-assertion

Each new estimator is checked against an independently-computed quantity, most of them against already-proven code:

  • φ vs pearson on the 0/1 coding
  • R² vs pearson² at one predictor
  • η² vs R² on a 0/1 dummy
  • η² vs t²/(t²+df), and F = t², from the pooled t
  • ω vs α — equal under tau-equivalence, and 0.9473684 > 0.8684211 on a hand-computed congeneric fixture (both values derived by hand, not read back)
  • the one-sample p against a closed-form incomplete-beta evaluation

Two defects found by the doc examples, both fixed

Filed as E-EXACT-FIT-IS-WHERE-ABSOLUTE-ZERO-GUARDS-BREAK-1:

  1. A real bug. The Heywood guard psi < 0.0 rejected the perfect model — ψ = 0 exactly in real arithmetic lands a few ulps either side of zero in f64, so zero-residual items were reported as misfit. Now a variance-relative tolerance, paired with a can-it-fire test proving the guard still bites on a genuine violation (relaxing a guard without that test is the strictly worse defect).
  2. A wrong example. Predictors a and a+1 are collinear with the intercept, so None was correct — the code was right, my example wasn't. Pinned by a regression test, since collinearity-with-the-intercept is the variety that gets written by accident.

Worth noting: 31 hand-written unit tests, including five cross-identity checks, passed while both defects were live. Unit tests here were built around hand-computable fixtures (deliberately non-degenerate); doc examples are written to be readable, which selects for the clean exact case. A suite optimised for verifiable references systematically under-samples the perfect case.

Gates

cargo test -p jc --lib107 passed. cargo test -p jc --doc11 passed. cargo clippy -p jc --lib --tests0 warnings in stats.rs.

The crate is pre-existing fmt-dirty and clippy-noisy (HEAD's reliability.rs alone carries 45 fmt-diff lines; the lib has 30 clippy warnings on main), so only the new file was formatted — reformatting the rest would be exactly the "while I was in there" cleanup the additive constraint forbids. That pre-existing debt is untouched and unhidden.

Board hygiene (same commit)

STATUS_BOARD D-KIA-C1b → In PR; plan C1b shipped-note; EPIPHANIES entry; PR_ARC_INVENTORY + LATEST_STATE entries.

Deferred, none blocking D3: ω²/ε² (bias-corrected η²), weighted κ, multi-rater Fleiss κ, the d family.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K3RyLEbuNSHxxB3NTTrGki


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added a statistics toolkit with Cohen’s κ, McDonald’s ω, φ, multiple correlation and R², η², t-tests, and one-way ANOVA.
    • Statistical results include relevant effect sizes, test statistics, degrees of freedom, and p-values.
    • Invalid, incomplete, or degenerate inputs now return safe failure results rather than producing invalid numeric output.
  • Bug Fixes

    • Corrected exact-fit regression handling so valid models are accepted while genuine violations remain rejected.
    • Fixed a documentation example involving an intercept-collinear predictor.

…t-tests, phi)

New module crates/jc/src/stats.rs. Closes the gap the C1 audit found: kappa
was genuinely absent and blocks D3's fusion falsifier, while phi and KR-20
turned out to be computations jc already shipped under other names.

Shipped:
- cohen_kappa — chance-corrected agreement. A DIFFERENT estimator from ICC,
  not a rename: ICC decomposes variance for interval ratings, kappa corrects
  counts for marginal-expected agreement. This unblocks D3.
- omega_total — McDonald's omega_t via Spearman's triad identity for the
  congeneric loadings. Where alpha assumes tau-equivalence, omega does not,
  so omega >= alpha whenever loadings differ.
- phi — named wrapper DELEGATING to reliability::pearson on a 0/1 coding;
  takes &[bool] so the binary precondition is unforgeable. The marginal-capped
  ceiling caveat is documented at the function.
- multiple_r / multiple_r_squared — OLS with intercept via normal equations
  and Gaussian elimination with partial pivoting; singular design returns None.
- eta_squared + anova_one_way — explained variance and its F test.
- t_test_one_sample / _paired / _welch / _student — each returning t, df and p.

Every p-value comes from ONE shared regularised-incomplete-beta core (Lanczos
ln-gamma + modified-Lentz continued fraction), pinned against textbook critical
values rather than against this code's own output.

The effect-size family is r (phi, R, R-squared, eta-squared). Cohen's d is out
by construction; the t-tests are the significance companion to eta-squared/
R-squared, not a d-family back door.

ADDITIVE CONSTRAINT HELD, TIGHTER THAN PERMITTED. The whole diff to existing
files is `fn` -> `pub(crate) fn` on mean and all_finite, plus doc comments and
one `pub mod` line. average_ranks and pop_var were NOT widened — the carve-out
allowed it, but this module does not consume them; and sample_var/sample_cov
here use the unbiased n-1 divisor, a different estimator from pop_var's n, not
a duplicate. No existing statistic's arithmetic, signature or semantics moved.

Validation is by CROSS-IDENTITY against independently-computed quantities, not
self-assertion: phi vs pearson; R-squared vs pearson-squared at one predictor;
eta-squared vs R-squared on a 0/1 dummy; eta-squared vs t^2/(t^2+df) and F=t^2
from the pooled t; omega vs alpha (equal under tau-equivalence, and
0.9473684 > 0.8684211 on a hand-computed congeneric fixture).

Two defects found by the doc examples, both fixed (see EPIPHANIES
E-EXACT-FIT-IS-WHERE-ABSOLUTE-ZERO-GUARDS-BREAK-1):
1. Real bug — the Heywood guard `psi < 0.0` rejected zero-residual models,
   because psi = 0 exactly in real arithmetic lands a few ulps either side of
   zero in f64. Now a variance-relative tolerance, PAIRED with a can-it-fire
   test proving the guard still bites on a real violation (relaxing a guard
   without that test is the strictly worse defect).
2. Wrong example — predictors `a` and `a+1` are collinear WITH THE INTERCEPT,
   so None was correct. A regression test now pins that case.

107 lib tests + 11 doctests green; stats.rs clippy-clean. The crate is
pre-existing fmt-dirty and clippy-noisy, so only the new file was formatted —
reformatting the rest would be the "while I was in there" cleanup the additive
constraint forbids.

Board hygiene in the same commit: STATUS_BOARD D-KIA-C1b -> In PR, plan C1b
shipped-note, EPIPHANIES entry, PR_ARC + LATEST_STATE entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3RyLEbuNSHxxB3NTTrGki
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds a public jc::stats module with agreement, reliability, effect-size, regression, t-test, and ANOVA estimators. It adds shared numerical validation and comprehensive tests. Existing estimators remain behaviorally unchanged.

Changes

jc statistics extension

Layer / File(s) Summary
Numerical foundation
crates/jc/src/stats.rs, crates/jc/src/reliability.rs, crates/jc/src/lib.rs
Adds shared covariance, regression, incomplete-beta, validation, and p-value helpers. Exposes the new stats module and widens two existing helpers to pub(crate).
Agreement and reliability estimators
crates/jc/src/stats.rs, .claude/board/EPIPHANIES.md
Adds Cohen’s κ and McDonald’s ω with degeneracy checks, Heywood-case rejection, and finite-result validation.
Effect-size and regression estimators
crates/jc/src/stats.rs
Adds φ, multiple R, multiple R², and η² with rank, variance, dimension, and finite-input checks.
Inferential tests and result types
crates/jc/src/stats.rs
Adds TTest, Anova, four t-test procedures, one-way ANOVA, and incomplete-beta-based p-values.
Validation and shipment records
crates/jc/src/stats.rs, .claude/plans/..., .claude/board/*
Adds reference, identity, edge-case, overflow, and numerical-tail tests. Records the shipped deliverable and corrected documentation examples.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: claude

Poem

A rabbit checks the sums at night,
κ and ω align just right.
R and η hop through the gate,
T and F now calculate.
Tests keep every number bright.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the additive statistics battery and names its main statistical features.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

@cursor

cursor Bot commented Aug 4, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_bc7afd8b-1220-4957-a085-6e0e8914b7da)

@AdaWorldAPI
AdaWorldAPI marked this pull request as ready for review August 4, 2026 15:10
@AdaWorldAPI
AdaWorldAPI merged commit a9f813c into main Aug 4, 2026
7 of 8 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f394d091a4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/jc/src/stats.rs
if lam_sq < -tol || !lam_sq.is_finite() {
return None; // single-factor model violated (negative common variance)
}
lambda[i] = lam_sq.max(0.0).sqrt();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve signed loadings when computing omega

When a scale contains a negatively loading item, taking sqrt here makes every loading positive and silently inflates ω because the numerator must use the signed sum of loadings. For example, three orthogonal-error items with loadings [1, -1, 1] and residual variances 0.25 produce approximately 0.923 here instead of the correct 0.571; infer each loading's sign from its covariance with a reference item before summing.

Useful? React with 👍 / 👎.

Comment thread crates/jc/src/stats.rs
Comment on lines +462 to +463
if max.abs() < 1e-12 {
return None; // singular → collinear predictors

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make regression singularity detection scale-relative

When an otherwise valid predictor is expressed in small units, this absolute pivot cutoff classifies the design as singular, making R² depend on the predictor's units. For example, y = [1,2,3,4,5] with x = [1e-7,2e-7,3e-7,4e-7,5e-7] is an exact one-predictor fit, but the centered pivot is about 1e-13, so the function returns None; use a matrix-relative tolerance or a scale-aware decomposition.

Useful? React with 👍 / 👎.

Comment thread crates/jc/src/stats.rs
Comment on lines +829 to +830
let ss_w = ss_t - ss_b;
if ss_w <= 0.0 || !ss_w.is_finite() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compute ANOVA within-group variation directly

When between-group separation is much larger than nonzero within-group variation, subtracting these nearly equal large sums loses the within variance and incorrectly returns None. For groups [0,1,2] and [1e9,1e9+1,1e9+2], the actual within-group sum of squares is 4, but both ss_t and ss_b round to 1.5e18, making this subtraction zero; accumulate deviations from each group mean directly instead.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
crates/jc/src/stats.rs (2)

274-284: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Count marginals in one pass to avoid quadratic cost.

The current loop scans a and b once per category. Labels are arbitrary usize, so the number of distinct categories can reach 2n. Then the cost is O(n²). A single counting pass over each slice gives O(n log n).

♻️ Proposed single-pass marginal counting
-    let n = a.len() as f64;
-    let cats: BTreeSet<usize> = a.iter().chain(b.iter()).copied().collect();
-
-    let agree = a.iter().zip(b.iter()).filter(|(x, y)| x == y).count() as f64;
-    let p_o = agree / n;
-
-    let mut p_e = 0.0;
-    for c in &cats {
-        let ma = a.iter().filter(|&v| v == c).count() as f64 / n;
-        let mb = b.iter().filter(|&v| v == c).count() as f64 / n;
-        p_e += ma * mb;
-    }
+    let n = a.len() as f64;
+
+    let agree = a.iter().zip(b.iter()).filter(|(x, y)| x == y).count() as f64;
+    let p_o = agree / n;
+
+    let mut count_a: BTreeMap<usize, usize> = BTreeMap::new();
+    let mut count_b: BTreeMap<usize, usize> = BTreeMap::new();
+    for (&x, &y) in a.iter().zip(b.iter()) {
+        *count_a.entry(x).or_default() += 1;
+        *count_b.entry(y).or_default() += 1;
+    }
+    let mut p_e = 0.0;
+    for (c, &ca) in &count_a {
+        if let Some(&cb) = count_b.get(c) {
+            p_e += (ca as f64 / n) * (cb as f64 / n);
+        }
+    }

Then replace the BTreeSet import with BTreeMap at Line 77.

🤖 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/jc/src/stats.rs` around lines 274 - 284, Update the agreement
calculation around cats, p_e, and the category loop to build marginal counts for
a and b in single passes using BTreeMap, then compute p_e from those counts
without rescanning either slice per category. Replace the BTreeSet import with
BTreeMap and preserve the existing category probabilities and result behavior.

462-464: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Scale the singularity threshold, and document it as hand-tuned.

1e-12 is an absolute pivot threshold. The entries of XᵀX scale with the square of the predictor magnitudes. A well-conditioned design with small values (for example predictors near 1e-7) is then rejected as collinear. A near-singular design with large values passes the check. Compare the pivot against the largest initial magnitude in the matrix instead.

The coding guidelines require hand-tuned thresholds to be documented as such. Add that note to the comment.

♻️ Proposed relative pivot threshold
 fn solve(mut a: Vec<Vec<f64>>, mut rhs: Vec<f64>) -> Option<Vec<f64>> {
     let n = rhs.len();
+    // Scale reference for the singularity test: the largest initial magnitude.
+    // The `1e-12` factor is HAND-TUNED, not derived — an absolute threshold
+    // would misclassify rank on very small or very large designs.
+    let scale = a
+        .iter()
+        .flat_map(|row| row.iter())
+        .fold(0.0f64, |m, v| m.max(v.abs()))
+        .max(1.0);
     for col in 0..n {
@@
-        if max.abs() < 1e-12 {
+        if max.abs() < 1e-12 * scale {
             return None; // singular → collinear predictors
         }
🤖 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/jc/src/stats.rs` around lines 462 - 464, Update the singularity check
in the surrounding stats computation to use a relative threshold based on the
largest initial magnitude in the matrix rather than the absolute 1e-12 cutoff,
so scaling predictor values does not change the collinearity decision. Preserve
the None return for pivots below the scaled threshold, and document the
threshold as hand-tuned in the existing comment.

Source: Coding guidelines

🤖 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 @.claude/board/PR_ARC_INVENTORY.md:
- Line 38: Update the file count and breakdown in the PR inventory entry to
accurately reflect the actual files in the stack: the current count of 5 files
is incorrect, and the breakdown should list the actual composition of three Rust
files and four board/plan files instead of the current enumeration that includes
visibility lines. Ensure visibility lines are not counted as part of the total
file count, only actual files.

In @.claude/board/STATUS_BOARD.md:
- Line 9: Restore the original D-KIA-C1b row in STATUS_BOARD.md, changing only
its permitted status or confidence field in place. Move the updated deliverable,
evidence, and shipment details into a new prepended entry while preserving all
other historical content unchanged.

In `@crates/jc/src/stats.rs`:
- Around line 384-396: Update the loading reconstruction in omega_total so it
preserves relative signs instead of always taking positive roots: keep the first
loading’s sign as the global convention and derive each subsequent sign from the
sign of its covariance with the first item, applying that sign to the recovered
magnitude before computing sum_lambda. Add a unit test for loadings (2, 3, -1)
and verify omega uses the signed hand-calculated total.

---

Nitpick comments:
In `@crates/jc/src/stats.rs`:
- Around line 274-284: Update the agreement calculation around cats, p_e, and
the category loop to build marginal counts for a and b in single passes using
BTreeMap, then compute p_e from those counts without rescanning either slice per
category. Replace the BTreeSet import with BTreeMap and preserve the existing
category probabilities and result behavior.
- Around line 462-464: Update the singularity check in the surrounding stats
computation to use a relative threshold based on the largest initial magnitude
in the matrix rather than the absolute 1e-12 cutoff, so scaling predictor values
does not change the collinearity decision. Preserve the None return for pivots
below the scaled threshold, and document the threshold as hand-tuned in the
existing comment.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 65a50232-442e-4f68-a17b-dc9ca527937e

📥 Commits

Reviewing files that changed from the base of the PR and between fe62f62 and f394d09.

📒 Files selected for processing (8)
  • .claude/board/EPIPHANIES.md
  • .claude/board/LATEST_STATE.md
  • .claude/board/PR_ARC_INVENTORY.md
  • .claude/board/STATUS_BOARD.md
  • .claude/plans/kanban-64k-inverted-awareness-v1.md
  • crates/jc/src/lib.rs
  • crates/jc/src/reliability.rs
  • crates/jc/src/stats.rs


## 2026-08-04 — lance-graph #887 — D-KIA-C1b: the additive `jc` statistics battery (κ, ω, R/η², t-tests, φ)

**Head:** `<this branch>` (entry written in the same commit as the change, per the hygiene rule's "SAME commit" wording; merge SHA follows on merge). 5 files: 1 new module + 2 visibility lines + 3 board/plan.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the arc file-count inventory.

Line 38 says 5 files, but the supplied stack lists three Rust files and four board/plan files. Update the count and breakdown to list the actual files. Do not count visibility lines as files.

🤖 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 @.claude/board/PR_ARC_INVENTORY.md at line 38, Update the file count and
breakdown in the PR inventory entry to accurately reflect the actual files in
the stack: the current count of 5 files is incorrect, and the breakdown should
list the actual composition of three Rust files and four board/plan files
instead of the current enumeration that includes visibility lines. Ensure
visibility lines are not counted as part of the total file count, only actual
files.

| D-KIA-0 | jc capability map + dichotomous-statistics decision note (phi/KR-20/kappa naming; Spearman dropped at view 2) | lance-graph | Queued | plan W0 |
| D-KIA-A1 | ⊘ RESCOPED 2026-08-04 (E-ACTOR-IS-NOT-THE-PHASE-PATH-1): #879 is the complete phase-progression path; KanbanActor has no assigned architectural responsibility (legacy compatibility code). SHIPPED: held-owner reschedule/wake. OPEN: run_cycle drained-writer retry guard; missing-owner counter in cognitive_pass | lance-graph | Queued | plan W1 |
| D-KIA-C1b | jc additive-only extension: kappa + McDonald's omega + r-family effect size (R/R-squared, eta-squared = explained variance) + t-test (t/df/p) + a named phi wrapper. Cohen's d explicitly OUT — calculated separately if ever wanted. HARD CONSTRAINT: additive only — pearson/spearman/cronbach_alpha/icc keep their arithmetic, signature and semantics; any diff changing an existing jc statistic is an automatic reject. ONE sanctioned edit: widening reliability.rs private helpers (mean/all_finite/average_ranks/pop_var) to pub(crate) for reuse, visibility only, no body change. C1 audit found phi = pearson-on-binaries (already present in substance) and KR-20 = alpha-on-dichotomous (naming only); kappa absent = the real gap. Blocks D3's fusion falsifier | lance-graph | Queued | plan W0/C1b |
| D-KIA-C1b | jc additive-only extension: kappa + McDonald's omega + r-family effect size (R/R-squared, eta-squared = explained variance) + t-test (t/df/p) + a named phi wrapper. Cohen's d explicitly OUT — calculated separately if ever wanted. HARD CONSTRAINT: additive only — pearson/spearman/cronbach_alpha/icc keep their arithmetic, signature and semantics; any diff changing an existing jc statistic is an automatic reject. ONE sanctioned edit: widening reliability.rs private helpers (mean/all_finite/average_ranks/pop_var) to pub(crate) for reuse, visibility only, no body change. C1 audit found phi = pearson-on-binaries (already present in substance) and KR-20 = alpha-on-dichotomous (naming only); kappa absent = the real gap. SHIPPED as crates/jc/src/stats.rs: cohen_kappa, omega_total, phi, multiple_r/multiple_r_squared, eta_squared, t_test_one_sample/paired/welch/student, anova_one_way; 31 new tests (107 lib + 11 doctests green), clippy-clean. Existing-file diff is visibility-only (mean/all_finite -> pub(crate); average_ranks/pop_var NOT widened, unused). Unblocks D3's fusion falsifier | lance-graph | In PR | plan W0/C1b |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the append-only board history.

This change rewrites the existing D-KIA-C1b row. It changes the deliverable text and evidence, not only the status or confidence field. Restore the historical row and limit the in-place edit to the allowed status/confidence field. Keep the new shipment details in a new prepended entry.

As per coding guidelines, “governance entries are append-only, with only status/confidence lines updated in place.” Based on learnings, merged historical entries must remain unchanged except for Status and Confidence updates.

🤖 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 @.claude/board/STATUS_BOARD.md at line 9, Restore the original D-KIA-C1b row
in STATUS_BOARD.md, changing only its permitted status or confidence field in
place. Move the updated deliverable, evidence, and shipment details into a new
prepended entry while preserving all other historical content unchanged.

Sources: Coding guidelines, Learnings

Comment thread crates/jc/src/stats.rs
Comment on lines +384 to +396
let lam_sq = acc / count as f64;
// Tolerance is RELATIVE to the item's own variance: a perfect-fit item
// has λ² == σ_ii exactly in real arithmetic but lands a few ulps either
// side of it in f64, and a bare `< 0.0` test would reject a valid model
// as misfit. Only a violation LARGER than rounding is a real one.
let tol = 1e-9 * cov[i][i].abs().max(1.0);
if lam_sq < -tol || !lam_sq.is_finite() {
return None; // single-factor model violated (negative common variance)
}
lambda[i] = lam_sq.max(0.0).sqrt();
}

let sum_lambda: f64 = lambda.iter().sum();

Copy link
Copy Markdown

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

omega_total loses loading signs, so ω is overstated for reverse-keyed items.

The triad identity recovers λ_i² only. Line 393 always takes the positive root. In a congeneric model the loadings share one global sign convention, but individual loadings may have opposite signs relative to each other. A reverse-keyed item has λ_i < 0.

Trace: take three items with true loadings λ = (2, 3, −1). Then σ₁₂ > 0, σ₁₃ < 0, σ₂₃ < 0. Every triad estimate stays positive, for example λ₃² = σ₁₃σ₂₃/σ₁₂ > 0. So all three loadings are taken as positive. Σλ becomes 2+3+1 = 6 instead of the true 2+3−1 = 4, and (Σλ)² is 36 instead of 16. ω is then substantially too high, and the function returns Some rather than None.

Recover the relative signs from the covariances. Fix the sign of the first loading and set every other sign from sign(σ_1j). If the module intends to reject reverse-keyed items instead, document that precondition and return None when the covariance signs are inconsistent with a single positive-loading factor.

🐛 Proposed sign recovery after the λ² loop
-    let sum_lambda: f64 = lambda.iter().sum();
+    // λ² alone does not fix the sign. Under a single factor the relative signs
+    // follow from σ_ij = λ_iλ_j: fix λ_0 > 0, then sign(λ_j) = sign(σ_0j).
+    // A reverse-keyed item otherwise inflates (Σλ)² and overstates ω.
+    let signs: Vec<f64> = (0..k)
+        .map(|i| {
+            if i == 0 {
+                1.0
+            } else if cov[0][i] < 0.0 {
+                -1.0
+            } else {
+                1.0
+            }
+        })
+        .collect();
+    let sum_lambda: f64 = lambda
+        .iter()
+        .zip(signs.iter())
+        .map(|(&l, &s)| s * l)
+        .sum();

Add a unit test with λ = (2, 3, −1) that asserts ω matches the hand-computed value for the signed loadings.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let lam_sq = acc / count as f64;
// Tolerance is RELATIVE to the item's own variance: a perfect-fit item
// has λ² == σ_ii exactly in real arithmetic but lands a few ulps either
// side of it in f64, and a bare `< 0.0` test would reject a valid model
// as misfit. Only a violation LARGER than rounding is a real one.
let tol = 1e-9 * cov[i][i].abs().max(1.0);
if lam_sq < -tol || !lam_sq.is_finite() {
return None; // single-factor model violated (negative common variance)
}
lambda[i] = lam_sq.max(0.0).sqrt();
}
let sum_lambda: f64 = lambda.iter().sum();
let lam_sq = acc / count as f64;
// Tolerance is RELATIVE to the item's own variance: a perfect-fit item
// has λ² == σ_ii exactly in real arithmetic but lands a few ulps either
// side of it in f64, and a bare `< 0.0` test would reject a valid model
// as misfit. Only a violation LARGER than rounding is a real one.
let tol = 1e-9 * cov[i][i].abs().max(1.0);
if lam_sq < -tol || !lam_sq.is_finite() {
return None; // single-factor model violated (negative common variance)
}
lambda[i] = lam_sq.max(0.0).sqrt();
}
// λ² alone does not fix the sign. Under a single factor the relative signs
// follow from σ_ij = λ_iλ_j: fix λ_0 > 0, then sign(λ_j) = sign(σ_0j).
// A reverse-keyed item otherwise inflates (Σλ)² and overstates ω.
let signs: Vec<f64> = (0..k)
.map(|i| {
if i == 0 {
1.0
} else if cov[0][i] < 0.0 {
-1.0
} else {
1.0
}
})
.collect();
let sum_lambda: f64 = lambda
.iter()
.zip(signs.iter())
.map(|(&l, &s)| s * l)
.sum();
🤖 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/jc/src/stats.rs` around lines 384 - 396, Update the loading
reconstruction in omega_total so it preserves relative signs instead of always
taking positive roots: keep the first loading’s sign as the global convention
and derive each subsequent sign from the sign of its covariance with the first
item, applying that sign to the recovered magnitude before computing sum_lambda.
Add a unit test for loadings (2, 3, -1) and verify omega uses the signed
hand-calculated total.

AdaWorldAPI pushed a commit that referenced this pull request Aug 4, 2026
…dence

External review of the merged #887 found two P1 numerical defects. Both were
REPRODUCED before fixing; both now carry regression tests.

1. omega_total erased loading SIGNS.
   The triad identity yields lambda_i^2, hence only |lambda_i|, and the code
   took the positive root for every item. omega depends on (sum lambda)^2,
   where a negatively-keyed item must SUBTRACT. On an exact signed-congeneric
   fixture (loadings [+1,-1,+1], equal orthogonal residuals) the true value is
   0.25 and the shipped code reported 0.75.

   Fixed by recovering signs from the covariance row: under one factor
   sign(sigma_ij) = s_i*s_j, so anchor s_0 = +1 and read the rest off row 0.
   The anchor is free because the model is identified only up to a global flip
   and (sum lambda)^2 is invariant to one — asserted as its own test.

   Sign CONSISTENCY is now also checked, which is a genuine partial one-factor
   structure test. At k=3 it is provably subsumed by the existing negative-
   lambda^2 guard (the sign product of the single triad is -1 exactly when the
   pattern is inconsistent); it only becomes reachable at k>=4 where lambda^2
   is averaged. The can-it-fire test therefore uses a k=4 fixture and
   pre-registers that every lambda^2 and psi is non-negative, so only the sign
   guard can be what rejects.

2. multiple_r_squared was SCALE-DEPENDENT.
   Normal equations were built on raw columns and a pivot was called singular
   below an ABSOLUTE 1e-12 — a statement about units, not rank. Measured: an
   exact linear fit at 1e-8 magnitude returned None, while the identical
   relationship at unit scale returned 1.0.

   Fixed by centering the response and every predictor and scaling predictors
   to unit norm. R-squared is affine-invariant so no correct answer changes,
   the rank test becomes relative (diagonal is exactly 1), and centering
   absorbs the intercept — so "collinear with the intercept" correctly
   reappears as two identical centered columns. The [0,1] clamp is now bounded
   to a rounding-scale band: a materially out-of-range value means the solve
   failed and surfaces as None rather than as a plausible 0 or 1.

Also corrected / added:

- omega's doc claimed rejection means "the congeneric model does not fit". It
  checks three NECESSARY conditions and cannot certify a one-factor matrix;
  the vanishing-tetrad constraints are not tested and k>=4 misfit can still
  return a number. Doc now states what is and is not verified.
- BinaryAssociation + binary_association: counts, BOTH marginals, p_o/p_e
  alongside kappa and phi. The shipped phi doc said marginals are required to
  interpret it while the function returned a lone scalar; that was an internal
  contradiction.
- kr20(&[Vec<bool>]): the dichotomous naming surface C2 asked for and C1b did
  not ship. Enforces binary input by type, delegates arithmetic to
  cronbach_alpha, does not duplicate it.
- betacf now returns Option and reports non-convergence instead of presenting
  the 300th iterate as a p-value; reg_inc_beta uses ln_1p(-x) near x=1 and
  clamps only a rounding-scale excursion.

Board corrections in the same commit:

- The "unblocks D3's fusion falsifier" claim was WRONG and contradicted the
  plan's own C3 (validity requires an external criterion). kappa is chance-
  corrected agreement under the observed marginals; it measures overlap, not
  incremental value. D3 is split: D3a (descriptive overlap, unblocked) and D3b
  (held-out fusion falsifier, BLOCKED on an external criterion and a
  criterion-appropriate scoring rule).
- C2's Spearman wording: on non-constant binary variables the average-rank
  transform is affine, so rho is REDUNDANT with phi, not degenerate.
- C4's Jirak scope: jc::stats p-values are classical independent-sample
  p-values; jirak.rs is a fingerprint-specific empirical probe, not a general
  uncertainty engine. A dependent-cohort claim needs its own justified
  dependence model.
- The exact-zero epiphany was too broad. Narrowed to theoretically
  non-negative FITTED quantities needing a scale-aware tolerance; it is not a
  licence to stop testing zero, since a zero determinant or zero within-group
  variance is a real degeneracy.
- New epiphany E-THE-CROSS-IDENTITY-SUITE-INHERITED-MY-BLIND-SPOT-1: five
  independent cross-identities all passed while both defects were live,
  because every omega fixture had positive loadings and every R-squared
  fixture was O(1). A cross-identity suite inherits the blind spots of its
  fixtures silently, since every check reports green. The practice that would
  have caught both: name the invariances the estimator should have (affine
  rescaling, global sign flip) and write one test per dimension.

116 lib tests + 13 doctests green; stats.rs clippy-clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3RyLEbuNSHxxB3NTTrGki
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.

2 participants