Skip to content

fix(ai): stop a single NaN from silently disabling two subsystems - #147

Merged
ApiliumDevTeam merged 1 commit into
ci/lint-gate-matches-the-declared-barfrom
fix/nan-in-clamp-shaped-expressions
Jul 30, 2026
Merged

fix(ai): stop a single NaN from silently disabling two subsystems#147
ApiliumDevTeam merged 1 commit into
ci/lint-gate-matches-the-declared-barfrom
fix/nan-in-clamp-shaped-expressions

Conversation

@ApiliumDevTeam

Copy link
Copy Markdown
Contributor

Third of the stack. Base is #146. This is a behaviour change, not cleanup — that is why it is not in #145.

Why the lint's advice would have been wrong

Both sites carried x.min(hi).max(lo), which clippy wants as x.clamp(lo, hi). They are not equivalent: min/max discard NaN and return the other operand, clamp propagates it.

SurpriseGate

Following the lint would have turned a NaN into a NaN threshold — and every comparison against NaN is false, so should_update stops firing and the memory quietly stops learning. Worse than either bound.

But the real entry point is upstream. record_surprise feeds Welford's algorithm, and one non-finite sample poisons the running mean permanently: mean += delta / n with a NaN delta makes the mean NaN, and every later update keeps it that way. So:

  • reject non-finite samples at the boundary, where the damage starts;
  • fall back to the configured threshold if unusable statistics arrive some other way — deserialised state is not covered by the entry guard. Not to an invented constant: set_threshold already provides the non-adaptive baseline.

TransactionClassifier::confidence

partial_cmp().unwrap() panics on a NaN distance, which l2_distance produces from a corrupt centroid — so the process died inside a confidence calculation. total_cmp is total and also sorts NaN last, so a corrupt centroid can no longer pass itself off as the nearest one.

And with every distance unusable, the old code fell past second_dist > 0.0 and returned 1.0 — full confidence at the exact moment there is no information. It now answers 0.5, the same neutral value the function already returns when there are no centroids at all.

Verification

Five tests, each pinning a failure that was silent: a poisoned mean, a NaN threshold, the fallback choice, the panic, and full-confidence-from-nothing. Both #[allow(clippy::manual_clamp)] removed.

cargo clippy --workspace --all-targets -- -D warnings exit 0 · 2247 tests green.

The new gate caught two lints in my own test code while writing this, which is the first evidence it works.

Both sites carried a clamp-shaped expression that clippy wanted rewritten.
Following that advice mechanically would have introduced a bug in one and
papered over a worse one in the other, so each is settled here instead.

SurpriseGate. `min`/`max` discard NaN and return the other operand;
`clamp` propagates it. But the real entry point is upstream:
`record_surprise` feeds Welford's algorithm, and one non-finite sample
makes the running mean NaN permanently — every later update keeps it, the
adaptive threshold inherits it, and because every comparison against NaN
is false the gate stops firing with nothing to show for it. Reject
non-finite samples at the boundary, and fall back to the CONFIGURED
threshold — the non-adaptive baseline this type already has — rather than
to an invented constant, if unusable statistics arrive some other way
(deserialised state is not covered by the entry guard).

TransactionClassifier::confidence. `partial_cmp().unwrap()` panics on a
NaN distance, which `l2_distance` produces from a corrupt centroid, so
the process died inside a confidence calculation. `total_cmp` is total
and also sorts NaN last, so a corrupt centroid can no longer pass itself
off as the nearest one. And with every distance unusable the old code
fell past the `second_dist > 0.0` test to return 1.0 — FULL confidence at
the exact moment there is no information. It now answers 0.5, the same
neutral value it already returns when there are no centroids at all.

Five tests, each pinning a failure that was silent: a poisoned mean, a
NaN threshold, the panic, and full confidence from no information.
@ApiliumDevTeam
ApiliumDevTeam force-pushed the fix/nan-in-clamp-shaped-expressions branch from 9b62d8d to 98fa442 Compare July 29, 2026 10:25
@ApiliumDevTeam
ApiliumDevTeam merged commit aa3fba8 into ci/lint-gate-matches-the-declared-bar Jul 30, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant