hex

4.3. Chinese remaindering🔗

The Sunzi Suanjing, a Chinese text of the third to fifth century, asks for a number that leaves remainder 2 when counted by threes, 3 when counted by fives, and 2 when counted by sevens. A Hex.Modular.Crt holds the answer so far: its modulus, which is the product of the moduli pushed so far, and the symmetric representative of the solution modulo it. Hex.Modular.Crt.init is the state modulo 1, and each Hex.Modular.Crt.push adds one more condition:

def sunzi : Option Crt := do let c ← Crt.init.push 2 3 let c ← c.push 3 5 c.push 2 7 some (23, 105)#eval sunzi.map fun c => (c.value, c.modulus)
some (23, 105)
🔗structure

A residue accumulated from coprime moduli. value is the symmetric representative of their common solution modulo modulus.

Hex.Modular.Crt.mk
modulus : ℕ

Product of the moduli already folded into the state.

value : ℤ

Symmetric representative of the accumulated residue.

pos : 0 < self.modulus

The accumulated modulus is positive.

le : 2 * self.value.natAbs ≤ self.modulus

The representative lies in the symmetric interval.

🔗def
Hex.Modular.Crt.push (c : Hex.Modular.Crt) (r : ℤ) (m : ℕ) : Option Hex.Modular.Crt
Hex.Modular.Crt.push (c : Hex.Modular.Crt) (r : ℤ) (m : ℕ) : Option Hex.Modular.Crt

Fold the residue r modulo m into a scalar CRT accumulation using one Garner mixed-radix step. Moduli zero and one and moduli not coprime to the accumulated modulus are rejected.

The moduli must be pairwise coprime. push checks this as it goes, and returns none when the new modulus shares a factor with the product so far, even when the conditions are consistent: 1 mod 4 and 3 mod 6 are both satisfied by 9, but 4 and 6 are not coprime.

#guard (Crt.init.push 1 4 >>= (·.push 3 6)).isNone

4.3.1. Garner's step🔗

Suppose the state holds v modulo M, and r modulo m is pushed, with M and m coprime. Let s be the inverse of M modulo m. Then δ = symMod ((r - v) s) m and the new value is symMod (v + M δ) (M m). Adding a multiple of M does not change the residue modulo M, and modulo m the sum is v + (r - v) = r. This is Garner's mixed-radix step. The inverse s comes from the extended Euclidean algorithm applied to M mod m and m, so its operands are no larger than m, whatever the size of M. When m fits in a word, the other operations (additions, multiplications by numbers smaller than m, and reductions) each take time linear in the bit length of M.

The final reduction is needed. Pushing 1 mod 3 and then 0 mod 2 gives δ = 1 and v + M δ = 4, which is outside (-3, 3]; the stored value is -2:

some (-2, 6)#eval (Crt.init.push 1 3 >>= (·.push 0 2)).map fun c => (c.value, c.modulus)
some (-2, 6)

Three theorems describe a successful push. The new modulus is the product, the new value has the new residue, and it keeps the old residue modulo every divisor of the old modulus. By induction, the value after a sequence of pushes has every residue that was pushed.

🔗theorem
Hex.Modular.Crt.push_modulus {c c' : Hex.Modular.Crt} {r : ℤ} {m : ℕ} (h : c.push r m = some c') : c'.modulus = c.modulus * m
Hex.Modular.Crt.push_modulus {c c' : Hex.Modular.Crt} {r : ℤ} {m : ℕ} (h : c.push r m = some c') : c'.modulus = c.modulus * m

A successful scalar push multiplies the accumulated modulus by the new one.

🔗theorem
Hex.Modular.Crt.push_congr_new {c c' : Hex.Modular.Crt} {r : ℤ} {m : ℕ} (h : c.push r m = some c') : c'.value % ↑m = r % ↑m
Hex.Modular.Crt.push_congr_new {c c' : Hex.Modular.Crt} {r : ℤ} {m : ℕ} (h : c.push r m = some c') : c'.value % ↑m = r % ↑m

A successful scalar push records the requested new residue.

🔗theorem
Hex.Modular.Crt.push_congr_old {c c' : Hex.Modular.Crt} {r : ℤ} {m d : ℕ} (hd : d ∣ c.modulus) (h : c.push r m = some c') : c'.value % ↑d = c.value % ↑d
Hex.Modular.Crt.push_congr_old {c c' : Hex.Modular.Crt} {r : ℤ} {m d : ℕ} (hd : d ∣ c.modulus) (h : c.push r m = some c') : c'.value % ↑d = c.value % ↑d

A successful scalar push preserves the accumulated value modulo every divisor of the old accumulated modulus.

