Skip to content

zorch.lnp.masking

The masking and rejection machinery every ABDLOP proof shares.

Fig. 4 (opening.py) and Fig. 6 (quadratic.py) are siblings, not consumers of one another: both mask the witness with Gaussians y_i ~ D_{s_i}, absorb their own first-round messages, squeeze one challenge c ∈ C, answer z_i = c·s_i + y_i over the integers, and rejection- sample both responses. What differs between them is only which messages go into the transcript and which equation the verifier checks — the parameter point, the draw, the response, the Rej1 gates and the [Ban93] norm bounds are one thing, held here.

They also have to be the same thing rather than merely alike. Fig. 8 runs a Π_eval-shaped layer over Π_many^(2), so a single proof carries both protocols against one commitment; two parameter objects that drifted apart would be a soundness bug no single-protocol suite could see.

Rejection is paid with a precomputed budget, the sampler discipline lifted to the protocol loop: one attempt accepts with probability ≈ 1/(rep1·rep2) (Lemma 2.14-1: M = exp(14/γ + 1/(2γ²)) at s = γ·T, T ≥ ‖c·s_i‖), so a prover runs at most ceil(log(fail_prob)/log(1 − 1/(rep1·rep2))) attempts and raises rather than looping open-endedly. Each attempt restarts from the caller's transcript value — ByteTranscript is functional, so a rejected attempt leaves no trace, which is exactly the Fiat-Shamir-with-aborts convention.

Host/device boundary per docs/reference/conventions.md: the responses live on the host by necessity — z = c·s + y must be computed over unreduced ℤ (the norm statement is about magnitudes a field dtype would fold away), and the rejection inner products and norm checks run over exact Python ints (lattice_frx.norms). The host touches verdicts and integer vectors, not ring arrays.

Prover randomness is the caller's np.random.Generator — private coins, not transcript-derived.

Masking

The parameter point a Fig.-4/Fig.-6 proof masks and rejects against.

Takes the derived numbers: the masking standard deviations s?_std, the Lemma 2.14-1 repetition rates rep? they were derived with, the challenge point (ChallengeParams, carrying its own budget), and the fail_prob pricing the rejection loop. Parameter derivation (the γ-factors of §2.6/§6.1) stays with the consumer — this seam takes the derived numbers, like every other seam in this package.

The relation count is deliberately not among them: it is a property of the statement each protocol is given, so storing it here would be a second representation of one number with nothing gating the two.

