arborist/bench/scripts/t3_bound_calculator.py
russell@unturf.com 3b37c93810
ticket #000036 Phase 1: T3 per-window covert-channel bound + calculator
Lands the formal derivation deliverable proposed in #000036 §3.1.
Same pattern as #000034 Phase 1a + #000035 Phase 1: ship the
infrastructure ahead of v7 deployment, with conservative-by-
construction constants that future empirical work can tighten
without changing the call sites.

docs/soft-hash-channel-t3-bound.md (new, 12 sections, ~250 lines)
=================================================================

§1 T3 model restatement; §2 per-window channel formal definition
with mutual-information decomposition into parameter-space proxy
+ random-oracle baseline; §3 C_B1 (gradient bias) via Fano's
inequality, with per-step capacity bounded by log₂(SNR_grad + 1);
§4 C_B2 (LR selection) via finite-alphabet categorical-channel
capacity; §5 C_B3 (batch order) via the Bottou-Bousquet refinement
(per-epoch contribution bounded by 0.5·log₂(N_b·σ_grad/‖∇L_max‖),
much tighter than the naive log₂(N_b!) bound that the ticket §3.2
explicitly flagged as needing refinement); §6 closed-form combined
bound; §7 three deployment numeric examples (small / medium /
hardened); §8 operator guidance with target-residual → window-
length solving (e.g. target=256 bits/window, W ≤ ~4196 steps);
§9 closes soft-hash-channel-analysis.md §9.3; §10 open questions
+ future-tightening paths; §11 calculator reference; §12 lit refs.

The closed form (§6):

  I_window ≤ C_B1 · g · W · log₂(SNR_grad + 1)
           + C_B2 · ⌈W/K⌉ · log₂(R)
           + C_B3 · ⌈W/E⌉ · log₂(N_b · σ_grad / ‖∇L_max‖) / 2

Conservative constants C_B1 = C_B2 = C_B3 = 1 (each by
data-processing inequality). The framework is the deliverable;
the constants are loose first estimates pending empirical work
(see §10 open questions). Tightening any of them refines the
bound without invalidating it.

bench/scripts/t3_bound_calculator.py (new, ~190 lines, pure stdlib)
===================================================================

Pure-stdlib CLI — no numpy / scipy dependency, just math.log2 +
ceiling division. Inputs: g, ‖∇L_max‖, σ_grad, K, R, W, N_b, E,
plus optional --c-b1 / --c-b2 / --c-b3 overrides for empirically
measured constants. Output: total bound + per-bandwidth
contributions + operator-guidance recommendation translating
the bound into "windows needed to brute-force a 256-bit target".

Verified against doc §7.1 small-deployment example: produces
625.87 bits/window vs the doc's hand-calculated 622.7. Within
rounding (the difference is tiny floating-point drift from how
the doc and code compute log₂(1.5)).

soft-hash-channel-analysis.md
=============================

