Revision history for Stats-LikeR


0.294 2026-08-07 CDT

 - bug fixes: https://www.cpantesters.org/cpan/report/368ca238-73ee-1014-a03f-97f1b88bf904

 - `binom_test` was cross-validated against R 4.6.1 `stats::binom.test` and SciPy
   1.17.1 `scipy.stats.binomtest` using their own test suites rather than cases
   invented here: SciPy's `TestBinomTest`, R's `binom.test(c(800,10))` from
   `tests/reg-tests-2.R`, the `?binom.test` example, and an R-generated corpus of
   383 p-values and 1560 Clopper-Pearson bounds. They are in
   `t/binom_test.R.scipy.t`. Two fixes came out of it, both in the incomplete beta
   that every tail and confidence bound goes through:

 - Its continued fraction stopped after a flat 500 terms, but it needs about
     0.25 sqrt(a+b) of them once the shape parameters are large, so it was quietly
     cut short at big `n`: `binom_test(10079990, 21000000, p => 0.48)` returned
     0.996781946606 where R and SciPy both give 0.9966892187965, i.e. wrong in the
     fourth decimal of a printed p-value. The cap now scales with sqrt(a+b), and
     the front factor moved off differenced `lgamma` onto the same saddle-point
     form `dbinom` already used here. Agreement with R over these cases went from
     9.3e-5 to 3.3e-13 relative.
 - The Clopper-Pearson bounds are found by bisection, which stopped at an
     absolute width of 1e-15, so a bound far below 1 came back with only four
     correct digits: `binom_test(1, 1000000000, alternative => 'greater',
     conf_level => 0.999)` gave 1.00053299e-12 against R's 1.00050033e-12. The
     stopping rule is now relative to where the bracket sits, and such bounds now
     hold about 1e-15.

 - Both fixes also help `t_test`, `var_test` and `cor_test`, which use the same
   function. One limit remains, pinned by the tests rather than left to chance: the
   upper bound for a handful of successes in a billion trials still carries about
   1e-9 of relative error, because the complement branch of the incomplete beta
   cannot resolve a tiny `x` past the spacing of `1-x`.

0.293 2026-08-06 CDT

 - Fixed quadmath error https://www.cpantesters.org/cpan/report/83bcd9a2-9123-11f1-aac1-f3cd035a6881

 - `fisher_test` was cross-validated against R 4.6.1 `stats::fisher.test` and SciPy
   1.17.1 `scipy.stats.fisher_exact` using their own test suites rather than cases
   invented here: SciPy's 84-case R-generated corpus
   (`scipy/stats/tests/data/fisher_exact_results_from_r.py`, four numbers per case
   over two confidence levels and all three alternatives), its
   `TestFisherExact`, R's regression suite (`tests/reg-tests-1{a,b,d,e}.R`:
   PR#644, PR#1662, PR#4688, PR#10558, PR#18336, PR#17671 and the "exact
   fisher.test" entry) and the `?fisher.test` examples. They are in
   `t/fisher_test.R.scipy.t`. Three fixes came out of it:

 - The 2x2 hypergeometric density was built by differencing `lgamma`, which
     costs the back half of a large table's p-value: at a margin of 8.4e7,
     `lgamma` is about 1.4e9, where a double's spacing is 2.4e-7, and exponentiating
     that turns into a relative error of the same size. SciPy's gh-3014 case came
     out right to only seven digits. The density is now assembled from Loader's
     saddle-point binomial, which is how R's own `dhyper` avoids this and which
     `binom_test` already had in the file; its three terms stay O(1) whatever the
     margins are. Worst-case agreement with R over the 84-case corpus went from
     2.1e-12 to 5.2e-14 relative, and gh-3014 from 2.2e-07 to 1.5e-16.
 - The R x C enumeration charged only its leaves against its safety cap, so a
     table wide enough to spend the time in the interior of the tree neither
     finished nor stopped: R's PR#4688 table (4x3, N = 16442), whose whole point
     upstream is that `fisher.test` must fail rather than return `p = Inf`, ran for
     over five minutes here without doing either. Every node is now counted, and
     that table is declined in about a second.
 - The R x C enumeration now bounds each subtree before walking it. `lgamma(x+1)`
     is convex and `a!b! <= (a+b)!`, which together bracket the probability of every
     completion of a partial table; when the whole subtree falls inside the tail its
     mass is added in closed form (`N'! / (prod R_i! prod C_j!)`, from counting the
     remaining observations into rows two ways), and when it falls outside the
     subtree is dropped. The margins are also transposed and sorted first, so the
     fattest row and column are the ones the enumeration gets for free. R's Job
     Satisfaction 4x4 example went from 7.5s to 0.3s and PR#644's 19x2 from 1.0s to
     under 0.05s, and the 6x6 table of PR#18336 -- which segfaulted R before 4.2.0
     and which R 4.6.1 still declines with `hash key 5e+09 > INT_MAX` -- is now
     computable at 0.6322160531, agreeing with R's own 2e6-replicate
     `simulate.p.value` fallback to within its sampling error.

 - Two behaviours that the two references disagree about are now pinned by tests
   rather than left to chance: a table with an empty row or column returns R's
   `p = 1` with an odds ratio of 0 and a CI of (0, Inf), not SciPy's NaN odds ratio;
   and a table with a single row or column is rejected as R rejects it, rather than
   returning SciPy's `p = 1`.

0.292 2026-08-05 CDT

 - fixed long-double bug https://www.cpantesters.org/cpan/report/506975f6-906a-11f1-8f30-a201c4f2440e

 - `power_t_test` was cross-validated against R 4.6.1 `power.t.test` and against
   `scipy.stats.nct` driven by `scipy.optimize.brentq`, over a grid of 288 cases
   covering all five solved-for parameters, all three types, both alternatives and
   `strict`. Three fixes came out of it:

 - The Simpson sum behind the noncentral *t* CDF put a fixed 30000-step grid on
      `u = w/(1+w)`, and the chi density it integrates defeats that at both ends.
      The density carries `w**(df-1)`, so unless `df` is a whole number some
      derivative of it is infinite at `w = 0` and Simpson's error bound does not
      hold: two good digits at `df = 1.2` with `sig_level = 1e-4`, five at
      `df = 1.2`, nine at `df = 1.8`. Substituting `w = z**m`, with `m` chosen so
      that `m*df - 1 >= 3`, restores the bounded derivatives and brings all of
      those to machine precision. It also puts the origin's contribution at zero,
      which subsumes a separate bug: the sum had been dropping its `u = 0` endpoint
      term, worth 7e-7 of absolute power at `df == 1`. `nu` is now also floored the
      way R floors it, per sample rather than in total.
 - The same density has standard deviation `1/sqrt(2*df)` and so narrows without
      bound, while the grid did not. Past `df` of about 1e7 the steps went clean
      over the peak: `power_t_test(n => 4e7, delta => 0)` returned 0.138 where the
      answer can only be `sig_level/2`, and a large-cohort `n` solved 9% low. Above
      `df` of 1e3 the steps now go on `w` across +/- 12 standard deviations of the
      mode, with the chi normalisation taken from Stirling's series to keep the peak
      height from cancelling away; and above 4e5, where those log terms cancel too
      hard for any grid to help, the Abramowitz & Stegun 26.7.10 asymptotic form
      takes over -- the same formula, at the same cut-off, that R's `pnt.c` uses.
      That is also 25 times quicker than integrating.
 - The power was formed as `1 - P(T <= t)`, which loses most of its digits to
      cancellation when the power is small. It is now integrated as the upper tail
      directly.
 - The four inverse solvers were plain bisection stopped at the bracket width,
      which capped `n`, `delta`, `sd` and `sig_level` at R's own four or five
      significant figures. They now use regula falsi with the Illinois correction
      against a relative tolerance, so they match machine-precision `brentq` roots
      to ~1e-13 in fewer evaluations than the bisection took. The `tol` default
      moved from `1.22e-4` to `1e-12` to match.
 - Nothing checked that the bracket held a root, so an unreachable target came
      back as a bracket endpoint wearing the requested power: solving for `sd` with
      `power => 0.01` returned `delta * 1e7`, and with a negative `delta` returned a
      negative standard deviation. Unreachable targets now croak and name the range
      searched. `sig_level` and `power` outside `[0, 1]`, an `n` below 2, a negative
      `sd`, and an unrecognised `type` or `alternative` are rejected as well --
      `type => 'twosample'` used to be read silently as `'two.sample'`.

 - New test file `t/power_t_test.R.scipy.t` carries the cross-validated grid.

