Hard-won invariants you must not break

These are the load-bearing, non-obvious rules this codebase depends on. Every one of them was found by a real bug, not by reading a spec — which is itself the pattern worth internalizing (see Process: validate empirically, not by inspection at the bottom). If you're extending the persistence engines, complex-construction streams, or anything Ripser-flavored, read this page in full before you start.

1. given/implicit resolution happens once, at construction, not per call

A given [CellT: Ordering, CoefficientT: Field] => (Chain[CellT, CoefficientT] is RingModule) instance resolves its Ordering[CellT] context-bound parameter exactly once, when the instance is first summoned — every method built on it permanently closes over that one resolution. Scala's implicit search is static (compile-time, lexical), not dynamic; calling those methods later, from a scope where a different Ordering[CellT] given is available, does not re-resolve anything.

Concretely: Simplex[VertexT] is OrderedCell's own .ordering is a fixed lexicographic order, and Chain.scala's given [CellT: OrderedCell] => Ordering[CellT] = oCell.ordering fallback means any chainRM = summon[Chain[Simplex[VertexT], CoefficientT] is RingModule] written before a stream-specific given Ordering[Simplex[VertexT]] = stream.filtrationOrdering is in scope silently captures that lexicographic fallback instead — every chain built through ⊠/.scale afterward pivots on vertex label order, not filtration order, regardless of what's nominally in scope at the call site.

The fix pattern, everywhere it's applied: declare given Ordering[CellT] = stream.filtrationOrdering as the first statement inside whatever state object holds the stream, before summoning chainRM.

This is genuinely dangerous rather than a style nit because it compiles cleanly either way and only manifests as a wrong answer on inputs where lexicographic and filtration order actually disagree — most hand-built fixtures don't exercise that unless someone deliberately constructs one (a fixture with vertex 1 born early and vertex 9 born late is the minimal discriminator). Not every class-scope chainRM instance is a live bug: whether it matters depends on whether anything downstream trusts that instance's output directly for pivot identity, or whether every consumer re-resolves ordering itself (e.g. via Chain.reduceByUntil, a def that resolves fresh at each call site). This needs a case-by-case, empirically-verified audit every time, not a blanket rule.

2. A stream's iteration order and its pivot order must be the same total order

Boundary-matrix reduction requires columns (processing order) and rows (pivot order) to be indexed by one shared total order. Concretely: a stream's .iterator/iterateDimension order and its filtrationOrdering (used to key Chain.reduceBy's map) must be the same ordering, one the consistent .reverse of the other — not merely "each independently a valid total order." Two orderings that are each individually consistent but disagree on which of two tied cells comes first corrupt reduction in a way that's easy to miss on random test input, because Vietoris-Rips complexes have structural ties (every simplex of dimension ≥ 2 ties with its own longest edge) while random fixtures often don't — see Degeneracies.

Symptom: IllegalStateException: reduction pivot ... was not a recorded open class. This means a cell got selected as a pivot the algorithm's own invariants say should have been impossible — check whether iteration order and pivot order for the stream in play are provably the same ordering before looking in the reduction code itself.

.reverse on the same Ordering object is safe; building a second, independently-written "reversed" comparator is not, even when it looks equivalent on paper — a hand-written second comparator's tie-break direction doesn't automatically stay consistent with a separately-reversed primary key, and this has broken more than one stream in this codebase's history for exactly that reason.

3. Colex vs. lex tie-breaks are not interchangeable in Ripser-flavored code

The generic default tie-breaks on plain lexicographic vertex-set order. EnumeratingCofaceSimplexStream's and RipserCohomologyContext's own orderings deliberately use colexicographic order instead, via SimplexIndexing's own combinatorial-number-system index — the exact tie-break Ripser's Definition 3.2/Proposition 3.9 (apparent pairs) are stated in terms of. Don't "simplify" a colex ordering to plain lex in code that touches SimplexIndexing/the Ripser engines; they need to agree with each other, not just each independently be a valid tie-break.

