# When does a NaN propagate through aggregation and silently void a sum? **Date of Record:** 2026 November 2 **Scope:** Applies to floating-point aggregates (sum, mean, min, max) over data that may contain NaN, where the aggregate is reported without an accompanying count of valid inputs. Excludes aggregates that explicitly reject NaN or count non-numeric inputs separately. ## Core Distinction **Predicate.** NaN is absorbing under IEEE-754 arithmetic: once any term is NaN, the sum, mean, and most aggregates are NaN. The aggregate is sound if and only if NaN either cannot enter or is surfaced as a distinct count rather than baked into the result. **Failure.** A single null numeric — from a join miss, a failed parse, or a division by zero — is coerced to NaN, flows into a sum, and makes the entire aggregate NaN. The aggregate is reported as NaN or, worse, silently converted to 0 at the serialization boundary. **Asymmetry.** A NaN input is a local data issue. A NaN aggregate wipes out every valid contribution, which is globally worse than the original defect and often attributed to the aggregate function rather than to the input. ## What Was Observed **Signature.** Aggregate value NaN or zero on days when a single input was malformed. Valid input counts unchanged. Recomputation over a filtered set yields a plausible number, which makes the failure look like intermittent data corruption. **Trace.** Daily revenue = sum(line_items). One line has a null price from a partial upstream record. `sum([1.0, 2.0, NaN, 3.0])` is NaN. Serialized to JSON, NaN is not valid JSON, so many writers coerce to null or 0. The revenue figure becomes 0 for the day. **Why monitoring stays green.** The aggregate succeeded. Row counts match. The number is plausible at the extremes (0 for revenue looks like "no traffic"). ## Condition of Validity Aggregates are sound only while at least one holds: 1. **Rejection.** NaN inputs are rejected before aggregation, and the rejection is alerted. 2. **Counting.** The aggregate is accompanied by a count of valid inputs, and any mismatch between expected and valid counts is an alert. 3. **Representation.** Null numerics are represented as absent (null / optional) rather than as NaN, and the aggregate treats absent as "skip" with an explicit denominator. Condition 3 is the structural fix; 1 and 2 are detection. ## Decision Boundary - Rejection at input: aggregate reflects only valid data. - Counting alongside aggregate: NaN cannot hide. - No rejection, no counting: a single NaN voids the aggregate. - Rejection plus counting: robust and diagnosable. ## Operational Effect The defect lives in the coercion step between typed inputs and untyped aggregates, which is often implicit in a query engine. Remediation is to make NaN-ness explicit at the boundary and to report counts alongside aggregates. The trap is to wrap the aggregate in a NaN check that substitutes 0. That converts a NaN into a plausible-looking number, which is strictly worse than reporting NaN. ```python import math from dataclasses import dataclass class NanPropagationError(ValueError): """Raised when NaN enters an aggregate and silently voids its result.""" @dataclass(frozen=True) class AggregateGate: reject_nan: bool count_valid: bool = False def verify(self, values) -> bool: has_nan = any(isinstance(v, float) and math.isnan(v) for v in values) if has_nan and not self.reject_nan: raise NanPropagationError( "NaN present and not rejected: aggregate becomes NaN silently" ) if has_nan and not self.count_valid: raise NanPropagationError( "NaN present and no valid-input count: aggregate hides the defect" ) return True def dispatch_aggregate_mode(gate: AggregateGate, values) -> str: try: gate.verify(values) except NanPropagationError: return "reject-or-count" return "aggregate" ```