Answer the fork-loss handoff: the section's residual is ~17x overstated, and it misses the real bias

Settles E1-E4 and E6 of handoff-fork-loss-validation.md against spec d6fd7648.

E1 costs nothing and reframes everything: analysis-block-times-blend-network.md
sets blending_delay as a FIXED per-hop dwell (the 3d+5 max-delay arithmetic
gives 14 s at d=3 and 11 s at d=2, matching its prose), not a mean or a bound.
The simulator's Uniform(0, delta_max) matches a 2 s dwell in the mean at
delta_max = 4, so D_vis = 8 s and rho = 0.27 -- inside the committed 40-replicate
paired design band, which answers C1-C3 from data of record.

C1 refuted: every U>=1 cell sits at 0.9985-0.9997, not 0.986. C2's mechanism is
right but its size is ~17x over: the paired first-fork cost is 0.08 pp pooled
(95% CI [0.03, 0.13], t = 3.08), resolved only because the arms share streams --
the U=0 negative control is exactly 0.00000 +- 0.00000. C3 is refuted in the
UNFAVOURABLE direction: the no-uncle loss is 33% at N=1000 and 34.6% at N=5000
(42% / 49.5% at delta_max = 8), so the section understates what uncles buy by
about half. C4 stands with ~7x margin (U=3 still recovers at rho = 1.87). C5 is
right in effect, wrong in wording -- the knee is at W_abs ~ 5, so the spec's 10
is ~2x above it, which is "has margin", not "never binds".

C6 is the section's real omission. The deployed estimator quantises the target
rate at PRECISION = 1e3, and measured in the full dynamics that reads
1.01026 +- 0.00056 against a closed form of 1.0101 -- a 1.0% bias ~13x the
first-fork cost the section is concerned with, opposite in sign, removed by a
one-constant change. It could not be measured before because PRECISION was a
module constant pinned at the RECOMMENDED 1e6; f_precision is now a config
field, appended to the RNG key only when non-default so no committed run moves.

Also from the guide: uncle_window_slots now floors rather than rounds, matching
w_u := floor(W/f) (identical at the defaults; matters only for the W and f
sweeps).

Reviewed sec 4.3's argument as sec 6 asks, and it holds -- inclusion stayed soft
("may reference fewer uncles than it could ... and its block remains valid"), so
row 10, the anti-mandate argument and the suppress adversary are all unaffected;
only the CONTENT of a reference became validity-gated. One correction: the
"no incentive to deviate" clause does still exist, so sec 8.5's implication (ii)
is live, not moot.

E5 -- the jitter diagnostic, and the only experiment that could invalidate the
report rather than the section -- is not run and is flagged as such.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcin Pawlowski 2026-08-06 18:45:00 +02:00
parent f342fd4f45
commit e5a89004f2
No known key found for this signature in database
9 changed files with 299 additions and 20 deletions

View File

@ -0,0 +1,96 @@
# Results — fork-loss claims in `analysis-total-stake-inference.md`
*Answers to [`handoff-fork-loss-validation.md`](handoff-fork-loss-validation.md). Simulator: `tools/simulators/tsi/tsi-sim-pernode`. Run 2026-08-06 against spec revision `d6fd7648` (`analysis-fork-loss-in-tsi`).*
---
## Verdict
**The added section's headline is wrong by ~10×, and it is agonising over the wrong bias.** The residual it attributes to deep forks is 0.08 pp, not 1.4 %. Meanwhile the deployed estimator carries a **+1.0 % bias of the opposite sign** that the section does not mention — from the `f` rounding, not from forks. Rewriting the section around the second number rather than the first is the substantive change.
The no-uncle loss is also **understated, not overstated**: 33 % at the operating point, not 18.7 %, and it deepens with network size.
## The requested table
```
E1 spec Blend profile: δ_max = 4 D_vis = 8 s ρ = 0.27
E2 at ρ = 0.27, N = 1000, 40 replicates, k = 2160, PAIRED
D̂/D U=0: 0.6677 ± 0.0044 U=1: 0.9985 U=2: 0.9986 U=4: 0.9997
countable ceiling, pooled U≥1: 0.00080 ± 0.00026 (t = 3.08)
U=0 negative control: 0.00000 ± 0.00000 (exact — paired streams)
at N = 5000, 12 replicates, UNPAIRED
D̂/D U=0: 0.6537 ± 0.0251 U=1: 0.9997 U=2: 0.9979 U=4: 0.9996
U=0 negative-control noise floor: ±0.027 → no first-fork cost resolvable at this N
E3 largest ρ with U=4 ≥ 0.98: > 1.87 (U=3 already suffices there) margin: ~7× the deployment
E4 D̂/D vs W_abs {1,2,3,5,7,10,15,20}:
0.854 / 0.938 / 0.967 / 0.992 / 0.999 / 0.998 / 0.999 / 1.000
knee at W_abs ≈ 5; spec's W = 10 sits ~2× above it
E5 not yet run (the jitter diagnostic — see "What is still open")
E6 fixed_point off / 1e3 / 1e6: 0.99997 / 1.01026 / 0.99990 (predicted 1.000 / 1.0101 / 1.00001)
```
## Claim by claim
| # | Claim | Measured | Verdict |
|---|---|---|---|
| C1 | Residual underestimate **1.4 %** | 0.030.15 % at every `U ≥ 1` cell | **Refuted.** Replace with "no residual resolvable in the design regime". |
| C2 | The residual is **deep-fork blocks** | Paired gap **0.08 pp ± 0.03** pooled (t = 3.08) | **Mechanism right, size ~17× overstated.** It is real and now resolved — but it is 0.0008, not 0.014. |
| C3 | Without uncles the loss is **18.7 %** | **33.2 %** at N = 1000, **34.6 %** at N = 5000 (and 42 % / 49.5 % at δ_max = 8) | **Refuted, in the unfavourable direction.** The section understates the value of uncle references by roughly half. |
| C4 | `MAX_UNCLES = 4` never binds | `U = 3` still recovers at ρ = 1.87, the largest measured | **Corroborated,** with ~7× margin over the deployment's ρ = 0.27. |
| C5 | `w_u = 300` never binds | Knee at `W_abs ≈ 5`; spec's 10 is ~2× above it | **Right in effect, wrong in wording.** Say "≈ 2× above the measured knee at the deployment's load", not "never binds". |
| C6 | *(not mentioned)* | `PRECISION = 1e3`**D̂/D = 1.01026 ± 0.00056** | **The section's main omission.** Larger than everything else it discusses, and opposite in sign. |
### E1 — the operating point
`analysis-block-times-blend-network.md` sets `blending_delay = 3` s as *"seconds spent in each Blend node"* — a **fixed per-hop dwell**, neither a mean of a distribution nor a bound. The max-delay arithmetic confirms it: `3δ + 5` gives 14 s at δ = 3 and 11 s at δ = 2, matching the prose exactly. The chosen profile is 2 s.
The simulator's per-hop delay is `Uniform(0, δ_max)`, mean `δ_max/2`, and the design laws reach latency only through the mean — so the matching value is **`δ_max = 4`**, giving `D_vis = 3·2 + 4·0.5 = 8 s` and **`ρ = 0.27`**. (Charging the spec's 1 s PoL proof time as well would give ρ ≈ 0.30; nothing below changes.)
This lands inside the committed 40-replicate paired design-band grid, so C1C3 are answered from data of record rather than new runs.
### E2 — accuracy and the first-fork cost
At ρ = 0.27 every `U ≥ 1` cell sits at **0.99850.9997**. The section's 1.4 % residual is not there. What *is* there, now that the paired design resolves it: a first-fork cost of **0.08 pp pooled** (95 % CI [0.03, 0.13], t = 3.08). C2's mechanism is correct — it is the deep-fork blocks — but at the deployment's load it is two orders of magnitude below the claim.
The `U = 0` negative control is **exactly 0.00000 ± 0.00000**: with no uncle slots the two models are identical by construction, and under common random numbers they agree bit-for-bit. That is the strongest available check that the 0.08 pp is signal rather than seed noise.
At N = 5000 the runs are unpaired and the same negative control reads ±0.027, so nothing below ~2.7 pp is resolvable there — the N = 5000 row is an exclusion bound, not a measurement.
### E6 — the bias the section missed
Three arms, identical but for the estimator's target-rate quantisation:
| arm | `D̂/D` | closed form |
|---|---|---|
| exact `f` (report convention) | 0.99997 ± 0.00060 | 1.00000 |
| **spec today, `PRECISION = 1e3`** | **1.01026 ± 0.00056** | 1.01010 |
| recommended, `PRECISION = 1e6` | 0.99990 ± 0.00061 | 1.00001 |
The deployed estimator drives density to `f_p = 0.033` instead of `1/30`, so the chain reads **1.0 % high** — measured in the full per-node dynamics, matching `theory.fixed_point_bias` to within one standard error. It is ~13× the first-fork cost the section is concerned with, opposite in sign, and removed entirely by a one-constant change.
## What is still open
**E5 — the diagnostic — has not been run.** It is the only experiment that could invalidate the *report* rather than the section, and the primary hypothesis for the original discrepancy: the standalone simulation drew an independent delay per (block, recipient), while the simulator's Blend cascade floods from the last relay, so views stay synchronised. If real Blend carries more per-recipient variance, the design-regime numbers above are optimistic. The lever exists (`jitter_mean`) and this is also the report's open item 15.
Until E5 runs, every number here carries the cascade model's assumption that nodes receive a block at nearly the same time.
## Notes on the guide (§4)
- **§4.1 item 1 — done.** `f_precision` is now a config field, so a spec-faithful arm can be run; it was a module constant pinned at the *recommended* 1e6, which is why nobody had measured what the chain would actually read. Default unchanged and appended to the RNG key only when non-default, so no committed run is reseeded.
- **§4.1 item 3 — done.** `uncle_window_slots` now floors rather than rounds, matching `w_u := ⌊W·f⁻¹⌋`. No committed result moves (W = 10, f = 1/30 is exactly 300 either way); it matters only for the `W` and `f` sweeps.
- **§4.1 item 2 — agreed, and the report needs it.** `--old` is no longer a candidate design: a block carrying a deep-fork reference is now *rejected*, so the unrestricted arm is an unreachable ceiling. This document labels it that way throughout; the report still calls it "the unrestricted model" in §2.1/§3.2/§6.6 and needs the same relabel.
### §6's review request — the incentive results survive
The handoff asks for §4.3's argument to be checked by someone who owns the incentive analysis rather than assumed. Checked, and it holds, for the reason given: **inclusion stayed soft.** The spec is explicit — a proposer *"may reference fewer uncles than it could, or pass over a candidate for another, and its block remains valid"*, and selection is *"a recommendation for filling the entries well, not a consensus rule."* So:
- §8.1 row 10 (*soft — never a validity rule*) is still satisfied: what became validity-gated is the **content** of a reference, not whether one is made.
- §6.8's argument against a hard inclusion mandate still applies to a rule the spec did not adopt.
- The `suppress` adversary — produce blocks, reference nothing — remains legal, so §6.3's results stand unchanged.
- The countable model's selection-time filter is now *exactly* what the protocol requires rather than a faithful approximation of it.
One correction to §4.3: it states the *"no incentive to deviate"* sentence "no longer exists in that form". It does — the clause survives verbatim inside a rewritten paragraph. **§8.5's implication (ii) is therefore still live:** the spec's no-deviation rationale rests on uncles granting no reward, and the report's recommendation to pay them removes it.
The one genuinely new consequence: referencing an ineligible orphan now costs a proposer its whole block rather than merely failing to count. No modelled strategy does this, so no result changes — but it makes such a strategy self-defeating rather than merely ineffective, which is worth stating if anyone later models junk-reference griefing.