4. Chain.reduceBy/reduceByUntil, never hand-rolled Chain arithmetic, inside a reduction loop

Chain's +/-/⊠ operators only lazily collapse the head of the underlying PriorityQueue, so a hand-rolled fold that repeatedly subtracts terms builds an ever-growing backlog of uncollapsed duplicate entries — confirmed directly by a first draft that hung on an 8-12 point complex that should take milliseconds. Chain.reduceBy/reduceByUntil go through a SortedMap that collapses duplicates on every insertion. Reserve raw chain arithmetic for small, one-shot combinations (building a V-column fold), never for anything that accumulates over many reduction steps.

5. Combinatorial helpers over the full point set don't know about maxDimension truncation

SimplexIndexing.cofacetIterator/facetIterator operate purely combinatorially over the complete n-point abstract simplex — no concept of any per-engine dimension cap. RipserCohomologyContext.coboundaryOf truncates explicitly instead, which is what makes top-dimension simplices come out essential. Any new code built directly on SimplexIndexing's iterators inherits none of that truncation and needs its own manual guard at the call site — SimplexIndexing itself has no way to know what any particular caller's cap is. The symptom (wrong finite bars where essential ones were expected) looks like a reduction bug, not an off-by-scope error two layers away, so it's easy to misdiagnose.

6. iterateDimension's domain must be contiguous from 0

StratifiedCellStream.iterator's default implementation is Iterator.from(0).takeWhile(iterateDimension.isDefinedAt).flatMap(iterateDimension) — it stops at the first dimension the stream doesn't define, and never asks about anything past it. A stream whose domain has a gap (defined at 0 and 2 but not 1) silently truncates at the gap rather than skipping past it. Every concrete stream in this codebase satisfies this for a structural reason, not by convention alone: a simplicial or cubical complex can't have a d-cell without its (d-1)-dimensional faces, so "no cells at d" implies "no cells beyond d" too. A hand-built or generated stream that doesn't have this structural guarantee needs to enforce contiguity itself — an unbounded case d => ... catch-all with no upper bound is also a hazard here (isDefinedAt always true, which defeats the "stops at the first gap" logic entirely and can loop until Int wraps around).

7. Opaque-type extension methods belong in the type's own companion object

An extension whose receiver is an opaque type (Simplex, Cube) should live inside that type's own companion object (object Simplex/object Cube), not as a top-level extension clause — extension-method resolution checks a receiver type's companion object by nominal type, so two unrelated opaque types can safely reuse the same method name this way (Simplex.underlying/Cube.underlying never collide). Two hazards to know before leaning on this: opaque-type transparency is scoped to the whole file the opaque type is declared in, so code in that same file calling the type's own extensions by dot-syntax can resolve to a same-named member of the underlying representation type instead of the intended extension — keep an opaque type's OrderedCell/similar instances in their own file when this matters. And a companion-object extension can still lose to a same-named stdlib extension reachable via a wildcard import at some call site (min/max deliberately stay top-level extensions on Simplex for exactly this reason, to avoid losing to scala.math.Ordering.Implicits).

Process: validate empirically, not by inspection

Every substantive design decision in this codebase's persistence-engine work (pivot orientation, whether clearing is optional, whether an ordering fix is complete, whether an optimization is sound) has gone through at least one empirical discriminating test before being trusted — never a plausibility argument alone, even a careful one. "This class-scope chainRM should be inert because X" has been both correct and wrong for structurally similar-looking classes in this codebase; only a discriminating test told the two apart.

The pattern to carry into new work: when a change to reduction/ordering logic seems obviously sound by inspection, build the cheap discriminating test anyway before implementing it for real. A good discriminating fixture deliberately puts filtration order and some other natural order (vertex label, insertion order) in conflict — a fixture where they happen to agree will not catch this class of bug.