Commit Graph
3 Commits
Author SHA1 Message Date
logaritmiskandClaude Opus 5 6139061740 fix: keep the truncated variance representable in the far tail
`v_w` returned `w` and let `trunc` form `1 - w`. `w` tends to 1 out in
the tail, so that subtraction lost about log10(alpha^2) digits — and the
quantity it was destroying is perfectly representable.

Two separate cancellations, fixed separately.

The non-tie half: `half_line_truncation` now returns `1 - w` computed
symbolically rather than as `1 - v*gap`. With `alpha*gap = 1 - inv^2*b`
the leading ones cancel on paper instead of in floating point. Measured
against the exact truncated variance:

  alpha    before          after
  1e6      8.9e-5 rel      0.0 rel (exact)
  1e8      returns 0.0     0.0 rel (exact)

At 1e8 the old form gave `sigma_trunc = 0`, and `from_ms(mu, 0.0)` is a
point mass whose `mu()` is inf/inf = NaN. `beta(1e-8).sigma(1e-8)` with
priors 1000 apart went from Err + NaN skills to a finite fit.

The tie half is a different subtraction — `w = v^2 - u`, where both grow
as alpha^2 while their difference stays O(1). The existing escape hatch
could not cover it: it keys on `alpha * width >= HALF_LINE_WINDOW`, how
many window-widths from the mean the window sits, and a NARROW window
fails that however deep it is. Measured at alpha 1e6 with a 1e-6 window
it kept four digits and returned `1 - w = -2.4e-4` where the truth is
+2.8e-13. One step earlier it was quietly wrong instead: `1 - w = 1.0`
exactly, a truncation reported as a no-op, where the truth was 5e-17.

Over a narrow window the density is a truncated exponential in
`s = (x - alpha)/width`, whose mean and variance are closed forms, so
`v = alpha + width*m(t)` and `1 - w = width^2 * V(t)` with no large
subtraction at all. Validated against high-precision quadrature: v exact
to 4e-10, `1 - w` to 4e-10 across the region it is used in.

The crossover is on `alpha / width` rather than on either alone, because
that ratio is what says how many digits the subtraction has left — and
the approximation is most accurate exactly where the subtraction is
worst, since both improve as the window narrows.

Defaults are bit-identical (pi 0.02398318151216503 before and after).

Tests: the three reproductions from the issue, the narrow-window form
against pinned quadrature values, and a continuity sweep across all three
tie branches — a misplaced crossover is the real risk here, and a jump at
a boundary is visible even without pinning absolute values.

Closes #60

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 17:42:16 +02:00
logaritmiskandClaude Opus 5 c65373f476 fix!: propagate NaN through the convergence reduction
`tuple_max` compared with a plain `>`, which is false against NaN, so a
NaN accumulator was replaced by the next finite delta. The fold runs over
`TimeSlice::posteriors()`, a HashMap, so whether a NaN survived to `step`
depended on per-process hash order.

Measured before, four competitors in one slice with one pathological
pair, same binary and input, 30 separate processes:

  16  Ok  converged=true, iterations=1, a = Gaussian { pi: NaN, tau: NaN }
  14  Err NonFiniteResult

After: 30/30 Err. A coin flip on whether a NaN fit was reported as an
error or as a successful, converged fit — inside the guard whose entire
purpose is "NaN is never convergence".

`f64::max` would not have fixed it. It also ignores NaN by design, which
is the same defect wearing a standard-library name, and a test pins that
we do not use it.

`Gaussian::delta` had to be fixed FIRST, and that ordering is the whole
subtlety. Two identical improper messages produced `(0.0, NaN)` — not
from `mu()`, which is guarded and returns 0.0, but from `inf - inf` in
the sigma component. That NaN is reachable in ordinary healthy inference:
once a pairing is more than about nine cavity-sigma apart the truncation
is a no-op and the chain compares one identity message against another.
Propagating NaN without fixing `delta` would therefore have turned
correct fits into NonFiniteResult errors. `delta` now answers the
identical-message case in natural space before touching the accessors.

My first version of the `delta` test asserted `mu()` was NaN. It is not;
the accessor guards `pi <= 0.0`. The test caught my own wrong premise,
and the doc comment is corrected to match.

BREAKING CHANGE: a fit that produced NaN in a non-final reduction
position previously returned `Ok` with `converged: true` and a NaN
posterior; it now returns `Err(NonFiniteResult)`. That was always the
documented intent.

Closes #58

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 16:59:56 +02:00
logaritmiskandClaude Opus 5 a18df521eb test: cover non-finite results and color-group disjointness
The two gaps #26 named that were never filled.

NonFiniteResult had no test at all — the name appeared in `tests/` only
inside a doc comment, and it is the sub-claim in that issue's title. It
turns out to be very much reachable, and from *finite* inputs: sigma at
1e300, beta at 1e300, sigma at 1e-300, score_sigma at 1e-300, and scores
at 1e308 all overflow inside inference, where the boundary checks cannot
see them. That matters because the failure is silent by default — NaN
fails every comparison, so a naive `step < epsilon` reads a NaN step as
converged, which is why the crate has `step_converged`/`step_is_finite`.
Pinned from outside, including that `converge_partial` does not launder a
breakdown into an `Ok`, and with a control asserting merely extreme
parameters still converge so the suite cannot pass by always failing.

Color-group disjointness was #26's fourth acceptance criterion and had
only five hand-written cases. Now a proptest over three shapes: a dense
pool where collisions force colors to multiply, a sparse one where most
events are independent, and repeated members within a single event.

Two of my first assertions were wrong about the code rather than the
reverse. A competitor named twice *within* one event is not a collision —
`color_greedy` collects each event's members into a set for that reason.
And contiguity is not a property of `color_greedy`: it holds only after
`recompute_color_groups` reorders events so each color occupies one
range. The test now asserts what is actually promised — that the reorder
is always *possible*, since the parallel sweep slices `&mut` sub-ranges
from those groups and overlapping ranges would be unsound.

Refs #26

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:09:25 +02:00