View File

@ -0,0 +1,34 @@
# Handoff E2, the size arm: does the no-uncle loss deepen with N at the SPEC's operating point?
#
# The design-band study (fine-delay-paired.yaml) already settles the countable-vs-unrestricted
# question at delta_max = 4 with 40 replicates, but only at N = 1000. Claim C3 of the handoff
# ("without uncles the loss is 18.7 %") is the U = 0 column, and the report shows that column is
# N-dependent (0.739 -> 0.635 over N = 1k -> 10k) at delta_max = 8. This measures it at the
# deployment's own delay instead, where nothing has been run at N > 1000.
#
# delta_max = 4 is the E1 answer: the spec's blending_delay is a FIXED 2 s per-hop dwell, and the
# simulator's Uniform(0, delta_max) matches it in the mean at 4. D_vis ~ 8 s, rho ~ 0.27.
#
# Run TWICE — default (countable) and with --old — so the first-fork gap is measured at this size
# too. `--old` is NOT a candidate design: under the current spec a block carrying a deep-fork
# reference is REJECTED, so the unrestricted arm is an unreachable upper bound on what any
# counting rule could recover. It is kept as exactly that ceiling.
# Latency is in SLOTS (1 slot = 1 s).
n_nodes: [5000] # the size arm; N = 1000 is already committed
stake_dist: [pareto] # heavy-tailed (realistic) stake distribution
topology: [blend] # Blend mixnet — the deployment transport
degree: [6] # peering degree of the d-regular graph
link_latency_mean: [0.5] # natural geographic transport (sub-slot)
link_latency_dist: [geo] # real-world geographic band mixture
blend_hops: [3] # the spec's Blend cascade length
blend_delay_max: [4.0] # E1: the spec's 2 s dwell, matched in the mean
max_uncles: [0, 1, 2, 4] # 0 = the C3 column; 4 = the spec's MAX_UNCLES
uncle_strategy: [oldest] # spec Uncle Selection
init_dest: [common] # per-node initial D_est from agreement
replicates: 12 # N = 5000 at true k is heavy; 12 gives ~±0.2 pp
base: # per-run settings shared by every cell
k: 2160 # true security parameter
epochs: 20 # equilibrium within ~2 epochs; burn 50%
f: 0.03333333333333333 # slot activation coefficient (1/30)
genesis_d_factor: 0.5 # start near true stake (cheap epoch 0)
early_stop: true

