4c2e98c54b99cfafdf07f708617cde9d5e75ea33
6
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e4d6dc4028 |
fix: warn on dropped builders and values; stop exporting EP internals
`h.event(1).team(["x"]).team(["y"]).ranking([0, 1]);` without the terminal `.commit()` was a silent no-op: no warning, no error, and the next thing the caller does is converge an empty history and read `None` skills. `EventBuilder` already carried a `#[must_use]`; the value types around it did not, so the same silence covered `Team::with_members`, `Member::new`, `Outcome::*`, `Joint` and `Prediction::outcomes`. `#[must_use]` now goes on the *types* rather than being sprinkled over methods, which covers every constructor and builder setter at once and gives the crate a rule where it previously had a list. Verified by compiling a program that drops each one and reading the warnings back, rather than by assuming the attribute took. Visibility, from #73: `Gaussian::damp_natural` was reachable from outside the crate despite being an EP damping internal called only from `src/factor/`. The stray `pub fn`s inside the private `time_slice`, `key_table` and `matrix` modules are now `pub(crate)`, so their visibility states what it means instead of relying on the module being private. `storage/mod.rs` and `factor/mod.rs` become `storage.rs` and `factor.rs`. Closes #67. Refs #73. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ |
||
|
|
a0c2f78aed |
feat: add the missing trait impls and make #[must_use] consistent
Trait coverage (#76), all additive: History Debug is still absent - see below HistoryBuilder + Debug (it derived Clone but not Debug) Rating + PartialEq (Gaussian had it; Rating is a Gaussian plus three scalars and had none) Event/Team/Member + PartialEq (input value types with no way to compare them, which made round-trip tests awkward) ConvergenceReport + PartialEq `#[must_use]` (#67). The coverage had no rule: `filtered_log_evidence` had it and `log_evidence` did not; `rating` had it and `current_skill` did not; `Rating::with_drift_scale` had it and `Member::with_drift_scale` did not. Now on the types — `EventBuilder`, `HistoryBuilder`, `Prediction`, `Gaussian`, `OwnedGame` — which covers most method returns at once, plus the `History` accessors individually. `EventBuilder` gets a message, because a dropped builder is the worst case in the set: measured, `h.event(1).team(["x"]).team(["y"]).winner(0)` without `.commit()` leaves `time_slices_len() == 0` and every skill `None`, with no warning at all. And `ConvergenceReport`'s `#[must_use]` moves off the TYPE onto `converge_partial`, where its stated reason is true. It read "from `converge_partial` this may describe a fit that stopped at max_iter" but fired on `converge` too — where that is false, since `converge` returns `Err(NotConverged)` in exactly that case. So the crate's own front-page example warned, and every quickstart had to write `let _ =`. Verified from a consumer crate: `h.converge()?;` now compiles clean. Marking the types made eight method-level attributes redundant, which clippy's `double_must_use` caught — that is the type-level marker doing its job, and the eight are removed. Refs #76, #67 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ |
||
|
|
bbc7705c75 |
fix!: report an unresolvable prediction grid instead of clamping
`grid_shape` asked for 12 nodes across the narrowest feature and then clamped to MAX_GRID_POINTS with no detection that the request was not met. Past `step/sigma ~ 1.7` the trapezoid rule stops resolving the density, and the result is unbounded: sigma_a step/sig_a P(a first) exact total 2.0e-3 0.86 0.515953 0.515953 1.000000 1.0e-3 1.72 0.517185 0.515953 1.002388 1.0e-4 17.17 2.791336 0.515953 5.410065 A probability of 2.79. Reachable through `predict_outcome` with a pinned reference competitor — a documented pattern — where `predict_outcome` and `predict_win_probabilities` disagreed 44x and `predict_outcome` was the wrong one. There is no useful answer on the far side of that cliff, so this reports `GridTooCoarse` rather than guessing, and the message points at `predict_win_probabilities`, which answers the same matchup through adaptive quadrature and is accurate there to 1e-13. The floor is 4 nodes per feature rather than the 12 requested, because the request carries margin: measured accurate to 2.2e-12 at 1.4 nodes per sigma and wrong by 1.2e-3 at 0.7. This also fixes the `ln k` ceiling violation. `expected_information_gain` weights `probability * divergence`, so probabilities of 3.97 and 2.62 made it return 3.237828 nats against `ln 2 = 0.693147` — 4.67x over. The crate's docs call that ceiling its sharpest test and record a prototype once returning 4.77 nats; it was live again by a different route. The new sweep then caught a second, independent defect: `kl_divergence` returned NEGATIVE values, worst -5.55e-17, exactly one ULP of its `- 1.0`. Rewritten as `0.5*(u - ln1p(u)) + gap^2/(2*var_p)` with `u = var_q/var_p - 1`, so both terms are non-negative by construction. It is also more accurate where it matters: at `u = 1e-9` the old form returned 0.0 where the true value is 2.5e-19, and well-conditioned cases are unchanged. tests/prediction_bounds.rs sweeps rather than spot-checks, because a single fixture cannot defend a bound like this — the previous check passed throughout. It asserts the sweep still reaches the coarse-grid regime, so it cannot quietly stop testing the case it was written for. BREAKING CHANGE: `predict_outcome`, `predict_ranking` and `expected_information_gain` return `GridTooCoarse` for matchups whose performance sigmas are too far apart to integrate on one grid. They previously returned wrong answers, including probabilities above 1. Closes #55, closes #56 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ |
||
|
|
17d072b2ae |
fix: route every transcendental through libm, and combine sigmas with hypot
Follow-on from #41, which added `libm` for `erfc`. Surveying what else the dependency offers: its unique surface over `std` is `erf`/`erfc`, `lgamma`/`tgamma` and Bessel functions, and only the first was ever needed. But the survey found something better than another special function. `std`'s `exp` and `ln` delegate to the *system* math library. IEEE 754 specifies the basic operations and `sqrt` exactly and says nothing about transcendentals, so those differ per platform. Measured here over 200k inputs: exp: 19425/200000 differ from libm (worst 1 ulp) log: 9932/200000 differ Inference is an iterative fixed point, so a one-ULP difference can change an iteration count and move the answer by more than one ULP. Routing every transcendental through `libm` makes a fit reproducible across platforms — a stronger guarantee than `tests/determinism.rs`, which only covers thread counts. It costs nothing. `Batch::iteration` measured -2.7% [-5.7%, -0.3%] with the whole set swapped, and not one golden moved. Also switches the two places that combined sigmas as `sqrt(a^2 + b^2)` to `hypot`. Squaring overflows to infinity above ~1.3e154 and flushes to zero below ~1.5e-154 — measured, the naive form returns `inf` where `hypot` returns 1.41e160 — and `Gaussian`'s constructors are public, so a caller can reach both ends. Deliberately not done: rewriting the KL divergence's `ln` of a ratio via `ln_1p`. The cancellation is real as the ratio approaches one, but measured absolute error is at most ~1e-11 in a quantity of order 0.4 nats, so it changes nothing. The invariant is recorded in `CLAUDE.md` and on `erfc`'s own docs, since nothing enforces it mechanically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ |
||
|
|
3dd659307a |
fix: replace the erfc approximation with libm, for free
#41 asked whether the Numerical Recipes `erfcc` approximation — 1.2e-7 relative, and the binding accuracy constraint on the whole crate — was worth replacing, given it sits in the inference hot loop. It is, and it costs nothing. Measured, against an independent incomplete-gamma reference: range previous (NR) libm [-3, 0] 7.95e-8 2.15e-14 [0, 0.5] 8.69e-8 4.70e-14 [0.5, 2] 9.38e-8 1.24e-12 [2, 6] 1.04e-7 5.20e-14 [6, 26] 1.07e-7 1.75e-13 erfc(0) 1.00000003 1.0 (exactly) |erfc(z)+erfc(-z)-2| 6.00e-8 2.22e-16 Performance, on `benches/batch.rs`: change [-3.34% +2.26%], p = 0.89 — no change detected. That result is counterintuitive, because libm's erfc is 1.65x slower when swept uniformly over [-2.5, 2.5]. The sweep was the wrong input distribution. Capturing the arguments inference actually passes: |x|<0.5 96.16% 0.5-0.84 2.05% 0.84-1.25 1.24% 1.25-2 0.54% 2-6 0.00% 98% fall below 0.84375, which is exactly where FDLIBM skips the exponential entirely — while the NR form always pays for one. On the real trace libm is the faster of the two (3.23 vs 3.78 ns/call). An ad-hoc `Instant` harness reported a 16% end-to-end speedup; that was an artifact of its own setup allocating and leaking per run, and criterion's verdict of "no change" is the one to believe. What it bought: - `compute_margin` against exact quantiles: 8.4e-8 -> 1.7e-16. - `cdf(mu, mu, sigma)` is now exactly 0.5; it was 1.5e-8 out. - `sf + cdf` sums to one within a ULP, from 3e-8. - `erfcx`'s two branches now agree to round-off across the crossover rather than to 1e-7, so the log-space evidence path and the linear one are consistent. - Ten test tolerances tightened from 1e-6 to 1e-13..1e-15, and the prediction floor is now the integrator's rather than `cdf`'s. Five goldens moved, by 2.4e-9 to 6e-7 — the magnitude of the removed error, and `test_env_ttt`'s mu still rounds to the same six decimals. Re-recorded with more digits so future drift stays visible. Verified as movement toward truth per the goldens policy: every value now derives from a primitive checked against an independent reference and satisfying the exact identities, which the previous one did not. Adds `libm` — zero transitive dependencies, rust-lang maintained. Closes #41 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ |
||
|
|
bb2a845882 |
feat!: N-team outcome prediction with draw mass, replacing the 2-team panic
`predict_outcome` asserted `teams.len() == 2` and returned `[p, 1 - p]`, allocating no probability to a draw even with `p_draw > 0`. For a draw-enabled model the numbers were simply wrong, at any team count. It now returns `Result<Prediction, InferenceError>` and supports N teams. Two algorithms, both deterministic: - Who finishes first. Performances are independent Gaussians, so this separates into a one-dimensional integral per team rather than a multivariate orthant probability. Adaptive Gauss-Kronrod evaluates it to ~1e-15, matching the exact two-team closed form. - A specific finishing order. The factor graph only constrains rank-adjacent teams, so a full order is a chain of local constraints, not a general orthant integral. That chain collapses into a sequential recursion over cumulative integrals: O(teams * grid) per order. Fixed-node Gauss-Hermite is the obvious tool for the first and is a trap: when a rival's sigma is small the CDF product becomes a step narrower than the node spacing, and the nodes step over it. Measured 4.4e-4 off the closed form on a mildly skewed matchup and 1.7e-2 on a small-sigma one, while still returning something that looks like a probability. Adaptive refinement is what makes that case safe, and `win_probabilities_survive_a_rival_with_a_tiny_sigma` pins it down. The acceptance test is an identity rather than a golden: the outcome space is exhaustive and disjoint, so the probabilities sum to one. Any drift is integration error and nothing else. Gauss-Hermite failed it at 4.4e-4; this holds to ~1e-9. Also from #21: unknown keys are now reported rather than dropped, so a team of strangers can no longer produce a confident-looking prediction. `predict_quality` returns `Result` for the same reason. BREAKING CHANGE: `predict_outcome` returns `Result<Prediction, _>` instead of `Vec<f64>`; `predict_quality` returns `Result<f64, _>`. Refs #21, #39 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ |