Skip to content

Exposure matrices are immutable; all growth happens in a builder

ExposureMatrix and Universe are immutable, and every mutation - growing a universe, setting an exposure, applying a default row - happens on a separate ExposureMatrixBuilder that produces a matrix via build(). A reader coming to the code will notice the matrix has no setters at all, which is deliberate.

Considered options

The design started somewhere else. Universes had to grow by first mention during ingest but be closed afterwards, so the matrix carried a per-axis freeze flag: mutable until declared, rejecting unknown keys after. That flag then had to be a serialized field, because a persisted curated matrix must come back curated.

The alternative of keeping universes immutable but growing them copy-on-write was rejected separately: populating n subjects through repeated set_exposure calls would be O(n^2).

Consequences

The persisted type has no modes. Nothing that round-trips through JSON has state affecting what later calls are allowed to do, so reading a stored matrix tells you everything about how it behaves. The freeze flags still exist, but on the builder, where they are transient and never serialized.

build() returns a snapshot and leaves the builder usable, which requires a defensive copy of records and universes. Without that copy the returned "immutable" matrix aliases mutable builder state and a later set_exposure silently mutates a value already handed off.

Editing a stored matrix requires a round trip through to_builder(), so the common "load, add this quarter's exposures, save" flow costs one extra object.