View File

@ -0,0 +1,34 @@
# Handoff E4: is w_u = 300 adequate at the SPEC's operating point, and how much margin is there?
#
# Claim C5 says the window "never binds" (observed median reference lag 34 slots, max 197). The
# report puts a hard recovery floor at ~7/f = 210 slots, set by block SPACING rather than by
# delay, and advises widening to 450-600 near rho ~ 1. "Has margin at rho < 1" is defensible;
# "never binds" is not, and the difference is what this measures.
#
# absorption-window.yaml sweeps the same axis but starts at delta_max = 8; nothing has been run at
# the deployment's delta_max = 4 (E1). Since the floor is set by block spacing the knee should sit
# in the same place, which is itself worth confirming rather than assuming — the report's
# delay-invariance claim is exactly what makes a single recommended W defensible.
#
# U = 1 isolates the window: with more uncle slots the queue drains faster and the window stops
# being the binding constraint, which would mask the knee.
# Latency is in SLOTS (1 slot = 1 s).
n_nodes: [1000] # network size
stake_dist: [pareto] # heavy-tailed (realistic) stake distribution
topology: [blend] # Blend mixnet — the deployment transport
degree: [6] # peering degree of the d-regular graph
link_latency_mean: [0.5] # natural geographic transport (sub-slot)
link_latency_dist: [geo] # real-world geographic band mixture
blend_hops: [3] # the spec's Blend cascade length
blend_delay_max: [4.0] # E1: the spec's operating point
window_absorption: [1, 2, 3, 5, 7, 10, 15, 20] # W in expected block-intervals; w_u = W/f slots
max_uncles: [1] # FIXED at one uncle: the question is about W
uncle_strategy: [oldest] # spec Uncle Selection
init_dest: [common] # per-node initial D_est from agreement
replicates: 12 # enough to place the knee, not to resolve 0.1 pp
base: # per-run settings shared by every cell
k: 2160 # true security parameter
epochs: 20 # equilibrium within ~2 epochs; burn 50%
f: 0.03333333333333333 # slot activation coefficient (1/30)
genesis_d_factor: 0.5 # start near true stake (cheap epoch 0)
early_stop: true

View File

