mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-08 20:23:28 +00:00
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:
parent
f342fd4f45
commit
e5a89004f2
96
reports/tsi/handoff-fork-loss-results.md
Normal file
96
reports/tsi/handoff-fork-loss-results.md
Normal 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.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.
|
||||
@ -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
|
||||
@ -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
|
||||
89
tools/simulators/tsi/tsi-sim-pernode/scripts/spec_point.py
Normal file
89
tools/simulators/tsi/tsi-sim-pernode/scripts/spec_point.py
Normal 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()
|
||||
@ -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``).
|
||||
|
||||
@ -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) ---------------
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user