To recover an integer x, push residues of x until 2 |x| is less than the modulus. The value then has the same residue as x modulo the whole modulus, and it is the symmetric representative of that residue, so it is x by symMod_unique. The same argument gives the uniqueness of strictly bounded solutions:

🔗theorem
Hex.Modular.crt_unique {c : Hex.Modular.Crt} {x y : ℤ} (h : 2 * x.natAbs < c.modulus) (h' : 2 * y.natAbs < c.modulus) (hx : x % ↑c.modulus = y % ↑c.modulus) : x = y
Hex.Modular.crt_unique {c : Hex.Modular.Crt} {x y : ℤ} (h : 2 * x.natAbs < c.modulus) (h' : 2 * y.natAbs < c.modulus) (hx : x % ↑c.modulus = y % ↑c.modulus) : x = y

Two integers strictly smaller than half the accumulated modulus and congruent modulo that modulus are equal.

4.3.2. Vectors of residues🔗

Recovering a polynomial or a matrix means recovering many integers modulo the same primes. A CrtVec k (Hex.Modular.CrtVec) holds k values sharing one modulus. Its Hex.Modular.CrtVec.push computes the inverse s once and uses it for every coordinate, so a push costs one extended Euclidean algorithm whatever k is. Here the second coordinate solves 1 mod 3, 0 mod 5 and -1 mod 7:

def lanes : Option (CrtVec 2) := do let c ← (CrtVec.init 2).push #v[2, 1] 3 let c ← c.push #v[3, 0] 5 c.push #v[2, -1] 7 some (#v[23, -50], 105)#eval lanes.map fun c => (c.value, c.modulus)
some (#v[23, -50], 105)

CrtVec has the same theorems as Crt, for every coordinate, together with Hex.Modular.CrtVec.push_congr, which combines the old and new residues into one congruence modulo the product, and Hex.Modular.CrtVec.eq_of_congr, the analogue of crt_unique.

4.3.3. Many moduli at once🔗

When every modulus is known before any residue, the moduli can be prepared once and used for many reconstructions. Hex.Modular.CrtPlan.build? checks that the moduli are greater than one and pairwise coprime, and arranges them as the leaves of a balanced binary tree. Each internal node stores the inverse of the product of its left subtree modulo the product of its right subtree. Hex.Modular.CrtPlan.reconstruct? then combines the residues from the leaves up, applying Garner's step at each node with its stored inverse, so it runs no extended Euclidean algorithm. Hex.Modular.CrtPlan.reconstructVec? does the same for k values at once: its input is an Array (Vector Int k) with one vector of residues per modulus, in the order of the moduli, and it uses each stored inverse for every coordinate. Both return none if the number of residues differs from the number of moduli, and both check the result against every residue before returning it.

A plan for the five primes above recovers the same residue of H₅₀ as the incremental computation:

def plan? : Option CrtPlan := CrtPlan.build? primes.toArray def viaPlan : Option Int := plan?.bind fun plan => plan.reconstruct? (primes.toArray.map (harmonicMod 50)) def viaPush : Option Int := (primes.foldlM (fun (c : Crt) p => c.push (harmonicMod 50 p) p) Crt.init).map (·.value) #guard viaPlan.isSome #guard viaPlan = viaPush #guard (CrtPlan.build? #[6, 10]).isNone

The result is the symmetric representative modulo the product of the moduli, so it recovers every integer that small:

🔗theorem
Hex.Modular.CrtPlan.reconstruct?_eq_candidate {plan : Hex.Modular.CrtPlan} {residues : Array ℤ} {value candidate : ℤ} (hresult : plan.reconstruct? residues = some value) (hbound : 2 * candidate.natAbs < plan.modulus) (hcongr : ∀ (i : Fin plan.moduli.size), candidate % ↑plan.moduli[i] = residues.getD (↑i) 0 % ↑plan.moduli[i]) : value = candidate
Hex.Modular.CrtPlan.reconstruct?_eq_candidate {plan : Hex.Modular.CrtPlan} {residues : Array ℤ} {value candidate : ℤ} (hresult : plan.reconstruct? residues = some value) (hbound : 2 * candidate.natAbs < plan.modulus) (hcongr : ∀ (i : Fin plan.moduli.size), candidate % ↑plan.moduli[i] = residues.getD (↑i) 0 % ↑plan.moduli[i]) : value = candidate

Strictly half-bounded candidates that match every input residue are recovered exactly by scalar reconstruction.

The incremental form is the one to use when the number of moduli is not known in advance, because a computation can stop as soon as it has enough. The section Costs and limits compares the two.