@ -0,0 +1,89 @@
"""What the DEPLOYED chain would read at the spec's own operating point — handoff E1/E2/E6.
The report measures the mechanism: it drives the estimator to exact `f`, so its numbers isolate
fork loss from every other effect. That is the right default for design questions and the wrong
one for "what will the deployed chain read", because the spec's estimator quantises the target
rate `cryptarchia-total-stake-inference.md` carries `const PRECISION: u64 = 1e3`, so
`f_p = 0.033` at `f = 1/30` and the recursion drives density to a target ~1 % below `f`.
E1 pins the operating point from `analysis-block-times-blend-network.md`: `blending_delay` is a
FIXED per-hop dwell of 2 s (the `3d+5` max-delay arithmetic gives 11 s at d=2 and 14 s at d=3,
matching the prose), so the simulator's `Uniform(0, delta_max)` matches it in the mean at
`delta_max = 4` -> `D_vis ~ 8 s`, `rho ~ 0.27`.
Three arms, everything else identical:
exact f fixed_point=False the report's convention -> expect 1.000
spec fixed_point=True, 1e3 what the chain does today -> expect ~1.010
proposed fixed_point=True, 1e6 the report's recommendation -> expect ~1.00001
The point of running rather than quoting `theory.fixed_point_bias`: the closed form predicts the
offset in isolation, and this confirms it survives the full per-node dynamics at the deployment's
actual load, alongside the fork loss rather than instead of it.
Run: python scripts/spec_point.py (writes runs/spec_point.parquet)
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
from joblib import Parallel, delayed
from tsi_sim.config import SimConfig
from tsi_sim.engine import run_trajectory
from tsi_sim.theory import fixed_point_bias
HERE = Path(__file__).resolve().parent.parent
RUNS = HERE / "runs"
RUNS.mkdir(exist_ok=True)
EPOCHS = 20
REPS = 20
N_JOBS = 6
# The spec's operating point (E1), with the spec's own MAX_UNCLES rather than the report's U = 2.
SPEC_POINT = dict(n_nodes=1000, stake_dist="pareto", topology="blend", degree=6,
link_latency_mean=0.5, link_latency_dist="geo", blend_hops=3,
blend_delay_max=4.0, max_uncles=4, uncle_strategy="oldest",
window_absorption=10.0, k=2160, epochs=EPOCHS,
genesis_d_factor=0.5, early_stop=True)
ARMS = [("exact f (report convention)", False, 1_000_000),
("spec today (PRECISION = 1e3)", True, 1_000),
("recommended (PRECISION = 1e6)", True, 1_000_000)]
def _cell(label: str, fixed_point: bool, precision: int, rep: int) -> dict:
cfg = SimConfig(**SPEC_POINT, fixed_point=fixed_point, f_precision=precision, replicate=rep)
t = pd.DataFrame(run_trajectory(cfg))
t = t[t.epoch >= t.epoch.max() // 2]
return dict(arm=label, fixed_point=fixed_point, f_precision=precision, rep=rep,
mean_ratio=float(t.mean_ratio.mean()),
fork_rate=float(t.fork_rate.mean()),
p_ref=float(t.p_ref.mean()),
range_ratio=float(t.range_ratio.max()))
def main() -> None:
print("=== the spec's operating point: delta_max = 4, D_vis ~ 8 s, rho ~ 0.27, U = 4 ===")
jobs = [(lab, fp, pr, r) for lab, fp, pr in ARMS for r in range(REPS)]
df = pd.DataFrame(Parallel(n_jobs=N_JOBS, backend="loky", inner_max_num_threads=1)(
delayed(_cell)(lab, fp, pr, r) for lab, fp, pr, r in jobs))
df.to_parquet(RUNS / "spec_point.parquet", index=False)
f = SimConfig(**SPEC_POINT).f
print(f"\n{'arm':>32} {'D-hat/D':>18} {'predicted':>10} {'consensus':>10}")
for lab, fp, pr in ARMS:
g = df[df.arm == lab]
pred = fixed_point_bias(f, pr) if fp else 1.0
print(f"{lab:>32} {g.mean_ratio.mean():10.5f}+-{g.mean_ratio.sem():.5f} "
f"{pred:10.5f} {('exact' if g.range_ratio.max() == 0 else 'SPREAD'):>10}")
print(f"\nfork rate {df.fork_rate.mean():.3f}, p_ref {df.p_ref.mean():.4f}, "
f"{REPS} replicates, k = 2160")
print(f"wrote {RUNS}/spec_point.parquet")
if __name__ == "__main__":
main()

View File

@ -163,11 +163,15 @@ class SimConfig:
genesis_d_factor: float = 0.5 # genesis D = factor * true total stake
epochs: int = 40
# If True, quantise the target rate the way an on-chain integer estimator does:
# f_p = int(f*tsi.PRECISION)/tsi.PRECISION. With tsi.PRECISION = 1_000_000 (the report's
# recommended 10^-6 f-precision, §8) this gives f_p = 0.033333 and a negligible residual
# f/f_p < 1e-5 — not the ~1% overestimate the old 10^-3 truncation produced.
# Default False keeps the analysis-faithful exact-f behaviour.
# f_p = int(f*f_precision)/f_precision. Default False keeps the analysis-faithful exact-f
# behaviour, which is right for DESIGN questions (it isolates the mechanism under test).
fixed_point: bool = False
# The on-chain fixed-point scale, read only when fixed_point is True. The deployed spec and
# this report disagree: cryptarchia-total-stake-inference.md carries `const PRECISION = 1e3`
# (f_p = 0.033 at f = 1/30, so f/f_p ~ 1.010 — the ~1 % over-estimate of Appendix A), while
# the report recommends 1e6 (f_p = 0.033333, residual < 1e-5). Default is the recommendation;
# set 1000 for a SPEC-FAITHFUL arm answering "what would the deployed chain read".
f_precision: int = 1_000_000
# If True, count uncle references per BLOCK ID (the pre-fix behaviour, which double-counts
# same-slot co-winners and inflates the equilibrium by c(f)). The correct default counts
# per SLOT (one count per slot, matching the pre-uncle design invariant). Kept as a flag
@ -272,6 +276,8 @@ class SimConfig:
raise ValueError(f"churn_period must be >= 1, got {self.churn_period}")
if self.clock_skew_max < 0:
raise ValueError(f"clock_skew_max must be >= 0, got {self.clock_skew_max}")
if self.f_precision < 1 or self.f_precision != int(self.f_precision):
raise ValueError(f"f_precision must be a positive integer, got {self.f_precision!r}")
if self.adversary_selection not in ("random", "whale"):
raise ValueError(f"adversary_selection must be random|whale, got "
f"{self.adversary_selection!r}")
@ -386,7 +392,11 @@ class SimConfig:
else self._base_key() + (self.uncle_model, self.window_absorption))
# Appended ONLY when non-default, for the same reason the uncle_model marker is: a
# "random"-coalition run's key must stay byte-identical to every historical run's.
return base if self.adversary_selection == "random" else base + (self.adversary_selection,)
if self.adversary_selection != "random":
base = base + (self.adversary_selection,)
# Same append-only-when-non-default discipline: a run at the default precision keeps a
# byte-identical key, so no committed result is reseeded by adding the knob.
return base if self.f_precision == 1_000_000 else base + (self.f_precision,)
def seed_key(self) -> tuple:
"""The identity the RNG root is actually derived from (see ``rng.seedseq_for``).

