Skip to content
About
Main content

The reconciliation ladder: detecting is cheap, locating is what costs

Why is detecting a difference cheap while locating it is the real cost?

The reference moves outside

asks: does this data match what I said I expected? asks: does this data match what someone else says? The reference moves from a human assertion to a second running system. Three things change at once.

First, no hypothesis is needed. A does not require you to have imagined the defect beforehand: a difference is self-announcing. This is the only mechanism here that partly escapes the " without " problem, and it is why reconciliation survives as a separate discipline instead of dissolving into "more checks".

Second, the verdict is symmetric, and therefore useless by itself. A that fails names the guilty value. A differential that fails names two values and says nothing about which one is wrong. Assigning blame needs a designated authority, and designating one is an organizational act with no computational content.

Third, the check now costs data movement. Validation rides on a scan you were already doing. Reconciliation needs the other side's data, or a summary of it, to cross a boundary. Most of the ladder below is the engineering of that compression.

The independence precondition

There is also a precondition that most implementations never examine. A differential is informative only in proportion to the independence of its two sides. If the system you reconcile against was filled from the system under test, agreement verifies the transport and nothing else. Two systems fed from the same corrupt source agree perfectly. Independence is assumed everywhere and true almost nowhere; here it decides whether a differential means anything at all. It sits under every mechanism below.


The ladder

Comparing two datasets of n rows exactly costs O(n) on both sides plus a . Nobody does this as routine. So every real reconciliation is a point on a ladder that trades bytes moved against localisation (how precisely you find where the difference is).

RungWhat it movesWhat it detectsWhat it cannot do
Row count1 numberLoss, duplicationValue corruption; errors that cancel each other out
Aggregate over measures (sums, etc.)k numbers in those measuresOffsetting errors; anything in columns you did not aggregate
aggregate over whole rows1 numberAny difference, anywhereLocate it: the verdict is one bit
Per-partition hashesp numbersA difference, down to partition levelLocate it inside the partition
over sorted keysabout d · log nA difference, down to the rowSay what changed inside the row
Cell-level diffO(n) joinEverything, so it can be repairedBe cheap

It is a search. You escalate:

  1. One number says something is wrong.
  2. Per-partition hashes cut the search space (it is in the 2024-03 partition, store region West).
  3. A tree walk isolates the rows.
  4. A cell-level diff explains them.

The theoretical result underneath is that locating d differing items among n takes on the order of bits of communication. It depends on the number of differences, not on the size of the data. Five bad rows in a billion can be found by exchanging kilobytes.

A Merkle tree is a tree of hashes. The leaves are hashes of ranges of rows sorted by key. Each parent holds the hash of its children's hashes. Two parties compare the root. If the roots are equal, the datasets are equal. If not, they compare the children, and descend only into the branches that differ.

Open only the branches that differ15 of 31 hashes

This is exactly the anti-entropy machinery used to repair replicas in distributed databases (replicas that are supposed to be identical are compared with a Merkle tree, and only the differing ranges are copied). Here the same structure is pointed at a quality problem instead of a durability problem: it reconciles systems that are claimed to be equivalent.

Three implementation constraints that matter in practice

1. Order-independence is mandatory. The two sides will not produce rows in the same order. So the combiner that turns row hashes into one number must be commutative and associative (the order of combining must not matter). Use the sum of row hashes, not a hash of the concatenation. And the folklore choice of XOR is wrong: XOR cancels equal values in pairs, so rows added or removed in identical pairs are invisible, and a table with every row duplicated hashes to 0, the same as an empty table.

2. Canonicalisation is a lossy decision, not a preprocessing detail. Most reconciliation failures on the first run are representation differences: decimal versus float, versus empty string, trailing spaces, upper or lower case, collation (sort order rules), time zones, rounding. So you normalise before hashing. But every normalisation rule is a class of real differences that you have agreed not to see. Round amounts to two decimal places to silence floating-point noise, and you have made errors below one paisa undetectable. The false-alarm versus miss dial returns here, as a list of decisions instead of a single threshold.

3. Non-determinism sets a floor. Floating-point addition is not associative: the answer depends on the order in which you add. In Python, (0.1 + 0.2) + 0.3 gives 0.6000000000000001, while 0.1 + (0.2 + 0.3) gives 0.6. A parallel sum over a different partitioning therefore produces different results, legitimately. So reconciling floating-point measures needs a tolerance band, and the band is a blind spot. The escape is to use exact decimal types for anything that will be reconciled. That costs compute and storage, and it is worth it. Here "ordering versus parallelism" shows up as a correctness issue, not only a performance issue.

The common cut

You cannot reconcile a against a live, changing source: the source moves while you read it, and every difference is ambiguous between "wrong" and "changed since". You need both sides pinned to a comparable point: a version, a , or a quiet window. This is the same constraint that set the false-alarm floor for cross-table checks (Beyond one record), with the same three escapes: pin a snapshot, allow , or hold a pending set.

Compression: detecting a difference costs one number. Locating it costs d · log n. Paying O(n) means you skipped the ladder.