From 0b3cf78bb05fbffda046622ccbf09a76002493f4 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:59:18 +0000 Subject: [PATCH] Site: live chain scene with miner sparks, shard fills and lock ripples; OS and GitHub logos Co-Authored-By: Claude Fable 5.1 --- proto-cuda/host.cu | 97 +++++++++++++++++++- proto-metal/main.swift | 24 +++-- sim/README.md | 37 ++++++++ sim/__pycache__/finality_v2.cpython-310.pyc | Bin 40592 -> 42840 bytes sim/finality_v2.py | 95 +++++++++++++------ site/index.html | 96 +++++++++++++------ 6 files changed, 282 insertions(+), 67 deletions(-) diff --git a/proto-cuda/host.cu b/proto-cuda/host.cu index 29db7416e..903a9a5f1 100644 --- a/proto-cuda/host.cu +++ b/proto-cuda/host.cu @@ -22,6 +22,14 @@ #include "program.h" #include "vectors.h" +// Packs written before 3 October 2026 have no dataset mode: they are closed-form (mode 0). +#ifndef IGNEUM_DATASET_MODE +#define IGNEUM_DATASET_MODE 0 +#endif +#if IGNEUM_DATASET_MODE == 1 +#include "memhard.h" // the memory-hard core, compiled here for the host reference (see proto-metal/MEMHARD.md) +#endif + #define CUDA_CHECK(call) do { cudaError_t err_ = (call); if (err_ != cudaSuccess) { \ std::fprintf(stderr, "CUDA error: %s (%d)\n at %s:%d\n in %s\n", cudaGetErrorString(err_), (int)err_, __FILE__, __LINE__, #call); \ std::exit(2); } } while (0) @@ -38,6 +46,80 @@ static uint32_t host_ds_elem(uint32_t i, uint32_t d0, uint32_t d1) { return x; } +static double wallMs(); + +static uint64_t fnv1a64(const void* p, size_t n) { + const uint8_t* b = (const uint8_t*)p; + uint64_t h = 0xcbf29ce484222325ull; + for (size_t i = 0; i < n; ++i) { h ^= b[i]; h *= 0x100000001b3ull; } + return h; +} + +#if IGNEUM_DATASET_MODE == 1 +// Memory-hard mode: the 256 MiB cache on the device (filled by the pack's kernel) and on the host (filled by the +// same mh_cache_segment text on one thread). Both are built once per process in setupCache(), compared word for +// word, and checked against the head, last line and FNV-1a 64 the Mac recorded in vectors.h. +static const uint32_t CACHE_WORDS_HOST = 1u << IGNEUM_CACHE_LOG2_WORDS; +static uint32_t* gCache = nullptr; // device +static std::vector hCache; // host +static double gCacheFillFirstMs = 0, gCacheFillSecondMs = 0, gCacheHostMs = 0; +static bool gCachePass = false; + +static bool setupCache() { + size_t bytes = (size_t)CACHE_WORDS_HOST * 4u; + CUDA_CHECK(cudaMalloc((void**)&gCache, bytes)); + cudaEvent_t e0, e1; + CUDA_CHECK(cudaEventCreate(&e0)); + CUDA_CHECK(cudaEventCreate(&e1)); + for (int pass = 0; pass < 2; ++pass) { + CUDA_CHECK(cudaEventRecord(e0)); + CUDA_CHECK(igneum_launch_cache_fill(gCache, IGNEUM_CACHE_SEGMENTS)); + CUDA_CHECK(cudaEventRecord(e1)); + CUDA_CHECK(cudaEventSynchronize(e1)); + float msf = 0.f; + CUDA_CHECK(cudaEventElapsedTime(&msf, e0, e1)); + if (pass == 0) gCacheFillFirstMs = msf; else gCacheFillSecondMs = msf; + } + CUDA_CHECK(cudaEventDestroy(e0)); + CUDA_CHECK(cudaEventDestroy(e1)); + std::printf("cache fill (GPU): %.2f ms first, %.2f ms second (%u chains x %u ChaCha blocks, %u MiB)\n", + gCacheFillFirstMs, gCacheFillSecondMs, (unsigned)IGNEUM_CACHE_SEGMENTS, + 1u << IGNEUM_CACHE_SEGMENT_LOG2_LINES, (unsigned)(bytes >> 20)); + + hCache.assign(CACHE_WORDS_HOST, 0u); + double h0 = wallMs(); + for (uint32_t seg = 0; seg < IGNEUM_CACHE_SEGMENTS; ++seg) mh_cache_segment(hCache.data(), seg); + gCacheHostMs = wallMs() - h0; + std::printf("cache fill (host, one thread): %.1f ms\n", gCacheHostMs); + + std::vector dev(CACHE_WORDS_HOST); + CUDA_CHECK(cudaMemcpy(dev.data(), gCache, bytes, cudaMemcpyDeviceToHost)); + bool same = std::memcmp(dev.data(), hCache.data(), bytes) == 0; + uint64_t fnv = fnv1a64(hCache.data(), bytes); + bool fnvOk = (fnv == IGNEUM_CACHE_FNV64); + bool headOk = std::memcmp(hCache.data(), IGNEUM_CACHE_HEAD, 64) == 0; + bool lastOk = std::memcmp(hCache.data() + CACHE_WORDS_HOST - 16u, IGNEUM_CACHE_LAST, 64) == 0; + if (!same) { + for (uint32_t i = 0; i < CACHE_WORDS_HOST; ++i) if (dev[i] != hCache[i]) { + std::printf(" cache[%u]: gpu 0x%08x host 0x%08x (first difference)\n", i, dev[i], hCache[i]); break; + } + } + gCachePass = same && fnvOk && headOk && lastOk; + std::printf("cache check: %s (GPU == host all %u words %s, host FNV-1a 64 %016llx vs Mac %016llx %s, head 16 vs Mac %s, last line vs Mac %s)\n", + gCachePass ? "PASS" : "FAIL", CACHE_WORDS_HOST, same ? "PASS" : "FAIL", + (unsigned long long)fnv, (unsigned long long)IGNEUM_CACHE_FNV64, fnvOk ? "PASS" : "FAIL", + headOk ? "PASS" : "FAIL", lastOk ? "PASS" : "FAIL"); + return gCachePass; +} + +// dataset[w] derived on the host from the host cache, exactly as proto-metal's verifier does it. +static uint32_t host_ds_word(uint32_t w) { + uint32_t s[16]; + mh_item(hCache.data(), w >> 4u, s); + return s[w & 15u]; +} +#endif + // --------------------------------------------------------------------------------------------- // Options @@ -150,20 +232,31 @@ static SizeResult runSize(const Options& o, int mib, uint64_t* dOut, uint32_t no CUDA_CHECK(cudaEventCreate(&e0)); CUDA_CHECK(cudaEventCreate(&e1)); - // Fill twice: the Mac showed a first-touch cost on the first fill of a process. + // Fill (or build) twice: the Mac showed a first-touch cost on the first fill of a process. for (int pass = 0; pass < 2; ++pass) { CUDA_CHECK(cudaEventRecord(e0)); +#if IGNEUM_DATASET_MODE == 1 + CUDA_CHECK(igneum_launch_build(dDs, gCache, r.words / 16u)); +#else CUDA_CHECK(igneum_launch_fill(dDs, r.words, IGNEUM_DAY0, IGNEUM_DAY1)); +#endif CUDA_CHECK(cudaEventRecord(e1)); CUDA_CHECK(cudaEventSynchronize(e1)); float msf = 0.f; CUDA_CHECK(cudaEventElapsedTime(&msf, e0, e1)); if (pass == 0) r.fillFirstMs = msf; else r.fillSecondMs = msf; } +#if IGNEUM_DATASET_MODE == 1 + std::printf("dataset build (memory-hard, from the cache): %.2f ms first, %.2f ms second -> %.1f M items/s, %.2f G cache-line reads/s (second, GPU time)\n", + r.fillFirstMs, r.fillSecondMs, (double)(r.words / 16u) / 1e6 / (r.fillSecondMs / 1000.0), + (double)(r.words / 16u) * (double)IGNEUM_ITEM_ROUNDS / 1e9 / (r.fillSecondMs / 1000.0)); +#else std::printf("dataset fill: %.2f ms first, %.2f ms second -> %.0f GB/s write (second, GPU time)\n", r.fillFirstMs, r.fillSecondMs, (double)bytes / 1e9 / (r.fillSecondMs / 1000.0)); +#endif - // Dataset self-test: head 16 (any size), element [MASK] (pack size only), 64 pseudo-random points vs host formula. + // Dataset self-test: head 16 (any size), element [MASK] (pack size only), 64 pseudo-random points vs the host + // formula (closed form) or the host derivation from the host cache (memory-hard), and the Mac's 64 sampled words. { uint32_t head[16]; CUDA_CHECK(cudaMemcpy(head, dDs, sizeof(head), cudaMemcpyDeviceToHost)); diff --git a/proto-metal/main.swift b/proto-metal/main.swift index 5f82fd19a..d76469362 100644 --- a/proto-metal/main.swift +++ b/proto-metal/main.swift @@ -1635,21 +1635,27 @@ func regexCount(_ pattern: String, in text: String) -> Int { // Static check: every dataset access in the generated MSL is `dataset[rN & MASK]`, and the identifier // `dataset` appears nowhere else except the kernel parameter. +// A wide load (lever b) is `dataset[(simd_broadcast(rN, 0) & WMASK) + lane]` with WMASK = MASK & ~31 and lane < 32, +// so its index is at most MASK as well. func maskCheckMSL(_ msl: String) -> (ok: Bool, detail: String) { let total = regexCount("dataset\\[", in: msl) let masked = regexCount("dataset\\[r[0-7] & MASK\\]", in: msl) + let wide = regexCount("dataset\\[\\(simd_broadcast\\(r[0-7], 0\\) & WMASK\\) \\+ lane\\]", in: msl) let words = regexCount("\\bdataset\\b", in: msl) - let ok = total == masked && words == total + 1 - return (ok, "MSL: \(total) dataset[ accesses, \(masked) of the form dataset[rN & MASK], identifier appears \(words) times (expected \(total + 1))") + let ok = total == masked + wide && words == total + 1 + return (ok, "MSL: \(total) dataset[ accesses, \(masked) of the form dataset[rN & MASK], \(wide) wide loads dataset[(simd_broadcast(rN, 0) & WMASK) + lane], identifier appears \(words) times (expected \(total + 1))") } // Same for the CUDA twin: hash accesses are `ds[rN & mask]`; the fill kernel's one write is guarded by `if (i < n)`. -func maskCheckCUDA(_ cu: String) -> (ok: Bool, detail: String) { +// The closed-form pack has one fill write guarded by `if (i < n)`; the memory-hard pack writes the dataset through a +// `d` pointer in igneum_build and has no `ds[` write at all. +func maskCheckCUDA(_ cu: String, memhard: Bool) -> (ok: Bool, detail: String) { let total = regexCount("\\bds\\[", in: cu) let masked = regexCount("\\bds\\[r[0-7] & mask\\]", in: cu) + let wide = regexCount("\\bds\\[\\(__shfl_sync\\(0xffffffffu, r[0-7], 0\\) & wmask\\) \\+ lane\\]", in: cu) let fill = regexCount("if \\(i < n\\) ds\\[i\\] = ds_elem", in: cu) - let ok = total == masked + fill && fill == 1 - return (ok, "CUDA: \(total) ds[ accesses, \(masked) of the form ds[rN & mask], \(fill) guarded fill write") + let ok = total == masked + wide + fill && fill == (memhard ? 0 : 1) + return (ok, "CUDA: \(total) ds[ accesses, \(masked) of the form ds[rN & mask], \(wide) wide, \(fill) guarded fill write (expected \(memhard ? 0 : 1))") } // MARK: - --fuzz @@ -1705,7 +1711,6 @@ func runFuzz(_ opts: Options, ctx: DatasetContext) -> Bool { fail += 1; print("GPU RUN FAIL seed \"\(seedString)\" dataset 2^\(log2)"); continue } let g1 = nowNs() - let mask = UInt32((1 << log2) - 1) let ds = ctx.source(log2: log2) var ok = true for (w, base) in bases.enumerated() { @@ -1925,7 +1930,6 @@ func popcount64(_ v: UInt64) -> Int { v.nonzeroBitCount } func runStats(_ opts: Options, ctx: DatasetContext) -> Bool { let gpu = ctx.gpu let log2 = opts.datasetLog2 - let mask = UInt32((1 << log2) - 1) let ds = ctx.source(log2: log2) let n = 1 << 20 print("\n=== output statistics, 2^20 consecutive nonces per seed, dataset 2^\(log2) words ===") @@ -2120,10 +2124,10 @@ func runDeterminism(_ opts: Options, ctx: DatasetContext) -> Bool { var fillFps = [UInt64]() var sampleBad = 0 if let shared = gpu.device.makeBuffer(length: words * 4, options: .storageModeShared) { - for (idx, ds) in [dataset, dataset2].enumerated() { + for (idx, dsBuf) in [dataset, dataset2].enumerated() { let cb = gpu.queue.makeCommandBuffer()! let blit = cb.makeBlitCommandEncoder()! - blit.copy(from: ds, sourceOffset: 0, to: shared, destinationOffset: 0, size: words * 4) + blit.copy(from: dsBuf, sourceOffset: 0, to: shared, destinationOffset: 0, size: words * 4) blit.endEncoding() cb.commit(); cb.waitUntilCompleted() fillFps.append(fnv64(shared.contents(), words * 4)) @@ -2160,7 +2164,7 @@ func runMemcheck(_ opts: Options, ctx: DatasetContext) -> Bool { if !r.ok { ok = false } print("static 2^\(log2): \(r.ok ? "PASS" : "FAIL") \(r.detail)") } - let cu = maskCheckCUDA(generateCUDA(program)) + let cu = maskCheckCUDA(generateCUDA(program, memhard: ctx.mp), memhard: !ctx.closed) if !cu.ok { ok = false } print("static CUDA twin: \(cu.ok ? "PASS" : "FAIL") \(cu.detail)") print("program has \(program.instrs.filter { $0.op == .load }.count) load instructions (\(program.loadsPerHash) loads/hash)") diff --git a/sim/README.md b/sim/README.md index d1b2f2651..4e2d92680 100644 --- a/sim/README.md +++ b/sim/README.md @@ -39,3 +39,40 @@ the first full day after the burst, the doubling or the churn. Model limits are listed at the end of `results.md`: no latency, no DAG, no VRF sampling noise, instant difficulty retarget, free keys. + +## finality_v2.py + +Simulates FINALITY RULE V2 (CLAUDE.md, review round 2): flat 30-day weight window, no damping, +dust threshold 100 blocks, a checkpoint every 30 blocks, every voter signs every checkpoint, +lock at 2/3 of the ACTIVE denominator (weight x participation over a 240-checkpoint presence +window) compared against the TOTAL denominator. Unlike `finality_sim.py` it steps one 30-s slot +at a time and models regions with message delay, uptime, partitions (each side forms its own +checkpoints and certificates, merged at the heal with conflicting locks counted), an eclipsed +pool, and equivocation stripping. The DAG stays abstract: every block is blue, a side's +checkpoint block at index i is the block at blue score 30i in that side's view. + +Three readings of "participation" are selectable with `--pmode` inside the script (the scenarios +run all three where it matters): `cert` (the brief, an uncertified index credits nobody), +`seen` (an uncertified index credits the keys whose votes were observed), `frozen` (the window +is over certified indices only). Scenarios A to G and every assumption are in `results_v2.md`. + +Requirements: Python 3, numpy (3.10.10, numpy 2.2.6 on 3 October 2026). + +Run everything (about four minutes): + + python3 finality_v2.py > out_v2.md + +Options: + + --seed N random seed, default 7 + --scenarios A,E subset of A,B,C,D,E,F,G + --delay 2.0 one-way inter-region delay in seconds for the main runs (A also sweeps 0.5, 2, 5) + --grace 15 seconds after a checkpoint during which late votes still enter the certificate + --quick shortened runs for development + +The runs behind `results_v2.md`: + + python3 finality_v2.py + python3 finality_v2.py --seed 11 --scenarios B,E + +Timing output goes to stderr, tables to stdout. Minutes in C to F are wall minutes after the event. diff --git a/sim/__pycache__/finality_v2.cpython-310.pyc b/sim/__pycache__/finality_v2.cpython-310.pyc index 3df129fe087d6d71d0311eba4a61a9896983da8e..ec0d5a41c6f005da05614f68123b0ea58bff8175 100644 GIT binary patch delta 18340 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 delta 16017 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 0: + s += "+floor%.2f" % self.floor + if self.daa != "none": + s += "+daa" + return s class View: @@ -257,7 +266,14 @@ class Sim: p = self.p slot = self.slot tot = self.hash.sum() - blocks = self.rng.poisson(BLOCKS_PER_CP * self.hash / tot) if tot > 0 else np.zeros(self.N) + if p.daa == "full" and len(self.views) > 1: + blocks = np.zeros(self.N) + for v in self.views: + side_tot = self.hash[v.mine_mask].sum() + if side_tot > 0: + blocks[v.mine_mask] = self.rng.poisson(BLOCKS_PER_CP * self.hash[v.mine_mask] / side_tot) + else: + blocks = self.rng.poisson(BLOCKS_PER_CP * self.hash / tot) if tot > 0 else np.zeros(self.N) hp = (slot // SLOTS_PER_HOUR) % WINDOW_HOURS if slot % SLOTS_PER_HOUR == 0: self.weight -= self.buckets[hp] @@ -306,6 +322,8 @@ class Sim: else: denom = float(self.weight[elig].sum()) total = float(self.weight[elig].sum()) + if p.denom == "active" and p.floor > 0: + denom = max(denom, p.floor * total) need = p.quorum * denom vi = np.flatnonzero(voters) signed_w = float(self.weight[vi].sum()) @@ -633,23 +651,35 @@ def scenario_b(args): def scenario_c(args): - out = ["### C. Silent set: a random set holding x of weight stops signing at day 60 and keeps mining", ""] + out = ["### C. Silent set: a random set holding x of weight stops signing at day 60 (hour 3 of the run) and keeps mining", ""] fracs = (0.34, 0.40, 0.45, 0.50, 0.55) - configs = [("active", "cert", args.hours_c), ("active", "seen", args.hours_c), ("active", "frozen", args.hours_c), - ("total", "cert", args.hours_c_total)] + configs = [("active", "cert", args.hours_c, 0.0, 240), ("active", "seen", args.hours_c, 0.0, 240), + ("active", "frozen", args.hours_c, 0.0, 240), ("active", "cert", args.hours_c_total, 0.0, 2880), + ("active", "cert", args.hours_c_total, 0.8, 240), ("total", "cert", args.hours_c_total, 0.0, 240)] + labels = [] + for denom, pmode, hours, floor, presence in configs: + lab = P(denom=denom, pmode=pmode, floor=floor, presence=presence).label() + if presence != 240: + lab += ", presence %d" % presence + labels.append(lab) rows = [] detail = [] for frac in fracs: row = [pct(frac, 0)] - for denom, pmode, hours in configs: - p = P(denom=denom, pmode=pmode, delay=args.delay) + for denom, pmode, hours, floor, presence in configs: + p = P(denom=denom, pmode=pmode, delay=args.delay, floor=floor, presence=presence) sim, rng, _ = build_honest(p, args.seed) sim.warm_start() sim.init_views(warm=True) - sim.run(SLOTS_PER_HOUR) + sim.run(3 * SLOTS_PER_HOUR) silent, got = pick_weight_subset(rng, sim.weight, frac) sim.signs[silent] = False t_event = sim.slot + v0 = sim.views[0] + e0 = sim.elig_mask() + s_on = float(sim.weight[e0 & sim.online & ~silent].sum() / sim.weight[e0].sum()) + p0 = float((sim.weight * sim.participation(v0, v0.next_idx))[e0].sum() / sim.weight[e0].sum()) + predicted = max(0, int(round(p.presence * (p0 - 1.5 * s_on)))) sim.run(int(hours * SLOTS_PER_HOUR)) recs = sim.recs() fl = first_lock_after(recs, t_event) @@ -661,37 +691,39 @@ def scenario_c(args): later = stalls_between(recs, fl, sim.slot) if fl is not None else st fl_txt = "never (%s)" % fmt_min(sim.slot - t_event) if fl is None else fmt_min(fl - t_event) row.append("%s, %d stalled" % (fl_txt, st)) - if denom == "active" and pmode == "cert": + if denom == "active" and pmode == "cert" and floor == 0 and presence == 240: tail = recs[(recs[:, 0] >= sim.slot - SLOTS_PER_HOUR) & (recs[:, 3] >= 0)] - detail.append([pct(frac, 0), pct(got), int(silent.sum()), fl_txt, st, later, "%.3f" % sil_part, + detail.append([pct(frac, 0), pct(got), int(silent.sum()), pct(s_on), "%.3f" % p0, predicted, fl_txt, st, later, "%.3f" % sil_part, "%.3f" % (np.median(tail[:, 4]) if tail.shape[0] else float("nan")), "%.3f" % (np.median(tail[:, 5]) if tail.shape[0] else float("nan"))]) rows.append(row) - out.append("Time from the event to the first lock, and checkpoints stalled in the run (%d h active runs, %d h total run):" % ( + out.append("Time from the event to the first lock, and checkpoints stalled in the run (%d h for the first three columns, %d h for the rest):" % ( args.hours_c, args.hours_c_total)) out.append("") - out.append(md_table(["silent weight"] + ["%s/%s" % (d, m) if d == "active" else d for d, m, _ in configs], rows)) + out.append(md_table(["silent weight"] + labels, rows)) out.append("") - out.append("Active/cert detail:") + out.append("Active/cert detail. Predicted stalls = presence x (p0 - 1.5 x online signing share), where p0 is the weight-averaged participation at the event:") out.append("") - out.append(md_table(["silent weight", "picked", "keys", "first lock", "stalled", "stalled after first lock", + out.append(md_table(["silent weight", "picked", "keys", "online signing share at event", "p0", "predicted stalls", "first lock", "stalled", "stalled after first lock", "silent participation at end", "signed/denominator at end (median, last hour)", "active/total at end"], detail)) return "\n".join(out) def scenario_d(args): - out = ["### D. Churn: a random set holding x of weight stops mining and signing at day 60", ""] + out = ["### D. Churn: a random set holding x of weight stops mining and signing at day 60 (hour 3 of the run)", ""] fracs = (0.35, 0.50) rows = [] + dconfigs = [P(denom="active", delay=args.delay), P(denom="active", presence=2880, delay=args.delay), + P(denom="active", floor=0.8, delay=args.delay), P(denom="total", delay=args.delay)] for frac in fracs: - for denom in ("active", "total"): + for p in dconfigs: days = args.days_d35 if frac < 0.4 else args.days_d50 - p = P(denom=denom, delay=args.delay) + denom = p.label() + (", presence 2880" if p.presence == 2880 else "") sim, rng, _ = build_honest(p, args.seed) sim.warm_start() sim.init_views(warm=True) - sim.run(SLOTS_PER_HOUR) + sim.run(3 * SLOTS_PER_HOUR) gone, got = pick_weight_subset(rng, sim.weight, frac) sim.signs[gone] = False sim.online[gone] = False @@ -780,7 +812,7 @@ def scenario_e(args): rows_conf.append(rc) rows_first.append(rf) labels = [p.label() for p in configs] - out.append("Conflicting locks (two certificates at one index, different blocks). Attacker = equivocating key holding the stated share of total weight, honest weight split as stated. Pass needs 0 at 0%% attacker for 30, 90 and 150 min.") + out.append("Conflicting locks (two certificates at one index, different blocks). Attacker = equivocating key holding the stated share of total weight, honest weight split as stated. Pass needs 0 at 0% attacker for 30, 90 and 150 min.") out.append("") out.append(md_table(["honest split", "attacker", "partition min"] + labels, rows_conf)) out.append("") @@ -788,18 +820,25 @@ def scenario_e(args): out.append("") out.append(md_table(["honest split", "attacker", "partition min"] + labels, rows_first)) out.append("") - # supplementary lopsided splits at 0% attacker, 150 min + # supplementary: more splits, 0% attacker, plus the DAA-retarget and floor variants + configs2 = configs + [P(denom="active", pmode="cert", daa="full", delay=args.delay), + P(denom="active", pmode="seen", daa="full", delay=args.delay), + P(denom="active", pmode="cert", floor=0.8, daa="full", delay=args.delay)] + labels2 = [p.label() for p in configs2] rows = [] - for name, fr in (("60/40", [0.6, 0.4]), ("67/33", [0.67, 0.33]), ("80/20", [0.8, 0.2])): + for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("67/33", [0.67, 0.33]), ("80/20", [0.8, 0.2]), + ("33/33/34", [0.33, 0.33, 0.34])): for dur in (150, 360): rc = [name, dur] - for p in configs: + for p in configs2: r = run_partition(args.seed, fr, 0.0, dur, p, post_min=120) - rc.append("%d conflicts; first locks %s min" % (r["conflicts"], " / ".join(fm(x) for x in r["side_first_lock"]))) + rc.append("%d; %s" % (r["conflicts"], " / ".join(fm(x) for x in r["side_first_lock"]))) rows.append(rc) - out.append("Supplementary, lopsided honest splits, 0% attacker:") + out.append("Supplementary, 0% attacker, 150 and 360 min. Cell = conflicting locks; minutes to each side's first lock. " + "'+daa' = each side retargets to 1 block/s at once (worst case for the presence clock); " + "'+floor0.80' = active denominator never below 80% of total weight (a lock needs at least 53.3% of total).") out.append("") - out.append(md_table(["honest split", "partition min"] + labels, rows)) + out.append(md_table(["honest split", "partition min"] + labels2, rows)) out.append("") # presence window sweep, active/cert, 0% attacker rows = [] @@ -937,8 +976,10 @@ def scenario_g(args): rows_lat.append(lat_row("%s, day before" % denom, lat_stats(recs, 0, t_event))) rows_lat.append(lat_row("%s, days 1 to 5 after" % denom, lat_stats(recs, t_event, t_event + 5 * SLOTS_PER_DAY))) rows_lat.append(lat_row("%s, days 5 to %d after" % (denom, args.days_g), lat_stats(recs, t_event + 5 * SLOTS_PER_DAY, None))) - ev.append([denom, last_23, next((d for d, so, sn, _ in series if sn >= 0.45), None), - next((d for d, so, sn, _ in series if sn >= 0.49), None), stalls_between(recs, t_event, sim.slot)]) + r45 = next((d for d, so, sn, _ in series if sn >= 0.45), None) + r49 = next((d for d, so, sn, _ in series if sn >= 0.49), None) + ev.append([denom, last_23, "not in run" if r45 is None else r45, "not in run" if r49 is None else r49, + stalls_between(recs, t_event, sim.slot)]) out.append("Weight shares (active run):") out.append("") out.append(md_table(["day after doubling", "old cohort", "new cohort", "new cohort formula t/60", "new keys under dust"], rows_share)) diff --git a/site/index.html b/site/index.html index 7f9e9ec20..04b6e9ea7 100644 --- a/site/index.html +++ b/site/index.html @@ -94,6 +94,11 @@ section{padding-block:clamp(56px,8vw,96px)} .sec-head p{font-size:16px;color:var(--ash);max-width:46ch} /* dag */ +.viz{padding:clamp(16px,3vw,28px);overflow:hidden} +.viz-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px 20px;margin-bottom:12px} +.viz-stats{display:flex;gap:18px;font-size:12px;color:var(--ash)} +.viz-stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums} +#chain{display:block;width:100%;height:clamp(220px,36vw,340px);border-radius:12px;background:var(--obsidian)} .dag{padding:clamp(18px,3vw,32px);overflow:hidden} .dag-rows{display:flex;flex-direction:column;gap:var(--dg,14px);--bs:34px;--dg:14px} @media (max-width:640px){.dag-rows{--bs:26px;--dg:9px}} @@ -234,13 +239,17 @@ footer .fl{display:flex;flex-wrap:wrap;gap:20px}

Watch the chain prove itself

Blocks arrive every second. Miners prove them in shards. A checkpoint locks every 30 seconds. All of it will be on this page, live.

-
@@ -387,7 +396,7 @@ footer .fl{display:flex;flex-wrap:wrap;gap:20px}
IGNEUM· nothing on this page is an offer to sell anything
@@ -437,24 +446,55 @@ footer .fl{display:flex;flex-wrap:wrap;gap:20px} } if(!reduce){var sp=spacing();for(var i=0;i