View File

@ -24,8 +24,15 @@ W_ABS_MAX_FACTOR = 0.6 # bound: W <= W_ABS_MAX_FACTOR * k
def uncle_window_slots(w_abs: float, f: float = F) -> int:
"""Derived uncle reference window ``w_u = W / f`` in slots (countable model)."""
return max(1, int(round(w_abs / f)))
"""Derived uncle reference window ``w_u = floor(W / f)`` in slots (countable model).
The spec writes this as a **floor** (``w_u := W·f¹``, cryptarchia-v1-protocol.md
Constants). At the defaults (W = 10, f = 1/30) the quotient is exactly 300 either way, so no
committed result moves but ``configs/absorption-window.yaml`` sweeps ``W`` and
``configs/block-rate.yaml`` sweeps ``f``, and at a non-integer ``W/f`` rounding would put the
derived window a slot above the spec's.
"""
return max(1, int(w_abs / f))
# --- Real-world inter-node network latency (per gossip link) ---------------

View File

@ -109,7 +109,8 @@ def simulate_epoch(
w=config.effective_uncle_window)
n_active_window = int((active_slots < T).sum())
d_next = tsi.update_D_vec(d_est, ms.m, T, f, config.beta, config.fixed_point)
d_next = tsi.update_D_vec(d_est, ms.m, T, f, config.beta, config.fixed_point,
config.f_precision)
attribution = coalition_mask if coalition_mask is not None else adversary_mask
adv_blocks, honest_blocks = _canonical_producer_split(tree, A, attribution, T, E)

