zorch.lnp.challenge¶
The LNP challenge space C ⊆ S_κ^{σ₋₁} over an injected byte stream.
LNP proofs (eprint 2022/284, §2.7) draw challenges from
C = { c ∈ S_κ : σ₋₁(c) = c, ²ᵏ√‖σ₋₁(cᵏ)·cᵏ‖₁ ≤ η },
polynomials of Z[X]/(X^d + 1) with coefficients in [-κ, κ], fixed under
the automorphism σ₋₁ : X ↦ X⁻¹, whose operator norm (as multiplication on
the ring) is bounded by η — the ℓ1 quantity's 2k-th root upper-bounds it,
tightening as k grows. The invariance is what makes σ₋₁(z)·z a quadratic
equation in the challenge for the framework's inner-product layer, and every
nonzero invariant element of S_κ is invertible in the partial-split ring
(q ≡ 5 (mod 8), lattice-frx's Lemma-2.6 predicate), which soundness
extraction stands on.
Sampling is a deterministic function of injected transcript bytes, under exactly the byte-stream contract of lattice-frx's sampler family (bytes / bytearray / uint8 ndarray; consumption fixed ahead of time; identical bytes, identical challenge; where the bytes come from is the consumer's choice — nothing here hashes):
- Each candidate consumes one
uniform_bytes_needed(2κ+1, d/2)-byte block:d/2uniform draws, balanced to[-κ, κ], embedded as the invariant shapec_0 .. c_{d/2-1}= draws,c_{d/2} = 0,c_{d-i} = -c_i. - A candidate is accepted iff it is nonzero and
‖σ₋₁(cᵏ)·cᵏ‖₁ ≤ η^{2k}. The check is exact integer arithmetic on purpose — at the Figure-3 parameter point (d=128, κ=2, η=59, k=32) the product's coefficients run hundreds of bits, past any float's mantissa, and a rounded gate would be a soundness parameter decided by rounding luck. - Rejection is paid with a precomputed budget rather than an open-ended
loop:
ceil(-log2(fail_prob))candidate blocks, from the stated conservative per-candidate rejection bound of 1/2 (the measured rate at the Figure-3 point is ~1%, so the first block nearly always decides).challenge_bytes_neededis the resulting exact byte count, and the sampler requires exactly that many bytes.
The candidate blocks keep the uniform sampler's own default stream budget
(fail_prob here prices gate rejections; the inner uniform_from_bytes
budget prices chunk rejections, a separate and far smaller-probability
failure) — the two knobs are deliberately not conflated.
Host by construction — and exactly where the device boundary sits. zorch's
posture is device-first (see docs/reference/conventions.md); this module
is host because the η gate multiplies out σ₋₁(cᵏ)·cᵏ over unreduced ℤ,
where coefficients reach ~2^318 at the Figure-3 point — past every
fixed-width lane a device carries, while a field dtype would fold the very
magnitude the gate measures back mod q. The gate is Python-bigint
arithmetic (the object arrays are containers for exact ints, not numpy
compute), it runs once per challenge on prover and verifier alike, and it
is the one part that can never trace; no XLA path exists for it, so its
performance lever is algorithmic (candidate count, norm-bound cost), not
codegen. The draws and the invariant embedding, by contrast, are
array-expressible: a device-resident transcript consumer derives them on
device from squeezed words directly (rejection as masked prefix-compaction
over the fixed budget, no byte detour) and crosses to the host only for
the gate. The byte-stream form here is the injection seam's shape — the
host oracle every traced derivation is checked against — and its LE-uint64
chunk convention is lattice-frx's pinned stream contract, not a device
choice.
ChallengeParams
dataclass
¶
The challenge-space point and its budget, carried as one value.
challenge_bytes_needed and challenge_from_bytes are a pair — the
parser must consume exactly the count its companion quotes — and
_layout exists so those two cannot drift. A protocol that passes the
five parameters to each call separately re-splits the pairing it was
given: the count and the parse become two independent argument lists,
and fail_prob is easy to reach in neither (the first consumer set the
other four in its constructor and left this one on the default, with no
way for its caller to move it). Protocol modules take this object whole
instead.
fail_prob here prices this budget — the candidate blocks the η gate
may reject — and is deliberately not the same knob as a protocol's own
rejection-loop fail_prob, in the same way the module docstring keeps
it separate from the inner uniform_from_bytes budget. It is the
dominant cost of a proof (the whole block budget is squeezed whether or
not the first candidate decides), so it is worth stating rather than
inheriting.
Source code in zorch/lnp/challenge.py
293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 | |
from_bytes ¶
from_bytes(
data: bytes | bytearray | np.ndarray,
) -> np.ndarray
One challenge from C, as challenge_from_bytes at this point.
Source code in zorch/lnp/challenge.py
339 340 341 342 343 | |
attempt_budget ¶
attempt_budget(
fail_prob: float, accept_prob: float = 0.5
) -> int
The fixed attempt count for a rejection loop: at acceptance
accept_prob per attempt, ceil(log(fail_prob) / log1p(-accept_prob))
attempts fail together with probability at most fail_prob.
The package's "budget rather than an open-ended loop" discipline in one
formula, so a protocol's own rejection loop and this module's candidate
loop cannot spell it two ways. The candidate loop is the accept_prob =
1/2 case (the stated conservative per-candidate rejection bound), where
it reduces to ceil(-log2(fail_prob)). Its eventual home is
lattice-frx's sampler family, whose docstring already states the same
contract; it lives here until the batched substrate move.
Both probabilities are gated here rather than left to callers, because
this is a public entry point and the raw formula fails on its domain
edges without naming what went wrong: accept_prob = 0 divides by
zero, accept_prob = 1 is a math-domain error. They are not treated
alike — zero is refused, while certain acceptance is answered (one
attempt suffices at any fail_prob), since that is the formula's limit
rather than a caller mistake.
A caller deriving acceptance as 1/(rep1·rep2) can underflow to
exactly 0.0, which is why the zero case raises here rather than
dividing. Such a caller should still catch the mis-derived rates in its
own vocabulary first — this gate names accept_prob, which that caller
never passed.
Source code in zorch/lnp/challenge.py
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | |
negacyclic_mul ¶
negacyclic_mul(a: np.ndarray, b: np.ndarray) -> np.ndarray
The exact negacyclic product over unreduced ℤ — full convolution,
then X^d ≡ -1 folds the top half in with a sign. Dtype-preserving on
purpose: object arrays carry Python bigints through the η gate, int64
carries the protocol responses' c·s — no mod-q product may replace
either (the split ring's mul reduces).
Source code in zorch/lnp/challenge.py
169 170 171 172 173 174 175 176 177 178 179 | |
challenge_bytes_needed ¶
challenge_bytes_needed(
d: SupportsIndex,
kappa: SupportsIndex,
eta: SupportsIndex,
k: SupportsIndex,
fail_prob: float = 2.0**-128,
) -> int
The exact byte count challenge_from_bytes consumes for these
parameters: the candidate budget times one uniform block. Parameters
ride in the same order as challenge_from_bytes minus the stream, as
with lattice-frx's *_bytes_needed companions; eta and k do not
move the count but complete the parameter point, so both entry points
take one tuple.
Source code in zorch/lnp/challenge.py
219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | |
challenge_from_bytes ¶
challenge_from_bytes(
data: bytes | bytearray | np.ndarray,
d: SupportsIndex,
kappa: SupportsIndex,
eta: SupportsIndex,
k: SupportsIndex,
fail_prob: float = 2.0**-128,
) -> np.ndarray
One challenge from C as a deterministic function of the injected
byte stream: the first candidate block (in stream order) that embeds
to a nonzero polynomial and passes the η gate decides, and the stream
length must be exactly challenge_bytes_needed(...).
Returns the length-d signed coefficient vector (int64, entries in
[-κ, κ]) — host-boundary raw ints, ready for a ring's from_signed,
like lattice-frx's own ternary sampler.
Source code in zorch/lnp/challenge.py
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 | |