Source code in zorch/lnp/masking.py
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
class Masking:
    """The parameter point a Fig.-4/Fig.-6 proof masks and rejects against.

    Takes the *derived* numbers: the masking standard deviations `s?_std`,
    the Lemma 2.14-1 repetition rates `rep?` they were derived with, the
    challenge point (`ChallengeParams`, carrying its own budget), and the
    `fail_prob` pricing the rejection loop. Parameter *derivation* (the
    γ-factors of §2.6/§6.1) stays with the consumer — this seam takes the
    derived numbers, like every other seam in this package.

    The relation count is deliberately not among them: it is a property of
    the statement each protocol is given, so storing it here would be a
    second representation of one number with nothing gating the two.
    """

    def __init__(
        self,
        scheme: AbdlopCommitment,
        s1_std: float,
        s2_std: float,
        rep1: float,
        rep2: float,
        challenge: ChallengeParams,
        fail_prob: float = 2.0**-128,
    ) -> None:
        for name, value in (("s1_std", s1_std), ("s2_std", s2_std)):
            if value <= 0.0:
                raise ValueError(f"masking: {name} must be positive, got {value!r}")
        for name, value in (("rep1", rep1), ("rep2", rep2)):
            if value <= 1.0:
                raise ValueError(f"masking: {name} must exceed 1, got {value!r}")
        # Rates past ~1e154 overflow their product to inf, which collapses
        # the acceptance to exactly 0.0 — and then `attempt_budget` refuses
        # `accept_prob`, a parameter this caller never passed, instead of
        # the budget guard below naming the rates that are actually wrong.
        if not math.isfinite(rep1 * rep2):
            raise ValueError(
                f"masking: rep1·rep2 overflows to infinity at rep1={rep1!r}, "
                f"rep2={rep2!r} — repetition rates this far from their Lemma "
                f"2.14-1 values are a parameter bug"
            )
        if not 0.0 < fail_prob < 1.0:
            raise ValueError(f"masking: fail_prob must be in (0, 1), got {fail_prob!r}")
        ring = scheme.ring
        if challenge.d != ring.d:
            raise ValueError(
                f"masking: challenge degree {challenge.d} does not match the "
                f"ring's {ring.d}"
            )
        self.scheme = scheme
        self.s1_std = s1_std
        self.s2_std = s2_std
        self.rep1 = rep1
        self.rep2 = rep2
        self.challenge = challenge
        self.fail_prob = fail_prob
        # Squeezed once per attempt, so it is resolved once here.
        self.challenge_bytes = challenge.bytes_needed
        # ‖z_i‖₂ ≤ s_i·√(2·m_i·d) [Ban93], compared squared over exact ints;
        # the floor only tightens the bound.
        self._bound1_sq = math.floor(s1_std**2 * 2 * scheme.s1_cols * ring.d)
        self._bound2_sq = math.floor(s2_std**2 * 2 * scheme.randomness_cols * ring.d)
        count1 = scheme.s1_cols * ring.d
        count2 = scheme.randomness_cols * ring.d
        # One attempt accepts both Rej1 gates with probability ≈ 1/(rep1·rep2).
        self.attempts = attempt_budget(fail_prob, 1.0 / (rep1 * rep2))
        # The budget grows as rep1·rep2·log(1/fail_prob), so a huge one means
        # a mis-derived rate — surface it here rather than as a prove() that
        # loops for hours.
        if self.attempts > _MAX_ATTEMPTS:
            raise ValueError(
                f"masking: rep1·rep2 = {rep1 * rep2!r} implies an attempt "
                f"budget of {self.attempts} — repetition rates this far "
                f"from their Lemma 2.14-1 values are a parameter bug"
            )
        # One resolved sampler per std: `sampler_for`'s tier decision is a
        # function of σ (its admissibility gate), so a callable resolved at
        # one std must not be invoked at the other.
        self._draw1 = sampler.sampler_for(s1_std, self.attempts * count1)
        self._draw2 = sampler.sampler_for(s2_std, self.attempts * count2)
        self._centers1 = np.zeros((scheme.s1_cols, ring.d), dtype=np.float64)
        self._centers2 = np.zeros((scheme.randomness_cols, ring.d), dtype=np.float64)

    def draw(self, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
        """One masking pair `(y1, y2) ~ (D_{s1}, D_{s2})`, as signed integer
        `(m_i, d)` arrays."""
        return (
            self._draw1(rng, self._centers1, self.s1_std),
            self._draw2(rng, self._centers2, self.s2_std),
        )

    def challenge_from(
        self, transcript: ByteTranscript, label: bytes, *stacks: np.ndarray
    ) -> tuple[ByteTranscript, np.ndarray]:
        """Absorb a protocol's first-round messages under its own `label` and
        squeeze the challenge — the one derivation both sides replay.

        The label is the caller's because it is what separates two protocols
        that would otherwise hash the same stacks to the same challenge."""
        t = absorb_stacks(transcript.observe_label(label), *stacks)
        t, raw = t.sample_scalar(self.challenge_bytes)
        return t, self.challenge.from_bytes(raw)

    def ajtai_image(
        self, a1: np.ndarray, a2: np.ndarray, x1: np.ndarray, x2: np.ndarray
    ) -> np.ndarray:
        """`A1·x1 + A2·x2`, the Ajtai half of the commitment equation.

        Both siblings send it as their first message at `x = y` and both
        rebuild it at `x = z` to check it, so it is one expression at four
        sites, not two protocols that happen to agree. Here rather than on
        `AbdlopCommitment` because what the layers pass is a *masking* or a
        *response*, not a witness — the scheme's own `commit` is the
        witness-shaped caller and keeps its bound checks."""
        ring = self.scheme.ring
        return ring.add(ring.matvec(a1, x1), ring.matvec(a2, x2))

    def masked_message(
        self, c: np.ndarray, b: np.ndarray, t_b: np.ndarray, z2: np.ndarray
    ) -> np.ndarray:
        """`c·t_B − B·z2` — `c·m` for the message the BDLOP half commits to
        but never sends.

        The only route either verifier has to `m`: Fig. 4 feeds it to the
        linear check, Fig. 6 lifts it as eq. 30's message half. Named once
        because the two must agree on it and a suite of either alone cannot
        see them drift."""
        ring = self.scheme.ring
        return ring.sub(ring.scale(c, t_b), ring.matvec(b, z2))

    def respond(
        self, c: np.ndarray, s1: np.ndarray, s2: np.ndarray
    ) -> tuple[np.ndarray, np.ndarray]:
        """`c·s_i` over the *integers*, one per witness half — the term both
        the response `z_i = c·s_i + y_i` and its Rej1 gate are stated in."""
        return _challenge_times(c, s1), _challenge_times(c, s2)

    def accepts(
        self,
        rng: np.random.Generator,
        z1: np.ndarray,
        cs1: np.ndarray,
        z2: np.ndarray,
        cs2: np.ndarray,
    ) -> bool:
        """Both Rej1 gates (Lemma 2.14-1) on one attempt's responses.

        Both coins are drawn before either gate is evaluated, so the rng
        stream a given seed produces stays a function of the attempt count
        alone — and the gates are then free to short-circuit, which matters
        because the exact-integer arithmetic is the expensive half of an
        attempt and a `1 − 1/rep1` share of attempts are already lost at the
        first gate."""
        coin1, coin2 = _coin(rng), _coin(rng)
        return _rej1(coin1, z1, cs1, self.s1_std, self.rep1) and _rej1(
            coin2, z2, cs2, self.s2_std, self.rep2
        )

    def within_bounds(self, z1: np.ndarray, z2: np.ndarray) -> bool:
        """`‖z_i‖₂ ≤ s_i·√(2·m_i·d)` [Ban93], the verifier's norm checks."""
        return (
            norms.l2_squared(z1) <= self._bound1_sq
            and norms.l2_squared(z2) <= self._bound2_sq
        )

    def exhausted(self, protocol: str) -> RuntimeError:
        """The error a prover raises when its whole budget was rejected —
        one message, because the diagnosis is the same for every protocol
        that masks this way."""
        return RuntimeError(
            f"{protocol}: every attempt was rejected — {self.attempts} "
            f"attempts at acceptance ≈ 1/(rep1·rep2) fail together with "
            f"probability <= {self.fail_prob!r}, so suspect the parameters "
            f"(a repetition rate far from its Lemma 2.14-1 value), not bad "
            f"luck."
        )

    def require_witness(self, name: str, s1: np.ndarray, s2: np.ndarray) -> None:
        """The witness halves are the caller's, so a malformed one raises —
        `zorch/lnp/wire.py`'s statement side."""
        ring = self.scheme.ring
        wire.require_signed(ring, f"{name}: s1", s1, self.scheme.s1_cols)
        wire.require_signed(ring, f"{name}: s2", s2, self.scheme.randomness_cols)

    def is_response(self, c: np.ndarray, z1: np.ndarray, z2: np.ndarray) -> bool:
        """Whether an untrusted `(c, z1, z2)` triple is structurally usable —
        the shared half of every masked protocol's `_is_well_formed`."""
        ring = self.scheme.ring
        return (
            wire.is_signed(ring, c)
            and wire.is_signed(ring, z1, self.scheme.s1_cols)
            and wire.is_signed(ring, z2, self.scheme.randomness_cols)
        )

draw

draw(
    rng: np.random.Generator,
) -> tuple[np.ndarray, np.ndarray]

One masking pair (y1, y2) ~ (D_{s1}, D_{s2}), as signed integer (m_i, d) arrays.

Source code in zorch/lnp/masking.py
146
147
148
149
150
151
152
def draw(self, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
    """One masking pair `(y1, y2) ~ (D_{s1}, D_{s2})`, as signed integer
    `(m_i, d)` arrays."""
    return (
        self._draw1(rng, self._centers1, self.s1_std),
        self._draw2(rng, self._centers2, self.s2_std),
    )

challenge_from

challenge_from(
    transcript: ByteTranscript,
    label: bytes,
    *stacks: np.ndarray
) -> tuple[ByteTranscript, np.ndarray]

Absorb a protocol's first-round messages under its own label and squeeze the challenge — the one derivation both sides replay.

The label is the caller's because it is what separates two protocols that would otherwise hash the same stacks to the same challenge.

Source code in zorch/lnp/masking.py
154
155
156
157
158
159
160
161
162
163
164
def challenge_from(
    self, transcript: ByteTranscript, label: bytes, *stacks: np.ndarray
) -> tuple[ByteTranscript, np.ndarray]:
    """Absorb a protocol's first-round messages under its own `label` and
    squeeze the challenge — the one derivation both sides replay.

    The label is the caller's because it is what separates two protocols
    that would otherwise hash the same stacks to the same challenge."""
    t = absorb_stacks(transcript.observe_label(label), *stacks)
    t, raw = t.sample_scalar(self.challenge_bytes)
    return t, self.challenge.from_bytes(raw)

ajtai_image

ajtai_image(
    a1: np.ndarray,
    a2: np.ndarray,
    x1: np.ndarray,
    x2: np.ndarray,
) -> np.ndarray

A1·x1 + A2·x2, the Ajtai half of the commitment equation.

Both siblings send it as their first message at x = y and both rebuild it at x = z to check it, so it is one expression at four sites, not two protocols that happen to agree. Here rather than on AbdlopCommitment because what the layers pass is a masking or a response, not a witness — the scheme's own commit is the witness-shaped caller and keeps its bound checks.

Source code in zorch/lnp/masking.py
166
167
168
169
170
171
172
173
174
175
176
177
178
def ajtai_image(
    self, a1: np.ndarray, a2: np.ndarray, x1: np.ndarray, x2: np.ndarray
) -> np.ndarray:
    """`A1·x1 + A2·x2`, the Ajtai half of the commitment equation.

    Both siblings send it as their first message at `x = y` and both
    rebuild it at `x = z` to check it, so it is one expression at four
    sites, not two protocols that happen to agree. Here rather than on
    `AbdlopCommitment` because what the layers pass is a *masking* or a
    *response*, not a witness — the scheme's own `commit` is the
    witness-shaped caller and keeps its bound checks."""
    ring = self.scheme.ring
    return ring.add(ring.matvec(a1, x1), ring.matvec(a2, x2))

masked_message

masked_message(
    c: np.ndarray,
    b: np.ndarray,
    t_b: np.ndarray,
    z2: np.ndarray,
) -> np.ndarray

c·t_B − B·z2c·m for the message the BDLOP half commits to but never sends.

The only route either verifier has to m: Fig. 4 feeds it to the linear check, Fig. 6 lifts it as eq. 30's message half. Named once because the two must agree on it and a suite of either alone cannot see them drift.

Source code in zorch/lnp/masking.py
180
181
182
183
184
185
186
187
188
189
190
191
def masked_message(
    self, c: np.ndarray, b: np.ndarray, t_b: np.ndarray, z2: np.ndarray
) -> np.ndarray:
    """`c·t_B − B·z2` — `c·m` for the message the BDLOP half commits to
    but never sends.

    The only route either verifier has to `m`: Fig. 4 feeds it to the
    linear check, Fig. 6 lifts it as eq. 30's message half. Named once
    because the two must agree on it and a suite of either alone cannot
    see them drift."""
    ring = self.scheme.ring
    return ring.sub(ring.scale(c, t_b), ring.matvec(b, z2))

respond

respond(
    c: np.ndarray, s1: np.ndarray, s2: np.ndarray
) -> tuple[np.ndarray, np.ndarray]

c·s_i over the integers, one per witness half — the term both the response z_i = c·s_i + y_i and its Rej1 gate are stated in.

Source code in zorch/lnp/masking.py
193
194
195
196
197
198
def respond(
    self, c: np.ndarray, s1: np.ndarray, s2: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
    """`c·s_i` over the *integers*, one per witness half — the term both
    the response `z_i = c·s_i + y_i` and its Rej1 gate are stated in."""
    return _challenge_times(c, s1), _challenge_times(c, s2)

accepts

accepts(
    rng: np.random.Generator,
    z1: np.ndarray,
    cs1: np.ndarray,
    z2: np.ndarray,
    cs2: np.ndarray,
) -> bool

Both Rej1 gates (Lemma 2.14-1) on one attempt's responses.

Both coins are drawn before either gate is evaluated, so the rng stream a given seed produces stays a function of the attempt count alone — and the gates are then free to short-circuit, which matters because the exact-integer arithmetic is the expensive half of an attempt and a 1 − 1/rep1 share of attempts are already lost at the first gate.

Source code in zorch/lnp/masking.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def accepts(
    self,
    rng: np.random.Generator,
    z1: np.ndarray,
    cs1: np.ndarray,
    z2: np.ndarray,
    cs2: np.ndarray,
) -> bool:
    """Both Rej1 gates (Lemma 2.14-1) on one attempt's responses.

    Both coins are drawn before either gate is evaluated, so the rng
    stream a given seed produces stays a function of the attempt count
    alone — and the gates are then free to short-circuit, which matters
    because the exact-integer arithmetic is the expensive half of an
    attempt and a `1 − 1/rep1` share of attempts are already lost at the
    first gate."""
    coin1, coin2 = _coin(rng), _coin(rng)
    return _rej1(coin1, z1, cs1, self.s1_std, self.rep1) and _rej1(
        coin2, z2, cs2, self.s2_std, self.rep2
    )

within_bounds

within_bounds(z1: np.ndarray, z2: np.ndarray) -> bool

‖z_i‖₂ ≤ s_i·√(2·m_i·d) [Ban93], the verifier's norm checks.

Source code in zorch/lnp/masking.py
221
222
223
224
225
226
def within_bounds(self, z1: np.ndarray, z2: np.ndarray) -> bool:
    """`‖z_i‖₂ ≤ s_i·√(2·m_i·d)` [Ban93], the verifier's norm checks."""
    return (
        norms.l2_squared(z1) <= self._bound1_sq
        and norms.l2_squared(z2) <= self._bound2_sq
    )

exhausted

exhausted(protocol: str) -> RuntimeError

The error a prover raises when its whole budget was rejected — one message, because the diagnosis is the same for every protocol that masks this way.

Source code in zorch/lnp/masking.py
228
229
230
231
232
233
234
235
236
237
238
def exhausted(self, protocol: str) -> RuntimeError:
    """The error a prover raises when its whole budget was rejected —
    one message, because the diagnosis is the same for every protocol
    that masks this way."""
    return RuntimeError(
        f"{protocol}: every attempt was rejected — {self.attempts} "
        f"attempts at acceptance ≈ 1/(rep1·rep2) fail together with "
        f"probability <= {self.fail_prob!r}, so suspect the parameters "
        f"(a repetition rate far from its Lemma 2.14-1 value), not bad "
        f"luck."
    )

require_witness

require_witness(
    name: str, s1: np.ndarray, s2: np.ndarray
) -> None

The witness halves are the caller's, so a malformed one raises — zorch/lnp/wire.py's statement side.

Source code in zorch/lnp/masking.py
240
241
242
243
244
245
def require_witness(self, name: str, s1: np.ndarray, s2: np.ndarray) -> None:
    """The witness halves are the caller's, so a malformed one raises —
    `zorch/lnp/wire.py`'s statement side."""
    ring = self.scheme.ring
    wire.require_signed(ring, f"{name}: s1", s1, self.scheme.s1_cols)
    wire.require_signed(ring, f"{name}: s2", s2, self.scheme.randomness_cols)

is_response

is_response(
    c: np.ndarray, z1: np.ndarray, z2: np.ndarray
) -> bool

Whether an untrusted (c, z1, z2) triple is structurally usable — the shared half of every masked protocol's _is_well_formed.

Source code in zorch/lnp/masking.py
247
248
249
250
251
252
253
254
255
def is_response(self, c: np.ndarray, z1: np.ndarray, z2: np.ndarray) -> bool:
    """Whether an untrusted `(c, z1, z2)` triple is structurally usable —
    the shared half of every masked protocol's `_is_well_formed`."""
    ring = self.scheme.ring
    return (
        wire.is_signed(ring, c)
        and wire.is_signed(ring, z1, self.scheme.s1_cols)
        and wire.is_signed(ring, z2, self.scheme.randomness_cols)
    )

BimodalMasking

The parameter point Fig. 9's projection masks and rejects against.

Masking above is the masking of a witness: two Gaussians, one per ABDLOP half, gated by Rej1. This is the masking of a projection — a single Gaussian y ~ D_{s3}^{256/d} over the 256 integers R⃗s shrinks the witness to, gated by the bimodal Rej0 of Fig. 2. The two are not the same object and must not be one: they mask different vectors, at different standard deviations, under different rejection algorithms.

Why bimodal here and not there. The projection is masked as z = b·R⃗s + y for a secret sign b ∈ {−1, 1}, which makes z's distribution the average of two Gaussians centred at ±R⃗s. Rej0 (Lemma 2.14-3) accepts that average against M = exp(1/(2γ²)) rather than Rej1's exp(14/γ + 1/(2γ²)), so the same repetition rate is reached at a much smaller s3 — and s3 is what the revealed z's bit length, and therefore the proof size, is set by. The price is proving b really is a sign, which is why Fig. 9 hands the layer above a quadratic relation and d − 1 evaluations it would not otherwise need.

Rej1 cannot be swapped in as a "safer default": it is stated for a unimodal z = v + y and says nothing about this distribution.

The witness masking is not absorbed here even though both are rejection loops, because a rejected attempt at this layer redraws (b, y) and re-derives R, while a rejected attempt inside the proof below redraws that proof's own (y1, y2). One loop around both would throw away work the inner loop had already accepted.

Parameters arrive derived, as everywhere in this package: mask_std is the paper's s3 = γ·√337·β (the √337 is Lemma 2.8's projection growth, the γ the usual s = γ·T), rep0 its Lemma 2.14-3 rate, and accept_t the t ≥ 1.64 of Prop. 5.1 sizing the verifier's norm gate. Deriving them from a witness bound is the consumer's, since β is a property of the statement rather than of this seam.

Source code in zorch/lnp/masking.py
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
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
class BimodalMasking:
    """The parameter point Fig. 9's projection masks and rejects against.

    `Masking` above is the masking of a *witness*: two Gaussians, one per
    ABDLOP half, gated by Rej1. This is the masking of a *projection* — a
    single Gaussian `y ~ D_{s3}^{256/d}` over the 256 integers `R⃗s` shrinks
    the witness to, gated by the bimodal Rej0 of Fig. 2. The two are not
    the same object and must not be one: they mask different vectors, at
    different standard deviations, under different rejection algorithms.

    **Why bimodal here and not there.** The projection is masked as
    `z = b·R⃗s + y` for a secret sign `b ∈ {−1, 1}`, which makes `z`'s
    distribution the average of two Gaussians centred at `±R⃗s`. Rej0
    (Lemma 2.14-3) accepts that average against `M = exp(1/(2γ²))` rather
    than Rej1's `exp(14/γ + 1/(2γ²))`, so the same repetition rate is
    reached at a much smaller `s3` — and `s3` is what the revealed `z`'s bit
    length, and therefore the proof size, is set by. The price is proving
    `b` really is a sign, which is why Fig. 9 hands the layer above a
    quadratic relation and `d − 1` evaluations it would not otherwise need.

    Rej1 cannot be swapped in as a "safer default": it is stated for a
    unimodal `z = v + y` and says nothing about this distribution.

    The witness masking is *not* absorbed here even though both are
    rejection loops, because a rejected attempt at this layer redraws
    `(b, y)` and re-derives `R`, while a rejected attempt inside the proof
    below redraws that proof's own `(y1, y2)`. One loop around both would
    throw away work the inner loop had already accepted.

    Parameters arrive derived, as everywhere in this package: `mask_std` is
    the paper's `s3 = γ·√337·β` (the `√337` is Lemma 2.8's projection
    growth, the `γ` the usual `s = γ·T`), `rep0` its Lemma 2.14-3 rate, and
    `accept_t` the `t ≥ 1.64` of Prop. 5.1 sizing the verifier's norm gate.
    Deriving them from a witness bound is the consumer's, since `β` is a
    property of the statement rather than of this seam.
    """

    def __init__(
        self,
        ring: HostSplitRing,
        mask_std: float,
        rep0: float,
        projection: int = PROJECTION,
        accept_t: float = 1.64,
        fail_prob: float = 2.0**-128,
    ) -> None:
        if projection < 1:
            raise ValueError(
                f"masking: projection must be positive, got {projection!r}"
            )
        if projection % ring.d:
            raise ValueError(
                f"masking: the mask is a stack of ring elements, so the "
                f"projection dimension must be a multiple of the ring degree "
                f"{ring.d}; got {projection}"
            )
        for name, value in (("mask_std", mask_std), ("accept_t", accept_t)):
            if value <= 0.0:
                raise ValueError(f"masking: {name} must be positive, got {value!r}")
        if rep0 <= 1.0:
            raise ValueError(f"masking: rep0 must exceed 1, got {rep0!r}")
        if not 0.0 < fail_prob < 1.0:
            raise ValueError(f"masking: fail_prob must be in (0, 1), got {fail_prob!r}")
        self.ring = ring
        self.projection = projection
        self.mask_cols = projection // ring.d
        self.mask_std = mask_std
        self.rep0 = rep0
        self.accept_t = accept_t
        self.fail_prob = fail_prob
        self.attempts = attempt_budget(fail_prob, 1.0 / rep0)
        if self.attempts > _MAX_ATTEMPTS:
            raise ValueError(
                f"masking: rep0 = {rep0!r} implies an attempt budget of "
                f"{self.attempts} — a repetition rate this far from its "
                f"Lemma 2.14-3 value is a parameter bug"
            )
        # `‖z‖₂ ≤ t·√256·s3` (Prop. 5.1), compared squared over exact ints
        # so the reveal never round-trips through a float; the floor only
        # tightens the bound, as in `Masking`.
        self._bound_sq = math.floor((accept_t**2) * projection * mask_std**2)
        self._draw = sampler.sampler_for(mask_std, self.attempts * projection)
        self._centers = np.zeros((self.mask_cols, ring.d), dtype=np.float64)

    def draw(self, rng: np.random.Generator) -> tuple[int, np.ndarray]:
        """One attempt's `(b, y)`: a sign and a `(256/d, d)` Gaussian mask.

        Both are private coins off the caller's generator, never off the
        transcript — the sign especially, since a transcript-derived `b`
        would be public and the bimodal trick buys nothing.

        Drawn together because they are one attempt: a loop that redrew the
        mask while holding the sign would be sampling neither Rej0's
        distribution nor Rej1's."""
        sign = 1 if rng.integers(0, 2) else -1
        return sign, self._draw(rng, self._centers, self.mask_std)

    def accepts(self, rng: np.random.Generator, z: np.ndarray, v: np.ndarray) -> bool:
        """Rej0 (Fig. 2) on one attempt's revealed projection.

        `v` is the *signed* centre `b·R⃗s`, not `R⃗s` — Lemma 2.14-3 is
        stated for `z = y + (−1)^β v` and the gate reads `⟨z, v⟩` at that
        same `v`. `cosh` is even, so the two spellings agree here by luck
        rather than by contract; passing the centre the response was built
        from is what stays true when a later leg is not symmetric."""
        return _rej0(_coin(rng), z, v, self.mask_std, self.rep0)

    def within_bounds(self, z: np.ndarray) -> bool:
        """`‖z‖₂ ≤ t·√256·s3` (Prop. 5.1) — the verifier's gate on the
        revealed projection, and the only place the range statement's slack
        is actually enforced."""
        return norms.l2_squared(z) <= self._bound_sq

    def exhausted(self, protocol: str) -> RuntimeError:
        """The Rej0 twin of `Masking.exhausted`."""
        return RuntimeError(
            f"{protocol}: every attempt was rejected — {self.attempts} "
            f"attempts at acceptance ≈ 1/rep0 fail together with probability "
            f"<= {self.fail_prob!r}, so suspect the parameters (a repetition "
            f"rate far from its Lemma 2.14-3 value), not bad luck."
        )

draw

draw(rng: np.random.Generator) -> tuple[int, np.ndarray]

One attempt's (b, y): a sign and a (256/d, d) Gaussian mask.

Both are private coins off the caller's generator, never off the transcript — the sign especially, since a transcript-derived b would be public and the bimodal trick buys nothing.

Drawn together because they are one attempt: a loop that redrew the mask while holding the sign would be sampling neither Rej0's distribution nor Rej1's.

Source code in zorch/lnp/masking.py
388
389
390
391
392
393
394
395
396
397
398
399
def draw(self, rng: np.random.Generator) -> tuple[int, np.ndarray]:
    """One attempt's `(b, y)`: a sign and a `(256/d, d)` Gaussian mask.

    Both are private coins off the caller's generator, never off the
    transcript — the sign especially, since a transcript-derived `b`
    would be public and the bimodal trick buys nothing.

    Drawn together because they are one attempt: a loop that redrew the
    mask while holding the sign would be sampling neither Rej0's
    distribution nor Rej1's."""
    sign = 1 if rng.integers(0, 2) else -1
    return sign, self._draw(rng, self._centers, self.mask_std)

accepts

accepts(
    rng: np.random.Generator, z: np.ndarray, v: np.ndarray
) -> bool

Rej0 (Fig. 2) on one attempt's revealed projection.

v is the signed centre b·R⃗s, not R⃗s — Lemma 2.14-3 is stated for z = y + (−1)^β v and the gate reads ⟨z, v⟩ at that same v. cosh is even, so the two spellings agree here by luck rather than by contract; passing the centre the response was built from is what stays true when a later leg is not symmetric.

Source code in zorch/lnp/masking.py
401
402
403
404
405
406
407
408
409
def accepts(self, rng: np.random.Generator, z: np.ndarray, v: np.ndarray) -> bool:
    """Rej0 (Fig. 2) on one attempt's revealed projection.

    `v` is the *signed* centre `b·R⃗s`, not `R⃗s` — Lemma 2.14-3 is
    stated for `z = y + (−1)^β v` and the gate reads `⟨z, v⟩` at that
    same `v`. `cosh` is even, so the two spellings agree here by luck
    rather than by contract; passing the centre the response was built
    from is what stays true when a later leg is not symmetric."""
    return _rej0(_coin(rng), z, v, self.mask_std, self.rep0)

within_bounds

within_bounds(z: np.ndarray) -> bool

‖z‖₂ ≤ t·√256·s3 (Prop. 5.1) — the verifier's gate on the revealed projection, and the only place the range statement's slack is actually enforced.

Source code in zorch/lnp/masking.py
411
412
413
414
415
def within_bounds(self, z: np.ndarray) -> bool:
    """`‖z‖₂ ≤ t·√256·s3` (Prop. 5.1) — the verifier's gate on the
    revealed projection, and the only place the range statement's slack
    is actually enforced."""
    return norms.l2_squared(z) <= self._bound_sq

exhausted

exhausted(protocol: str) -> RuntimeError

The Rej0 twin of Masking.exhausted.

Source code in zorch/lnp/masking.py
417
418
419
420
421
422
423
424
def exhausted(self, protocol: str) -> RuntimeError:
    """The Rej0 twin of `Masking.exhausted`."""
    return RuntimeError(
        f"{protocol}: every attempt was rejected — {self.attempts} "
        f"attempts at acceptance ≈ 1/rep0 fail together with probability "
        f"<= {self.fail_prob!r}, so suspect the parameters (a repetition "
        f"rate far from its Lemma 2.14-3 value), not bad luck."
    )