diff --git a/reports/tsi/handoff-fork-loss-results.md b/reports/tsi/handoff-fork-loss-results.md new file mode 100644 index 0000000..f32d72a --- /dev/null +++ b/reports/tsi/handoff-fork-loss-results.md @@ -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.03–0.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 C1–C3 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.9985–0.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. diff --git a/tools/simulators/tsi/tsi-sim-pernode/configs/spec-point-n5000.yaml b/tools/simulators/tsi/tsi-sim-pernode/configs/spec-point-n5000.yaml new file mode 100644 index 0000000..c1b09e2 --- /dev/null +++ b/tools/simulators/tsi/tsi-sim-pernode/configs/spec-point-n5000.yaml @@ -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 diff --git a/tools/simulators/tsi/tsi-sim-pernode/configs/spec-point-window.yaml b/tools/simulators/tsi/tsi-sim-pernode/configs/spec-point-window.yaml new file mode 100644 index 0000000..1a4bfec --- /dev/null +++ b/tools/simulators/tsi/tsi-sim-pernode/configs/spec-point-window.yaml @@ -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 diff --git a/tools/simulators/tsi/tsi-sim-pernode/scripts/spec_point.py b/tools/simulators/tsi/tsi-sim-pernode/scripts/spec_point.py new file mode 100644 index 0000000..b6b45e8 --- /dev/null +++ b/tools/simulators/tsi/tsi-sim-pernode/scripts/spec_point.py @@ -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() diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/config.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/config.py index d754313..adeea38 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/config.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/config.py @@ -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``). diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/constants.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/constants.py index c69dda4..9b7838e 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/constants.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/constants.py @@ -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) --------------- diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/epoch.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/epoch.py index d29d113..0d07b47 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/epoch.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/epoch.py @@ -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) diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/metrics.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/metrics.py index 42e6b6a..ab5c932 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/metrics.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/metrics.py @@ -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", ) diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/tsi.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/tsi.py index 32d1f45..dcd4e20 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/tsi.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/tsi.py @@ -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)