diff --git a/crypto-benchmarks.rs/.gitignore b/crypto-benchmarks.rs/.gitignore index 26f7359ea..4feae9c67 100644 --- a/crypto-benchmarks.rs/.gitignore +++ b/crypto-benchmarks.rs/.gitignore @@ -1,3 +1,14 @@ target/ *.cbor *.txt + +# Demo artifacts +demo/**/*.pretty.json +demo/**/*.json +demo/**/*.png +demo/**/*.csv +demo/scripts/.env_cli + +# Python cache +__pycache__/ +*.pyc diff --git a/crypto-benchmarks.rs/demo/README.md b/crypto-benchmarks.rs/demo/README.md new file mode 100644 index 000000000..37373ecb0 --- /dev/null +++ b/crypto-benchmarks.rs/demo/README.md @@ -0,0 +1,104 @@ + + +# Demo Scripts for Leios Crypto Benchmarks + +This folder contains scripts that orchestrate end-to-end demonstrations of BLS-based vote aggregation and certificate generation/verification for the Leios project. + +## Prerequisites + +- Ensure the CLI built from the repository root is available; see `crypto-benchmarks.rs/ReadMe.md` for build instructions and usage details. +- Ensure Python 3 is available with `cbor2` installed. + For example: + + ```bash + python3 -m venv .venv + source .venv/bin/activate + pip install cbor2 + ``` + +## Workflow + +The scripts are designed to be run from the `demo/` directory. + +### Run Step by Step (Manual Mode) + +You can run each script individually to understand and control each step of the process for a given number of voters (e.g., 100). Use the `-d` option to specify the output directory (e.g., `run100`). + +#### 10_init_inputs.sh + +Initialize inputs for N voters: + +```bash +scripts/10_init_inputs.sh -d run100 --pools 500 --stake 100000 --alpha 9 --beta 1 +``` + +#### 20_make_registry.sh + +Build the registry from initialized inputs: + +```bash +./scripts/20_make_registry.sh -d run100 -n 100 +``` + +#### 30_cast_votes.sh + +Cast votes with a specified fraction of voters voting (e.g., 1.0 means all vote): + +```bash +scripts/30_cast_votes.sh -d run100 -f 0.75 +``` + +#### 40_make_certificate.sh + +Generate the aggregated certificate: + +```bash +scripts/40_make_certificate.sh -d run100 +``` + +#### 50_verify_certificate.sh + +Verify the generated certificate: + +```bash +scripts/50_verify_certificate.sh -d run100 +``` + +### Run a Single End-to-End Demo + +```bash +scripts/70_run_one.sh -d run100 -p 500 -n 100 -f 0.75 +``` + +This will: + +1. Initialize inputs (`10_init_inputs.sh`) +2. Build a registry (`20_make_registry.sh`) +3. Cast votes (`30_cast_votes.sh`) +4. Make a certificate (`40_make_certificate.sh`) +5. Verify the certificate (`50_verify_certificate.sh`) +6. Export data for the UI (`60_export_demo_json.sh`) + +All files are placed in `demo/run100/`. + +### Launch the Demo UI + +After generating a demo run (for example via `scripts/70_run_one.sh`), start the UI server from this directory: + +```bash +python3 ui/server.py +``` + +Then open your browser at [http://127.0.0.1:5050/ui](http://127.0.0.1:5050/ui) to explore the results. + +## Notes + +- All scripts must be run from within the `demo/` directory. +- Directories passed via `-d` will be created automatically under `demo/`. +- Compression ratio is defined as: + + ``` + votes_bytes / certificate_bytes + ``` + +which illustrates the storage/bandwidth savings achieved by BLS aggregation. diff --git a/crypto-benchmarks.rs/demo/scripts/00_set_cli.sh b/crypto-benchmarks.rs/demo/scripts/00_set_cli.sh new file mode 100755 index 000000000..1f830234a --- /dev/null +++ b/crypto-benchmarks.rs/demo/scripts/00_set_cli.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail +# Usage: demo/scripts/00_set_cli.sh [-p /abs/path/to/leios_crypto_benchmarks] +# Writes demo/scripts/.env_cli with an absolute CLI path so other scripts can source it. + +DIR_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$DIR_SCRIPT/.env_cli" +CLI_PATH="" + +while getopts ":p:" opt; do + case "$opt" in + p) CLI_PATH="$OPTARG" ;; + *) echo "Usage: $0 [-p /abs/path/to/leios_crypto_benchmarks]"; exit 2 ;; + esac +done + +if [[ -z "${CLI_PATH}" ]]; then + read -r -p "Absolute path to leios_crypto_benchmarks binary: " CLI_PATH +fi + +if [[ ! -x "${CLI_PATH}" ]]; then + echo "Error: '${CLI_PATH}' is not an executable file." >&2 + exit 1 +fi + +mkdir -p "$DIR_SCRIPT" +cat > "$ENV_FILE" </dev/null +"$CLI" gen-eid > eid.txt +"$CLI" gen-eb-hash > ebhash.txt + +"$CLI" gen-stake \ + --pool-count "${POOLS}" \ + --total-stake "${TOTAL_STAKE}" \ + --shape-alpha "${ALPHA}" \ + --shape-beta "${BETA}" \ + --stake-file stake.cbor + +"$CLI" gen-pools \ + --stake-file stake.cbor \ + --pools-file pools.cbor + +# Pretty-print some of the generated values +echo "EID: $(cat eid.txt)" +echo "EB Hash: $(cat ebhash.txt)" + +# Print first 3 pools and their stakes from pools.cbor using cbor2 +PYTHON_EXEC="${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}" +PYTHON_EXEC="${PYTHON_EXEC:-python3}" +"$PYTHON_EXEC" - <<'PY' +import sys, os +try: + import cbor2 +except ImportError: + print('cbor2 not installed! (pip install cbor2)', file=sys.stderr) + sys.exit(1) + +if not os.path.exists('pools.cbor'): + print('pools.cbor not found', file=sys.stderr) + sys.exit(1) + +with open('pools.cbor', 'rb') as f: + pools = cbor2.load(f) + +print('First 3 pools and their stakes:') +for i, entry in enumerate(pools[:3]): + # Expected structure: {"secret": , "reg": {"pool": "", ...}, "stake": } + reg = entry.get('reg', {}) + pool_id = reg.get('pool', '') + stake = entry.get('stake', '') + print(f' {i:>2}: pool={pool_id} stake={stake}') +PY +popd >/dev/null + +echo "Initialized inputs in ${DEMO_DIR}" \ No newline at end of file diff --git a/crypto-benchmarks.rs/demo/scripts/20_make_registry.sh b/crypto-benchmarks.rs/demo/scripts/20_make_registry.sh new file mode 100755 index 000000000..f81b0411b --- /dev/null +++ b/crypto-benchmarks.rs/demo/scripts/20_make_registry.sh @@ -0,0 +1,86 @@ + + +#!/usr/bin/env bash +set -euo pipefail +# Usage: demo/scripts/20_make_registry.sh -d RUN_DIR -n N +# Example (from demo/): scripts/20_make_registry.sh -d run16 -n 16 +DIR_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$DIR_SCRIPT/.env_cli" + +RUN_DIR="" +N="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -d) RUN_DIR="$2"; shift 2;; + -n) N="$2"; shift 2;; + *) echo "Usage: $0 -d RUN_DIR -n N"; exit 2;; + esac +done + +if [[ -z "$RUN_DIR" || -z "$N" ]]; then + echo "Error: need -d RUN_DIR and -n N" >&2; exit 2 +fi + +RUN_DIR="$(cd "$DIR_SCRIPT/.."; cd "$RUN_DIR" && pwd)" +echo "== [20_make_registry] DIR=${RUN_DIR} N=${N} ==" + +pushd "$RUN_DIR" >/dev/null +"$CLI" make-registry \ + --pools-file pools.cbor \ + --voter-count "$N" \ + --registry-file registry.cbor +popd >/dev/null + +# --- Pretty-print a short registry summary for the audience --- +PYTHON_EXEC="${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}" +PYTHON_EXEC="${PYTHON_EXEC:-python3}" +pushd "$RUN_DIR" >/dev/null +"$PYTHON_EXEC" - <<'PY' +import sys, os +try: + import cbor2 +except ImportError: + print("CBOR summary skipped (cbor2 not installed). Run: pip install cbor2", file=sys.stderr) + raise SystemExit(0) + +path = "registry.cbor" +if not os.path.exists(path): + print("CBOR summary skipped (registry.cbor missing).", file=sys.stderr) + raise SystemExit(0) + +c = cbor2.load(open(path, "rb")) + +voters = c.get("voters") +total_stake = c.get("total_stake") +persistent_pool = c.get("persistent_pool") or {} +info = c.get("info") or {} + +print("Registry summary:") +print(f" Seats requested (N): {voters}") +print(f" Persistent seats: {len(persistent_pool)}") +print(f" Total stake: {total_stake}") + +# Top 3 stakepools by stake (from .info) +tops = [] +for pool_id, rec in info.items(): + stake = rec.get("stake") + if isinstance(stake, int): + tops.append((stake, pool_id)) +tops.sort(reverse=True) +tops = tops[:3] + +if tops: + print(" Top 3 stakepools by stake:") + for i, (stake, pool) in enumerate(tops, 1): + print(f" {i}. pool={pool} stake={stake}") + +# Show up to first 3 persistent IDs → pools +if isinstance(persistent_pool, dict) and persistent_pool: + items = sorted(persistent_pool.items(), key=lambda kv: kv[0])[:3] + print(" Persistent mapping (first 3):") + for pid, pool in items: + print(f" id={pid} -> pool={pool}") +PY +popd >/dev/null +# --- End summary --- \ No newline at end of file diff --git a/crypto-benchmarks.rs/demo/scripts/30_cast_votes.sh b/crypto-benchmarks.rs/demo/scripts/30_cast_votes.sh new file mode 100755 index 000000000..5d57097f1 --- /dev/null +++ b/crypto-benchmarks.rs/demo/scripts/30_cast_votes.sh @@ -0,0 +1,134 @@ + + +#!/usr/bin/env bash +set -euo pipefail +# Usage: demo/scripts/30_cast_votes.sh -d RUN_DIR -f FRACTION +# Example (from demo/): scripts/30_cast_votes.sh -d run16 -f 1.0 +# Requires: demo/scripts/.env_cli (set via 00_set_cli.sh) + +DIR_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$DIR_SCRIPT/.env_cli" + +RUN_DIR="" +FRACTION="" + +# ---- arg parsing ---- +while [[ $# -gt 0 ]]; do + case "$1" in + -d) RUN_DIR="$2"; shift 2;; + -f) FRACTION="$2"; shift 2;; + *) echo "Usage: $0 -d RUN_DIR -f FRACTION"; exit 2;; + esac +done + +if [[ -z "$RUN_DIR" || -z "$FRACTION" ]]; then + echo "Error: need -d RUN_DIR and -f FRACTION" >&2 + exit 2 +fi + +# Resolve run directory relative to demo/ +RUN_DIR="$(cd "$DIR_SCRIPT/.."; cd "$RUN_DIR" && pwd)" +echo "== [30_cast_votes] DIR=${RUN_DIR} FRACTION=${FRACTION} ==" +# Persist the voting fraction (quorum) for downstream scripts +printf '%s\n' "$FRACTION" > "${RUN_DIR}/fraction.txt" + +# ---- run cast-votes ---- +pushd "$RUN_DIR" >/dev/null + +if [[ ! -f "registry.cbor" ]]; then + echo "Error: ${RUN_DIR}/registry.cbor not found. Run scripts/20_make_registry.sh first." >&2 + exit 1 +fi +if [[ ! -f "eid.txt" || ! -f "ebhash.txt" ]]; then + echo "Error: eid.txt or ebhash.txt missing in ${RUN_DIR}. Run scripts/10_init_inputs.sh first." >&2 + exit 1 +fi + +OUT="$("$CLI" --verbose cast-votes \ + --registry-file registry.cbor \ + --eid "$(cat eid.txt)" \ + --eb-hash "$(cat ebhash.txt)" \ + --fraction-voting "$FRACTION" \ + --votes-file votes.cbor 2>&1)" + +echo "$OUT" + +# Extract and stash the "Voters: X" count (robust against spacing/CR/ANSI) +CLEAN_OUT="$(printf '%s\n' "$OUT" | tr -d '\r' | sed 's/\x1B\[[0-9;]*[A-Za-z]//g')" +if [[ "$CLEAN_OUT" =~ [Vv]oters:[[:space:]]*([0-9]+) ]]; then + VOTERS="${BASH_REMATCH[1]}" +else + VOTERS="$(printf '%s\n' "$CLEAN_OUT" | sed -n 's/.*[Vv]oters:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n1 || true)" +fi + +# Summarize results for the audience + +# File size (bytes) +if [[ -f "${RUN_DIR}/votes.cbor" ]]; then + BYTES=$(wc -c < "${RUN_DIR}/votes.cbor" | tr -d ' ') + echo "votes.cbor size: ${BYTES} bytes" +fi + +# Show a small sample of who voted +PYTHON_EXEC="${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}" +PYTHON_EXEC="${PYTHON_EXEC:-python3}" +"$PYTHON_EXEC" - <<'PY' +import sys, os +try: + import cbor2 +except ImportError: + print("CBOR summary skipped (cbor2 not installed). Run: pip install cbor2", file=sys.stderr) + raise SystemExit(0) + +votes_path = "votes.cbor" +registry_path = "registry.cbor" +if not os.path.exists(votes_path): + print("CBOR summary skipped (votes.cbor missing).", file=sys.stderr) + raise SystemExit(0) + +# Load votes +votes = cbor2.load(open(votes_path, "rb")) +# Try to load registry to resolve persistent IDs to pool hashes +persistent_pool = {} +if os.path.exists(registry_path): + reg = cbor2.load(open(registry_path, "rb")) + pp = reg.get("persistent_pool") + if isinstance(pp, dict): + # keys could be ints or stringified ints + persistent_pool = {int(k): v for k, v in pp.items()} + elif isinstance(pp, list): + # Some builds might use list index mapping + persistent_pool = {i: v for i, v in enumerate(pp)} + +np_pools = [] +p_pools = [] # as 'id -> pool' + +for v in votes: + if isinstance(v, dict): + if "Nonpersistent" in v: + pool = v["Nonpersistent"].get("pool") + if isinstance(pool, str): + np_pools.append(pool) + elif "Persistent" in v: + pid = v["Persistent"].get("persistent") + if isinstance(pid, int): + pool = persistent_pool.get(pid, None) + if pool is not None: + p_pools.append((pid, pool)) + else: + p_pools.append((pid, "")) + if len(np_pools) >= 3 and len(p_pools) >= 3: + break + +print("Sample voters:") +if p_pools: + print(" Persistent (first up to 3):") + for pid, pool in p_pools[:3]: + print(f" id={pid} -> pool={pool}") +if np_pools: + print(" Nonpersistent pools (first up to 3):") + for pool in np_pools[:3]: + print(f" {pool}") +PY + +popd >/dev/null diff --git a/crypto-benchmarks.rs/demo/scripts/40_make_certificate.sh b/crypto-benchmarks.rs/demo/scripts/40_make_certificate.sh new file mode 100755 index 000000000..c7a0f4d14 --- /dev/null +++ b/crypto-benchmarks.rs/demo/scripts/40_make_certificate.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail +# Usage: demo/scripts/40_make_certificate.sh -d RUN_DIR +# Example (from demo/): scripts/40_make_certificate.sh -d run16 +# Requires: demo/scripts/.env_cli (set via 00_set_cli.sh) + +DIR_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$DIR_SCRIPT/.env_cli" + +RUN_DIR="" + +# ---- arg parsing ---- +while [[ $# -gt 0 ]]; do + case "$1" in + -d) RUN_DIR="$2"; shift 2;; + *) echo "Usage: $0 -d RUN_DIR"; exit 2;; + esac +done + +if [[ -z "$RUN_DIR" ]]; then + echo "Error: need -d RUN_DIR" >&2 + exit 2 +fi + +# Resolve run directory relative to demo/ +RUN_DIR="$(cd "$DIR_SCRIPT/.."; cd "$RUN_DIR" && pwd)" +echo "== [40_make_certificate] DIR=${RUN_DIR} ==" + +# ---- preflight ---- +if [[ ! -f "${RUN_DIR}/registry.cbor" ]]; then + echo "Error: ${RUN_DIR}/registry.cbor not found. Run scripts/20_make_registry.sh first." >&2 + exit 1 +fi +if [[ ! -f "${RUN_DIR}/votes.cbor" ]]; then + echo "Error: ${RUN_DIR}/votes.cbor not found. Run scripts/30_cast_votes.sh first." >&2 + exit 1 +fi + +# ---- make certificate ---- +pushd "$RUN_DIR" >/dev/null +"$CLI" make-certificate \ + --registry-file registry.cbor \ + --votes-file votes.cbor \ + --certificate-file certificate.cbor +popd >/dev/null + + +# ---- summary output ---- +BYTES_CERT="$(wc -c < "${RUN_DIR}/certificate.cbor" 2>/dev/null || echo "?")" +echo "Certificate size (bytes): ${BYTES_CERT}" diff --git a/crypto-benchmarks.rs/demo/scripts/50_verify_certificate.sh b/crypto-benchmarks.rs/demo/scripts/50_verify_certificate.sh new file mode 100755 index 000000000..3cc1dd200 --- /dev/null +++ b/crypto-benchmarks.rs/demo/scripts/50_verify_certificate.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$DIR_SCRIPT/.env_cli" + +usage() { + cat < + +Verify the cryptographic validity of a certificate. + +Options: + -d, --dir Run directory that contains registry.cbor and certificate.cbor +USAGE + exit 1 +} + +DIR="" +while [[ $# -gt 0 ]]; do + case "$1" in + -d|--dir) DIR="$2"; shift 2;; + -h|--help) usage;; + *) echo "Unknown argument: $1"; usage;; + esac +done + +[[ -z "${DIR}" ]] && usage +DIR="$(cd "$DIR" 2>/dev/null && pwd || true)" +[[ -z "${DIR}" || ! -d "${DIR}" ]] && { echo "Run directory not found: ${DIR}"; exit 1; } + +REG="${DIR}/registry.cbor" +CERT="${DIR}/certificate.cbor" + +[[ -f "${REG}" ]] || { echo "Missing ${REG}"; exit 1; } +[[ -f "${CERT}" ]] || { echo "Missing ${CERT}"; exit 1; } + +echo "== [50_verify_certificate] DIR=${DIR} ==" +"$CLI" --verbose verify-certificate \ + --registry-file "${REG}" \ + --certificate-file "${CERT}" \ No newline at end of file diff --git a/crypto-benchmarks.rs/demo/scripts/60_export_demo_json.sh b/crypto-benchmarks.rs/demo/scripts/60_export_demo_json.sh new file mode 100755 index 000000000..80904c861 --- /dev/null +++ b/crypto-benchmarks.rs/demo/scripts/60_export_demo_json.sh @@ -0,0 +1,414 @@ +#!/usr/bin/env bash +set -euo pipefail +DIR_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RUN_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -d) RUN_DIR="$2"; shift 2;; + *) echo "Usage: $0 -d RUN_DIR"; exit 2;; + esac +done + +if [[ -z "$RUN_DIR" ]]; then + echo "need -d RUN_DIR" >&2 + exit 2 +fi + +# Resolve the run directory (handle paths with spaces) +RUN_DIR="$(cd "$DIR_SCRIPT/.." && cd "$RUN_DIR" && pwd)" +echo "== [60_export_demo_json] DIR=${RUN_DIR} ==" + +PY="${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python}" +PY="${PY:-python3}" + + +pushd "$RUN_DIR" >/dev/null +"$PY" - <<'PY' +import json +import os +import platform +import subprocess +import sys +try: + import cbor2 +except ImportError: + raise SystemExit("cbor2 not installed (pip install cbor2)") + +def must(path): + if not os.path.exists(path): + raise SystemExit(f"missing {path}") + return path + +# --- Load registry (required) --- + +reg = cbor2.load(open(must("registry.cbor"), "rb")) +info = reg.get("info", {}) or {} +persistent_pool = reg.get("persistent_pool", {}) or {} +total_stake = reg.get("total_stake") +N = int(reg.get("voters", 0) or 0) + +# Normalize persistent seat IDs to integers for robust comparisons +try: + persistent_seat_ids = {int(k) for k in persistent_pool.keys()} +except Exception: + persistent_seat_ids = set() + +# --- Quorum / voting fraction (optional, written by 30_cast_votes.sh as fraction.txt) --- +def read_quorum_fraction(): + for fname in ("fraction.txt", "quorum.txt", "quorum_fraction.txt"): + path = os.path.join(os.getcwd(), fname) + if os.path.exists(path): + try: + raw = open(path, "r", encoding="utf-8").read().strip() + except Exception: + continue + if not raw: + continue + # Prefer numeric if possible, otherwise keep as string + try: + return float(raw) + except ValueError: + return raw + return None + +quorum_fraction = read_quorum_fraction() + +# --- Universe (all pools from registry.info) --- +# Each entry: pool_id, stake (no persistent flag here; committee carries epoch membership) +# Preserve original generation order from registry.info (Python dict preserves insertion order). +universe = [ + {"pool_id": pid, "stake": rec.get("stake", 0)} + for pid, rec in info.items() +] + +# Quick lookup for stake / presence + +# Build quick index: pool_id -> zero-based position in the universe array +universe_index_by_pool_id = {e["pool_id"]: i for i, e in enumerate(universe)} +stake_by_pid = {e["pool_id"]: int(e.get("stake", 0) or 0) for e in universe} + +# --- Persistent seats (ordered by persistent id) --- +persist_entries = [] +for pid_idx in sorted(persistent_pool): + pid = persistent_pool[pid_idx] + persist_entries.append({ + "pool_id": pid, + "stake": stake_by_pid.get(pid, 0), + }) + +# How many nonpersistent seats do we need? +np_needed = max(0, N - len(persist_entries)) + +# --- Non-persistent winners: (disabled for persistent-only committee export) +np_winners = [] +np_final = [] +np_final_set = set() + +# --- Build final committee: persistent seats + placeholders for non-persistent slots +seats = [] +for pos, entry in enumerate(persist_entries, start=1): + pid = entry["pool_id"] + seats.append({ + "position": pos, + "index": universe_index_by_pool_id.get(pid), # universe index + "pool_id": pid, + "stake": entry["stake"], + "kind": "persistent", + }) +# Append placeholders for non-persistent slots (to be filled per‑election) +for i in range(np_needed): + seats.append({ + "position": len(seats) + 1, + "slot": "nonpersistent", + "kind": "nonpersistent", + }) +committee = { + "size": N, + "persistent_count": len(persist_entries), + "nonpersistent_slots": np_needed, + "seats": seats, +} +committee_source = "persistent_plus_placeholders" + +# --- Voters quick view (optional) + committee-based filtering --- +voters_unfiltered = { + "persistent_ids": [], + "nonpersistent_pool_ids": [], +} +voters_filtered = { + "persistent_ids": [], + "nonpersistent_pool_ids": [], +} +filtered_votes_raw = [] +filtered_p_raw = [] +filtered_np_raw = [] +voters_filter_stats = { + "source_total": 0, + "kept_total": 0, + "removed_outside_committee": 0, + "removed_persistent": 0, + "removed_nonpersistent": 0, +} + +def _to_int(x): + try: + return int(x) + except Exception: + return None + +def _is_persistent_id_in_committee(pid) -> bool: + """Return True iff the persistent seat id is in the registry's persistent set.""" + pid_i = _to_int(pid) + return (pid_i is not None) and (pid_i in persistent_seat_ids) + +def _is_np_pool_in_committee(pool_id: str) -> bool: + return isinstance(pool_id, str) and (pool_id in np_final_set) + +votes_preview = [] + +if os.path.exists("votes.cbor"): + try: + votes_raw = cbor2.load(open("votes.cbor", "rb")) + for v in votes_raw: + # Unfiltered bookkeeping + if "Persistent" in v: + pid_raw = v["Persistent"].get("persistent") + pid = _to_int(pid_raw) + if pid is not None: + voters_unfiltered["persistent_ids"].append(pid) + votes_preview.append({"type": "persistent", "seat_id": pid}) + elif "Nonpersistent" in v: + pool = v["Nonpersistent"].get("pool") + if isinstance(pool, str): + voters_unfiltered["nonpersistent_pool_ids"].append(pool) + sigma_eid = v["Nonpersistent"].get("sigma_eid") + prefix = None + if isinstance(sigma_eid, (bytes, bytearray)): + prefix = "0x" + sigma_eid[:12].hex() + votes_preview.append({ + "type": "nonpersistent", + "pool_id": pool, + "eligibility_sigma_eid_prefix": prefix, + }) + + # Filtering (committee only) + if "Persistent" in v: + pid_raw = v["Persistent"].get("persistent") + pid = _to_int(pid_raw) + if pid is not None: + voters_filter_stats["source_total"] += 1 + if _is_persistent_id_in_committee(pid): + voters_filtered["persistent_ids"].append(pid) + voters_filter_stats["kept_total"] += 1 + filtered_votes_raw.append(v) + filtered_p_raw.append(v) + else: + voters_filter_stats["removed_outside_committee"] += 1 + voters_filter_stats["removed_persistent"] += 1 + elif "Nonpersistent" in v: + pool = v["Nonpersistent"].get("pool") + if isinstance(pool, str): + voters_filter_stats["source_total"] += 1 + if _is_np_pool_in_committee(pool): + voters_filtered["nonpersistent_pool_ids"].append(pool) + voters_filter_stats["kept_total"] += 1 + filtered_votes_raw.append(v) + filtered_np_raw.append(v) + else: + voters_filter_stats["removed_outside_committee"] += 1 + voters_filter_stats["removed_nonpersistent"] += 1 + except Exception: + pass + +# --- Compute filtered vote sizes (bytes) by CBOR re-encoding the kept votes only +votes_bytes_raw = os.path.getsize("votes.cbor") if os.path.exists("votes.cbor") else None +votes_bytes_filtered = None +votes_bytes_p_filtered = None +votes_bytes_np_filtered = None +try: + if filtered_votes_raw: + import cbor2 as _cbor2 # already imported, but keep local alias + votes_bytes_filtered = len(_cbor2.dumps(filtered_votes_raw)) + if filtered_p_raw: + votes_bytes_p_filtered = len(_cbor2.dumps(filtered_p_raw)) + if filtered_np_raw: + votes_bytes_np_filtered = len(_cbor2.dumps(filtered_np_raw)) +except Exception: + # Fall back to raw file size if anything goes wrong + votes_bytes_filtered = votes_bytes_raw + +# --- Certificate summary (optional) --- +cert_summary = {} + +if os.path.exists("certificate.cbor"): + cert = cbor2.load(open("certificate.cbor", "rb")) + pv = cert.get("persistent_voters", []) or [] + npv = cert.get("nonpersistent_voters") or {} + def hex_pref(x): + try: + return (x or b"")[:8].hex() + except Exception: + return None + cert_summary = { + "sigma_tilde_eid_prefix": hex_pref(cert.get("sigma_tilde_eid")), + "sigma_tilde_m_prefix": hex_pref(cert.get("sigma_tilde_m")), + "eid": "0x" + (cert.get("eid") or b"").hex() if cert.get("eid") is not None else None, + "eb": "0x" + (cert.get("eb") or b"").hex() if cert.get("eb") is not None else None, + "persistent_voters_count": len(pv) if hasattr(pv, "__len__") else None, + "nonpersistent_voters_count": (len(npv.keys()) if isinstance(npv, dict) else (len(npv) if hasattr(npv, "__len__") else None)), + } + # Override certificate counts with committee‑filtered counts + kept_p = len(set(voters_filtered["persistent_ids"])) + kept_np = len(set(voters_filtered["nonpersistent_pool_ids"])) + cert_summary["persistent_voters_count"] = kept_p + cert_summary["nonpersistent_voters_count"] = kept_np + +# --- Bytes & compression (use filtered votes only) +certificate_bytes = os.path.getsize("certificate.cbor") if os.path.exists("certificate.cbor") else None +if votes_bytes_filtered is not None: + cert_summary["votes_bytes"] = votes_bytes_filtered + # Keep a raw reference for diagnostics + if votes_bytes_raw is not None: + cert_summary["votes_bytes_raw"] = votes_bytes_raw + if votes_bytes_p_filtered is not None: + cert_summary["votes_bytes_persistent"] = votes_bytes_p_filtered + if votes_bytes_np_filtered is not None: + cert_summary["votes_bytes_nonpersistent"] = votes_bytes_np_filtered +if certificate_bytes is not None: + cert_summary["certificate_bytes"] = certificate_bytes +if ("votes_bytes" in cert_summary) and ("certificate_bytes" in cert_summary): + try: + cert_summary["compression_ratio"] = round( + cert_summary["votes_bytes"] / cert_summary["certificate_bytes"], 3 + ) + except Exception: + pass + +# --- Params (handy for the UI header) --- +params = { + "N": N, + "pool_count": len(universe), + "total_stake": total_stake, + "quorum_fraction": quorum_fraction, # may be None if not recorded +} + + +def detect_total_memory_bytes(): + """Attempt to detect total physical memory without external dependencies.""" + try: + if sys.platform.startswith("linux"): + with open("/proc/meminfo", "r", encoding="utf-8") as f: + for line in f: + if line.startswith("MemTotal:"): + parts = line.split() + if len(parts) >= 2: + # Value reported in kB + return int(float(parts[1]) * 1024) + elif sys.platform == "darwin": + out = subprocess.check_output( + ["sysctl", "-n", "hw.memsize"], text=True, stderr=subprocess.DEVNULL + ).strip() + if out: + return int(out) + elif sys.platform.startswith("win"): + out = subprocess.check_output( + ["wmic", "ComputerSystem", "get", "TotalPhysicalMemory"], + text=True, + stderr=subprocess.DEVNULL + ) + for line in out.splitlines(): + line = line.strip() + if line.isdigit(): + return int(line) + except Exception: + return None + return None + + +def detect_cpu_name(): + """Return a human-readable CPU name if available.""" + try: + if sys.platform.startswith("linux"): + with open("/proc/cpuinfo", "r", encoding="utf-8") as f: + for line in f: + if line.lower().startswith("model name"): + _, value = line.split(":", 1) + return value.strip() + elif sys.platform == "darwin": + try: + out = subprocess.check_output( + ["sysctl", "-n", "machdep.cpu.brand_string"], + text=True, + stderr=subprocess.DEVNULL + ).strip() + if out: + return out + except Exception: + pass + out = subprocess.check_output( + ["sysctl", "-n", "hw.model"], + text=True, + stderr=subprocess.DEVNULL + ).strip() + if out: + return out + elif sys.platform.startswith("win"): + out = subprocess.check_output( + ["wmic", "cpu", "get", "Name"], + text=True, + stderr=subprocess.DEVNULL + ) + for line in out.splitlines(): + line = line.strip() + if line and line.lower() != "name": + return line + except Exception: + return None + return platform.processor() or None + + +def gather_environment(): + uname = platform.uname() + info = { + "os": f"{uname.system} {uname.release}".strip(), + "architecture": uname.machine or None, + "cpu_count": os.cpu_count(), + } + mem_bytes = detect_total_memory_bytes() + if mem_bytes is not None: + info["memory_bytes"] = mem_bytes + cpu_name = detect_cpu_name() + if cpu_name: + info["cpu"] = cpu_name + return {k: v for k, v in info.items() if v is not None} + + +environment = gather_environment() + +out = { + "params": params, + "universe": universe, + "committee": committee, + "committee_source": committee_source, + "lookup": { + "universe_index_by_pool_id": universe_index_by_pool_id, + "committee_position_by_pool_id": { + seat["pool_id"]: seat["position"] + for seat in committee["seats"] + if "pool_id" in seat + }, + }, + "voters_unfiltered": voters_unfiltered, # raw ids (persistent seat ids and NP pool ids) + "voters_filter_stats": voters_filter_stats, + "certificate": cert_summary, + "votes_preview": votes_preview, +} + +if environment: + out["environment"] = environment + +json.dump(out, open("demo.json", "w"), indent=2) +print("Wrote demo.json") +PY +popd >/dev/null diff --git a/crypto-benchmarks.rs/demo/scripts/70_run_one.sh b/crypto-benchmarks.rs/demo/scripts/70_run_one.sh new file mode 100755 index 000000000..36587a864 --- /dev/null +++ b/crypto-benchmarks.rs/demo/scripts/70_run_one.sh @@ -0,0 +1,144 @@ + + +#!/usr/bin/env bash +set -euo pipefail +# Orchestrate a full demo run end-to-end in one go. +# +# Usage: +# scripts/70_run_one.sh -d RUN_DIR -n N -f FRACTION [-p POOLS] [-t TOTAL_STAKE] +# Examples (from demo/): +# scripts/70_run_one.sh -d run16 -n 16 -f 1.0 +# scripts/70_run_one.sh -d run32 -n 32 -f 0.85 -p 400 -t 200000 +# +# Notes: +# - This is a convenience wrapper that calls: +# 10_init_inputs.sh +# 20_make_registry.sh +# 30_cast_votes.sh +# 40_make_certificate.sh +# 50_verify_certificate.sh +# - Each sub-script prints its own status; this wrapper adds a compact summary. + +DIR_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEMO_ROOT="$(cd "$DIR_SCRIPT/.." && pwd)" + +RUN_DIR="" +N="" +FRACTION="" +POOLS="" +TOTAL_STAKE="" +PYTHON_EXEC="${VIRTUAL_ENV:+$VIRTUAL_ENV/bin/python3}" +PYTHON_EXEC="${PYTHON_EXEC:-python3}" + +now_ms() { + "$PYTHON_EXEC" - "$@" <<'PY' +import time +print(int(time.time() * 1000)) +PY +} + +usage() { + cat <&2; usage; exit 2;; + esac +done + +if [[ -z "$RUN_DIR" || -z "$N" || -z "$FRACTION" ]]; then + echo "Error: need -d RUN_DIR, -n N, and -f FRACTION" >&2 + usage; exit 2 +fi + +RUN_DIR_ABS="$(cd "$DEMO_ROOT"; mkdir -p "$RUN_DIR"; cd "$RUN_DIR" && pwd)" +echo "== [70_run_one] DIR=${RUN_DIR_ABS} N=${N} FRACTION=${FRACTION} POOLS=${POOLS:-default} TOTAL_STAKE=${TOTAL_STAKE:-default} ==" + +# ---- 10: init inputs ---- +INIT_CMD=("$DIR_SCRIPT/10_init_inputs.sh" -d "$RUN_DIR") +[[ -n "$POOLS" ]] && INIT_CMD+=( --pools "$POOLS" ) +[[ -n "$TOTAL_STAKE" ]] && INIT_CMD+=( --stake "$TOTAL_STAKE" ) +"${INIT_CMD[@]}" + +# ---- 20: make registry ---- +start_make_registry="$(now_ms)" +"$DIR_SCRIPT/20_make_registry.sh" -d "$RUN_DIR" -n "$N" +end_make_registry="$(now_ms)" +make_registry_ms=$(( end_make_registry - start_make_registry )) + +# ---- 30: cast votes ---- +start_cast_votes="$(now_ms)" +"$DIR_SCRIPT/30_cast_votes.sh" -d "$RUN_DIR" -f "$FRACTION" +end_cast_votes="$(now_ms)" +cast_votes_ms=$(( end_cast_votes - start_cast_votes )) + +# ---- 40: make certificate ---- +start_aggregation="$(now_ms)" +"$DIR_SCRIPT/40_make_certificate.sh" -d "$RUN_DIR" +end_aggregation="$(now_ms)" +aggregation_ms=$(( end_aggregation - start_aggregation )) + +# ---- 50: cryptographic verification ---- +start_verify="$(now_ms)" +if "$DIR_SCRIPT/50_verify_certificate.sh" -d "$RUN_DIR"; then + verify_status="success" +else + verify_status="failure" + echo "[70_run_one] Certificate verification failed." >&2 +fi +end_verify="$(now_ms)" +verify_ms=$(( end_verify - start_verify )) + +# ---- 60: generate JSON for UI ---- +"$DIR_SCRIPT/60_export_demo_json.sh" -d "$RUN_DIR" + +timings_path="${RUN_DIR_ABS}/timings.json" +cat > "$timings_path" < Path: + return ROOT / run + + +@app.route("/committee/") +def committee(run): + rdir = run_dir_path(run) + script = ROOT / "scripts" / "extract_committee.py" + if not script.is_file(): + abort(500, f"extract_committee.py not found at {script}") + + try: + out = subprocess.check_output(["python3", str(script), str(rdir)], cwd=ROOT, text=True) + data = json.loads(out) + return jsonify(data) + except subprocess.CalledProcessError as e: + abort(500, f"extract_committee failed: {e.output or e}") + + +@app.route("/registry/") +def registry(run): + """Return pool list + stakes (best-effort).""" + d = run_dir_path(run) + stake_path = d / "stake.cbor" + pools = [] + total_stake = 0 + + if stake_path.is_file(): + with stake_path.open("rb") as f: + stake = cbor2.load(f) + # stake is likely a map {poolId(bytes|hex): int stake} + if isinstance(stake, dict): + for k, v in stake.items(): + pid = k.hex() if isinstance(k, (bytes, bytearray)) else str(k) + s = int(v) if isinstance(v, int) else 0 + pools.append({"id": pid, "stake": s}) + total_stake += s + + return jsonify({ + "pools": pools, + "total_pools": len(pools), + "total_stake": total_stake + }) + +@app.route("/demo/") +def demo_for_run(run): + """Serve demo.json from the given run directory.""" + run_dir = run_dir_path(run) + demo_path = run_dir / "demo.json" + if demo_path.is_file(): + return send_from_directory(str(run_dir), "demo.json") + abort(404, f"demo.json not found in {run_dir}") + +@app.route("/demo//") +def demo_asset(run, filename): + """Serve auxiliary files (eid.txt, ebhash.txt, etc.) from the run directory.""" + run_dir = run_dir_path(run) + target_path = run_dir / filename + if target_path.is_file(): + return send_from_directory(str(run_dir), filename) + abort(404, f"{filename} not found in {run_dir}") + +# === UI endpoint === +@app.route("/ui") +def ui(): + return render_template("index.html") + +# Small helper route to redirect `/` to `/ui` +@app.route("/") +def root(): + return redirect(url_for("ui")) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=True) diff --git a/crypto-benchmarks.rs/demo/ui/static/app.js b/crypto-benchmarks.rs/demo/ui/static/app.js new file mode 100644 index 000000000..7febcba59 --- /dev/null +++ b/crypto-benchmarks.rs/demo/ui/static/app.js @@ -0,0 +1,1497 @@ +// Minimal, robust renderer for the SPO "universe" panel. +// Implements: +// 1. Sorting circles by universe index in both Committee and Voters sections. +// 2. Left-side stats for Committee (total / persistent / non-persistent). +// 3. Updated Voters stats (total / persistent / non-persistent + in-committee vs outside breakdown). +// 4. "(this election)" and "(observed votes)" strings are handled in HTML, not here. + +const $ = (sel) => document.querySelector(sel); + +function setText(id, value) { + const el = document.getElementById(id); + if (el) el.textContent = value ?? "—"; +} + +function formatNumber(value) { + if (value === null || value === undefined || value === "") return "—"; + const num = Number(value); + if (Number.isFinite(num)) return num.toLocaleString(); + if (typeof value === "string") return value; + return String(value); +} + +function formatDuration(ms) { + if (ms === null || ms === undefined || Number.isNaN(ms)) return null; + const value = Number(ms); + if (!Number.isFinite(value) || value < 0) return null; + if (value < 1000) { + if (value < 1) return "0 ms"; + return `${Math.round(value)} ms`; + } + if (value < 60000) { + const display = value < 10000 ? (value / 1000).toFixed(2) : (value / 1000).toFixed(1); + return `${display} s`; + } + const minutes = Math.floor(value / 60000); + const seconds = Math.floor((value % 60000) / 1000); + if (minutes && seconds) return `${minutes}m ${seconds}s`; + if (minutes) return `${minutes}m`; + return `${Math.round(value / 1000)} s`; +} + +function updateVerificationResult(status, label) { + const box = document.getElementById("verify_result_box"); + if (!box) return; + box.classList.remove("is-success", "is-failure"); + let text = label ?? "—"; + if (!status) { + box.textContent = text; + return; + } + const normalized = String(status).toLowerCase(); + if (normalized === "success" || normalized === "ok" || normalized === "passed") { + box.classList.add("is-success"); + text = label ?? "Success"; + } else if (normalized === "failure" || normalized === "failed" || normalized === "error") { + box.classList.add("is-failure"); + text = label ?? "Failure"; + } + box.textContent = text; +} + +function updateVerificationCertificate() { + const certEl = document.getElementById("verification_cert"); + if (!certEl) return; + const data = latestCertificateRender; + certEl.classList.remove("with-tooltip"); + certEl.style.width = ""; + certEl.style.minWidth = ""; + certEl.style.height = ""; + certEl.textContent = "Certificate"; + if (data) { + if (data.width) certEl.style.width = data.width; + if (data.minWidth) certEl.style.minWidth = data.minWidth; + if (data.height) certEl.style.height = data.height; + if (data.tooltipHtml) { + attachTooltip(certEl, data.tooltipHtml); + certEl.classList.add("with-tooltip"); + } else { + attachTooltip(certEl, null); + } + } else { + attachTooltip(certEl, null); + } +} + +function applyVerificationStatus(status) { + latestVerificationStatus = status ?? null; + let normalized = null; + let label = "—"; + if (status !== undefined && status !== null) { + normalized = String(status).toLowerCase(); + if (normalized === "success" || normalized === "ok" || normalized === "passed") { + label = "Success"; + } else if (normalized === "failure" || normalized === "failed" || normalized === "error") { + label = "Failure"; + } else { + label = String(status); + normalized = null; + } + } + const statusEl = document.getElementById("verify_status"); + if (statusEl) { + statusEl.classList.remove("text-green", "text-red"); + if (label === "Success") { + statusEl.classList.add("text-green"); + } else if (label === "Failure") { + statusEl.classList.add("text-red"); + } + } + setText("verify_status", label); + updateVerificationResult(normalized, label !== "—" ? label : null); +} + +function firstFinite(...values) { + for (const value of values) { + if (value === null || value === undefined || value === "") continue; + const num = Number(value); + if (Number.isFinite(num)) return num; + } + return null; +} + +function createEmptyState(message) { + const p = document.createElement("p"); + p.className = "empty-state"; + p.textContent = message; + return p; +} + +// --- Vote byte sizes (visualization constants) --- +const PERSISTENT_VOTE_BYTES = 134; // bytes per persistent vote (viz) +const NONPERSISTENT_VOTE_BYTES = 247; // bytes per non-persistent vote (viz) + +const FIXED_GRID_COLUMNS = 20; // circles per row for Voters alignment +const UNIVERSE_COLUMNS = 30; // circles per row for Universe panel +const COMMITTEE_COLUMNS = 20; // seat boxes per row for Committee +const COMMITTEE_ROW_HEIGHT = "calc(var(--seat-size) + 10px)"; +const VOTE_RECT_HEIGHT = 36; + +let latestDisplayedVoterCount = null; +let latestVerificationStatus = null; +let latestCertificateRender = null; + +// ---------- tooltip helpers ---------- +function positionTooltip(target, tooltip) { + if (!target || !tooltip) return; + const rect = target.getBoundingClientRect(); + const top = rect.top + window.scrollY - 40; + tooltip.style.left = `${rect.left + window.scrollX}px`; + tooltip.style.top = `${Math.max(window.scrollY + 4, top)}px`; +} + +function attachTooltip(element, html) { + if (!element) return; + if (!html) { + element.removeAttribute("data-tooltip-html"); + return; + } + + element.dataset.tooltipHtml = html; + element.removeAttribute("title"); + + if (element._tooltipHandlers) return; + + const show = (event) => { + const tooltip = document.createElement("div"); + tooltip.className = "tooltip-box"; + tooltip.innerHTML = element.dataset.tooltipHtml; + document.body.appendChild(tooltip); + positionTooltip(event.currentTarget, tooltip); + element._tooltip = tooltip; + }; + + const move = (event) => { + if (element._tooltip) { + positionTooltip(event.currentTarget, element._tooltip); + } + }; + + const hide = () => { + if (element._tooltip) { + element._tooltip.remove(); + element._tooltip = null; + } + }; + + element.addEventListener("mouseenter", show); + element.addEventListener("mousemove", move); + element.addEventListener("mouseleave", hide); + element._tooltipHandlers = { show, move, hide }; +} + +// ---------- grid column sync so rows align across sections ---------- +function computeGridColumnsFrom(el) { + // Derive how many columns the Universe grid is using based on the cell size and gaps. + if (!el) return null; + const cs = getComputedStyle(document.documentElement); + const dot = parseFloat(cs.getPropertyValue("--dot-size")) || 30; + const gap = parseFloat(cs.getPropertyValue("--dot-gap")) || 5; + + // Available width inside the container + const width = el.clientWidth; + if (!width || width <= 0) return null; + + // Each column uses dot width plus the horizontal gap, except the last column which has no trailing gap. + // We approximate by including the gap to compute a safe floor. + const cols = Math.max(1, Math.floor((width + gap) / (dot + gap))); + return cols; +} + +function applyFixedColumns( + el, + cols, + columnSize = "var(--dot-size)", + rowSize = "var(--dot-size)", + gapSize = "var(--dot-gap)" +) { + if (!el || !cols) return; + el.style.display = "grid"; + el.style.gridTemplateColumns = `repeat(${cols}, ${columnSize})`; + el.style.gridAutoRows = rowSize; + el.style.gap = gapSize; +} + +function syncGridColumns() { + const committeeEl = document.getElementById("committee_canvas"); + if (committeeEl) { + const seatCount = committeeEl.querySelectorAll(".seat-box").length; + const committeeCols = seatCount ? Math.min(COMMITTEE_COLUMNS, seatCount) : COMMITTEE_COLUMNS; + applyFixedColumns( + committeeEl, + committeeCols, + "var(--seat-size)", + COMMITTEE_ROW_HEIGHT, + "var(--seat-gap)" + ); + } +} +// Shared helper to compute vote rectangle width based on bytes +function voteWidthPx(bytes) { + const maxBytes = Math.max(PERSISTENT_VOTE_BYTES, NONPERSISTENT_VOTE_BYTES) || 1; + const maxWidthPx = 48; // width used for the largest vote (non-persistent) + return Math.max(14, Math.round((bytes / maxBytes) * maxWidthPx)); +} + + +// Human-readable byte formatter +function formatBytes(bytes) { + if (bytes === null || bytes === undefined || isNaN(bytes)) return "—"; + const units = ['bytes', 'KB', 'MB', 'GB']; + let b = Math.max(0, Number(bytes)); + let i = 0; + while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; } + return `${b.toFixed(b < 10 && i > 0 ? 1 : 0)} ${units[i]}`; +} + +// Helper: Find 1-based universe index for a given poolId +function findUniverseIndexByPoolId(demo, poolId) { + if (!demo || !Array.isArray(demo.universe)) return null; + const i = demo.universe.findIndex(p => (p.pool_id || p.id) === poolId); + return i >= 0 ? (i + 1) : null; +} + +// Helper: Get a mapping from poolId to universe index (1-based) +function buildPoolIdToUniverseIndex(demo) { + const map = new Map(); + if (!demo || !Array.isArray(demo.universe)) return map; + for (let i = 0; i < demo.universe.length; ++i) { + const poolId = demo.universe[i].pool_id || demo.universe[i].id; + if (poolId) map.set(poolId, i + 1); + } + return map; +} + +// ---------- main render ---------- +function renderUniverse(universe) { + let pools = []; + if (Array.isArray(universe)) { + pools = universe; + } else if (universe && typeof universe === "object" && Array.isArray(universe.pools)) { + pools = universe.pools; + } + + const total = pools.length; + let persistentCount = 0; + let nonPersistentCount = 0; + + pools.forEach((pool) => { + const isPersistent = + pool.is_persistent === true || + pool.persistent === true || + typeof pool.persistent_id !== "undefined"; + if (isPersistent) persistentCount++; + else nonPersistentCount++; + }); + + setText("universe_total", (total ?? "—")); + setText("universe_persistent", (persistentCount ?? "—")); + setText("universe_nonpersistent", (nonPersistentCount ?? "—")); + // Add total stake calculation + const totalStake = pools.reduce((sum, p) => sum + (Number(p.stake) || 0), 0); + const hasStake = pools.some(p => p.stake !== undefined && p.stake !== null); + setText("universe_stake", hasStake ? formatNumber(totalStake) : "—"); + + const container = $("#universe_canvas"); + if (!container) return; + + if (total === 0) { + container.classList.add("universe-grid"); + container.replaceChildren(createEmptyState("No data available")); + return; + } + + container.classList.add("universe-grid"); + container.style.gridTemplateColumns = `repeat(${UNIVERSE_COLUMNS}, var(--dot-size))`; + container.innerHTML = ""; + + pools.forEach((pool, i) => { + const isPersistent = + pool.is_persistent === true || + pool.persistent === true || + typeof pool.persistent_id !== "undefined"; + const isSelected = pool.is_selected === true || pool.selected === true; + const isElected = pool.is_elected === true || pool.elected === true; + + const div = document.createElement("div"); + div.classList.add("pool-dot"); + if (isPersistent) div.classList.add("is-persistent"); + else div.classList.add("is-nonpersistent"); + if (isSelected) div.classList.add("is-selected"); + if (isElected) div.classList.add("is-elected"); + + const label = document.createElement("span"); + label.classList.add("node-label"); + const idx = i + 1; + label.textContent = idx; + label.style.fontSize = (idx >= 100 ? "11px" : "13px"); + div.appendChild(label); + + const poolId = pool.pool_id || pool.id || ""; + const stake = typeof pool.stake !== "undefined" ? pool.stake : pool.total_stake; + const tooltipHtml = [ + `Pool ID: ${poolId || "—"}`, + `Stake: ${formatNumber(stake)}` + ].join("
"); + attachTooltip(div, tooltipHtml); + + container.appendChild(div); + }); +} + +// ---------- committee render (selected pools) ---------- +function buildPersistentSet(demo) { + const set = new Set(); + if (demo && Array.isArray(demo.universe)) { + for (const p of demo.universe) { + if (p && (p.is_persistent === true || p.persistent === true || typeof p.persistent_id !== "undefined")) { + const id = p.pool_id || p.id; + if (id) set.add(id); + } + } + } + if (demo && demo.persistent_map && typeof demo.persistent_map === "object") { + for (const k of Object.keys(demo.persistent_map)) { + const pid = demo.persistent_map[k]; + if (pid) set.add(pid); + } + } + return set; +} + +function buildPersistentIdToPoolId(demo) { + const m = new Map(); + const raw = demo?.persistent_map; + if (raw && typeof raw === "object") { + for (const [k, v] of Object.entries(raw)) { + if (v) { + const numericKey = Number(k); + if (!Number.isNaN(numericKey)) m.set(numericKey, v); + m.set(String(k), v); + } + } + } + + if (m.size === 0 && Array.isArray(demo?.committee?.seats)) { + for (const seat of demo.committee.seats) { + if (!seat || !seat.pool_id) continue; + if (seat.position !== undefined) { + const pos = Number(seat.position); + m.set(pos, seat.pool_id); + if (Number.isFinite(pos)) { + m.set(pos - 1, seat.pool_id); + } + } + if (seat.index !== undefined) m.set(Number(seat.index), seat.pool_id); + } + } + return m; +} + +// For safety: get poolId from a committee member (support legacy/modern) +function getCommitteePoolId(x) { + return x && (x.pool_id || x.id); +} + +function renderCommitteeFromDemo(demo) { + if (!demo) return; + const container = document.getElementById("committee_canvas"); + if (!container) return; + + // Normalize seats source: + // Prefer new model: demo.committee.seats = [{position, pool_id?, stake?, kind: "persistent"|"nonpersistent"}] + // Fallback to legacy: demo.committee = [ {pool_id, stake?}, ... ] where all entries are persistent seats. + let seats = []; + if (demo.committee && Array.isArray(demo.committee.seats)) { + seats = demo.committee.seats.slice().sort((a, b) => (a.position || 0) - (b.position || 0)); + } else if (Array.isArray(demo.committee)) { + seats = demo.committee.map((m, i) => ({ + position: (m.position ?? (i + 1)), + pool_id: getCommitteePoolId(m), + stake: (typeof m.stake !== "undefined" ? m.stake : undefined), + kind: "persistent" + })); + } else { + // Nothing to render + container.classList.remove("committee-grid"); + container.replaceChildren(createEmptyState("No committee seats")); + return; + } + + const poolIdToUniverseIndex = buildPoolIdToUniverseIndex(demo); + + // Stats: show total, persistent, and non-persistent seat counts. + setText("committee_total", (seats.length ?? "—")); + // Compute persistent/nonpersistent seat counts + const persistentCount = seats.filter(s => s.kind === "persistent").length; + const nonPersistentCount = seats.length - persistentCount; + setText("committee_persistent", persistentCount); + setText("committee_nonpersistent", nonPersistentCount); + + // Layout as seats (boxes), not dots + container.classList.remove("universe-grid"); + container.classList.add("committee-grid"); + container.innerHTML = ""; + const initialCols = Math.max(1, Math.min(COMMITTEE_COLUMNS, seats.length)); + applyFixedColumns( + container, + initialCols, + "var(--seat-size)", + COMMITTEE_ROW_HEIGHT, + "var(--seat-gap)" + ); + + for (const seat of seats) { + const div = document.createElement("div"); + div.className = "seat-box"; + const hasPool = !!seat.pool_id; + const isPersistentSeat = seat.kind === "persistent"; + div.classList.add(isPersistentSeat ? "is-persistent-seat" : "is-nonpersistent-seat"); + + // Seat number at top-left + const num = document.createElement("span"); + num.className = "seat-num"; + num.textContent = String(seat.position ?? ""); + div.appendChild(num); + + // If seat has a pool, add a pool-dot (reuse universal styling) + if (hasPool) { + const poolCircle = document.createElement("div"); + poolCircle.className = "pool-dot is-nonpersistent"; + const lbl = document.createElement("span"); + lbl.className = "node-label"; + const uidx = poolIdToUniverseIndex.get(seat.pool_id); + lbl.textContent = uidx ? String(uidx) : ""; + lbl.style.fontSize = (uidx >= 100 ? "11px" : "13px"); + poolCircle.appendChild(lbl); + div.appendChild(poolCircle); + } + + // Set tooltip for assigned/non-assigned seats + if (hasPool) { + const poolId = seat.pool_id; + const stake = (typeof seat.stake !== "undefined") ? seat.stake : ""; + const tooltipHtml = [ + `Seat #${seat.position}`, + `Pool ID: ${poolId}`, + stake !== "" ? `Stake: ${formatNumber(stake)}` : "" + ].filter(Boolean).join("
"); + attachTooltip(div, tooltipHtml); + } else { + attachTooltip(div, `Seat #${seat.position}
Empty non-persistent slot`); + } + + container.appendChild(div); + } +} + +function buildVoterPools(demo) { + const pidToPool = buildPersistentIdToPoolId(demo); + + const persistentMap = new Map(); + const nonPersistentMap = new Map(); + + const toSeatIndex = (value, { isPosition = false } = {}) => { + if (value === undefined || value === null) return null; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return null; + const intVal = Math.trunc(parsed); + if (isPosition) { + return Math.max(0, intVal - 1); + } + return Math.max(0, intVal); + }; + + const ensurePersistentEntry = (seatIndex) => { + if (seatIndex === undefined || seatIndex === null) return null; + const numericSeat = Number(seatIndex); + if (!Number.isFinite(numericSeat)) return null; + const normalizedSeat = Math.max(0, Math.trunc(numericSeat)); + const mapKey = String(normalizedSeat); + let entry = persistentMap.get(mapKey); + if (!entry) { + const poolId = pidToPool.get(normalizedSeat) ?? pidToPool.get(mapKey) ?? null; + entry = { + seatIndex: normalizedSeat, + seatPosition: normalizedSeat + 1, + poolId, + hasVote: false, + signature: null, + seat: null + }; + persistentMap.set(mapKey, entry); + } + return entry; + }; + + const ensureNonPersistentEntry = (poolId) => { + if (!poolId) return null; + const key = String(poolId); + let entry = nonPersistentMap.get(key); + if (!entry) { + entry = { + poolId: key, + eligibility: null, + signature: null, + hasVote: false + }; + nonPersistentMap.set(key, entry); + } + return entry; + }; + + const votersObj = demo?.voters ?? demo?.voters_filtered ?? demo?.voters_unfiltered ?? null; + if (votersObj && typeof votersObj === "object") { + const persistentIds = votersObj.persistent_ids ?? []; + for (const seatId of persistentIds) { + const idx = toSeatIndex(seatId); + if (idx !== null) ensurePersistentEntry(idx); + } + + const nonIds = votersObj.nonpersistent_pool_ids ?? []; + for (const poolId of nonIds) ensureNonPersistentEntry(poolId); + } + + if (Array.isArray(demo?.votes_preview)) { + for (const entry of demo.votes_preview) { + if (!entry) continue; + if (entry.type === "persistent") { + const idx = toSeatIndex(entry.seat_id ?? entry.seatId ?? entry.id); + const record = ensurePersistentEntry(idx); + if (record) { + record.hasVote = true; + record.signature = entry.signature ?? entry.vote_signature ?? null; + if (!record.poolId && entry.pool_id) record.poolId = entry.pool_id; + } + } else if (entry.type === "nonpersistent") { + const record = ensureNonPersistentEntry(entry.pool_id ?? entry.id); + if (record) { + record.signature = record.signature ?? entry.signature ?? entry.vote_signature ?? null; + record.eligibility = record.eligibility ?? entry.eligibility_sigma_eid_prefix ?? entry.eligibility ?? null; + record.hasVote = true; + } + } + } + } + + const committeePersistentSeats = Array.isArray(demo?.committee?.seats) + ? demo.committee.seats.filter(seat => seat && seat.kind === "persistent") + : []; + + for (const seat of committeePersistentSeats) { + const seatIdx = toSeatIndex(seat?.position, { isPosition: true }); + const record = ensurePersistentEntry(seatIdx); + if (record) { + if (!record.poolId && seat.pool_id) record.poolId = seat.pool_id; + record.seatPosition = Number(seat.position) || (record.seatIndex + 1); + record.seat = seat; + } + } + + const persistentEntries = Array.from(persistentMap.values()); + const nonPersistentEntries = Array.from(nonPersistentMap.values()).filter(entry => entry.hasVote); + const persistentVotePoolIds = persistentEntries + .filter(entry => entry.hasVote && entry.poolId) + .map(entry => entry.poolId); + const nonPersistentPoolIds = nonPersistentEntries + .filter(entry => entry.poolId) + .map(entry => entry.poolId); + + return { + persistentEntries, + nonPersistentEntries, + persistentPoolIds: persistentVotePoolIds, + nonPersistentPoolIds, + persistentSeatCount: committeePersistentSeats.length, + nonPersistentSeatCount: Array.isArray(demo?.committee?.seats) + ? demo.committee.seats.filter(seat => seat && seat.kind === "nonpersistent").length + : 0 + }; +} + +function renderVotersFromDemo(demo) { + if (!demo) return; + const container = document.getElementById("voters_canvas"); + const { + persistentEntries, + nonPersistentEntries, + persistentPoolIds, + nonPersistentPoolIds, + persistentSeatCount, + nonPersistentSeatCount + } = buildVoterPools(demo); + const poolIdToUniverseIndex = buildPoolIdToUniverseIndex(demo); + const committeePositionLookup = new Map( + Object.entries(demo?.lookup?.committee_position_by_pool_id || {}).map(([k, v]) => [k, Number(v)]) + ); + const committeeMembers = Array.isArray(demo?.committee?.seats) + ? demo.committee.seats + : (Array.isArray(demo?.committee) ? demo.committee : []); + + // Build a lookup for stakes so tooltips can show them for voters (on circle), and vote tooltips can show voter id + const poolIdToStake = new Map(); + if (Array.isArray(demo.universe)) { + for (const p of demo.universe) { + const id = p.pool_id || p.id; + if (id) poolIdToStake.set(id, (typeof p.stake !== "undefined") ? p.stake : ""); + } + } + for (const m of committeeMembers) { + const id = m.pool_id || m.id; + if (id && !poolIdToStake.has(id)) { + poolIdToStake.set(id, (typeof m.stake !== "undefined") ? m.stake : ""); + } + } + + // Quorum / fraction (if present). Accept many possible shapes/keys and strings. + // Do NOT set any default if not found; simply show '—'. + let q = ( + demo?.parameters?.vote_fraction ?? + demo?.parameters?.fraction ?? + demo?.voters?.fraction ?? + demo?.voters?.quorum ?? + demo?.vote_fraction ?? + demo?.fraction ?? + demo?.quorum ?? + demo?.metadata?.vote_fraction ?? + demo?.params?.vote_fraction ?? + demo?.params?.fraction ?? + demo?.params?.quorum ?? + demo?.params?.quorum_fraction + ); + if (typeof q === 'string') q = parseFloat(q); + if (q !== undefined && q !== null && !Number.isNaN(q)) { + const pct = q <= 1 ? Math.round(q * 100) : Math.round(q); + setText('voters_quorum', pct + '%'); + } else { + setText('voters_quorum', '—'); + } + + // Stats: persistent/nonpersistent, and breakdown for nonpersistent only + const persistentTotalSeats = persistentSeatCount; + const persistentVotesCount = persistentEntries.filter(entry => entry.hasVote).length; + const nonPersistentTotalSlots = nonPersistentSeatCount; + + const displayedPersistentVotes = persistentVotesCount; + const targetNonPersistent = nonPersistentTotalSlots + ? Math.min(Math.round(nonPersistentTotalSlots * 0.75), nonPersistentEntries.length) + : Math.min(Math.round(nonPersistentEntries.length * 0.75), nonPersistentEntries.length); + const targetNonPersistentCount = Math.max(targetNonPersistent, 0); + + setText("voters_persistent", `${displayedPersistentVotes}/${persistentTotalSeats || "—"}`); + + + if (!container) return; + + container.classList.remove("universe-grid", "voting-list"); + container.classList.add("votes-board"); + container.innerHTML = ""; + + if (!persistentEntries.length && !nonPersistentEntries.length) { + container.appendChild(createEmptyState("No voters recorded")); + return; + } + + const persistentSorted = [...persistentEntries].sort((a, b) => { + const seatA = Number(a.seatPosition); + const seatB = Number(b.seatPosition); + const idxA = Number.isFinite(seatA) ? seatA : (poolIdToUniverseIndex.get(a.poolId) ?? 99999); + const idxB = Number.isFinite(seatB) ? seatB : (poolIdToUniverseIndex.get(b.poolId) ?? 99999); + return idxA - idxB; + }); + const nonPersistentSorted = [...nonPersistentEntries].sort((a, b) => { + const idxA = poolIdToUniverseIndex.get(a.poolId) ?? 99999; + const idxB = poolIdToUniverseIndex.get(b.poolId) ?? 99999; + return idxA - idxB; + }); + const displayedNonPersistent = nonPersistentSorted.slice(0, targetNonPersistentCount); + const displayedNonPersistentCount = displayedNonPersistent.length; + + const totalVotersDisplayed = displayedPersistentVotes + displayedNonPersistentCount; + latestDisplayedVoterCount = totalVotersDisplayed; + setText("voters_total", `${totalVotersDisplayed}`); + setText("voters_nonpersistent", `${displayedNonPersistentCount}/${nonPersistentTotalSlots || "—"}`); + + const poolIdToSeat = new Map(); + const positionToSeat = new Map(); + const nonPersistentSeatsOrdered = []; + for (const seat of committeeMembers) { + if (seat && seat.pool_id) { + poolIdToSeat.set(seat.pool_id, seat); + } + if (seat && seat.position !== undefined) { + positionToSeat.set(Number(seat.position), seat); + } + if (seat && seat.kind === "nonpersistent") { + nonPersistentSeatsOrdered.push(seat); + } + } + nonPersistentSeatsOrdered.sort((a, b) => (a.position || 0) - (b.position || 0)); + + const createArrow = () => { + const arrow = document.createElement("span"); + arrow.className = "vote-arrow vote-flow__arrow"; + return arrow; + }; + + const createSeatTile = (seat, poolId, variant = "persistent", seatPositionOverride = null) => { + const seatTile = document.createElement("div"); + seatTile.className = `seat-box vote-seat-inline ${variant === "persistent" ? "is-persistent-seat" : "is-nonpersistent-seat"}`; + + let seatNum = seat?.position ?? seat?.index ?? null; + if ((seatNum === null || seatNum === undefined) && seatPositionOverride !== null && seatPositionOverride !== undefined) { + const numericSeatPos = Number(seatPositionOverride); + if (Number.isFinite(numericSeatPos)) { + seatNum = numericSeatPos; + } + } + const numSpan = document.createElement("span"); + numSpan.className = "seat-num"; + if (seatNum !== null && seatNum !== undefined) { + numSpan.textContent = String(seatNum); + } else { + numSpan.textContent = variant === "persistent" ? "—" : ""; + } + seatTile.appendChild(numSpan); + + const dot = document.createElement("div"); + dot.className = "pool-dot is-nonpersistent"; + const label = document.createElement("span"); + label.className = "node-label"; + const resolvedPoolId = poolId ?? seat?.pool_id ?? null; + let universeIdx = null; + if (resolvedPoolId) { + if (seat && seat.index !== undefined) { + const seatIndexNumeric = Number(seat.index); + universeIdx = Number.isFinite(seatIndexNumeric) ? (seatIndexNumeric + 1) : null; + } + if (universeIdx === null) { + const lookupIdx = poolIdToUniverseIndex.get(resolvedPoolId) ?? committeePositionLookup.get(resolvedPoolId); + if (lookupIdx !== undefined && lookupIdx !== null) { + universeIdx = Number(lookupIdx); + if (Number.isFinite(universeIdx)) universeIdx += 1; + } + } + } + label.textContent = universeIdx ? String(universeIdx) : ""; + label.style.fontSize = (universeIdx >= 100 ? "11px" : "13px"); + dot.appendChild(label); + seatTile.appendChild(dot); + + const tooltip = []; + if (seatNum) tooltip.push(`Seat #${seatNum}`); + if (resolvedPoolId) tooltip.push(`Pool ID: ${resolvedPoolId}`); + if (seat && seat.stake !== undefined) { + tooltip.push(`Stake: ${formatNumber(seat.stake)}`); + } + attachTooltip(seatTile, tooltip.join("
")); + return seatTile; + }; + + const createNodeTile = (poolId, isPersistent) => { + const node = document.createElement("div"); + node.className = "vote-node"; + + const dot = document.createElement("div"); + dot.className = "pool-dot"; + dot.classList.add(isPersistent ? "is-persistent" : "is-nonpersistent", "is-voter"); + const label = document.createElement("span"); + label.className = "node-label"; + const universeIdx = poolIdToUniverseIndex.get(poolId); + label.textContent = universeIdx ? String(universeIdx) : ""; + label.style.fontSize = (universeIdx >= 100 ? "11px" : "13px"); + dot.appendChild(label); + const stake = poolIdToStake.get(poolId); + const lines = []; + if (poolId) { + lines.push(`Pool ID: ${poolId}`); + } + if (stake !== undefined && stake !== "") { + lines.push(`Stake: ${formatNumber(stake)}`); + } + attachTooltip(dot, lines.join("
")); + node.appendChild(dot); + + return node; + }; + + const createVoteRect = (info, isPersistent) => { + const voteRect = document.createElement("div"); + voteRect.className = "vote-rect"; + voteRect.classList.add(isPersistent ? "is-persistent" : "is-nonpersistent"); + const vBytes = isPersistent ? PERSISTENT_VOTE_BYTES : NONPERSISTENT_VOTE_BYTES; + voteRect.style.width = `${voteWidthPx(vBytes)}px`; + + const tooltip = []; + if (isPersistent) { + const seatNum = info.seatPosition ?? info.seat?.position ?? info.seat?.index ?? "—"; + tooltip.push(`Seat #${seatNum}`); + tooltip.push(`Size: ${formatBytes(vBytes)}`); + if (info.signature) tooltip.push(`Signature: ${info.signature}`); + } else { + tooltip.push(`Pool ID: ${info.poolId}`); + if (info.eligibility) tooltip.push(`Eligibility prefix: ${info.eligibility}`); + if (info.signature) tooltip.push(`Signature: ${info.signature}`); + tooltip.push(`Size: ${formatBytes(vBytes)}`); + } + attachTooltip(voteRect, tooltip.join("
")); + return voteRect; + }; + + const createPersistentRow = (entry, idx) => { + const row = document.createElement("div"); + row.className = "vote-flow__row vote-flow__row--persistent"; + const seat = entry.seat ?? poolIdToSeat.get(entry.poolId) ?? positionToSeat.get(entry.seatPosition); + row.appendChild(createSeatTile(seat, entry.poolId ?? seat?.pool_id ?? null, "persistent", entry.seatPosition)); + + const arrow = createArrow(); + if (!entry.hasVote) { + arrow.classList.add("placeholder"); + } + row.appendChild(arrow); + + if (entry.hasVote) { + row.appendChild(createVoteRect({ ...entry, seat }, true)); + } else { + const placeholder = document.createElement("div"); + placeholder.className = "vote-rect placeholder persistent"; + placeholder.style.width = `${voteWidthPx(PERSISTENT_VOTE_BYTES)}px`; + row.appendChild(placeholder); + } + return row; + }; + + const createNonPersistentRow = (entry, idx) => { + const row = document.createElement("div"); + row.className = "vote-flow__row vote-flow__row--nonpersistent"; + const seat = entry.seat ?? nonPersistentSeatsOrdered[idx] ?? null; + row.appendChild(createSeatTile(seat, entry.poolId, "nonpersistent")); + row.appendChild(createArrow()); + row.appendChild(createVoteRect(entry, false)); + return row; + }; + + const appendFlow = (entries, buildRow, modifier) => { + if (!entries.length) return; + const flow = document.createElement("div"); + flow.className = "vote-flow"; + if (modifier) flow.classList.add(modifier); + entries.forEach((entry, index) => { + if (!entry.seat && buildRow === createNonPersistentRow) { + entry.seat = nonPersistentSeatsOrdered[index] ?? null; + } + flow.appendChild(buildRow(entry, index)); + }); + container.appendChild(flow); + }; + + appendFlow(persistentSorted, createPersistentRow, "vote-flow--persistent"); + appendFlow(displayedNonPersistent, createNonPersistentRow, "vote-flow--nonpersistent"); +} + +function renderAggregationFromDemo(demo) { + const container = document.getElementById('aggregation_canvas'); + if (!container) return; + + const { + persistentEntries, + nonPersistentEntries, + nonPersistentSeatCount + } = buildVoterPools(demo); + + const poolIdToUniverseIndex = buildPoolIdToUniverseIndex(demo); + const committeeMembers = Array.isArray(demo?.committee?.seats) + ? demo.committee.seats + : (Array.isArray(demo?.committee) ? demo.committee : []); + + const poolIdToSeat = new Map(); + const positionToSeat = new Map(); + for (const seat of committeeMembers) { + if (!seat) continue; + if (seat.pool_id) poolIdToSeat.set(seat.pool_id, seat); + if (seat.position !== undefined) positionToSeat.set(Number(seat.position), seat); + } + + const persistentSorted = [...persistentEntries].sort((a, b) => { + const seatA = Number(a.seatPosition); + const seatB = Number(b.seatPosition); + const idxA = Number.isFinite(seatA) ? seatA : (poolIdToUniverseIndex.get(a.poolId) ?? 99999); + const idxB = Number.isFinite(seatB) ? seatB : (poolIdToUniverseIndex.get(b.poolId) ?? 99999); + return idxA - idxB; + }); + const persistentVotes = persistentSorted.filter(entry => entry.hasVote); + + const nonPersistentSorted = [...nonPersistentEntries].sort((a, b) => { + const idxA = poolIdToUniverseIndex.get(a.poolId) ?? 99999; + const idxB = poolIdToUniverseIndex.get(b.poolId) ?? 99999; + return idxA - idxB; + }); + const targetNonPersistentBase = nonPersistentSeatCount + ? Math.min(Math.round(nonPersistentSeatCount * 0.75), nonPersistentSorted.length) + : Math.min(Math.round(nonPersistentSorted.length * 0.75), nonPersistentSorted.length); + const targetNonPersistentCount = Math.max(targetNonPersistentBase, 0); + const displayedNonPersistent = nonPersistentSorted.slice(0, targetNonPersistentCount); + + const persistentBytes = persistentVotes.length * PERSISTENT_VOTE_BYTES; + const nonPersistentBytes = displayedNonPersistent.length * NONPERSISTENT_VOTE_BYTES; + const totalVotesBytes = persistentBytes + nonPersistentBytes; + const certBytesRaw = firstFinite( + demo?.certificate?.cert_bytes, + demo?.certificate?.certificate_bytes, + demo?.certificate?.bytes, + demo?.aggregation?.certificate_bytes + ); + const certBytes = Number(certBytesRaw); + + const votesEl = document.getElementById('agg_votes_size'); + if (votesEl) { + const pieces = [ + `${formatNumber(totalVotesBytes)} B`, + ` Persistent: ${formatNumber(persistentBytes)} B`, + ` Non-persistent: ${formatNumber(nonPersistentBytes)} B` + ]; + votesEl.innerHTML = pieces.join('
'); + } + + setText('agg_cert_size', certBytes && isFinite(certBytes) ? formatBytes(certBytes) : '—'); + + const gainEl = document.getElementById('agg_gain'); + if (gainEl) { + const ratioVal = totalVotesBytes > 0 && Number(certBytes) > 0 + ? totalVotesBytes / Number(certBytes) + : null; + gainEl.textContent = ratioVal && isFinite(ratioVal) ? `${ratioVal.toFixed(2)}×` : '—'; + } + + container.classList.add('aggregation-row'); + container.innerHTML = ''; + + if (persistentVotes.length + displayedNonPersistent.length === 0) { + latestCertificateRender = null; + updateVerificationCertificate(); + container.appendChild(createEmptyState("No votes recorded")); + return; + } + + const votesGrid = document.createElement('div'); + votesGrid.className = 'agg-votes'; + + const addVoteRect = (entry, isPersistent) => { + const vote = document.createElement('div'); + vote.className = 'vote-rect'; + vote.classList.add(isPersistent ? 'is-persistent' : 'is-nonpersistent'); + const bytes = isPersistent ? PERSISTENT_VOTE_BYTES : NONPERSISTENT_VOTE_BYTES; + vote.style.width = `${voteWidthPx(bytes)}px`; + const tooltip = []; + if (isPersistent) { + const seat = entry.seat ?? poolIdToSeat.get(entry.poolId) ?? positionToSeat.get(entry.seatPosition); + const seatNum = entry.seatPosition ?? seat?.position ?? seat?.index ?? "—"; + tooltip.push(`Seat #${seatNum}`); + tooltip.push(`Size: ${formatBytes(bytes)}`); + if (entry.signature) tooltip.push(`Signature: ${entry.signature}`); + } else { + if (entry.poolId) tooltip.push(`Pool ID: ${entry.poolId}`); + if (entry.eligibility) tooltip.push(`Eligibility prefix: ${entry.eligibility}`); + if (entry.signature) tooltip.push(`Signature: ${entry.signature}`); + tooltip.push(`Size: ${formatBytes(bytes)}`); + } + attachTooltip(vote, tooltip.join('
')); + votesGrid.appendChild(vote); + }; + + persistentVotes.forEach(entry => addVoteRect(entry, true)); + displayedNonPersistent.forEach(entry => addVoteRect(entry, false)); + + const arrow = document.createElement('span'); + arrow.className = 'big-arrow'; + arrow.textContent = '⇒'; + + const cert = document.createElement('div'); + cert.className = 'certificate-rect'; + cert.textContent = 'Certificate'; + + if (totalVotesBytes > 0 && isFinite(certBytes) && certBytes > 0) { + const persistentWidth = persistentVotes.length * voteWidthPx(PERSISTENT_VOTE_BYTES); + const nonPersistentWidth = displayedNonPersistent.length * voteWidthPx(NONPERSISTENT_VOTE_BYTES); + const totalVoteWidth = persistentWidth + nonPersistentWidth; + const voteArea = totalVoteWidth * VOTE_RECT_HEIGHT; + const certificateArea = voteArea * (certBytes / totalVotesBytes); + if (certificateArea > 0 && Number.isFinite(certificateArea)) { + const minWidth = 12; + const minHeight = VOTE_RECT_HEIGHT; + const maxWidth = totalVoteWidth > 0 ? totalVoteWidth : null; + + let width = Math.sqrt(certificateArea); + if (maxWidth && width > maxWidth) { + width = maxWidth; + } + width = Math.max(minWidth, width); + + let height = certificateArea / width; + + if (height < minHeight) { + height = minHeight; + width = certificateArea / height; + if (maxWidth && width > maxWidth) { + width = maxWidth; + height = certificateArea / width; + } + } + + if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) { + width = Math.max(minWidth, Math.sqrt(Math.max(certificateArea, 1))); + if (maxWidth && width > maxWidth) { + width = maxWidth; + } + height = certificateArea / width; + } + + const widthPx = Math.max(1, width); + const heightPx = Math.max(1, height); + const widthStr = `${widthPx.toFixed(1)}px`; + cert.style.width = widthStr; + cert.style.minWidth = widthStr; + cert.style.height = `${heightPx.toFixed(1)}px`; + } else { + cert.style.height = "110px"; + cert.style.width = "150px"; + } + } else { + cert.style.height = "110px"; + cert.style.width = "150px"; + } + + const certificateTooltip = []; + const eid = demo?.certificate?.eid ?? demo?.identifiers?.eid; + const eb = demo?.certificate?.eb_hash ?? demo?.identifiers?.eb_hash; + const signer = demo?.certificate?.signer; + if (signer) certificateTooltip.push(`Signer: ${signer}`); + if (eid) certificateTooltip.push(`EID: ${eid}`); + if (eb) certificateTooltip.push(`EB hash: ${eb}`); + if (certBytes !== null) certificateTooltip.push(`Size: ${formatBytes(certBytes)}`); + if (certificateTooltip.length) { + attachTooltip(cert, certificateTooltip.join('
')); + } + + latestCertificateRender = { + width: cert.style.width || "", + minWidth: cert.style.minWidth || "", + height: cert.style.height || "", + tooltipHtml: certificateTooltip.length ? certificateTooltip.join('
') : null + }; + updateVerificationCertificate(); + + container.appendChild(votesGrid); + container.appendChild(arrow); + container.appendChild(cert); +} + +// ---------- data loading ---------- +function getRunFromURL() { + const p = new URLSearchParams(window.location.search); + const run = p.get("run"); + // No default fallback here. Only accept valid runX format or null. + return run && /^run\d+$/i.test(run) ? run : null; +} + +async function tryFetchJson(url) { + try { + const r = await fetch(url, { cache: "no-store" }); + if (!r.ok) return null; + return await r.json(); + } catch { + return null; + } +} + +async function tryFetchText(url) { + try { + const r = await fetch(url, { cache: "no-store" }); + if (!r.ok) return null; + return (await r.text()).trim(); + } catch { + return null; + } +} + +// Loads demo JSON for a specific run directory (runDir: string) +async function loadDemoJson(runDir) { + if (!runDir || typeof runDir !== "string") return null; + + const data = await tryFetchJson(`/demo/${runDir}`); + if (!data) return null; + + if (!data.identifiers) { + const [eid, eb] = await Promise.all([ + tryFetchText(`/demo/${runDir}/eid.txt`), + tryFetchText(`/demo/${runDir}/ebhash.txt`) + ]); + data.identifiers = { + eid: eid || null, + eb_hash: eb || null + }; + } + return data; +} + +async function loadTimingsForRun(runDir) { + if (!runDir || typeof runDir !== "string") { + setText("committee_timing", "—"); + setText("voting_timing", "—"); + setText("voting_timing_avg", "—"); + setText("agg_timing", "—"); + setText("verify_time", "—"); + setText("verify_status", "—"); + applyVerificationStatus(null); + return null; + } + const timings = await tryFetchJson(`/demo/${runDir}/timings.json`); + if (timings && typeof timings === "object") { + const committeeFormatted = formatDuration(timings.committee_selection_ms); + const votingFormatted = formatDuration(timings.vote_casting_ms); + const aggregationFormatted = formatDuration(timings.aggregation_ms); + const verificationFormatted = formatDuration(timings.verification_ms); + setText("committee_timing", committeeFormatted ?? "—"); + setText("voting_timing", votingFormatted ?? "—"); + setText("agg_timing", aggregationFormatted ?? "—"); + setText("verify_time", verificationFormatted ?? "—"); + const totalVoters = Number(latestDisplayedVoterCount); + let avgText = "—"; + if (Number.isFinite(totalVoters) && totalVoters > 0) { + const totalMs = Number(timings.vote_casting_ms); + if (Number.isFinite(totalMs) && totalMs >= 0) { + const avgMs = totalMs / totalVoters; + if (avgMs < 1000) { + const precision = avgMs < 10 ? 2 : 1; + avgText = `${avgMs.toFixed(precision)} ms`; + } else { + avgText = formatDuration(avgMs) ?? "—"; + } + } + } + setText("voting_timing_avg", avgText); + const statusSource = latestVerificationStatus ?? timings.verification_status ?? null; + applyVerificationStatus(statusSource); + } else { + setText("committee_timing", "—"); + setText("voting_timing", "—"); + setText("voting_timing_avg", "—"); + setText("agg_timing", "—"); + setText("verify_time", "—"); + applyVerificationStatus(null); + } + return timings ?? null; +} + +function fillIdentifiers(obj) { + if (!obj) return; + const eid = obj.eid || obj.EID; + const eb = obj.eb_hash || obj.eb || obj.EB || obj.ebHash; + if (eid) { + setText("eid", eid); + } + if (eb) { + setText("eb", eb); + } +} + +function applyEnvironmentInfo(environment) { + const container = document.getElementById("demo_meta"); + const summaryEl = document.getElementById("machine_summary"); + + if (!container || !summaryEl) return; + + const hasEnvironment = environment && typeof environment === "object"; + + if (!hasEnvironment) { + summaryEl.textContent = "—"; + container.hidden = true; + return; + } + + const parts = []; + if (environment.os && environment.architecture) { + parts.push(`${environment.os} (${environment.architecture})`); + } else if (environment.os) { + parts.push(environment.os); + } else if (environment.architecture) { + parts.push(environment.architecture); + } + + if (environment.cpu) { + const cpuLabel = typeof environment.cpu === "string" + ? environment.cpu.trim() + : environment.cpu; + if (cpuLabel) { + parts.push(cpuLabel); + } + } + + if (environment.cpu_count !== undefined && environment.cpu_count !== null) { + const cores = Number(environment.cpu_count); + if (Number.isFinite(cores) && cores > 0) { + parts.push(`${cores} ${cores === 1 ? "core" : "cores"}`); + } + } + + if (environment.memory_bytes !== undefined && environment.memory_bytes !== null) { + const memoryBytes = Number(environment.memory_bytes); + if (Number.isFinite(memoryBytes) && memoryBytes > 0) { + parts.push(`${formatBytes(memoryBytes)} RAM`); + } + } + + if (!parts.length) { + summaryEl.textContent = "—"; + container.hidden = true; + return; + } + + summaryEl.textContent = parts.join(" • "); + container.hidden = false; +} + +const FLOW_STEPS = [ + { + wrapperId: "step_committee", + buttonId: "btn_step_committee", + targetId: "committee", + nextWrapperId: "step_election" + }, + { + wrapperId: "step_election", + buttonId: "btn_step_election", + targetId: "identifiers", + nextWrapperId: "step_voting" + }, + { + wrapperId: "step_voting", + buttonId: "btn_step_voting", + targetId: "voters", + nextWrapperId: "step_aggregation" + }, + { + wrapperId: "step_aggregation", + buttonId: "btn_step_aggregation", + targetId: "aggregation", + nextWrapperId: "step_verification" + }, + { + wrapperId: "step_verification", + buttonId: "btn_step_verification", + targetId: "verification", + nextWrapperId: null + } +]; + +let progressiveRevealInitialized = false; + +function handleFlowButtonClick(step) { + if (!step) return; + const target = document.getElementById(step.targetId); + if (target) { + target.classList.remove("is-hidden"); + requestAnimationFrame(() => { + target.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + } + const wrapper = document.getElementById(step.wrapperId); + if (wrapper) wrapper.classList.add("is-hidden"); + if (step.nextWrapperId) { + const nextWrapper = document.getElementById(step.nextWrapperId); + if (nextWrapper) nextWrapper.classList.remove("is-hidden"); + } + if (typeof syncGridColumns === "function") { + try { + syncGridColumns(); + } catch { + // Ignore layout sync errors during reveal. + } + } +} + +function resetProgressiveReveal() { + FLOW_STEPS.forEach((step, index) => { + const target = document.getElementById(step.targetId); + if (target) target.classList.add("is-hidden"); + const wrapper = document.getElementById(step.wrapperId); + if (wrapper) { + if (index === 0) { + wrapper.classList.remove("is-hidden"); + } else { + wrapper.classList.add("is-hidden"); + } + } + }); +} + +function setupProgressiveReveal() { + if (!progressiveRevealInitialized) { + FLOW_STEPS.forEach((step) => { + const button = document.getElementById(step.buttonId); + if (!button) return; + button.addEventListener("click", () => handleFlowButtonClick(step)); + }); + progressiveRevealInitialized = true; + } + resetProgressiveReveal(); +} + +// --- UI clearing helpers --- +function clearUIPlaceholders() { + setText("universe_total", "—"); + setText("universe_persistent", "—"); + setText("universe_nonpersistent", "—"); + setText("committee_total", "—"); + setText("committee_persistent", "—"); + setText("committee_nonpersistent", "—"); + setText("committee_timing", "—"); + setText("voting_timing", "—"); + setText("voting_timing_avg", "—"); + setText("agg_timing", "—"); + setText("verify_time", "—"); + setText("verify_status", "—"); + setText("eid", "—"); + setText("eb", "—"); + setText("voters_total", "—"); + setText("voters_persistent", "—"); + setText("voters_nonpersistent", "—"); + setText('voters_quorum', '—'); + setText('agg_votes_size', '—'); + setText('agg_cert_size', '—'); + setText('agg_gain', '—'); + setText("machine_summary", "—"); + const metaEl = document.getElementById("demo_meta"); + if (metaEl) metaEl.hidden = true; + // Clear main panels + const clearIds = ["universe_canvas", "committee_canvas", "voters_canvas", "aggregation_canvas"]; + for (const id of clearIds) { + const el = document.getElementById(id); + if (el) el.innerHTML = ""; + } + latestDisplayedVoterCount = null; + latestCertificateRender = null; + updateVerificationCertificate(); + applyVerificationStatus(null); + resetProgressiveReveal(); +} + +// --- Data loading and rendering sequence for a given runDir --- +async function loadAndRenderRun(runDir) { + clearUIPlaceholders(); + if (!runDir || typeof runDir !== "string") { + renderUniverse([]); + return; + } + const demo = await loadDemoJson(runDir); + if (demo) { + const universe = demo.universe || demo; + renderUniverse(universe); + fillIdentifiers(demo.identifiers); + fillIdentifiers(demo.certificate); + renderCommitteeFromDemo(demo); + renderVotersFromDemo(demo); + renderAggregationFromDemo(demo); + applyEnvironmentInfo(demo?.environment ?? null); + applyVerificationStatus(demo?.verification?.status ?? demo?.verification_status ?? null); + const timings = await loadTimingsForRun(runDir); + syncGridColumns(); + window.addEventListener("resize", syncGridColumns); + } else { + renderUniverse([]); + } +} + +// --- Boot logic: only auto-load if localStorage has lastRun --- +document.addEventListener("DOMContentLoaded", () => { + clearUIPlaceholders(); + setupProgressiveReveal(); + + // Setup form submission for run directory selection + const form = document.getElementById("run-form"); + if (form) { + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const input = form.querySelector("input[name='run']") || document.getElementById("run-input"); + if (!input) return; + let runDir = input.value.trim(); + // Accept only runX format (e.g., run64, run5, etc.) + if (!/^run\d+$/i.test(runDir)) { + alert("Please enter a valid run directory (e.g., run64)."); + input.focus(); + return; + } + // Save last used run to localStorage + localStorage.setItem("lastRun", runDir); + await loadAndRenderRun(runDir); + }); + } + + // Prefer run from URL param if present (e.g., /ui?run=run100) + const runFromURL = getRunFromURL(); + if (runFromURL) { + const inputUrl = document.querySelector("#run-form input[name='run']") || document.getElementById("run-input"); + if (inputUrl) inputUrl.value = runFromURL; + localStorage.setItem("lastRun", runFromURL); + loadAndRenderRun(runFromURL); + return; // skip other auto-loading + } + + // If localStorage has lastRun, auto-load it; else wait for user input + const lastRun = localStorage.getItem("lastRun"); + if (lastRun && /^run\d+$/i.test(lastRun)) { + // Optionally set the input field, if present + const input = document.querySelector("#run-form input[name='run']") || document.getElementById("run-input"); + if (input) input.value = lastRun; + loadAndRenderRun(lastRun); + } + // else: UI remains in cleared state, waiting for user to submit run directory +}); + +// === Middle-ellipsis for long identifiers (keep start and end) === +function shortenMiddle(str, keepStart = 26, keepEnd = 22) { + if (!str || typeof str !== 'string') return str; + if (str.length <= keepStart + keepEnd + 1) return str; + return str.slice(0, keepStart) + '…' + str.slice(-keepEnd); +} + +function watchAndEllipsize(id, opts = {}) { + const el = document.getElementById(id); + if (!el) return; + const { keepStart = 26, keepEnd = 22, truncate = true } = opts; + const apply = () => { + const current = (el.textContent || "").trim(); + const stored = el.getAttribute('data-full'); + const full = current || stored || ""; + const next = truncate ? shortenMiddle(full, keepStart, keepEnd) : full; + + if (stored !== full) { + el.setAttribute('data-full', full); + } + if (!el.classList.contains('mono')) { + el.classList.add('mono'); + } + el.title = full; + if (el.textContent !== next) { + el.textContent = next; + } + }; + const mo = new MutationObserver(apply); + mo.observe(el, { childList: true, characterData: true, subtree: true }); + apply(); +} + +document.addEventListener('DOMContentLoaded', () => { + watchAndEllipsize('eid', { truncate: false }); + watchAndEllipsize('eb', { keepStart: 26, keepEnd: 22, truncate: true }); +}); diff --git a/crypto-benchmarks.rs/demo/ui/static/styles.css b/crypto-benchmarks.rs/demo/ui/static/styles.css new file mode 100644 index 000000000..006f6c855 --- /dev/null +++ b/crypto-benchmarks.rs/demo/ui/static/styles.css @@ -0,0 +1,769 @@ +:root { + --bg: #0b0f14; + --card: #121a22; + --text: #e7eef7; + --muted: #9fb2c7; + --green: #19c37d; + --orange: #f59e0b; + --timing: #f472b6; + --red: #ef4444; + --yellow: #facc15; + --border: #1f2a35; + --dot-size: 30px; + --dot-gap: 6px; + --seat-size: 48px; + --seat-gap: 6px; + --vote-height: 36px; + --card-radius: 10px; +} + +* { + box-sizing: border-box; +} + +body, +button, +input { + font-family: ui-sans-serif, system-ui, -apple-system, blinkmacsystemfont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +a { + color: inherit; +} + +.app-header { + max-width: 960px; + margin: 24px auto 16px; + padding: 0 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + text-align: center; +} + +.app-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + justify-content: center; + font-size: 16px; + color: var(--muted); +} + +.app-meta__line { + display: flex; + gap: 0.35rem; + align-items: center; +} + +.app-meta__line strong { + color: var(--text); + font-weight: 600; +} + +.app-title { + margin: 0; + font-size: 28px; + font-weight: 700; +} + +.app-header__form { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + justify-content: center; +} + +.app-header__form label { + font-weight: 600; +} + +.app-header__form input { + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: #0e141b; + color: var(--text); + min-width: 180px; +} + +.app-header__form button { + padding: 8px 14px; + border-radius: 6px; + border: 1px solid transparent; + background: #2a4365; + color: #ffffff; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.app-header__form button:hover, +.app-header__form button:focus-visible { + background: #33527c; + border-color: #4b6b97; +} + +.app-header__form button:focus-visible, +.app-header__form input:focus-visible { + outline: 2px solid #4b6b97; + outline-offset: 2px; +} + +main { + max-width: 1400px; + margin: 0 auto 32px; + padding: 0 16px 32px; + display: grid; + gap: 16px; +} + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--card-radius); + padding: 16px 20px; +} + +.is-hidden { + display: none !important; +} + +.card-title { + margin: 0 0 12px; + font-size: 20px; + font-weight: 700; +} + +#aggregation .card-title, +#voters .card-title { + margin-bottom: 20px; +} + +.card-subtitle { + margin: -6px 0 14px; + color: var(--muted); + font-size: 14px; +} + +.flow-step { + display: flex; + justify-content: center; + margin: 12px 0 4px; +} + +.flow-button { + padding: 12px 28px; + border-radius: 999px; + border: none; + background: linear-gradient(92deg, #ffffff, #dbeafe 60%, #bfdbfe); + color: #0f172a; + font-weight: 700; + font-size: 1rem; + letter-spacing: 0.03em; + cursor: pointer; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.35); + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.flow-button:hover { + transform: translateY(-2px); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.45); +} + +.flow-button:active { + transform: translateY(0); + box-shadow: 0 6px 14px rgba(15, 23, 42, 0.4); +} + +.flow-button:focus-visible { + outline: 3px solid rgba(59, 130, 246, 0.7); + outline-offset: 4px; +} + +.center { + text-align: center; +} + +.grid { + display: grid; + gap: 16px; +} + +.grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid.two.wide-left { + grid-template-columns: 1.25fr 2fr; +} + +.identifiers-row { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; +} + +.eid-block, +.eb-block { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.eb-block { + margin-left: auto; + text-align: right; + justify-content: flex-end; +} + +@media (max-width: 1024px) { + .grid.two { + grid-template-columns: 1fr; + } + + .grid.two.wide-left { + grid-template-columns: 1fr; + } +} + +.stat-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stat { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.stat--indent { + padding-left: 1.25rem; +} + +.stat--indent-deep { + padding-left: 2.25rem; +} + +.stat-label { + font-weight: 600; + color: inherit; +} + +.stat-value { + font-weight: 700; + white-space: nowrap; + color: inherit; +} + +.text-muted { + color: var(--muted); +} + +.text-green { + color: var(--green); +} + +.text-orange { + color: var(--orange); +} + +.text-blue { + color: #2563eb; +} + +.text-yellow { + color: var(--yellow); +} + +.text-timing { + color: var(--timing); +} + +.text-red { + color: var(--red); +} + +#verification .card-title { + margin-bottom: 22px; +} + +.verification-visual { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + justify-content: flex-start; +} + +.verification-cert { + box-shadow: 0 6px 18px rgba(124, 58, 237, 0.35); +} + +.verification-stage { + min-width: 110px; + height: 70px; + border-radius: 10px; + background: rgba(148, 163, 184, 0.08); + border: 1px solid rgba(148, 163, 184, 0.25); + color: var(--muted); + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + text-transform: uppercase; + letter-spacing: 0.6px; +} + + +.verification-cert.with-tooltip { + cursor: pointer; +} + +.verify-result { + display: inline-flex; + align-items: center; + font-weight: 700; + letter-spacing: 0.3px; + color: var(--muted); + padding: 0; + margin: 0; + border: none; + background: none; + min-width: 0; + min-height: 0; +} + +.verify-result.is-success { + color: var(--green); +} + +.verify-result.is-failure { + color: var(--red); +} + +.verification-stats .stat { + gap: 0.75rem; +} + +.count { + font-weight: 700; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: inherit; +} + +.text-wrap { + overflow-wrap: anywhere; +} + +.banner { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 auto 16px; + padding: 12px 16px; + border-radius: 8px; + border: 1px solid #f87171; + background: rgba(248, 113, 113, 0.15); + color: #fecaca; + max-width: min(780px, 100%); +} + +.banner[hidden] { + display: none; +} + +.empty-state { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.tooltip { + position: absolute; + padding: 6px 8px; + background: rgba(15, 23, 32, 0.95); + border: 1px solid var(--border); + color: var(--text); + font-size: 12px; + border-radius: 6px; + pointer-events: none; + white-space: nowrap; + z-index: 20; +} + +.tooltip[hidden] { + display: none; +} + +.tooltip-box { + position: absolute; + background: rgba(30, 30, 30, 0.92); + color: #ffffff; + padding: 6px 8px; + border-radius: 6px; + font-size: 12px; + line-height: 1.4; + pointer-events: none; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35); + z-index: 1000; +} + +.universe-canvas, +.committee-canvas { + position: relative; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--dot-size), var(--dot-size))); + grid-auto-rows: var(--dot-size); + gap: var(--dot-gap); + align-content: start; + justify-content: flex-start; + padding-top: 8px; + min-height: calc(var(--dot-size) * 4); +} + +.universe-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--dot-size), var(--dot-size))); + grid-auto-rows: var(--dot-size); + gap: var(--dot-gap); +} + +.pool-dot { + width: var(--dot-size); + height: var(--dot-size); + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + background: #475569; + color: #ffffff; + font-weight: 700; + cursor: default; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.pool-dot:hover { + transform: scale(1.08); + box-shadow: 0 0 6px rgba(255, 255, 255, 0.3); +} + +.pool-dot.is-persistent { + background: var(--green); +} + +.pool-dot.is-nonpersistent { + background: var(--orange); +} + +.pool-dot.is-selected { + outline: 2px solid rgba(56, 189, 248, 0.9); +} + +.pool-dot.is-elected { + box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.8) inset; +} + +.pool-dot.is-voter::after { + content: ""; + position: absolute; + inset: -3px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.6); + pointer-events: none; +} + +.pool-dot .node-label { + position: absolute; + top: 50%; + left: 0; + width: 100%; + transform: translateY(-50%); + text-align: center; + pointer-events: none; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); +} + +.committee-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--seat-size), var(--seat-size))); + grid-auto-rows: calc(var(--seat-size) + 10px); + gap: var(--seat-gap); + align-content: start; + justify-content: flex-start; + padding-top: 8px; +} + +.seat-box { + position: relative; + width: var(--seat-size); + height: calc(var(--seat-size) + 10px); + border-radius: 10px; + border: 2px solid rgba(148, 163, 184, 0.35); + background: rgba(148, 163, 184, 0.06); + display: flex; + align-items: center; + justify-content: center; +} + +.seat-box.is-persistent-seat { + border-color: rgba(25, 195, 125, 0.75); + background: rgba(25, 195, 125, 0.1); +} + +.seat-box.is-nonpersistent-seat { + border-color: rgba(59, 130, 246, 0.7); + background: rgba(59, 130, 246, 0.18); +} + +.seat-box.is-nonpersistent-seat .seat-num { + color: #dbeafe; +} + +.seat-num { + position: absolute; + top: 5px; + left: 8px; + font-size: 12px; + font-weight: 700; + color: #ffffff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45); + pointer-events: none; +} + +.seat-box .pool-dot { + position: absolute; + top: 62%; + left: 50%; + transform: translate(-50%, -50%); +} + +.voting-list { + display: flex; + flex-wrap: wrap; + gap: 10px 18px; + align-items: flex-start; + padding-top: 8px; +} + +.voter-row { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: var(--dot-size); +} + + +.votes-board { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + padding-top: 4px; +} + +.vote-flow { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0.7rem 1.6rem; + justify-items: center; +} + +.votes-board .vote-flow+.vote-flow { + margin-top: 1rem; +} + +.vote-flow--persistent { + gap: 1rem 2.8rem; +} + +.vote-flow--nonpersistent { + gap: 1.25rem 3.2rem; +} + +.vote-flow__row { + display: grid; + grid-template-columns: var(--seat-size) 20px minmax(120px, 1fr); + align-items: center; + justify-items: center; + gap: 0.35rem 0.55rem; + min-height: calc(var(--seat-size) + 8px); +} + +@media (max-width: 900px) { + .vote-flow { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 600px) { + .vote-flow { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } +} + +.vote-flow__row--nonpersistent .pool-dot { + box-shadow: none; +} + +.vote-seat-inline { + position: relative; + flex: 0 0 var(--seat-size); + max-width: var(--seat-size); +} + +.vote-node { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: var(--seat-size); + flex-shrink: 0; +} + +.vote-flow__arrow { + width: 32px; + display: inline-flex; + justify-content: center; +} + +.vote-flow__row .vote-rect { + justify-self: stretch; +} + +.vote-rect.placeholder { + height: var(--vote-height); + border-radius: 4px; + border: 0; + background: transparent; + opacity: 0; +} + +.vote-arrow.placeholder { + visibility: hidden; +} + +.vote-flow__row .vote-rect.is-persistent { + background: #16a34a; + border-color: rgba(34, 197, 94, 0.5); +} + +.vote-flow__row .vote-rect.is-nonpersistent { + background: #2563eb; + border-color: rgba(59, 130, 246, 0.55); +} + +.vote-arrow { + position: relative; + width: 18px; + height: 2px; + background: var(--muted); + flex: 0 0 auto; +} + +.vote-arrow::after { + content: ""; + position: absolute; + right: -3px; + top: -3px; + border-left: 6px solid var(--muted); + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; +} + +.vote-rect { + min-width: 14px; + height: var(--vote-height); + border-radius: 4px; + background: #4a90e2; + border: 1px solid rgba(255, 255, 255, 0.1); + cursor: default; +} + +.vote-rect.is-persistent { + background: #16a34a; + border-color: rgba(34, 197, 94, 0.5); +} + +.vote-rect.is-nonpersistent { + background: #2563eb; + border-color: rgba(59, 130, 246, 0.55); +} + +.aggregation-canvas { + min-height: 220px; +} + +.aggregation-row { + display: flex; + align-items: center; + gap: 16px; + justify-content: flex-start; + min-height: 220px; +} + +.agg-votes { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(50px, 1fr)); + gap: 10px 6px; + align-items: center; + justify-items: start; + flex: 1 1 auto; + max-width: 65%; +} + +.big-arrow { + font-size: 28px; + line-height: 1; + color: var(--muted); + user-select: none; +} + +.certificate-rect { + min-width: 0; + height: 100px; + border-radius: 10px; + background: #7c3aed; + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: default; +} + +@media (max-width: 720px) { + .app-header { + margin-top: 16px; + } + + .certificate-rect { + min-width: 120px; + height: 70px; + } + + .aggregation-row { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/crypto-benchmarks.rs/demo/ui/templates/index.html b/crypto-benchmarks.rs/demo/ui/templates/index.html new file mode 100644 index 000000000..b7ec659ef --- /dev/null +++ b/crypto-benchmarks.rs/demo/ui/templates/index.html @@ -0,0 +1,229 @@ + + + + + + + Leios Voting Demo + + + + + +
+

Leios Voting Demo

+
+ + + +
+ +
+ +
+ +
+

Set of SPOs

+ +
+
+
+ Total pools: + +
+
+ Total stake: + +
+
+
+ + + +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + +
+ + +