0.291 2026-08-04 CDT

 - POD formatting improvements

 [`lm`, `glm`]

 - Formula parsing and data reading are now shared between `lm` and `glm` too, so the
   two agree on what a formula means and on what a row is called. `lm` had the better
   parser and `glm` the better row naming; each now has both.

 - `lm` now names rows the way `glm` does — from a `row.names`, `_row`,
   `rownames` or `.rownames` column when the data has one, and 1-based integers
   otherwise. `lm` previously always used integers, so `fitted.values` and
   `residuals` came back keyed `1..n` for data whose rows had names, and did not
   match what `glm` or `predict` returned for the same data; the `predict`
   documentation already described the shared behaviour. A row-name column is a label
   rather than a measurement, so `y ~ .` now excludes it in both.

 - Design-matrix construction is now shared between `lm` and `glm`, and decides a
   categorical column's coding term by term using R's margin rule: the reference
   level is dropped when the term with that column removed is itself in the model.
   Three bugs fall out of that, all confirmed against R 4.6.1 and statsmodels
   0.14.6.

 - Bug fixes:

 - Four in `glm`, from the parser it now shares with `lm`. Three of them ended the
   same way: a term that names no column evaluates to `NaN` for every row, every row
   is dropped as incomplete, and the fit dies with `0 degrees of freedom (too many
   NAs or parameters > observations)` — never mentioning the formula.

 - **`glm` truncated a formula at 511 characters.** It copied the formula into a
     fixed `char[512]`, so a model with enough predictors to overrun that lost the
     tail. The buffer now grows with the formula.

 - **`glm` did not understand `.`.** It parsed the formula before reading the data,
     so there were no column names to expand `.` into and the term stayed a literal
     `.`. Formula splitting now happens first and term expansion after the data is
     read, so `y ~ .` works in both.

 - **`glm` did not understand `+ 0` or a leading `0 +`.** Only `- 1` suppressed the
     intercept; the other two spellings R accepts left a term named `0`. All three now
     work in both, as do `+ 1` and a leading `1 +`.

 - **`glm` read the `-1` inside `I(...)` as intercept suppression.** It searched the
     whole right-hand side for the substring, so `y ~ I(x-1)` silently became
     `y ~ I(x) - 1`: a different model, fitted without complaint. The scan now steps
     over `I(...)`, leaving the term alone. `I()` still supports only `^power`, so
     that formula is an error in both rather than a wrong answer in one.

 - And the three that fall out of the shared design matrix:

 - **A categorical column in a model with no intercept lost a level.** With no
     intercept there is no baseline for a reference level to be measured against, so
     R codes the factor in full — one column per group, each coefficient that
     group's own mean. Both functions dropped the reference level anyway, so
     `len ~ supp - 1` fitted `len ~ suppVC - 1`: a model forcing every observation
     at the reference level to a fitted value of 0. On R's `ToothGrowth` that meant
     a residual sum of squares of 16056 against R's 3247, and an R² of 0.35 against
     0.87. Where two categorical main effects appear with no intercept only the
     first is coded in full, as in R, since coding both would be rank deficient.

 - **An interaction involving a categorical column could not be built.** The
     interaction was looked up as a single column literally named `dose:supp`;
     finding none, it evaluated to `NaN` for every row, every row was dropped as
     incomplete, and the fit died with `0 degrees of freedom (too many NAs or
     parameters > observations)`. Interactions now expand to the product of their
     components' indicator columns, so `len ~ dose * supp` gives `dose`, `suppVC`
     and `dose:suppVC`. `predict` already understood such coefficient names; now
     they can be produced.

 - **`a*b*c` expanded only its first `*`.** Crossing is associative, so
     `y ~ a * b * c` now yields every non-empty subset (`a`, `b`, `c`, `a:b`, `a:c`,
     `b:c`, `a:b:c`), ordered by degree as R's `terms()` orders them. Previously the
     chunk was split once, producing the unusable terms `b*c` and `a:b*c`, and the
     fit died the same way as above. Crossing more than 16 columns now croaks rather
     than expanding to 2^n terms.

 - **`predict` scored reference-level rows as if the term were absent.** It
     registered factor dummies from `levels[1..]` only, on the assumption that a
     reference level never has a coefficient — true for a factor coded by contrasts,
     but not for one coded in full. Every row at the reference level of a
     no-intercept model therefore came back 0.

 - **`glm` halved its IRLS step whenever the deviance rose, costing iterations and
     accuracy in the standard errors.** R truncates a step only when the deviance
     comes out non-finite; a deviance that merely increases is not divergence. The
     standard IRLS start puts `mu` at `y + 0.1`, essentially on the data, so the
     initial deviance is near zero and the first real step almost always raises it —
     on the nine-point poisson fit in `t/glm.t`, from 0.016 to 1.54. That was read as
     divergence and the step was halved ten times over, turning R's four iterations
     into seven.

 -   The extra iterations reached the same coefficients, so the symptom appeared
     only in the standard errors. They are built from the information matrix of the
     *penultimate* iterate — in R because `summary.glm` inverts the QR that
     `glm.fit` kept from its last weighted least squares call, and here because the
     IRLS sweep leaves that inverse in place — so stopping on a different iteration
     than R means reporting a different matrix. Poisson standard errors were 5e-8 to
     2e-5 away from R's while the coefficients agreed to twelve digits; they now
     agree to about 1e-14. Binomial standard errors were up to 6e-7 out and now
     agree to 2e-14, except on a near-separable fit, where the `varmu` floor of
     1e-10 (a guard against dividing by an underflowed variance) accounts for the
     remaining difference — 1.9e-9 on `am ~ wt * hp`, where three of 32 fitted
     probabilities are within 1e-12 of 0 or 1 and R itself warns. Gaussian fits are
     unaffected: their weights are all 1, so the matrix is `X'X` either way.

 -   The same condition had its `isfinite` test on the accepting side, so a
     genuinely divergent step producing a non-finite deviance was kept rather than
     truncated. That is now the one case that does trigger halving.

 - **The negative-binomial theta alternation stopped early and started from the
     wrong place.** `MASS::glm.nb` does not simply maximise over theta; it alternates
     between an IRLS fit at the current theta and a fresh ML estimate of theta at the
     current fitted means, and which fit it lands on depends on the schedule. Four
     details of that schedule were wrong here, and all four are now reproduced:

 - The alternation stopped on a relative test of the log-likelihood alone,
     `|dll| < 1e-7 * (|ll| + 0.1)`. `glm.nb` requires
     `(|dLm| / d1 + |dtheta|) < 1e-8` with `d1 = sqrt(2 * max(1, df.residual))`
     taken from its Poisson pass — theta itself has to have settled, not just the
     log-likelihood. The old test was satisfied roughly 2e-5 of log-likelihood
     early, which left theta 8e-7 out and dragged the coefficients 8e-6 with it.
 - The first pass now runs as a genuine **Poisson** fit, as `glm.nb`'s does,
     rather than a negative-binomial fit at a large stand-in theta. That pass
     supplies both the first theta and the `d1` above.
 - Later passes are **warm started** from the previous pass's means
     (`etastart = log(mu)`), so they converge to the fit `glm.nb` reaches rather
     than to the same optimum approached from a cold start.
 - Theta is re-estimated at the means each pass **started** from, not the ones it
     produced: `glm.nb` calls `theta.ml(Y, mu)` and only then reassigns
     `mu <- fit$fitted.values`. `theta.ml` itself now also uses MASS's own stopping
     rule, an absolute Newton-step tolerance of `.Machine$double.eps^0.25`.

 -   Across eighteen fits spanning dispersion from theta 0.41 to theta 69000, theta
     now agrees with `glm.nb` to 3.4e-9, coefficients to 5.8e-9, standard errors to
     8.4e-10 and deviance to 1.3e-9 — previously 8e-7, 8e-6, 3e-6 and 6e-7. The one
     exception is genuinely near-Poisson data, where theta is not identified at all
     (its own standard error exceeds the estimate, and `glm.nb`'s `theta.ml` reports
     "iteration limit reached"); theta there agrees only to about 4e-6 relative,
     while the coefficients still agree to 1.6e-10.

 -   A separate consequence: a negative-binomial fit with theta supplied was
     starting from the Poisson `mustart` of `y + 0.1`, where R's
     `negative.binomial()$initialize` sets `y + (y == 0)/6`. Different starting
     values walk different iterates, and since the standard errors come from the
     penultimate one, that showed as standard errors 6e-7 from R's while the
     coefficients agreed to 1e-9. Such fits now match R to 2e-15.

 -   Note on that comparison: standard errors for a negative-binomial fit hold the
     dispersion at 1, which is what `glm.nb` and `summary.negbin` do. R's
     `summary.glm`, handed a `negative.binomial` family directly, instead *estimates*
     the dispersion and prints standard errors scaled by its square root — 1.0839 on
     one of the test data sets, so about 4% larger. Compare against
     `summary(fit, dispersion = 1)` to see the values this module reports.

