Skip to content

Symmetric matrix storage is a canonical two-variant union

SymmetricMatrix persists its values in one of two lower-triangle encodings - a dense flat list, or a dense diagonal plus sparse off-diagonal triples - as a tagged union in the serialized model. The variant is chosen by a fixed rule the caller cannot override, so that two matrices with the same content always encode identically.

Storing only a lower triangle is the part that needs no defense: it halves the payload and makes an asymmetric value unrepresentable, so there is no symmetry validator and no tolerance for "how symmetric is symmetric enough". Carrying two triangle encodings is the decision worth recording, along with the price paid to keep them from being observable.

Considered options

A single dense triangle is the obvious design. It is simple, and it is wasteful for the sparse cases this type is expected to see - a matrix over a few thousand keys where almost every pair is unrelated stores millions of zeros.

Reusing ExposureMatrix's sparse index triples for everything fails the other way. The dense cases - correlation and covariance matrices, which are the motivating ones - are close to fully populated, and triples cost three numbers per value against the triangle's one.

Neither encoding dominates, so both exist. The crossover is exact rather than tuned: with n keys and k stored off-diagonal cells, the dense triangle costs n(n+1)/2 numbers and the sparse form costs n + 3k, which are equal at k = n(n-1)/6. Sparse is canonical below one third of the off-diagonal cells populated, dense at or above it. Anyone can re-derive that constant, which is the point of deriving it rather than picking a round number.

Letting the caller pick the encoding was rejected. The moment two matrices with identical content can disagree about their form, byte equality stops tracking semantic equality, and Pydantic's field-wise __eq__ starts returning False for matrices that denote the same thing. Recovering correct equality would mean a hand-written __eq__ and __hash__ that normalize across variants - at which case equal matrices would still serialize to different bytes, breaking any content-addressed cache or "did this change?" check downstream.

Consequences

The rule must be a pure function of (n, k). Nothing about how a matrix was built, what it is used for, or how it was previously stored may influence the choice, or the equality guarantee is lost.

Canonicalization runs in the model validator as well as in build(), so a hand-written JSON document in the non-canonical form is silently rewritten on load. This is the same bargain ADR 0002 struck for record sorting: silent where the transformation is lossless, loud where it is ambiguous. Losslessness holds here only because an absent off-diagonal cell means exactly 0.0 - there is no configurable fill, and cell_fill from the exposure glossary does not apply.

The representation is invisible in the interface. There is no nnz, no is_sparse, and no "stored entries only" accessor; values_of returns an entry for every key in the universe and pairs() yields every pair. A sparse matrix therefore saves storage but not iteration cost. That is deliberate - an observable density would promote the crossover constant into the public contract, and changing it later would be a breaking change rather than a re-encoding.

Bumping the constant does shift the stored bytes of matrices that straddle it. Correctness is unaffected, but any hash taken over the serialized form will move.