View File

@ -21,6 +21,7 @@ _CONFIG_FIELDS = (
"paired_streams",
"f", "beta", "k", "genesis_d_factor", "epochs", "fixed_point", "legacy_block_count",
"replicate",
"f_precision",
"adversary_frac", "adversary_strategy", "adversary_selection", "adversary_period",
"adversary_withhold_epochs",
)

View File

@ -89,34 +89,40 @@ def density_m(tree: BlockTree, canonical_ids: list[int], T: int,
PRECISION = 1_000_000
def _f_eff(f: float, fixed_point: bool) -> float:
def _f_eff(f: float, fixed_point: bool, precision: int | None = None) -> float:
"""Target rate used in the recursion: exact ``f``, or the spec's integer quantisation.
Guards the quantised path against ``f`` so small that ``int(f*PRECISION) == 0`` (e.g. f < .001),
``precision`` defaults to :data:`PRECISION`. It is a *parameter* because the deployed spec and
this report's recommendation disagree on it — ``cryptarchia-total-stake-inference.md`` carries
``const PRECISION: u64 = 1e3`` while the report recommends 1e6 so a spec-faithful arm has to
be able to run the value the chain would actually use.
Guards the quantised path against ``f`` so small that ``int(f*precision) == 0`` (e.g. f < .001),
which would make ``f_eff = 0`` and divide-by-zero in the recursion.
"""
if not fixed_point:
return f
q = int(f * PRECISION)
p = PRECISION if precision is None else int(precision)
q = int(f * p)
if q == 0:
raise ValueError(
f"fixed_point=True with f={f} quantises the target rate to 0 "
f"(int(f*{PRECISION})==0); use f >= 1/{PRECISION} or fixed_point=False")
return q / PRECISION
f"(int(f*{p})==0); use f >= 1/{p} or fixed_point=False")
return q / p
def update_D(
d_prev: float, m: int, T: int, f: float, beta: float, fixed_point: bool = False
d_prev: float, m: int, T: int, f: float, beta: float, fixed_point: bool = False,
precision: int | None = None,
) -> float:
"""Spec TSI recursion: ``max(1, D_prev * (1 - beta*(f_eff - m/T)/f_eff))``.
With ``fixed_point=True`` the target rate ``f`` is quantised as the on-chain algorithm does
(``f_p = int(f*PRECISION)/PRECISION``). At the raised ``PRECISION = 10**6`` this is
``f_p = 0.033333`` for f=1/30, so ``f/f_p = 1.00001`` and the residual over-estimate is
below 10^-5 (negligible) the report's f-precision recommendation, applied. (At the
original spec ``PRECISION = 1000`` the offset was ~1%.)
(``f_p = int(f*precision)/precision``). At the deployed spec's ``precision = 1e3`` this is
``f_p = 0.033`` for f=1/30, so ``f/f_p ~ 1.010`` the ~1 % over-estimate of Appendix A. At the
report's recommended 1e6 it is ``f_p = 0.033333``, ``f/f_p = 1.00001``, residual below 1e-5.
"""
f_eff = _f_eff(f, fixed_point)
f_eff = _f_eff(f, fixed_point, precision)
measured_density = m / T
d_new = d_prev * (1.0 - beta * (f_eff - measured_density) / f_eff)
return max(d_new, 1.0)
@ -124,13 +130,14 @@ def update_D(
def update_D_vec(
d_prev: np.ndarray, m: np.ndarray, T: int, f: float, beta: float, fixed_point: bool = False,
precision: int | None = None,
) -> np.ndarray:
"""Per-node TSI recursion: :func:`update_D` applied elementwise over ``(N,)`` arrays.
Each node updates its OWN estimate ``d_prev[i]`` from its OWN measured slot count
``m[i]``. Identical formula to :func:`update_D`, clamped at 1.
"""
f_eff = _f_eff(f, fixed_point)
f_eff = _f_eff(f, fixed_point, precision)
measured_density = np.asarray(m, dtype=float) / T
d_new = np.asarray(d_prev, dtype=float) * (1.0 - beta * (f_eff - measured_density) / f_eff)
return np.maximum(d_new, 1.0)