0.29 2026-08-03 CDT

 [t_test]

 - `t_test` was cross-checked against R's `stats::t.test` and `scipy.stats` case by
   case, including the cases their own suites pin: R's regression tests
   (`reg-tests-1a.R`, "t.test with one group of size one") and scipy's
   `TestTTest_1samp`, `TestTTest_ind.test_special_cases`, `test_ttest_rel_ci_1d`,
   `test_1samp_ci_1d` and `test_pvalue_ci`. On 2000 randomised comparisons against
   R — all four modes, all three alternatives, random `mu` and `conf_level`, sample
   sizes 2 to 40 and data scales spanning 1e-4 to 1e4 — the statistic and the
   degrees of freedom agree to 2e-11 and the p-value to 3e-9, holding to eight
   digits even where the p-value is subnormal (5e-310). What the comparison did
   turn up was seven ways a call could come back wrong rather than loud, all of
   them now fixed and covered by `t/t_test.t`.

 - **`undef` was coerced to 0 instead of being dropped.** This is the one worth
     re-running results over. `t_test` did not filter missing values, so a column
     with gaps in it was tested with every gap counted as a zero: R gives
     `t.test(c(1,2,NA,4,5))` a `t` of 3.286 on 3 degrees of freedom, and `t_test`
     answered 2.588 on 4. No error, no warning, and an answer close enough to the
     real one to look right. `undef` and `NaN` are now dropped the way R drops `NA`,
     per-vector for a one-sample or unpaired test, and on complete cases when
     `paired` so a half-missing pair goes whole rather than contributing a
     difference against zero.
 - **A `y` of fewer than two observations returned a silent `NaN`.** `var_y`
     divided by `ny - 1`, so `t_test(\@x, [$one_value])` propagated `0/0` into the
     statistic, the p-value and both interval bounds without raising. The two
     thresholds R uses are now both in place: a Welch test needs a variance from
     each side and refuses without one, while a pooled test tolerates a side of one
     observation, since that side contributes no sum of squares. That second case is
     what R's own regression suite pins — `t.test(y=x[1], x=x[-1], var.equal=TRUE)`
     is a well-defined test with 8 degrees of freedom, and `t_test` now answers it
     instead of returning `NaN` in one direction and croaking in the other. An empty
     `y` is caught by the same check.
 - **`alternative` was never validated.** The p-value helper fell through to
     two-sided for any string it did not recognise, so a typo — `'gerater'` — ran a
     different test than the caller asked for and reported nothing. It is now
     checked the way R's `match.arg` checks it. `scipy`'s `"two-sided"` spelling is
     unambiguous, so it is accepted rather than rejected.
 - **A one-sided interval was wrong when `conf_level < 0.5`.** That case needs a
     negative t quantile, and `qt_tail` searched upward from zero only, so it
     returned roughly zero and collapsed the bound onto `mu`: R puts the upper bound
     of `t.test(1:10, mu=5, conf.level=0.3, alternative="less")` at 4.9797 where
     `t_test` reported 5.0000000036. `qt_tail` now reduces by symmetry first, so the
     root it brackets is always positive.
 - **`qt_tail` silently saturated at 1e6.** Past that its doubling loop gave up
     and returned the ceiling, so `conf_level` of 0.99999999 and 0.9999999999 came
     back with the *identical* interval, ±1048576, against R's ±6.4e7 and ±6.4e9.
     The ceiling is gone; the loop now runs until `t * t` would overflow.
 - **Interval accuracy no longer depends on the data's scale.** `qt_tail`
     bisected to an absolute 1e-8 on the quantile, which is 1e-8 × `std_err` on the
     interval — fine for data around 1, an error of 2 units for data around 1e9. It
     now bisects to adjacent doubles. Worst interval error across the 2000
     randomised cases went from 2.1e-4 to 5.3e-11 relative. At extreme
     `conf_level` this makes `t_test` the more accurate of the two: `t.test` asks
     for `qt(1 - alpha/2, df)`, and representing a 5e-9 tail as the double
     `1 - 5e-9` costs eight significant figures of it, so R's own interval for
     `conf.level=0.99999999` is off by 0.7 in the eighth digit. Working in the upper
     tail throughout agrees with R's `qt(alpha/2, df, lower.tail=FALSE)` to 15
     digits.
 - **"Essentially constant" was an absolute test.** Only an exactly-zero variance
     was rejected, so a spread below what a double can resolve at the data's own
     magnitude was reported as a finding: four values around 1e10 differing by 1e-5
     gave `t` = 4e15 and a p-value of 3e-47. The comparison is now relative, as R's
     is. The exactly-zero case, where R returns `NaN`, raises here instead.
 - **A defined non-array `y` was ignored.** `t_test(\@x, y => 5)` quietly ran a
     one-sample test. It now raises. An explicit `undef` still means absent, as R's
     `y = NULL` does.

 - `qt_tail` is shared with `power_t_test`, which gains the same precision; it is
   only ever called there with a tail below 0.5, so nothing about its behaviour
   changes. `t_test` remains allocation-free — the missing-value filtering happens
   inside the same single Welford pass that was already there, and the result hash
   is built after the last error check rather than before the first.

 [write_table: `tex.longtable.head`]

 - A `longtable` freezes only the header sitting inside `\endfirsthead` /
   `\endhead`, and `tex.longtable` never wrote those blocks — its header was an
   ordinary first body row, leaving the frozen one to be hand-written by the
   caller. That header then had no link to `col.names`: reorder the columns and
   the labels at the top of every page keep the old order while the data below
   them moves, and the generated header appears again as a duplicate first row.

 - `tex.longtable.head` generates the repeat machinery from the table's own header
   record, so it cannot drift. A true-but-numeric value emits
   `\endfirsthead`/`\endhead`/`\endfoot` with no continuation caption; any other
   true value is the caption used on pages after the first, written verbatim.
   Implies `tex.longtable`. `tex.longtable` on its own is unchanged.

 - The wrapper keeps one static token, the `\hline` closing its `\caption` line,
   because a leading `\hline` in an `\input`ed file is a `Misplaced \noalign`
   error — TeX has already begun the row by the time it expands the `\input`.

 [skew, kurtosis]

 - Two new XS functions describing the shape of a sample beyond its spread:
   `skew` for the third central moment and `kurtosis` for the fourth. Both take
   arguments the way `sd` and `var` do — numbers, array references or a mixture,
   flattened into one sample — and both also accept `x => \@data` and
   `type => 1|2|3`.

 - `type` selects among the three sample conventions, which disagree noticeably on
   small samples. The default is `type => 2`: `G1` and `G2`, the estimators
   unbiased for a normal sample, as reported by SAS, SPSS, Stata, Excel's `SKEW()`
   and `KURT()`, and `scipy.stats` with `bias => FALSE`. `type => 1` is the plain
   moment ratio (`moments::skewness`) and `type => 3` is `b1`/`b2`
   (`e1071::skewness`'s own default). All three, for both functions, agree with R
   to about 1e-15. `kurtosis` returns *excess* kurtosis — 3 is already subtracted,
   so a normal sample sits near 0.

 - One pass, no allocation: the third and fourth central moments accumulate
     through Welford's recurrence extended to higher moments (Terriberry) rather
     than the textbook expansion in raw moments. That expansion is not usable on
     real data — for a column of values around 1e7, a lab value in the wrong units
     or a timestamp, `sum(x**3)/n` is about 1e21 while the third central moment is
     single digits, so every significant figure cancels away.
 - A constant sample croaks rather than returning a silent `NaN` from `0/0`, and
     a `type` whose denominator the sample is too small for (`type => 2` needs
     `n >= 3` for `skew` and `n >= 4` for `kurtosis`) says which.
 - Both read tied arrays. `av_fetch` on a tied array returns a deferred `PVLV`
     rather than the value, and `SvOK` on one of those is false until its
     get-magic has run, so without an `SvGETMAGIC` every element of a tied array
     looks undefined.

 [median]

 - `median` now reads tied arrays too. It already had a separate `av_fetch` path
   for them — a tied array keeps nothing in `AvARRAY`, so the fast path would read
   off a null pointer — but that path was missing the `SvGETMAGIC` described
   above, so it rejected every tied array as undefined instead of computing the
   answer. `mean`, `sd`, `var`, `sum`, `min` and `max` still reject tied arrays.
   They have no `AvARRAY` fast path to guard, so they croak rather than crash, and
   the same one-line fix would make each of them work.

 [oneway_test]

 - `oneway_test` was cross-checked case by case against R's `stats::oneway.test`
   (both branches), R's `anova(aov())` for the `Sum Sq` / `Mean Sq` columns,
   `statsmodels.stats.oneway.anova_oneway(use_var="unequal")` and
   `scipy.stats.f_oneway`. The 37 data sets are R's own built-ins — `chickwts`,
   `InsectSprays`, `PlantGrowth`, `iris`, `ToothGrowth`, `mtcars`, `warpbreaks`,
   `sleep`, `airquality`, `CO2`, `esoph`, `OrchardSprays`, `faithful`, `quakes` —
   plus hand-built numerical edge cases. The statistic and both degrees of freedom
   already matched R everywhere; what the comparison turned up was four ways a call
   could come back wrong rather than loud, all now fixed and covered by
   `t/oneway_test.R.scipy.t`. Statistic, degrees of freedom and p-value now agree
   with R to 1.3e-12 relative error across all 37, and on 2000 randomised
   comparisons against R — both branches, 2 to 8 groups, sizes 2 to 40,
   deliberately heteroscedastic, data scales 1e-4 to 1e4 — the statistic and the
   degrees of freedom agree to 1e-12 and the p-value to 8e-11, the worst of those
   being a p-value of 2.4e-66.

 - **Every p-value below about 1e-16 was returned as a flat 0.** `Pr(>F)` was
     built as `1 - pf(F, df1, df2)`, and 1 minus something that close to 1 has no
     bits left to carry the answer: `faithful` split at `waiting > 70` should give
     `1.2099104551915e-76` under Welch and `5.50783574504386e-103` pooled, and
     `oneway_test` reported `0` for both. Anything from about 1e-9 downward was
     losing relative precision the same way, quietly — `ToothGrowth` by dose came
     back as `9.99200722162641e-16` against R's `9.53272701169993e-16`, off by 4.6%
     with nothing to indicate it. The p-value is now evaluated in the upper tail
     directly, via the beta symmetry `1 - I_x(a, b) = I_{1-x}(b, a)`, so no
     subtraction from 1 happens at any point, and the range down to the smallest
     representable double is reported at full precision.
 - **An `F` of `Inf` produced a p-value of `NaN` instead of 0.** When every group
     is constant but their means differ, the within-group sum of squares is 0 and
     `F` is legitimately infinite; R reports `p = 0`. `pf` formed
     `df1*f/(df1*f + df2)`, which is `Inf/Inf` — a `NaN` that propagated straight
     into `Pr(>F)`. `Inf` and `NaN` are now handled explicitly, matching R's
     `p = 0` and `p = NaN` respectively.
 - **A `NaN` Welch denominator df was reported as 1e300.** A group with zero
     variance gets an infinite Welch weight, which makes R's `tmp` term `NaN` and
     its denominator df `NaN` with it. `oneway_test` had a `(tmp > 0.0)` guard that
     a `NaN` fails, so it substituted a magic `1e300` — a number that reads as a
     real, very large degrees of freedom and would be believed as one. The guard is
     gone; `Residuals`/`Df` and `Residuals`/`Mean Sq` are `NaN` there, as in R.
 - **`formula` mode read `undef` and non-numeric response cells as 0.0.** The
     hash and array-of-arrays shapes were already fixed to die on these (and pinned
     by `t/oneway_test.bugs.t`), but the formula path has its own fill loop and was
     missed, so

         oneway_test({ y => [1, 2, 3, undef, 5, 6], lab => [qw(a a a b b b)] },
             formula => 'y ~ lab');

 -   silently tested group `b` as `(0, 5, 6)` — a mean of 3.67 instead of 5.5 — and
     returned an `F` of 0.735 with no complaint. All three input shapes now enforce
     the documented contract identically.

 - Two places where `oneway_test` is the more accurate side and the reference is
   not, now documented rather than treated as disagreements: the sums of squares
   are accumulated two-pass, so on two groups near 1e8 `Residuals`/`Sum Sq` is
   exactly `10` where R's QR-based `anova(aov())` gives `10.0000000521067`; and
   where the exact between-group sum of squares is 0, `oneway_test` returns 0
   rather than R's 1e-30-scale residue.

0.281 2026-08-03 CDT

 - `median` (LikeR.xs) — the same answers in about an eighth of the time. On the
   `benchmark.pl` case (10,000 normals in one array ref) a call went from 0.83 ms
   to 0.097 ms, which puts it ahead of the two implementations it was behind:
   `numpy.median` at 0.105 ms and R's `median` at 0.196 ms, measured on the same
   machine. Small samples — a per-group median under `agg` or `group_by`, which is
   where most calls to it come from — went from 647 ns to 251 ns.

 - A median is the middle one or two values, so most of the sort the function used
   to do was wasted work: `qsort` orders all n elements at a cost of n log n
   comparisons, every one an indirect call through a function pointer the compiler
   cannot see into, to answer a question that depends on one or two of them. Those
   values are now selected instead, and the sample is walked once rather than
   twice.

 - The selection is introselect, the same shape numpy's `partition` uses:
     quickselect with a median-of-three pivot, an insertion sort once a range is
     small, and a heapsort fallback past a depth limit, so an input crafted to
     defeat the pivot choice degrades to O(n log n) rather than O(n²). The awkward
     data people actually have comes out faster than random data rather than
     slower — sorted, reversed, all-equal and organ-pipe samples of 100,000 values
     each take about 0.25 ms against 0.90 ms for random ones. For an even count the
     lower of the middle pair is the largest value left below the upper one, which
     a scan of that side finds without a second selection.
 - The counting pass is gone. It walked every element through `av_fetch` before
     any arithmetic, only to size the buffer; the array lengths give the same
     count, exactly, because an undef anywhere still dies.
 - The pass that remains reads cells through `AvARRAY` instead of `av_fetch`,
     with tied arrays kept on the `av_fetch` path, since only it sees their values.
 - A sample of 256 values or fewer is copied to the C stack rather than the heap,
     so the common small call no longer pays for a malloc and free at all: 10,000
     of them now grow RSS by nothing. Larger samples still copy n values, which is
     what leaves the caller's array in its original order — the selection reorders
     whatever it works on, and `t/median.t` checks that the input comes back
     untouched.

 - Error messages that carry an index or a count were unreadable on older perls,
   in nineteen places across LikeR.xs, and now are not. `croak` runs perl's own
   formatter rather than the C library's, and that formatter does not understand
   C99's `z` length modifier: it printed the conversion literally, so
   `median(1, undef)` on perl 5.10 or 5.12 said `undefined value at argument index
   %zu` instead of naming the argument that was undefined — the one thing the
   message existed to say. `min`, `max`, `mode`, `sum`, `sd`, `var`, `median`,
   `mcnemar_test`, `friedman_test`, `hoa2hoh` and `oneway_test` were all affected.
   They now use `UVuf`, as the rest of the file already did. The `snprintf` calls
   elsewhere in the file are unaffected and unchanged: those do go to the C
   library, where `%zu` means what it says.

 - New `t/croak.messages.t` covers every one of those messages: each is triggered,
   checked for the number it should name, and then swept for any conversion left
   unexpanded, which is what will catch the next one written with `%zu`. Run
   against the code as it stood before this change, thirty-two of its assertions
   fail on perl 5.10.

 - New `t/median.t`: every length from 1 to 25 and from 254 to 258 — either side of
   the insertion-sort cutoff and of the point where the buffer moves off the stack
   — across sorted, reversed, all-equal, two-valued, duplicate-heavy, organ-pipe
   and median-of-three-killer samples, each checked against a plain Perl sort,
   together with the error messages and `Test::LeakTrace` over the stack, heap,
   mixed-argument and croak paths.

 - fix for threaded Perls https://www.cpantesters.org/cpan/report/2dbacf8f-7138-1014-a1ab-f0f91cf3b922

0.28 2026-08-02 CDT

 - `p_adjust` (LikeR.xs) now takes a data frame as well as a flat list of
   p-values, and hands the corrected values back in the shape they arrived in. An
   AoA, AoH, HoA or HoH goes in and a new frame of the same kind comes out, with
   the same rows, columns and row labels; the input is left alone. Everything the
   flat form did is unchanged — an arrayref of p-values still returns a list, in
   order, with the same numbers.

 - `columns => 'p_value'` (or an arrayref of names, or 0-based positions for an
     AoA) says which columns hold p-values, and copies the rest of the frame
     through untouched, so a results table with a `gene` column no longer has to
     be taken apart and put back together around the call. Without `columns`
     every cell is treated as a p-value, which is right for a frame that is
     nothing but p-values; a label column in one dies with a message naming the
     offending value and pointing at `columns`, rather than correcting a string
     coerced to zero.
 - All the p-values in the frame are corrected as one family, whichever shape
     they came in, so the family size is the number of p-value cells.
 - The method still reads positionally and may now also be given as
     `method => ...`. `none`, which the function has always accepted, is now
     documented along with the rest.
 - Cells are visited in a fixed order — by row and then column name, or column
     name and then row for a HoA — so tied p-values break the same way on every
     run instead of following hash iteration order.

 - `drop_duplicates`, `filter`, `t_test`, `vals`: speed/RAM improvements

 - Incompatible: the `'?'` / `'h'` argument added in 0.27 is gone (lib/Stats/LikeR.pm). `agg('h')`, `read_table('?')` and the fifty-odd other pure-Perl functions that took it no longer print help and die — they treat the string as data, the way the XS functions always have. `h('agg')`, `h(*agg)` and `h(\&agg)` are unchanged and remain the way to ask, for every function in the distribution.
 - It was a help route that only half the module had, so what a lone `'h'` meant depended on whether the callee happened to be written in XS or in Perl, and a column, file or option value really named `'h'` needed `$Stats::LikeR::HELP = 0` to get through. That variable is gone too; nothing reads its arguments for a help flag any more.
 - `bedroc` still prints its own short XS usage summary for `bedroc('h' | 'H' | '?')`, which is hand-written and predates all of this.

 - `merge` (LikeR.xs) — same joins, a third of the time and a fifth of the memory. Nothing about the result changes: every join type, shape combination and edge case produces exactly what it did before, and `t/merge.t` now checks all six input/output paths against a plain-Perl reference join over a randomized corpus.

 - The old implementation transposed both frames into arrays of row hashes, joined those, and transposed the result back. A 10,000-row HoA joined to itself therefore built 20,000 throwaway row hashes and copied every cell three times before returning. It now reads each frame where it lies — a HoA column by column, an AoH/HoH row by row — and writes the result straight into the shape being returned, so the only cells copied are the ones the caller keeps.

 - The right frame's index is a hash of row numbers chained through a flat array, rather than an array-ref of index scalars per distinct key, and one reused buffer builds every join key instead of one scalar per row.
 - Column names are resolved to their column (HoA) or interned once as shared hash keys (AoH/HoH) before the join starts, so the per-row work is a lookup rather than a lookup and a rehash.
 - Measured on the `benchmark.pl` case (two 10,000-row frames, six columns, inner join on `id`): 0.052 s and 41.4 MB before, 0.017 s and 7.3 MB after. An outer join of the same frames went from 0.113 s to 0.008 s.

 - `write_table` (LikeR.xs) — two changes, one of them incompatible.
 - Every format now prints the coloured `wrote <file>` confirmation line, not just LaTeX and `.xlsx`. Delimited output (csv/tsv) was silent before. The line is identical in all cases: the file name in black on cyan, with the SGR codes inline so there is still no `Term::ANSIColor` dependency. Nothing is announced when nothing is written.
 - **Incompatible:** `row.names` now defaults to **off** in every format. It previously defaulted **on** everywhere, following R's `write.table`, which meant a call that said nothing about row names got a label column and a leading empty header cell (`,gene,n`) it had not asked for. Pass `row.names => 1` for the old behaviour; `row.names => 'col'` is unchanged.

 - New `h2aoh` and `aoh2h` (lib/Stats/LikeR.pm), which add the flat hash to the shapes the conversion family understands. A plain hash is a two-column table folded shut, and until now nothing would unfold it: `value_counts` hands one back, and no frame function would take it.
 - `h2aoh(\%h, var_name => .., value_name => ..)` unfolds a flat hash into a two-column AoH, one row per pair, under column names the caller picks. `sort => 'key' | 'value' | 'none'` fixes the row order, which hash iteration otherwise leaves to chance; `'value'` is biggest-first for numbers, so `value_counts` output comes out the way pandas' `Series.value_counts()` orders it.
 - `aoh2h` folds a two-column AoH back down, with `duplicates => 'die' | 'first' | 'last'` deciding what a repeated key means. The two are exact inverses under their defaults.
 - The column options are named `var_name` / `value_name` after `melt`, which emits the same two columns. R spells this pair `tibble::enframe()` / `deframe()`; pandas spells it `pd.Series(d).reset_index()` and `Series.to_dict()`.

0.27 2026-07-26 CDT

 - New `h` function: `h('agg')`, `h(*agg)` or `h(\&agg)` prints that function's section of this document and returns, in the spirit of R's `?function`. `h()` lists every documented function. It covers the XS functions as well as the Perl ones, because it looks the name up in the module's POD instead of reading an argument list — see [Getting help](#getting-help).
 - The pure Perl functions also accept `'?'` or `'h'` in place of their arguments, which prints the same text and then dies. `$Stats::LikeR::HELP = 0` switches that off for code that has to pass a column or file really named `'h'`.
 - `qcut`'s hand-written usage message was replaced by its section of this document; `qcut('h')` and `qcut('?')` still die, but `qcut('H')` no longer means help.

 - speed improvements in calculation of Kendall tau and p-value.  Improvement of writing xlsx files that won't show in time, but pure waste was removed.

 - Addition of `auc`, `auroc`, `cmh_test`, `epi_2x2`, `roc` functions

 - `prcomp` now accepts AoH input

 - glm extended (LikeR.xs)
 - family => 'poisson' (log link) and family => 'negbin' — negative-binomial θ estimated by ML via a MASS::glm.nb-style outer loop, or fixed with theta =>. Matched R to ~1e-8 (coefs, deviance, null-dev, AIC, SE, θ); exact Poisson limit when data aren't over-dispersed.
 - Every non-gaussian family now returns exp (odds/rate/incidence-rate ratios + conf.low/conf.high), link-scale conf.int, conf.level, and theta (negbin). Count families report z-statistics. OR/CI matched R's confint.default exactly.

 - New XS tests (all matched R exactly)
 - prop_test — 1/2/k-sample proportions (Yates, Wilson & Wald-diff CIs)
 - mcnemar_test — matrix or paired vectors; continuity correction; exact => 1 binomial
 - friedman_test — repeated-measures rank test, tie-corrected
 - dunn_test — post-Kruskal pairwise, 7 adjustment methods

 - New Perl functions (lib/Stats/LikeR.pm, matched base-R references)
 - Effect sizes: cohen_d (+Hedges g, CI), smd, cramers_v (+Bergsma bias-corrected), eta_squared (η²/partial/ω²)
 - vif, hosmer_lemeshow (matches hoslem.test)
 - age_standardize — direct standardization + Fay–Feuer gamma CI (matches epitools::ageadjust.direct)

0.26 2026-07-20 CDT

 - https://www.cpantesters.org/cpan/report/fc7d01a0-83f4-11f1-b543-8a9ac547de9a
   Fixed a long-double issue

0.25 2026-07-19 CDT

 - https://www.cpantesters.org/cpan/report/3376f80e-83bf-11f1-a5f3-44496e8775ea

 - Fixed a use-after-free in `fisher_test` on the hash (HoH) input path: the "row is missing column key" error freed its scratch arrays and then read the key strings back out of them to build the croak message. This was harmless on glibc but crashed (`SIGBUS`) under stricter allocators such as FreeBSD's, failing `t/fisher_test.t` on CPAN smokers. The key pointers are now captured before the arrays are freed.

0.24 2026-07-19 CDT

 - `interpolate`'s numeric core moved from pure Perl to XS (`_interp_column_xs`): ~5× faster for `linear` on large columns, ~11× for `pchip`, and ~50× for the spline methods whose dense solve dominates. Results are unchanged (bit-for-bit versus the former Perl kernels).

 - `Ronly` now accepts one or more array references (like `Lonly`), returning the values found only in the last reference; the two-argument form is unchanged, and `Ronly(@refs)` equals `Lonly(reverse @refs)`.

 - `interpolate` gains full `pandas.DataFrame.interpolate` method parity: `nearest`, `zero`, `slinear`, `pad`/`ffill`, `bfill`/`backfill`, `quadratic`, `cubic`, `cubicspline`, `pchip`, `akima`, `barycentric`, `krogh`, `polynomial`, `spline`, and `index`/`values`/`time`, plus an `x` argument for custom abscissae and an `order` argument. Matched to pandas/scipy within 1e-6.

 - `t/transpose.t` no longer loads `Devel::Confess` in its leak tests: its `$SIG{__DIE__}` stack-trace objects landed in `$@` and were reported as leaks by `Test::LeakTrace` on the croak paths under older perls (e.g. 5.12.3). The die-path leak checks now also clear `$@` so the exception object cannot be miscounted.

 - `cfilter` simplification, use of `qr///` filtering on columns

 - `summary` output now looks more like `view`, and accepts HoH

 - `fisher_test` can compute larger tables than just 2x2

 - `read_table` reads xlsx files significantly faster and with less RAM.

 - Addition of `bfill`, `drop_duplicates`, `ffill`, `melt`, and `pivot_table`

 - Original `Lonly` code removed, as it was a special case of `get_unique`, and `get_unique` was re-named to `Lonly`.

 - Removal of `Devel::Confess` from testing and dependencies.

0.23 2026-07-10 CDT

 - `rename_cols` takes HoH as input

 - `write_table` prints row names as first column; writes longtable with comments

 - `assign` gains `map_cell { ... }` for in-place per-cell column edits

 [assign]

 - `assign` now accepts a third kind of column value, `map_cell { ... }`, for editing an existing column in place — no "copy, substitute, return" boilerplate and no dependence on `s///r` (unavailable on the older perls this module supports).

 - Inside a `map_cell` block, `$_` is the **named column's current cell** (not the whole row), the block's return value is **ignored**, and the modified `$_` is stored back: `assign($df, 'Res.' => map_cell { s/^[A-Z]:// })`.
 - The row is still available as `$_[0]` (sibling columns), the index as `$_[1]`, and the row key as `$_[2]` (HoH only).
 - **Undef/missing cells pass through untouched** (undef in → undef out): the block is skipped for them, so `s///` never warns on an uninitialized value.
 - Supported on all three shapes; for HoA the target column must already exist. A plain `sub { ... }` is unchanged, so `map_cell` is purely additive.
 - `map_cell` is exported alongside `assign`.

 - Tests: `assign.t` (AoH + HoA) and `assign.HoH.t` gained `map_cell` coverage — in-place `s///`, `$_[0]`/`$_[1]`/`$_[2]` context, new-column-from-undef, the missing-HoA-column death path, and `no_leaks_ok` guards. Verified building and passing the full suite on perl 5.10.1, 5.12.5 (long-double), and 5.42.2.

 [`group_by`]

 - Fixed group_by to honor all filter hashrefs (option 1)

 - Root cause: the XS captured only ST(3), so every filter hashref after the first was silently dropped — including the README's documented multi-hashref form.

 - Change (LikeR.xs):
 - Removed the single-ST(3) capture and the filter_hv PREINIT var.
 - Added a FOR_EACH_FILTER(body) macro that walks the arg stack from ST(3) to ST(items-1), iterating every { column => sub } pair and ANDing them together. It iterates the stack directly rather than heap-collecting the hashrefs, so a croaking filter sub still can't leak anything (verified). Non-hashref args are skipped.
 - Rewrote the filter loop in all three branches (AoH / HoA / HoH) to use the macro, keeping each branch's own value-fetch logic.

 - One build wrinkle worth noting: xsubpp parses every non-# line in the inter-XSUB region as a candidate function signature, so a /* ... */ comment there breaks the build (it tried to parse column => sub / (ST(3)..) as a signature). I moved the macro's documentation into the XSUB body (real C) and left the macro comment-free, matching the existing EVAL_FILTER style.

 - Tests (t/group_by.HoH.filter.t, 17 assertions):
 - HoH single-column filter, AND filter (both the one-hashref and separate-hashref forms now give identical results), no-match → empty hash, missing/undef target excluded despite passing the filter, and no_leaks_ok

 - Mentioning a non-existent column is now fatal.

0.22 2026-07-07 CDT

 - returned `Devel::Confess` to required dependencies to fix for CPAN testers.

0.21 2026-07-07 CDT

 - Better warning message for undefined data for `aoh2hoh`, `assign`, `dropna`

 - addition of `agg`, `concat`, `drop_cols`, `rank`, `rename_cols`, `select_cols` functions

 - Improving Kwalitee (sic): added `[PodWeaver]` to dist.ini; as well as `Changes` file

 [`assign`]

 - `assign` now accepts two kinds of column value, so a function that already returns a whole column (like `rank`) drops in without wrapping.

 - **Per-row coderef** (unchanged): called once per row, `$_` is the row, and the single scalar it returns is the cell. A single arrayref return is still stored *as the cell*, so arrayref-valued columns keep working.
 - **Whole-column coderef** (new): if the coderef returns a *list* of more than one value, that whole list becomes the column, laid down positionally. This is what makes `'ΔG rank' => sub { rank( vals($df, 'dG_kcal_mol') ) }` work directly — no `[ ... ]` needed.
 - **Arrayref value** (new): a ready-made column, e.g. `col => [ rank(...) ]`, copied into the frame.

 - The coderef is probed once (row 0 for AoH/HoH, the first synthesized view for HoA) to decide per-row vs whole-column, so per-row code is never run twice on row 0. Every column value is length-checked against the row count and a mismatch dies. HoH is now a supported, documented shape alongside AoH and HoA; whole-column and arrayref values align to sorted key order.

 - Tests: `assign.t` (AoH + HoA) and `assign_HoH.t` were expanded to cover every shape × value-kind combination — per-row scalar, whole-column list, arrayref value, single-arrayref-as-cell, `rank()` integration, chaining, `$_[1]` index, `$_[2]` row key (HoH), overwrite, ragged HoA columns, empty frames, length-mismatch and bad-value / odd-arg / non-hash-row death paths, and `no_leaks_ok` guards on the new whole-column and arrayref paths.

 [`read_table`]

 - Fixed handling of commented-out header lines and made filter columns
   referenceable by the name as it appears in the file.

 - **Commented-out header recovery.** `_parse_csv_file` treats a line whose
     comment marker is followed by whitespace (e.g. `# PDB<TAB>score`) as a
     comment and drops it, so a header written that way never reached the
     callback and the first *data* row was silently mistaken for the header.
     `read_table` now recovers it: the first physical line, if it is
     `marker + whitespace` and splits into two or more fields, is held as a
     candidate header and confirmed only when its field count matches the first
     data row. If the counts disagree the candidate was an ordinary leading
     comment and is discarded, so a prose comment that happens to contain the
     separator (e.g. `# note, see README`) is never mistaken for a header. A
     marker hugging its text (`#id,val`) is delivered by the parser and
     un-commented in the callback as before. The marker and any following
     whitespace are stripped, so `# PDB` is stored as the clean name `PDB`.

 - **Filter columns may be named as written in the file.** Filter keys are
     matched against the header by exact name first, then retried with the
     leading comment marker (and surrounding whitespace) stripped, so a
     commented-header column resolves whether it is referenced as `# PDB` or by
     its clean name `PDB`:

         read_table(
             'regression_rank.tabular.tsv',
             filter => { '# PDB' => sub { $_ == 2 } },
         );

 - **Clearer "column not found" error.** The failure now names the file and
     lists the actual header instead of printing it to STDOUT (a library
     shouldn't print):

         read_table: Filter column 'nope' not found in the header of FILE;
         header is: 'PDB', 'score'

0.20 2026-07-05 CDT

 - addition of `ncol`, `nrow`, and `pnorm` functions

 - `filter` can filter by row names with `$_[1]`

 - `view` now accepts array of arrays in addition to AoH, HoA, and HoH

 [csort]

 - Two behavioural changes, both contained to the `csort` XSUB (the `cs_*` helpers are untouched).

 - Row names survive a Hash-of-Hashes sort. Sorting a HoH previously discarded the outer keys. Now each row is folded into a *fresh* row hash (a private container over aliased, read-only cells) that carries its outer key under a `row.name` column, so the name flows into whichever shape you request:

     my $hoh = { alpha => { id => 1 }, beta => { id => 2 } };

     csort($hoh, 'id');          # AoH: each row gains a row.name field
     csort($hoh, 'id', 'hoa');   # HoA: an aligned row.name column

 - The column name defaults to `row.name` and can be overridden with an optional 4th argument (mirroring `hoa2hoh`'s named-key style): `csort($df, 'id', 'aoh', 'sample')`.
 - The outer key is authoritative — it wins over any pre-existing same-named field in the row.
 - Once present, the column is sortable like any other: `csort($hoh, 'row.name')`.
 - Because rows are now *copied* rather than shared, the caller's HoH is never mutated by the injection. (Minor behaviour change: output rows are no longer the same refs as the source rows.)

 - Clearer usage message. The signature is now `csort(...)`, so xsubpp no longer emits the misleading auto-generated `Usage: Stats::LikeR::csort(data, by, output=&PL_sv_undef)`. Argument count is checked by hand, and the croak now shows both real calling forms:

     Usage: csort($df, 'column.name', 'HoA')
        or  csort($df, sub { $b->{'No.'} <=> $a->{'No.'} }, 'hoa')
       (optional 4th arg names the row-name column when sorting a HoH; default 'row.name')

 - `data`/`by`/`output` are read as `ST(0..2)`; `output` still defaults to matching the input shape.

 - Tightened validation messages. The `$data` croak now reads `hash-ref (HoA or HoH)`, and the `$by` croak includes a concrete example: `a column name (e.g. 'No.') or a comparator code-ref using $a and $b, e.g. sub { $b->{'No.'} <=> $a->{'No.'} }`. Existing HoA croaks (`unequal lengths`, `not found`, `not an array-ref`) are unchanged.

 - When sorting, undefined values in the sorting column are placed at the bottom

 [cor]

 - Fixed an unsigned-integer underflow in `kendall_tau_b` and added a regression test.

 - Bug:

 - In `kendall_tau_b`, concordant/discordant counts `C` and `D` are declared `size_t` (unsigned). The numerator was computed as:

     return (NV)(C - D) / denom;

 - The subtraction `C - D` happens in unsigned arithmetic *before* the cast to `NV`. When discordant pairs dominate (`D > C`), the result wraps to a huge positive value instead of going negative.

 - For the arrays:

     dG_kcal_mol:  -7.765, -9.328, -10.326, -9.038, -9.608, -9.779, -9.975, -6.906
     anomaly_rank: 154, 155, 161, 188, 76, 172, 173, 69

 - there are `C = 9` concordant and `D = 19` discordant pairs (no ties). `9 - 19` wraps to `18446744073709551607`, so the function returned ~`6.6e17` instead of the correct `-10/28 = -0.3571428571`.

 - Fix:

 - Cast each operand to `NV` before subtracting, so the arithmetic is signed:

     return ((NV)C - (NV)D) / denom;

 - Only that one line changed. The denominator sums (`C + D + tie_x`, `C + D + tie_y`) are non-negative, so they were left as-is.

 - Regression test — `cor.t`:

 - Kendall on the offending arrays pinned to `-0.3571428571`.
 - Explicit `[-1, 1]` range guard (the real backstop — the pre-fix value `~6.6e17` blows past the bound regardless of exact magnitude), plus a negative-sign assertion.
 - Pearson (`-0.4889102301`), Spearman (`-0.4761904762`), and default-method coverage of the three `compute_cor` branches.
 - Kendall boundary cases: perfectly concordant (`+1`), perfectly discordant (`-1`), self-correlation (`+1`), and a tie case exercising `tie_x` in the denominator.
 - `no_leaks_ok` per method (guarded with `unless $INC{'Devel/Cover.pm'}`).
 - Croak paths: length mismatch, unknown method, zero-variance input.

 [XS refactor]

 - Consolidate helper functions to reduce binary size, find bugs, and back the changes with tests. Every change was validated by translating the XS (`ExtUtils::ParseXS`) and compiling the result
   with the module's own `ccflags`.

 - Outcome:

 - **Net change to the source:** ~154 fewer lines; helper-function count down by 4 (7 removed, 3 added).
 - **Genuine bugs fixed:** two instances of the same latent defect (see below). The rest of the work was behavior-preserving consolidation.

 - Function consolidation:

 - | Change | Before | After |
   |---|---|---|
   | Three-way `NV` comparator | `compare_rank`, `cmp_rank_item`, `cmp_rank_info`, `compare_NVs` | single `cmp_nv3` (reads the leading `NV` member, valid for `RankInfo`/`RankItem`/raw `NV`) |
   | Average-rank routine | `compute_ranks` + `compare_index` restoration sort | existing `rank_data` (scatters ranks into `out[idx]`, no second sort) |
   | String comparator | `cmp_string_wt`, `lm_str_qsort` (byte-identical) | single `cmp_string_wt` |
   | Multiplicity filter & set difference | `intersection` + `get_unique` (~90% shared); `Lonly`/`Ronly` duplicated bodies; a separate `set_difference()` | one shared `set_multiplicity()` with an "all vs. one" mode flag and a `from_last` flag: `intersection` (all), `Lonly` (one, first array), `Ronly` (one, last array) |

 - All merges were confirmed behavior-preserving: the collapsed comparators are
   equivalent on ordinary values, `NaN`, and infinities, and `compute_ranks` and
   `rank_data` produce identical average ranks.

 - Bugs:

 - Two comparators stabilized their sort by returning `a->idx - b->idx` directly,
   where the index field is an unsigned `size_t`. The subtraction wraps and is then
   truncated to `int`, which is implementation-defined and gives the wrong sign
   once a difference exceeds `INT_MAX`.

 - `compare_index` — removed entirely (the routine that used it, `compute_ranks`, was replaced by `rank_data`).
 - `cmp_pval` — the tie-break comparator in the p-adjust path. **Missed in the initial review; found later** via a `-Wconversion` compile of the earlier source. Fixed to compare with the `(a > b) - (a < b)` idiom.

 - Caveat on severity: on every mainstream ABI (LP64, LLP64, ILP32), the
   low-word truncation happens to reproduce the correct sign for any array smaller
   than ~2^31 elements, so this never produces a wrong result at realistic sizes.
   It is a portability/UB issue, not a runtime failure, which is why no functional
   test detects it (see "Testing", below).

 -  `LikeR.xs` — consolidated helpers; `compare_index` removed; `cmp_pval` fixed.

 [`view`]
 -  non-ASCII characters now print

 [`write_table`]

 - new option to output to LaTeX table

0.19 2026-07-01 CDT

 - numerous `SSize_t var1 = av_len(var) + 1` are changed to `size_t var1 = av_len(var) + 1` as `size_t`; as the result cannot be negative, in order to expand numerical range

 - Addition of `hoa2hoh`, `binom_test`, `chunk`, `get_union`, `get_unique`, `Lonly`, `Ronly`, `qcut`, and 3 tukey functions

 - Better warnings when non-array references are given to `intersection`

 - `view` now breaks columns into chunks for very wide data sets, more closely matching R's behavior

0.18 2026-06-28 CDT

 - `restrict` keyword added to numerous places within `intersection` to decrease CPU time

 - fix to dist.ini for dependencies

 - fixed POD rendering

0.17 2026-06-23 CDT (approx)

 - addition of `assign`, which adds new columns based on calculations from other columns

 - addition of `hoa2aoh`, transforming hash of arrays to array of hashes

 - addition of `predict`, using results from `aov`, `glm`, and `lm`

 - addition of `aoh2hoh` transforming array of hash into hash of hashes, `intersection`, `uniq`, and `vals`

 [`aov`]

 - Bug fixes:
 - **`size_t` underflow on empty arrays.** Three loops were bounded by `av_len(...)`
     compared against an unsigned counter; `av_len` returns `-1` for an empty array,
     which turned `k <= len` into a `SIZE_MAX` loop. The `stack()` value loop, the `.`
     column-expansion loop, and the `group_stats` column loop now use a signed
     `SSize_t` bound.
 - **HoH row count.** Row count for hash-of-hashes input was taken from the return
     value of `hv_iterinit`; it now uses `HvUSEDKEYS(hv)` with a separate
     `hv_iterinit`, matching `predict`.
 - **Buffer overflow in interaction parsing.** `strcpy(right, colon + 1)` into a
     fixed `char right[256]` is now `snprintf(right, sizeof(right), ...)`.

 - Performance / memory:
 - **Removed the per-row `row_x` scratch allocation.** Design rows are built
     directly into `X_mat[valid_n]`; `valid_n` simply does not advance on a rejected
     row. Interaction columns read their operands from the same in-progress row, so
     the logic is unchanged.
 - **`row_names` is no longer dead.** Surviving row names are transferred (pointer
     move, no copy) into `surv_names` to key `fitted.values`; rejected rows are freed
     in place.
 - **Dropped a `restrict` UB.** `orig_data_sv` aliases `data_sv`; the `restrict`
     qualifier was removed.

 - New, `predict`-compatible output keys:
 - **`coefficients`** — OLS estimates recovered by back-substitution on the R factor
     left in `X_mat` against Q'y in `Y` (no re-derivation). Keys are the expanded term
     names (`Intercept`, continuous names, `base.level` dummies, and `a:b` interaction
     products). Aliased columns are reported as `NaN`, which `predict` drops.
 - **`fitted.values`** — `Xb` over the non-aliased columns, keyed by surviving row
     name. Computed from a snapshot of the design (`Dsav`) taken before the QR
     overwrites `X_mat`. Costs one transient copy of the design matrix; negligible for
     typical ANOVA where the column count is small.
 - **`xlevels`** — sorted level list per factor, index 0 = reference, aligned with
     the contrast coding used to build the dummies.
 - **`family`** — `"gaussian"`.

 - Cleanup-path correctness:
 - `xlevels_hv`, `Dsav`, and `surv_names` are freed on both the "0 degrees of
     freedom" croak and the normal exit. The interaction-main-effects croak in
     PHASE 3 also frees `xlevels_hv`.

 - Known limitations (unchanged):
 - The intercept-stripping string surgery (`-1`, `+0`, `+1`, ...) operates on the
     whole RHS and can still mangle `I(x-1)`-style transforms; treat `I()` with
     arithmetic constants carefully.
 - Top-level keys `coefficients` / `fitted.values` / `xlevels` / `family` /
     `group_stats` share the return hash with the ANOVA rows; a predictor literally
     named one of those would collide.

 [`predict`]

 - New: factor-bearing interaction terms:
 - Previously, interaction coefficients such as `GroupB:Sexmale` or `GroupB:x` fell
   through to the continuous `evaluate_term` path and died on a nonexistent column.
   They are now handled directly:

 - **`dummy_hv`** stores each dummy's factor base index (an `IV`) instead of
     `&PL_sv_yes`, so a dummy name maps back to its `(base, level)` in O(1)
     (`level == name + strlen(base)`). `hv_exists` lookups are unaffected.
 - During coefficient caching, any `:` term with at least one factor-dummy component
     is routed to a separate list (`icopy` / `ibeta`); pure-continuous interactions
     (e.g. `x:z`) stay on the existing `evaluate_term` path, so prior behavior is
     preserved.
 - Each routed term is parsed once into flat component arrays. Factor components
     store a base index and level pointer; continuous components store the term string
     and get the same up-front column-existence validation as main terms.
 - Per row, each factor's raw level is read once into `raw_lv[]` and reused by both
     main effects and interactions (no duplicate `get_data_string_alloc`). An
     interaction's value is the product of its components: a factor component
     contributes `1.0` iff the row's level matches the dummy's level (reference levels
     give `0`), continuous components go through `evaluate_term`.

 - This covers factor×factor, factor×continuous, continuous×continuous, and n-way
   combinations.

 - Other:
 - HoH row count uses `HvUSEDKEYS` (already present).
 - The unseen-factor-level croak now frees every level string already read for the
     current row, not just the current one.

 [Tests]

 - **`aov.t`** — one-way ANOVA against hand-computed values (Df / Sum Sq / Mean Sq /
     F / decomposition); identical results across HoA / HoH / AoH / stacked input;
     simple regression; `.` expansion; intercept removal (`-1`); two-way with
     interaction (Type I SS on a balanced design); NaN listwise deletion; all croak
     paths; leak checks.
 - **`predict.t`** — `predict(training) == fitted.values` round-trips for one-way,
     regression, factor×factor, factor×continuous, and continuous×continuous models;
     explicit predicted values; agreement across HoA / AoH / HoH / flat newdata;
     no-newdata path; binomial `link` vs `response`; gaussian identity link; all croak
     paths; leak checks.

 - Leak tests use `no_leaks_ok` guarded by `unless $INC{'Devel/Cover.pm'}` and skipped
   when `Test::LeakTrace` is absent.

 - Assumptions worth confirming:
 - The NaN-deletion test relies on `evaluate_term` returning `NaN` for a non-finite
     response value (an `Inf - Inf` NaN is fed in deterministically).
 - The continuous×continuous round-trip relies on `evaluate_term("x:z")` yielding
     `x * z` — the same assumption the pre-existing `predict` continuous-interaction
     path already made. If that path was untested, this round-trip now exercises it.

 [`view`]

 - now returns colored output; fixed bug with incorrect widths; undefined values show as `undef` rather than `NA`, as in Data::Printer

 [`csort`]

 - now accepts Hash of Hashes; addition of `restrict` which should decrease calculation time

 [filter]

 - **Added hash-of-hashes (HoH) input.** In addition to AoH and HoA, `filter` now accepts an HoH (`{ key => { col => val, ... }, ... }`); each inner hash is one row, and matching keys are preserved by default (HoH -> HoH).
 - **Added `output.type`.** `filter($df, $pred, 'output.type' => 'aoh'|'hoa')` selects the returned shape (aliases `out` / `output_type`; a bare positional type also works). When omitted, the input shape is preserved. `hoh` is not a selectable output, since it would require choosing a key column.
 - **`col()` reworked, not removed.** Both predicate forms are kept: `col('age') >= 18` still works and is the concise/composable option, while a coderef covers everything else. Internally `col()` is now **pure Perl** — an overloaded class that builds a per-row closure — and `filter` unwraps that closure so `col()` and a coderef share one evaluation path. The previous standalone XS predicate evaluator (`filt_eval`/`filt_ctx`) is gone; delete it if your tree still has it. One consequence: a `col()` comparison now costs the same per row as the equivalent coderef (a Perl call), rather than being evaluated in C.
 - **Unchanged guarantees:** the input frame is never modified; `undef` (and, for numeric ops, non-numeric) cells never match a `col()` comparison; AoH/HoH rows are shared rather than copied where possible; keep-all/keep-none shapes are well defined per output type; Perl 5.10 compatibility is retained. A latent `SvTRUE(POPs)` double-evaluation in the per-row call helper (which crashed on perls where `SvTRUE` is a multi-eval macro) was fixed along the way.

 [read_table]

 - Added an opt-in `auto.row.names` argument so `read_table` can read the file R
   produces by default from `write.table(x, sep="\t")`.

 - The problem:

 - R's `write.table` defaults to `row.names=TRUE, col.names=TRUE`, which writes the
   row-names column in every data row but emits no header label for it. So a
   frame with N columns comes out as N header fields over N+1 data fields — e.g.
   `mtcars` gives 11 headers but 12-field rows. By default `read_table` (correctly)
   rejects that as ragged:

     Alignment error on mtcars.tsv data row 1 (12 fields vs 11 headers).

 - The change:

 - `auto.row.names` turns on R's own `read.table` rule: **when, and only when, the
   header is exactly one field short of the data rows, treat the first field of
   each row as an (unlabelled) row-names column.**

     # default: the leading column is named 'row_name'
     my $df = read_table('mtcars.tsv', 'auto.row.names' => 1);

     # or give it a name
     my $df = read_table('mtcars.tsv', 'auto.row.names' => 'model');

 - The synthesized column behaves like any other first column: it appears in `aoh`
   and `hoa` output, and for `hoh` it becomes the default key (so rows are keyed by
   the model name). This also lines up with the existing handling of R's
   `col.names=NA` output (a blank leading header), which still produces a
   `row_name` column with no flag needed.

 - What did not change:

 - The strict alignment check is still the default. Without `auto.row.names` the
   lopsided file still croaks, and even with it, a row that is off by anything
   other than exactly one field still croaks — so the corruption guard only relaxes
   for the one case R itself treats specially.

 - Tested in `t/read_table.2.t` (16 assertions, Perl 5.10.1 and 5.38): aoh / hoa /
   hoh output, custom column name, the already-aligned file (flag is a no-op), the
   `col.names=NA` path, and the strict / ragged croak paths.

 - additional bugfix:

     # This is a comment
     id,name,val
     1,Alice,10.5
     2,Bob,
     3,Charlie,15.2

 - would not be read correctly using `read_table`, but now is read correctly

 [value_counts]

 - now accepts array of hashes

0.16 2026-06-17 CDT

 - changes to dist.ini, the minimum Perl version disappeared when I fixed other problems

 - clarifications between run time and test dependencies

 - addition of `csort` function to sort AoH and HoA

 - addition of `aoh2hoa` to translate array of hashes into a hash of arrays

 - fix of long double functions: https://www.cpantesters.org/cpan/report/5d5d9836-6a5f-11f1-aadb-63fd6d8775ea

 [`glm`]

 - output residual keys now use names, not integers

 [`lm`]

 [Bug fixes]

 - Memory leak on the zero-degrees-of-freedom error path. When
   `valid_n <= p`, the cleanup freed the `valid_row_names` *array* but not the
   per-row name strings it held (those had been transferred out of `row_names`,
   whose own array was already freed). The strings leaked on every such error.
   Added the per-entry `Safefree` loop before freeing the array, matching the
   normal path.

 - HoH input validated only the first row. Only the first hash value was
   checked to be a `HASHREF`; subsequent values were `SvRV`'d unconditionally, so
   a malformed row (`{ a => {...}, b => 5 }`) dereferenced a non-reference. Every
   row is now validated, with the partial allocations cleaned up before the
   `croak`, mirroring the existing AoH path.

 - `isspace` on a possibly-signed `char`. `isspace(*src)` is undefined for
   byte values ≥ 0x80 on platforms where `char` is signed. Cast to
   `(unsigned char)` before the call.

 [Speed / RAM improvements]

 - Formula buffer is now heap-allocated to fit. `char f_cpy[512]` silently
   truncated any longer formula. Replaced with a buffer sized to
   `strlen(formula) + 1`, so there is no fixed limit and no truncation.

 - `.`-expansion buffer is now a growable heap buffer. `char rhs_expanded[2048]`
   silently dropped expanded terms once full. It is now a buffer that doubles on
   demand. Appends also went from `strcat` (which rescans from the start every
   time — O(n²) over many columns) to an O(1) amortised append that tracks the
   write position.

 - No more per-row scratch allocation in matrix construction. The original
   `safemalloc`'d a `row_x` buffer, filled it, copied it into `X`, and freed it
   *for every row* — `n` allocations plus `n*p` copies. Each candidate row is now
   written straight into `X` at its prospective commit slot; a row that fails
   listwise deletion is simply overwritten by the next candidate. This removes the
   `n` allocate/free cycles and the copy loop entirely.

 - Categorical levels sorted with `qsort`. The level list used an O(n²) bubble
   sort; replaced with `qsort` (relevant only for high-cardinality factors).

 - Unused tail of `X` reclaimed after listwise deletion. `X` is allocated for
   all `n` rows up front (`valid_n` is unknown until rows are scanned). When rows
   are dropped, `X` is now `Renew`ed down to `valid_n * p`, returning the unused
   tail to the allocator before the OLS phase.

 - Minor robustness. The argument-parsing index was widened from
   `unsigned short` to `I32` to match `items`, and the HoH row count now uses
   `HvUSEDKEYS` rather than relying on `hv_iterinit`'s return value.

 [Known limitations (left unchanged)]

 - A multi-way term such as `a*b*c` is split only on the first `*`, so it yields
     `a`, `b*c`, and `a:b*c` rather than a full three-way expansion. Deeper
     interactions silently fail (the unparsable term evaluates to `NaN` and the
     rows are dropped). This matches the documented two-way `*` support.
 - HoA input takes the row count from the first column; columns shorter than
     that simply contribute dropped rows rather than raising an error.

 [`oneway_test`]

 - Bug fixes:

 - Memory leaks on error paths. Nearly every `croak` after an allocation
   leaked memory. `croak` does a `longjmp`, so anything allocated but not yet
   freed is lost. Affected paths:

 - AoA and hash first-pass errors leaked `sizes` and any `gnames[]` entries
     allocated so far.
 - Formula-mode "not found as an array ref" errors leaked `lhs` and `rhs`.

 - All post-allocation errors now route through a single `fail:` label that frees
   every pointer unconditionally. Pointers are initialised to `NULL` and `gnames`
   is zero-allocated with `Newxz`, so the cleanup is always safe to run.

 - Undefined and non-numeric cells silently coerced to `0.0`. The original
   second pass used `(svp && *svp) ? SvNV(*svp) : 0.0`, meaning an `undef` or
   non-numeric cell was quietly treated as zero, silently corrupting the
   F-statistic. Each cell is now validated with `SvOK` and `looks_like_number`;
   the call dies naming the group and observation index, consistent with the rest
   of `Stats::LikeR` (`mean`, `sum`, `cor`, etc.).

 - Unsigned wraparound on empty array input. `k = (size_t)av_len(in_av) + 1`
   cast to `size_t` *before* adding, so an empty array (`av_len` returns `-1`)
   produced `SIZE_MAX` rather than `0`. Changed to
   `k = (size_t)(av_len(in_av) + 1)` so the `+1` is done in signed arithmetic
   before the cast.

 - Unreliable group count from `hv_iterinit`. `hv_iterinit` returns the
   number of buckets in use rather than the number of keys for tied hashes.
   Replaced with `HvUSEDKEYS`, which always returns the correct key count.

 - Improvements:

 - `var.equal` accepted as an alias for `var_equal`. R users write
   `var.equal`; the argument parser now accepts both spellings.

 - Perl memory API used throughout. `safemalloc` and manual `memcpy` replaced
   with `Newx`, `Newxz`, `savepv`, and `savepvn`. `savepvn` additionally
   preserves embedded NUL bytes in group key strings, which the previous
   `strlen`-based copies silently truncated.

 - Known limitations (not changed):

 - A factor column named `Residuals` or `group_stats` in a formula call will
     collide with reserved top-level keys in the result hash.
 - Group names containing an embedded NUL are stored correctly but are still
     truncated at `strlen` when written into the output hash keys.

 [`view`]

 - default view shifted to 80 characters to match Linux window length

 - New features:

 - **`rows` is accepted as a synonym for `n`** (the number of rows shown).
     Passing both `n` and `rows` is an error.
 - **Unknown arguments are now rejected.** `view` validates its argument names
     against the documented set (`n`, `rows`, `na`, `max_width`, `ellipsis`,
     `gap`, `cols`, `columns`, `to`, `return_only`, `row.names`, `row_names`) and
     dies listing any it does not recognise, so a misspelt option (e.g. `widht`)
     is caught instead of silently ignored.
 - **`n` / `rows` is validated.** It must be a non-negative integer; `undef` or
     a non-numeric value now dies with a clear message instead of producing
     warnings and being treated as `0`.
 - **flat/simple hashes are accepted as input**

 - Bug fixes:

 - **`n => 0` now still prints the column header.** Column names were collected
     only from the rows being shown, so requesting zero rows produced an empty
     header line. At least one row is now scanned (when data exists) so the
     header always lists the columns.
 - **An empty hash (`{}`) no longer dies.** It was rejected as
     *"neither ARRAY nor HASH"*; it is now shown as an empty table
     (`0 rows x 0 cols`), matching the handling of an empty array.
 - **The `row_names` alias now drives the Hash-of-Hashes label header.** The
     header for the row-label column consulted only `row.names`, so
     `row_names => 'id'` displayed `row_name` instead of `id`. Both spellings are
     now honoured consistently.
 - **Malformed nested values degrade gracefully.** A Hash-of-Arrays column or
     Hash-of-Hashes row whose value is not actually an array/hash reference now
     renders as empty cells rather than throwing a dereference error.

 - Performance:

 - Column gathering no longer sorts once per scanned row. Unique column names
     are collected across the scanned rows and sorted a single time (same output
     order), and the ellipsis length is computed once rather than per cell.

 - Tests:

 - `t/view.t` is self-contained (the `view` implementation is inlined; it loads
     no other files) and covers the new argument handling, the bug fixes above,
     and the existing AoH / HoA / HoH behaviour, alignment, truncation, and
     output-path handling.

 [`wilcox_test`]

 - Corrected four bugs in the `wilcox_test` XSUB plus a portability fix in its exact signed-rank helper. Behaviour on valid input is unchanged: the R-agreement cases (unpaired `W = 58`, `p = 0.13292`; paired one-sided `V = 40`, `p = 0.019531`; separated exact `W = 0`, `p = 0.028571`) all still match R's `wilcox.test`.

 - Bug fixes:

 - **Invalid `alternative` is now rejected.** Any value other than `less` or `greater` previously fell through to the two-sided branch and returned a two-sided result mislabelled with the bad string, so a typo like `alternative => "twosided"` silently "worked". It now croaks unless `alternative` is one of `two.sided`, `less`, `greater`.
 - **Zero/negative variance is guarded.** When every observation is tied the approximation's variance collapses to 0 and the old code divided by `sqrt(0)`: `wilcox_test([5,5,5], [5,5,5])` returned `p = 0` (a "significant" difference between identical samples). It now warns and returns `p = 1`.
 - **Two-sided continuity correction at `z = 0`.** R uses `sign(z) * 0.5`, so the correction is `0` when the statistic sits exactly on its mean; the old code used `-0.5`. Example: `wilcox_test([1,4], [2,3], exact => 0)` changed from `p = 0.698535` to `p = 1` (matches R).
 - **`exp` no longer shadows libm.** The local `exp` accumulator (mean of the statistic) shadowed the C library `exp()`; renamed to `mean_w` (two-sample) and `mean_v` (signed-rank). No active miscompute, removed as a latent hazard.

 - Cosmetic:

 - Collapsed a no-op ternary that assigned the same signed-rank exact method string on both branches; the `method` field is now simply `Wilcoxon signed rank exact test`.

 - Portability (exact signed-rank helper):

 - **`exact_psignrank` no longer calls `powl()`.** The `2^n` normaliser is now built by exact repeated doubling, which has no long-double libm dependency. This fixes an `Undefined symbol "powl"` load failure reported by a CPAN smoker (FreeBSD, perl 5.20, `nvtype=double`) whose libm lacks the long-double math functions; the symbol resolved on glibc, which is why local builds passed. `long double` accumulation in the DP is retained — only the `powl` call was at fault.
 - **`int` → `size_t`** for `n`, `max_v`, and the DP loop counters, which also removes a `size_t`-to-`int` narrowing at the call site. The `floor()` result (`k`) stays signed so its negative-`q` sentinel still fires, and is cast to `size_t` only after the `k < 0` check.

 - Tests:

 - Added `t/wilcox_test.t` (flat, no subtests): R-agreement cases, option handling (`paired`, `correct`, `exact`, `mu`, named/positional `x`/`y`, NA dropping), regressions for all four bug fixes, argument-error and `alternative`-validation checks, output shape, and `no_leaks_ok` coverage of the two-sample, exact, and paired allocation paths.

0.15 2026-06-11 CDT

 - `view` function added, similar to R's `head`

 - `read_table`:

     filter => {
         'Testosterone, total (nmol/L)' => sub { defined $_ },
     }

 - was broken by the change in undefined variables in 0.14, but is back to being `undef`

 - `col2col` improvement in sectioning in README

 - Numerous changes to prevent quadmath/long double CPAN test failures

 - Minimum Scalar::Util version in dist.ini is now 1.22, see https://www.cpantesters.org/cpan/report/6b682236-6567-11f1-a3bc-a055f9c4ba34

 - `Digest::SHA` removed as a dependency

 [`read_table`]

 - Bug fixes:

 - **A comment-prefixed header is now read correctly.** `read_table` strips a
     leading comment marker from the header line (so a file may begin with
     `#id,val`), but that strip was dead code: the XS parser skipped *every* line
     beginning with the comment string before the callback ever saw it, so a
     commented header was silently dropped and the first data row was mistaken for
     the header. The parser now delivers the first content line even when it
     begins with the comment marker, and only skips comment lines after the header
     has been seen.

 - **Carriage returns inside quoted fields are preserved.** The parser stripped
     `\r` unconditionally, so a quoted value such as `"x\ry"` lost its carriage
     return and would not survive a `write_table` -> `read_table` round-trip. `\r`
     is now stripped only as part of a trailing CRLF line ending and as a stray CR
     *outside* quotes; inside quotes it is literal data.

 - **Duplicate column names no longer corrupt `hoa` output.** With
     `output.type => 'hoa'`, a repeated column name pushed the same cell once per
     occurrence, so the affected columns came out longer than the others and the
     arrays no longer lined up by row. Columns are now keyed by unique header name
     (first-seen order preserved, later values win, one warning emitted).

 - **A defined non-CODE callback is now an error.** Passing a defined argument
     that was not a CODE reference silently fell through to slurp mode and ignored
     the argument; it now croaks
     (*"callback must be a CODE reference"*).

 - **An undefined/empty `hoh` row-name now dies instead of keying on `""`.**
     With `output.type => 'hoh'`, a row whose row-name column was empty/undef was
     stored under the `''` key and raised *"uninitialized value"* warnings. It now
     dies, naming the column and the offending data row.

 - **A numeric filter key past the last column now dies.** A 1-based numeric
     filter key greater than the column count was accepted, then silently extended
     every row through the `$_` write-back. It is now rejected up front with a
     message naming the column count.

 - **`sep` and `delim` together now die.** Supplying both silently preferred
     `delim`; passing both is now an explicit error (`delim` remains an alias for
     `sep` when used alone).

 - **The library no longer prints to STDOUT.** The unknown-argument path used
     `say` to dump the offending names to STDOUT before dying; the names are now
     carried in the `die` message itself.

 - Better diagnostics:

 - Alignment errors now report **which data row** is ragged
     (*"Alignment error on FILE data row N (X fields vs Y headers)"*), instead of
     only the field/header counts.

 - Memory-leak fixes (exception paths):

 - The parser allocated its working buffers (`current_row`, `field`, and — in
   slurp mode — `data`) in the XS `INIT:` block, i.e. *before* any validation, and
   freed them only by falling off the end of the function. Any non-local exit
   therefore leaked:

 - the open-failure `croak` leaked the row buffer and field (and the slurp
     accumulator);
 - far more commonly, a `die` thrown **inside the row callback** — which
     `read_table` does routinely on alignment errors, bad row names, and filter
     exceptions — unwound straight out of the XS frame and leaked the field, the
     current row, the line buffer, the slurp accumulator, *and the open file
     handle*.

 - Allocations now happen in `CODE:` after every croak-able check, and every
   long-lived resource (the file handle via `SAVEDESTRUCTOR_X`, the buffers via
   `SAVEFREESV`) is tied to the save stack, which an exception unwinds. Measured
   with `Test::LeakTrace`: a `die` mid-file went from 5 leaked SVs to 0, and an
   open failure from 2 to 0. This is the likely source of the constant-size leaks
   seen in CPAN-tester reports for the exception-path tests.

 - Performance:

 - **~2.5x faster parsing** (57 -> 145 MB/s on a 100k-row quoted file). The core
     loop appended one character at a time with `sv_catpvn(field, &ch, 1)`; it now
     scans runs of ordinary bytes with `memchr` / a bounded scan and appends each
     run in a single `sv_catpvn`, copying field contents in bulk rather than byte
     by byte.

 - Internal / non-behavioral:

 - XS declarations moved from `INIT:` to `PREINIT:`; allocations deferred into
     `CODE:` (see the leak fixes above).
 - The filter loop now aliases the row hash with `local *_ = \%line_hash`
     instead of copying it with `local %_ = %line_hash`. This removes a full
     per-row hash copy for every filtered row and fixes a latent staleness bug:
     after a filter mutated `$_` and the change was written back, `%_` still
     reflected the pre-mutation copy, so a subsequent filter in the same row saw
     stale values. With aliasing, `%_` *is* the row, so write-backs are always
     visible.

 - Known limitation (not changed):

 - **`undef.val` does not round-trip back to `undef`.** `write_table` renders an
     `undef` cell as an empty field by default, and `read_table` maps an empty
     field back to `undef`, so the *default* round-trip is clean. But if a file is
     written with a token such as `'undef.val' => 'NA'`, `read_table` has no
     inverse option and reads `NA` back as the string `'NA'`. `read_table` also
     cannot distinguish a deliberately quoted empty string (`""`) from a missing
     value -- both become `undef`. Adding an `na.strings`-style option to
     `read_table` (mapping configurable tokens and/or empty fields to `undef`)
     would close this gap.

 [`write_table`]

 - Behavior change:

 - **`undef` cells now write as an empty field, not an empty string.** A missing
     or `undef` value renders as nothing between separators (`a,,c`) rather than a
     quoted empty string (`a,'',c` / `a,"",c`). Supplying `'undef.val' => 'NA'`
     (or any other token) still overrides this, exactly as before. This is the
     only change that can alter the bytes of an existing output file; if you relied
     on the previous default, pass `'undef.val' => ''` to keep an explicit empty
     field, or your chosen placeholder.

 - Bug fixes:

 - **Wide-character / UTF-8 column names and row keys now round-trip.**
     Previously, cells were looked up with the raw bytes of the column name
     (`hv_fetch(..., SvPV_nolen(name), strlen(name), ...)`), which fails to match a
     UTF-8-flagged hash key: the column header printed correctly but every cell
     under it came back empty. All lookups now fetch by SV (`hv_fetch_ent`), header
     lists are gathered and sorted as SVs (`sortsv` + `sv_cmp`, preserving the
     flag) instead of being round-tripped through `char *`, and the `row.names`
     column is matched with `sv_eq` rather than `strcmp`. Embedded NUL bytes in
     keys are handled correctly as a side effect.

 - **`col.names => []` no longer loops forever.** An empty `col.names` array made
     `av_len()` return `-1`, which — compared against an unsigned `size_t` loop
     index — wrapped to `SIZE_MAX` and ran effectively without end. This was fixed
     for flat hashes previously; it was still present for hash-of-hashes,
     hash-of-arrays, and array-of-hashes, plus both `row.names` header-filtering
     loops. All such loops now use a signed index.

 - **Tables wider than 65,535 columns no longer hang.** One header loop used an
     `unsigned short` index that silently wrapped past 65,535 and never terminated.
     It now uses `size_t` like the rest of the code.

 - **Flat-hash cells holding a reference now croak.** Every other input shape
     rejects a nested reference with
     *"Cannot write nested reference types to table"*; a flat hash instead
     stringified it (e.g. `ARRAY(0x55...)`) into the file. It now croaks
     consistently.

 - **`'undef.val' => undef` is handled cleanly.** It previously called
     `SvPV_nolen` on `undef`, raising an *"uninitialized value"* warning and
     yielding an empty string by accident. It is now treated explicitly as an empty
     field, with no warning.

 - Memory-leak fixes (exception paths):

 - The row-key list gathered for hash-of-hashes input was leaked when the output
     file could not be opened.
 - The *"Could not get headers"* croak on hash-of-arrays input leaked both the
     already-open filehandle and the headers array.

 - Internal / non-behavioral:

 - Numeric row labels are now formatted into a reused stack buffer instead of a
     per-row `savepv()` / `safefree()` allocation (no functional change; removes a
     cast-away-`const` and one allocation per row).
 - Several signed/unsigned index types were made consistent (`SSize_t` vs
     `size_t`) to match `av_len()` and silence the conditions behind the loop bugs
     above.

 - Tests:

 - `t/write_table.t` expanded from 17 to 69 assertions. New coverage targets each
     fix above: the empty-field default and `undef.val => undef` (no warning),
     `col.names => []` termination across all four input shapes, the
     >65,535-column header loop (gated behind `EXTENDED_TESTING=1`), in-sequence
     numeric row labels, nested-reference rejection, CSV quoting corners
     (carriage return, separators inside column names, multi-character separators),
     empty input writing no file, and UTF-8 column names and row keys. Two leak
     assertions cover the exception paths above.

0.14 2026-06-08 CDT

 - `filter` function added for rows

 - `read_table` reads undefined values to `undef` instead of `NA`, which makes calculations easier

 - `write_table` writes undef by default as an empty string `''`

 - `hoh2hoa` transforms a hash of hashes into an hash of arrays

 - `quantile` uses `NV` instead of `double` to allow for high-precision 128-bit floats to be used on quadmath machines when available: https://www.cpantesters.org/cpan/report/296f4868-631f-11f1-abba-ff15558d240b

 - Numerous switches from `double` to `NV` for local precision, like above

 - numerous changes to `col2col` for ease of use and working with datasets with numerous undefined values

 - dist.ini now links to math library when compiling: https://www.cpantesters.org/cpan/report/785e26d8-6397-11f1-89c0-dc066e8775ea

 - `fisher_test` now should be complete, errors with confidence intervals fixed

0.13 2026-06-07 CDT

 - `read_table`: speed improvements; commented headers are now allowed

 - `write_table`: fix for 

     Attempt to free temp prematurely: SV 0x56417a2ae610 at t/write_table.t line 182.
     	main::wrote_ok(",age\x{a}Alice,30\x{a}Bob,25\x{a}", "row.names => 'name' uses that column as labels", HASH(0x56417a272250), "row.names", "name") called at t/write_table.t line 203
     Attempt to free unreferenced scalar: SV 0x56417a2ae610 at t/write_table.t line 183.
     	main::wrote_ok(",age\x{a}Alice,30\x{a}Bob,25\x{a}", "row.names => 'name' uses that column as labels", HASH(0x56417a272250), "row.names", "name") called at t/write_table.t line 203

 - `write_table` gives better warnings for incorrect types of data given

 - Numerous changes to dist.ini to improve CPAN testing, especially for Win32

0.12 2026-06-08 CDT

 - `add_data` can also take hash of arrays, and various mixes of data types

 - `ljoin`: Addition of `restrict` keywords in many places; should improve CPU performance

 - Better POD formatting, correction of output hash for README's `add_data`

 - `chisq_test` can now accept hash of hashes as input

 - new `transpose` function for switching 2D hash keys and 2D array indices, and `col2col` for comparing columns against columns

 - removed unused function from C helpers

 - `value_counts`: addition of restrict keywords in preinit, should improve CPU performance

 - MANIFEST.skip changed to MANIFEST.SKIP to improve CPAN testing

 - using `is_deeply` for tests of `transpose`, which may or may not work with CPAN testers (experimental)

 - Added function name to warnings, so I actually know which function is producing the error

 - `write_table` can also take `file` and `data` as args, in addition to positions

 - fixed `write_table` as it could hang if given empty `col.names` or `row.names`

 - Added `__EXTENSIONS__` to source XS file for better CPAN testing

0.11 2026-06-03 CDT

 - better POD formatting for tables

 - addition of MANIFEST.skip to get better testing results on CPAN

 - `glm`: bugfix for when there is no intercept in the formula, new test cases in t/glm.t

 - `write_table` now accepts simple hashes as input, in addition to hash of arrays, hash of hashes, and arrays of hashes

 - Better documentation for t-test

0.10 2026-06-01 CDT (approx)

 - changes to compilation for CPAN, trying to get this work on Windows

 - Addition of `prcomp` and `value_counts`

 - `matrix` will work without key names, just like in R.  Testing for `matrix` has improved.

0.09 2026-06-01 CDT (approx)

 - context changes in XS `dTHX`, `pTHX_`, and `aTHX_` to get better CPAN testing results

 - `restrict` keywords added to `lm` to increase speed

0.08 2026-05-26 CDT

 - Speed improvement in `summary` of hashes.

 - Addition of `add_data`, `dnorm`, `group_by`, `ljoin`, and `mode` functions

 - Chi-squared function no longer has Perl wrapper, and all code is in XS, which should result in a minor speed increase with 1 less function call.

 - Compiler changes for GNU source and inclusion of `strings.h`, to ensure more CPAN testing works better.

 - `read_table` now returns hash-of-hash in {row}{column}

0.07 2026-05-24 CDT

 - Addition of `summary` function.

 - Formulas can now be omitted from `aov`, resulting in a stacked calculation as R would think.

 - Addition of `oneway_test` for multi-group comparisons that does not assume normality like `aov` does.

 - `read_table` and `write_table` now automatically set separators for `.csv` files as `,` and `.tsv` files as `"\t"`, respectively, so these values no longer need to be specified separately from the file name.

0.06 2026-05-19 CDT

 - Changed compiler options so that Solaris will work

 - signed integers changed to unsigned in `glm`

 - Added restrict keywords to `power_t_test`, and made `int` to `unsigned int`

0.05 2026-05-08 CDT

 - Leak testing for `sample`

 - removal of Data::Printer dependency for easier CPAN testing

 - switched several `unsigned int` variable to `I32` so that clang doesn't complain

 - added restrict keyword for `sample`

0.04 2026-5-17 CDT

 - addition of `sample` function

 - GNU source, to maximize compatibility and ease installation

 - removal of JSON dependency to ease installation

0.03 2026-5-13 CDT

 - Compatibility back to Perl 5.10

0.02 2026-5-7 CDT

 - back-compatible to Perl 5.10, instead of original 5.40, ensuring more people can use it

 - added var_test

 - mean, min, sum, median, var, and max die with undefined values, and print the offending indices

 - "group_stats" added to aov, for TukeyHSD in the future

 - "cor" dies when given data with standard deviation of 0

 - `write_table` now has `undef.val` option, which shows how undefined values are printed to tables, which is `NA` by default.