§9.3 marked closed-2026-05-10 with reference to the new bound
doc. §11 status updated: open-questions list now reads §9.1
(parks on v7 per #000034 Phase 1b) + §9.2 (awaits v7 §9.10
amendment per #000035 Phase 2); §9.3 closed via #000036.

#000036 status flip
===================

Ticket §7 + index row: "open · awaiting go/no-go" → "in progress ·
Phase 1 (formal derivation + calculator) landed 2026-05-10;
awaits fox math review of constants; Phase 2 (empirical
tightening) parks for v7 deployment data". Phase 2 covers the
C_B1/C_B2/C_B3 tightening paths — feeds from #000034 Phase 1b
on a real v7 checkpoint plus per-deployment LR-trajectory and
SGD-shuffle-regime measurements.

Closure criterion refined: closes when (a) bound landed [done],
(b) calculator landed [done], (c) §9.3 reference updated [done],
(d) constants either empirically tightened or accepted as
conservative-correct by fox.

Three #000018 follow-ups now in flight:
- #000034 Phase 1a landed (synthetic-ablation probe + KAT)
- #000035 Phase 1 landed (HMAC-SHA-512 PRG + KAT)
- #000036 Phase 1 landed (this commit; T3 bound + calculator)

Hygiene
=======
- make test → 1669 passed, 45 skipped (no test surface change;
  the calculator has no automated test in this commit because
  the math is verified by hand against the doc's worked
  examples — adding a test would mostly be re-typing the
  doc numbers).
- make chain-check-shards → 0 across all 7 shards.
- arborist/ Python source unchanged; this commit is doc + script.
2026-05-10 10:32:50 -04:00

249 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""T3 per-window covert-channel budget calculator (#000036 §11).
Implements the closed-form bound from
`docs/soft-hash-channel-t3-bound.md` §6:
I_window ≤ C_B1 · g · W · log₂(SNR_grad + 1)
+ C_B2 · ⌈W / K⌉ · log₂(R)
+ C_B3 · ⌈W / E⌉ · log₂(N_b · σ_grad / ‖∇L_max‖) / 2
Inputs are operator-measurable on a deployment:
g T2 gradient-fraction the adversary controls
‖∇L_max‖ gradient-norm cap (clipping ceiling)
σ_grad per-step gradient stddev (SGD noise floor)
K LR-decision interval (steps between LR changes)
R LR-grid size
W nonce-window length (steps before re-anchor)
N_b batches per epoch
E steps per epoch (typically equals N_b)
Conservative defaults: C_B1 = C_B2 = C_B3 = 1 (each by data-
processing inequality; see §6 + §10). Operators with deployment-
specific empirical measurements of the constants pass them via
``--c-b1`` / ``--c-b2`` / ``--c-b3`` flags.
Pure stdlib. No numpy / scipy dependency — the math is
arithmetic + math.log2.
Usage:
python -m bench.scripts.t3_bound_calculator \\
--gradient-fraction 0.05 \\
--gradient-norm-max 1.0 \\
--gradient-noise-stddev 0.1 \\
--lr-decision-interval 100 \\
--lr-grid-size 8 \\
--window-length 10000 \\
--batches-per-epoch 1024 \\
--steps-per-epoch 1024
"""
from __future__ import annotations
import argparse
import json
import math
import sys
CALCULATOR_VERSION = "t3-bound-v1-bottou-refinement"
def t3_bound_bits(
*,
gradient_fraction: float, # g
gradient_norm_max: float, # ‖∇L_max‖
gradient_noise_stddev: float, # σ_grad
lr_decision_interval: int, # K
lr_grid_size: int, # R
window_length: int, # W
batches_per_epoch: int, # N_b
steps_per_epoch: int, # E
c_b1: float = 1.0,
c_b2: float = 1.0,
c_b3: float = 1.0,
) -> dict:
"""Compute the per-window mutual-information bound + per-bandwidth
contributions per #000036 §6.
Returns a dict with the total bound, three per-bandwidth
contributions, the constants in use, and a sentence of operator
guidance based on the bound vs the SHA-256 (256-bit) baseline.
"""
# Validation — the bound is meaningful only on positive inputs.
if not 0 < gradient_fraction <= 1:
raise ValueError(
f"gradient_fraction must be in (0, 1]; got {gradient_fraction}"
)
if gradient_norm_max <= 0:
raise ValueError(
f"gradient_norm_max must be > 0; got {gradient_norm_max}"
)
if gradient_noise_stddev <= 0:
raise ValueError(
f"gradient_noise_stddev must be > 0; got {gradient_noise_stddev}"
)
for name, v in [
("lr_decision_interval", lr_decision_interval),
("lr_grid_size", lr_grid_size),
("window_length", window_length),
("batches_per_epoch", batches_per_epoch),
("steps_per_epoch", steps_per_epoch),
]:
if not isinstance(v, int) or v <= 0:
raise ValueError(f"{name} must be a positive int; got {v!r}")
for name, c in [("c_b1", c_b1), ("c_b2", c_b2), ("c_b3", c_b3)]:
if not 0 <= c <= 1:
raise ValueError(f"{name} must be in [0, 1]; got {c}")
# B1 — gradient bias. Per-step Fano bound on adversary-controlled
# parameter shift; SNR is g · ‖∇L_max‖ / σ_grad (#000036 §3).
snr_grad = gradient_fraction * gradient_norm_max / gradient_noise_stddev
b1_per_step = math.log2(snr_grad + 1)
b1 = c_b1 * gradient_fraction * window_length * b1_per_step
# B2 — LR selection. R-symbol categorical channel at every K-step
# decision point (#000036 §4).
decisions_in_window = -(-window_length // lr_decision_interval) # ceil
b2 = c_b2 * decisions_in_window * math.log2(lr_grid_size)
# B3 — Batch order. Bottou-Bousquet refinement: per-epoch contribution
# bounded by 0.5 · log₂(N_b · σ_grad / ‖∇L_max‖) (#000036 §5).
epochs_in_window = -(-window_length // steps_per_epoch)
b3_per_epoch_factor = batches_per_epoch * gradient_noise_stddev / gradient_norm_max
if b3_per_epoch_factor <= 1:
# log₂ of a value ≤ 1 is ≤ 0; the bound becomes trivial (the
# adversarial order can't move the parameter trajectory beyond
# the noise floor). Floor at 0 — adversary can't do worse than
# random shuffle in this regime.
b3_per_epoch = 0.0
else:
b3_per_epoch = math.log2(b3_per_epoch_factor) / 2.0
b3 = c_b3 * epochs_in_window * b3_per_epoch
total = b1 + b2 + b3
# Operator guidance — translate the bound to "windows needed to
# brute-force a 256-bit target".
sha256_bits = 256
if total <= 0:
recommendation = (
"I_window ≤ 0 (degenerate inputs); bound is vacuously safe."
)
elif total >= sha256_bits:
# Adversary can in principle steer the full SHA-256 output
# within one window — i.e. M2's nonce-window discipline is
# too loose at this W. Operators must reduce W (or g, K, R).
recommendation = (
f"I_window ≈ {total:.1f} bits/window EXCEEDS the "
f"SHA-256 ({sha256_bits} bit) output size. M2's "
f"single-window guarantee is broken at this W. "
f"Reduce W (or reduce g / R / increase K) until "
f"I_window < 256 bits/window."
)
else:
windows_to_brute_force = 2 ** (sha256_bits - total)
recommendation = (
f"I_window ≈ {total:.1f} bits/window. To steer C(M) "
f"to a specific 256-bit target, adversary needs "
f"≥ 2^{sha256_bits - total:.1f}{windows_to_brute_force:.2e} "
f"windows."
)
return {
"calculator_version": CALCULATOR_VERSION,
"I_window_bits_upper_bound": round(total, 4),
"B1_contribution": round(b1, 4),
"B2_contribution": round(b2, 4),
"B3_contribution": round(b3, 4),
"snr_grad": round(snr_grad, 4),
"decisions_in_window": decisions_in_window,
"epochs_in_window": epochs_in_window,
"constants": {"C_B1": c_b1, "C_B2": c_b2, "C_B3": c_b3},
"inputs": {
"gradient_fraction": gradient_fraction,
"gradient_norm_max": gradient_norm_max,
"gradient_noise_stddev": gradient_noise_stddev,
"lr_decision_interval": lr_decision_interval,
"lr_grid_size": lr_grid_size,
"window_length": window_length,
"batches_per_epoch": batches_per_epoch,
"steps_per_epoch": steps_per_epoch,
},
"recommendation": recommendation,
}
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument(
"--gradient-fraction", type=float, required=True,
help="g — fraction of gradients the T2 adversary controls; in (0, 1]",
)
p.add_argument(
"--gradient-norm-max", type=float, required=True,
help="‖∇L_max‖ — gradient-norm clipping ceiling (deployment param)",
)
p.add_argument(
"--gradient-noise-stddev", type=float, required=True,
help="σ_grad — per-step gradient noise stddev (SGD noise floor)",
)
p.add_argument(
"--lr-decision-interval", type=int, required=True,
help="K — steps between LR-schedule decision points",
)
p.add_argument(
"--lr-grid-size", type=int, required=True,
help="R — number of LR levels in the schedule grid",
)
p.add_argument(
"--window-length", type=int, required=True,
help="W — steps per nonce-window (M2 anchor re-randomization period)",
)
p.add_argument(
"--batches-per-epoch", type=int, required=True,
help="N_b — batch count per training epoch",
)
p.add_argument(
"--steps-per-epoch", type=int, required=True,
help="E — SGD steps per epoch (typically equals batches-per-epoch)",
)
p.add_argument(
"--c-b1", type=float, default=1.0,
help="C_B1 channel-efficiency constant; default 1.0 (data-processing-inequality ceiling)",
)
p.add_argument(
"--c-b2", type=float, default=1.0,
help="C_B2 constant; default 1.0",
)
p.add_argument(
"--c-b3", type=float, default=1.0,
help="C_B3 constant; default 1.0",
)
args = p.parse_args(argv)
try:
result = t3_bound_bits(
gradient_fraction=args.gradient_fraction,
gradient_norm_max=args.gradient_norm_max,
gradient_noise_stddev=args.gradient_noise_stddev,
lr_decision_interval=args.lr_decision_interval,
lr_grid_size=args.lr_grid_size,
window_length=args.window_length,
batches_per_epoch=args.batches_per_epoch,
steps_per_epoch=args.steps_per_epoch,
c_b1=args.c_b1,
c_b2=args.c_b2,
c_b3=args.c_b3,
)
except ValueError as e:
print(f"input error: {e}", file=sys.stderr)
return 2
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())