# What invariants prevent NaN propagation from silently voiding an aggregate? **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. Does not apply to aggregates that reject NaN or count non-numeric inputs separately. ## Core Distinction NaN is absorbing under IEEE-754: once any term is NaN, the sum, mean, and most aggregates are NaN. An instance that aggregates unvalidated floats cannot distinguish "the total is zero" from "one term was malformed." **Invariant.** The aggregate is sound if and only if NaN either cannot enter or is surfaced as a distinct count alongside the aggregate, not baked into the result. **Violation.** A single null numeric — from a join miss, a failed parse, or a division by zero — enters a sum and makes the entire aggregate NaN, which serializers commonly coerce to null or zero. The revenue figure for the day becomes 0. **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 is commonly attributed to the aggregate function rather than to the input. ## What Was Observed **Signature.** Aggregate NaN or zero on days when a single input was malformed. Valid input counts unchanged. Recomputation over a filtered set yields a plausible number, making the failure look intermittent. **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, so many writers coerce to null or zero, and the day's revenue figure becomes 0. **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 The instance must ensure at least one holds before it trusts an aggregate over floating-point inputs: 1. **Must reject at input.** NaN inputs are rejected before aggregation, and the rejection is alerted. 2. **Must count valid.** The aggregate is accompanied by a count of valid inputs, and any mismatch between expected and valid counts is an alert. 3. **Must represent absence explicitly.** Null numerics are represented as absent (null / optional) rather than NaN, and the aggregate treats absent as skip with an explicit denominator. Condition 3 is structural; 1 and 2 are detection. The instance must know which of the three applies before it interprets an aggregate as complete. ## 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, often implicit in a query engine. Remediation is explicit NaN-ness at the boundary plus 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, strictly worse than reporting NaN, because the number is now wrong in a way no check can find. ```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" ```