From 0319f115d00ff3319e3bc0d6ee4d58e81fa5853a Mon Sep 17 00:00:00 2001 From: mjthatch Date: Thu, 12 Nov 2026 16:10:50 +0000 Subject: [PATCH 01/16] site: move each page's script into site/js/ The pages load js/index.js, js/stateless.js and js/stateful.js; the tests and synth_generated.py run those files instead of cutting the HTML at comment markers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QabhpThuWQEq1AZRZo24vm --- site/index.html | 124 +--- site/js/index.js | 121 ++++ site/js/stateful.js | 984 +++++++++++++++++++++++++++ site/js/stateless.js | 1352 +++++++++++++++++++++++++++++++++++++ site/js/ui.js | 3 +- site/stateful.html | 987 +-------------------------- site/stateless.html | 1355 +------------------------------------- tests/synth_generated.py | 5 +- tests/test_regression.py | 30 +- 9 files changed, 2478 insertions(+), 2483 deletions(-) create mode 100644 site/js/index.js create mode 100644 site/js/stateful.js create mode 100644 site/js/stateless.js diff --git a/site/index.html b/site/index.html index 6ac4701..ecd5eaf 100644 --- a/site/index.html +++ b/site/index.html @@ -219,129 +219,7 @@

Stateful, UXMSS `; + } else { + row += ``; + } + row += ''; + tbody.innerHTML += row; + } + + table.innerHTML = ''; + table.appendChild(thead); + table.appendChild(tbody); + + table.querySelectorAll('.row-remove').forEach(btn => { + btn.addEventListener('click', e => { + const id = e.target.dataset.id; + state.candidates = state.candidates.filter(c => c.id !== id); + render(); + }); + }); +} + +// Sweep panel +const SWEEP_FACTORIES = []; + +function buildSweepPool() { + const stdSize = state.candidates[0].metrics.size; // SLH-DSA-128s size + + if (SITE_DATA) { + const xi = fieldIndex(SITE_DATA.stateful.xmssmt); + for (const rec of SITE_DATA.stateful.xmssmt.rows) { + const c = candFromXmssMtRec(rec, xi); + if (c.metrics.size >= stdSize) continue; // must beat SLH-DSA size + SWEEP_FACTORIES.push({ + key: `xmssmt-${rec[xi.ots]}-${rec[xi.w]}-${rec[xi.h]}-${rec[xi.d]}`, + factory: () => candFromXmssMtRec(rec, xi), + cached: c, + }); + } + const ui = fieldIndex(SITE_DATA.stateful.uxmss); + for (const rec of SITE_DATA.stateful.uxmss.rows) { + const c = candFromUxmssRec(rec, ui); + if (c.metrics.size <= 0 || c.metrics.size >= stdSize) continue; + SWEEP_FACTORIES.push({ + key: `uxmss-${rec[ui.ots]}-${rec[ui.w]}`, + factory: () => candFromUxmssRec(rec, ui), + cached: c, + }); + } + return; + } + + const otsList = ['TW', 'WC']; + const wList = [16, 32, 64, 128, 256, 65536]; + + for (const ots of otsList) { + for (const w of wList) { + for (let h = 10; h <= 40; h++) { + for (let d = 1; d <= 10; d++) { + if (h % d !== 0) continue; + const c = buildXmssMt(ots, h, d, w); + if (c.metrics.size >= stdSize) continue; // must beat SLH-DSA size + SWEEP_FACTORIES.push({ + key: `xmssmt-${ots}-${w}-${h}-${d}`, + factory: () => buildXmssMt(ots, h, d, w), + cached: c, + }); + } + } + } + } + + for (const ots of otsList) { + for (const w of wList) { + const c = buildUxmss(ots, w); + if (c.metrics.size <= 0 || c.metrics.size >= stdSize) continue; + SWEEP_FACTORIES.push({ + key: `uxmss-${ots}-${w}`, + factory: () => buildUxmss(ots, w), + cached: c, + }); + } + } +} + +// Sweep filter coefficients +const SWEEP_DEFAULTS_X = { size: 1.00, kg: 20.00, sg: 20.00, sv: 20.00, cb: 20.00, minQs: 0 }; +const SWEEP_DEFAULTS_W = { size: 1.0, kg: 1.0, sg: 1.0, sv: 1.0, cb: 1.0 }; +const sweepState = { + X: { ...SWEEP_DEFAULTS_X }, + W: { ...SWEEP_DEFAULTS_W }, + xMax: { kg: 20, sg: 20, sv: 20, cb: 20 }, + sort: { col: 'distance', dir: 'asc' }, + lastRanges: Object.fromEntries(['size', 'kg', 'sg', 'sv', 'cb'].map(k => [k, { min: 0, max: 1 }])), + lastSurvivors: [], // survivors of the current bounds, in display order (for CSV export) +}; + +// Bound coefficient descriptors +const SWEEP_BOUND_FILTERS = [ + { key: 'size', label: 'Size', min: 0.10, max: 1.00, step: 0.01, dp: 2 }, + { key: 'kg', label: 'KeyGen', min: 0.10, log: true }, + { key: 'sg', label: 'SigGen', min: 0.10, log: true }, + { key: 'sv', label: 'SigVer', min: 0.10, log: true }, + { key: 'cb', label: 'ρ', min: 0.10, log: true }, + { key: 'minQs', label: 'Min. sigs (log₂)', min: 0, max: 40, step: 1, dp: 0 }, +]; +// Importance-weight descriptors (W) +const SWEEP_WEIGHT_FILTERS = [ + { key: 'size', label: 'Size', min: 0.10, max: 10.00, step: 0.10, dp: 1 }, + { key: 'kg', label: 'KeyGen', min: 0.10, max: 10.00, step: 0.10, dp: 1 }, + { key: 'sg', label: 'SigGen', min: 0.10, max: 10.00, step: 0.10, dp: 1 }, + { key: 'sv', label: 'SigVer', min: 0.10, max: 10.00, step: 0.10, dp: 1 }, + { key: 'cb', label: 'ρ', min: 0.10, max: 10.00, step: 0.10, dp: 1 }, +]; + +// Metric-normalised weighted Euclidean distance — same definition as the +// Parameter Explorer (report, "Metric-Normalized Weighted Distance"): each +// ratio axis is range-normalised to [0, 1] across the CURRENT survivor pool, +// so weights keep equal leverage regardless of how loose each bound is. +const SWEEP_METRIC_KEYS = ['size', 'kg', 'sg', 'sv', 'cb']; + +function sweepRatios(m, std) { + return { + size: m.size / std.size, + kg: m.keygen / std.keygen, + sg: sigGenOf(m) / sigGenOf(std), + sv: m.sigver / std.sigver, + cb: (m.sigver / m.size) / (std.sigver / std.size), + }; +} + +function computeSweepRanges(survivors, std) { + const ranges = {}; + SWEEP_METRIC_KEYS.forEach(k => ranges[k] = { min: Infinity, max: -Infinity }); + for (const f of survivors) { + const r = sweepRatios(f.cached.metrics, std); + SWEEP_METRIC_KEYS.forEach(k => { + if (r[k] < ranges[k].min) ranges[k].min = r[k]; + if (r[k] > ranges[k].max) ranges[k].max = r[k]; + }); + } + SWEEP_METRIC_KEYS.forEach(k => { + if (!isFinite(ranges[k].min)) ranges[k] = { min: 0, max: 1 }; + else if (ranges[k].max === ranges[k].min) ranges[k].max = ranges[k].min + 1; // avoid /0 + }); + return ranges; +} + +function sweepDistance(m, std, W) { + const sum = W.size + W.kg + W.sg + W.sv + W.cb; + const R = sweepState.lastRanges; + const X = sweepRatios(m, std); + let d2 = 0; + for (const k of SWEEP_METRIC_KEYS) { + const norm = (X[k] - R[k].min) / (R[k].max - R[k].min); // in [0,1] for survivors + d2 += (W[k] / sum) * norm * norm; + } + return Math.sqrt(d2); +} + +// Shared slider/number-input renderer. +// Filters flagged `log: true` get a geometric slider: the range element +// stores an integer track position 0..SLIDER_STEPS while the real bound +// range lives on the paired number input's min/max (max = sweepState.xMax). +const SLIDER_STEPS = 1000; +function posToX(pos, min, max) { + return min * Math.pow(max / min, pos / SLIDER_STEPS); +} +function xToPos(x, min, max) { + const t = Math.log(x / min) / Math.log(max / min); + return Math.round(SLIDER_STEPS * Math.max(0, Math.min(1, t))); +} +function fmtX(v) { + if (v < 100) return v.toFixed(2); + if (v < 1e6) return String(Math.round(v)); + return v.toExponential(1); +} + +// Round a ratio up to a 1-2-5 decade; 20x is the floor. +function niceCap(m) { + if (!isFinite(m) || m <= 20) return 20; + const e = Math.pow(10, Math.floor(Math.log10(m))); + for (const f of [1, 2, 5, 10]) if (f * e >= m) return f * e; + return 10 * e; +} + +// Largest per-metric ratio across the sweep pool (both signer modes and both +// verify variants), so every candidate — w = 65536 included — is reachable. +function computeSweepXMax() { + const std = state.candidates[0].metrics; + const maxes = { kg: 0, sg: 0, sv: 0, cb: 0 }; + for (const f of SWEEP_FACTORIES) { + const m = f.cached.metrics; + maxes.kg = Math.max(maxes.kg, m.keygen / std.keygen); + maxes.sg = Math.max(maxes.sg, m.siggen / std.siggen, m.siggenCold / std.siggenCold); + maxes.sv = Math.max(maxes.sv, m.sigver / std.sigver, m.sigverWorst / std.sigverWorst); + maxes.cb = Math.max(maxes.cb, (m.sigver / m.size) / (std.sigver / std.size), + (m.sigverWorst / m.size) / (std.sigverWorst / std.size)); + } + sweepState.xMax = { kg: niceCap(maxes.kg), sg: niceCap(maxes.sg), + sv: niceCap(maxes.sv), cb: niceCap(maxes.cb) }; +} + +function clampSweepXToMax() { + for (const k of ['kg', 'sg', 'sv', 'cb']) { + if (sweepState.X[k] > sweepState.xMax[k]) sweepState.X[k] = sweepState.xMax[k]; + } +} + +function renderSweepFilterGroup(hostId, group, filters) { + const host = document.getElementById(hostId); + const store = sweepState[group]; + host.innerHTML = filters.map(f => { + const max = f.log ? sweepState.xMax[f.key] : f.max; + const numVal = f.log ? fmtX(store[f.key]) : store[f.key].toFixed(f.dp); + const sliderAttrs = f.log + ? `min="0" max="${SLIDER_STEPS}" step="1" + value="${xToPos(store[f.key], f.min, max)}" data-sw="${f.key}" data-log="1"` + : `min="${f.min}" max="${f.max}" step="${f.step}" + value="${store[f.key]}" data-sw="${f.key}"`; + return ` +
+ + +
+ `; + }).join(''); + + host.querySelectorAll('input[data-sw]').forEach(slider => { + slider.addEventListener('input', e => { + const key = e.target.dataset.sw; + const inp = host.querySelector(`input[data-sw-input="${key}"]`); + let v; + if (e.target.dataset.log) { + v = posToX(parseFloat(e.target.value), + parseFloat(inp.min), parseFloat(inp.max)); + inp.value = fmtX(v); + } else { + v = parseFloat(e.target.value); + inp.value = v.toFixed(parseInt(inp.dataset.dp, 10)); + } + store[key] = v; + requestSweepRender(); + }); + }); + host.querySelectorAll('input[data-sw-input]').forEach(inp => { + inp.addEventListener('input', e => { + const key = e.target.dataset.swInput; + const raw = parseFloat(e.target.value); + if (!isFinite(raw)) return; + const min = parseFloat(e.target.min), max = parseFloat(e.target.max); + const v = Math.max(min, Math.min(max, raw)); + store[key] = v; + const slider = host.querySelector(`input[data-sw="${key}"]`); + slider.value = slider.dataset.log ? xToPos(v, min, max) : v; + requestSweepRender(); + }); + inp.addEventListener('blur', e => { + const key = e.target.dataset.swInput; + e.target.value = e.target.dataset.dp + ? store[key].toFixed(parseInt(e.target.dataset.dp, 10)) + : fmtX(store[key]); + }); + }); +} + +function renderSweepFilters() { + renderSweepFilterGroup('sweep-bounds', 'X', SWEEP_BOUND_FILTERS); + renderSweepFilterGroup('sweep-weights', 'W', SWEEP_WEIGHT_FILTERS); +} + +// Sweep-table metric columns +const SWEEP_COLS = [ + { field: 'size', label: 'Size (B)', value: m => sizeCell(m), sortFn: (m) => m.size, ratioFn: m => m.size, lowerIsBetter: true }, + { field: 'keygen', label: 'KeyGen (C)', value: m => fmtNum(m.keygen), sortFn: (m) => m.keygen, ratioFn: m => m.keygen, lowerIsBetter: true }, + { field: 'siggen', label: () => `SigGen, ${SIGNER_MODE} (C)`, + value: m => fmtNum(sigGenOf(m)), sortFn: (m) => sigGenOf(m), ratioFn: m => sigGenOf(m), lowerIsBetter: true }, + { field: 'state', label: 'State (B)', value: m => (SIGNER_MODE === 'cold' || m.state === undefined) ? '—' : fmtNum(m.state), + sortFn: (m) => m.state || 0 }, + { field: 'sigver', label: 'SigVer (C)', value: m => fmtNum(m.sigver), sortFn: (m) => m.sigver, ratioFn: m => m.sigver, lowerIsBetter: true }, + { field: 'cb', label: `${RHO} (C/B)`, value: (m, c) => rhoCell(m, c, m.sigver, 1, fmtRho), sortFn: (m) => m.sigver / m.size, ratioFn: m => m.sigver / m.size, lowerIsBetter: true }, + { field: 'cbSchnorr', label: `${RHO} ÷ Schnorr`, + value: (m, c) => rhoCell(m, c, m.sigver, SCHNORR.cb64, fmtRatio), sortFn: (m) => m.sigver / m.size }, + { field: 'distance', label: 'Distance', value: (m, c, std) => sweepDistance(m, std, sweepState.W).toFixed(4), + sortFn: (m, c, std) => sweepDistance(m, std, sweepState.W) }, + { field: 'qs', label: 'Max signatures', value: (_, c) => qsCell(c.metrics), sortFn: (m) => (m.qsLog2 !== null ? Math.pow(2, m.qsLog2) : m.qsExact) }, +]; + +function renderSweepTable() { + const std = state.candidates[0].metrics; + const minQsAbs = Math.pow(2, sweepState.X.minQs); + const survivors = []; + for (const f of SWEEP_FACTORIES) { + const m = f.cached.metrics; + if (m.size > sweepState.X.size * std.size) continue; + if (m.keygen > sweepState.X.kg * std.keygen) continue; + if (sigGenOf(m) > sweepState.X.sg * sigGenOf(std)) continue; + if (m.sigver > sweepState.X.sv * std.sigver) continue; + const cbTarget = m.sigver / m.size; + const cbStd = std.sigver / std.size; + if (cbTarget > sweepState.X.cb * cbStd) continue; + const candQs = m.qsLog2 !== null ? Math.pow(2, m.qsLog2) : m.qsExact; + if (candQs < minQsAbs) continue; + survivors.push(f); + } + sweepState.lastRanges = computeSweepRanges(survivors, std); + const sortCol = SWEEP_COLS.find(c => c.field === sweepState.sort.col) || SWEEP_COLS[0]; + const sign = sweepState.sort.dir === 'asc' ? 1 : -1; + survivors.sort((a, b) => { + const av = sortCol.sortFn(a.cached.metrics, a.cached, std); + const bv = sortCol.sortFn(b.cached.metrics, b.cached, std); + if (av === bv) return 0; + return sign * (av < bv ? -1 : 1); + }); + sweepState.lastSurvivors = survivors; + scheduleStatefulHashWrite(); + + document.getElementById('sweep-info').innerHTML = + `${survivors.length.toLocaleString('en-US')} of ` + + `${SWEEP_FACTORIES.length.toLocaleString('en-US')} candidates within the bounds.`; + + const table = document.getElementById('sweep-table'); + table.innerHTML = ''; + + if (survivors.length === 0) { + table.innerHTML = `No candidate is within all bounds. Relax one.`; + return; + } + + const thead = document.createElement('thead'); + let hr = 'Candidate'; + for (const col of SWEEP_COLS) { + const isActive = sweepState.sort.col === col.field; + const arrow = isActive ? (sweepState.sort.dir === 'asc' ? '▲' : '▼') : ''; + const lbl = typeof col.label === 'function' ? col.label() : col.label; + hr += `${lbl}${arrow}`; + } + hr += ''; + thead.innerHTML = hr; + + const inComparison = new Set(); + for (const c of state.candidates) { + inComparison.add(c.label); + } + + const tbody = document.createElement('tbody'); + const MAX_ROWS = 200; + const shown = survivors.slice(0, MAX_ROWS); + // Collect the rows and parse them once. + const rowsHtml = []; + for (const f of shown) { + const c = f.cached; + const m = c.metrics; + let row = ``; + row += `${c.label}`; + for (const col of SWEEP_COLS) { + let cell = col.value(m, c, std); + if (col.ratioFn) { + const target = col.ratioFn(m); + const stdVal = col.ratioFn(std); + if (isFinite(target) && isFinite(stdVal) && stdVal > 0) { + const ratio = target / stdVal; + const cls = ratioClass(ratio, col.lowerIsBetter); + cell += `${ratioNote(target, stdVal)}`; + } + } + row += `${cell}`; + } + const disabled = inComparison.has(c.label) ? 'disabled' : ''; + row += ``; + row += ''; + rowsHtml.push(row); + } + if (survivors.length > MAX_ROWS) { + rowsHtml.push(`… and ${(survivors.length - MAX_ROWS).toLocaleString('en-US')} more; tighten a bound to see them.`); + } + tbody.innerHTML = rowsHtml.join(''); + + table.appendChild(thead); + table.appendChild(tbody); + + table.querySelectorAll('.row-add').forEach(btn => { + btn.addEventListener('click', e => { + const key = e.target.dataset.key; + const f = SWEEP_FACTORIES.find(x => x.key === key); + if (!f) return; + state.candidates.push(f.factory()); + render(); + renderSweepTable(); // refresh disabled state + }); + }); + + table.querySelectorAll('th.sortable').forEach(th => { + th.addEventListener('click', e => { + if (e.target.closest('.info')) return; + const col = th.dataset.sort; + if (sweepState.sort.col === col) { + sweepState.sort.dir = (sweepState.sort.dir === 'asc') ? 'desc' : 'asc'; + } else { + sweepState.sort.col = col; + sweepState.sort.dir = 'asc'; + } + renderSweepTable(); + }); + }); +} + +// Add form +// Shareable URL state + CSV export +const DEFAULT_CAND_KEYS = ['uxmss-TW-16', 'uxmss-WC-16', 'xmssmt-TW-16-10-1', + 'xmssmt-WC-16-10-1', 'xmssmt-TW-16-20-1', 'xmssmt-WC-16-20-1']; +const HASH_X_KEYS = ['size', 'kg', 'sg', 'sv', 'cb', 'minQs']; +const HASH_W_KEYS = ['size', 'kg', 'sg', 'sv', 'cb']; + +// Rebuild any comparison candidate from its compact key +// (xmssmt---- or uxmss--). +function candidateFromKey(key) { + const p = String(key).split('-'); + if (p[0] === 'uxmss' && p.length === 3) { + const ots = p[1], w = parseInt(p[2], 10); + if (!['TW', 'WC'].includes(ots) || ![16, 32, 64, 128, 256, 65536].includes(w)) return null; + return buildUxmss(ots, w); + } + if (p[0] === 'xmssmt' && p.length === 5) { + const ots = p[1], w = parseInt(p[2], 10); + const h = parseInt(p[3], 10), d = parseInt(p[4], 10); + if (!['TW', 'WC'].includes(ots) || ![16, 32, 64, 128, 256, 65536].includes(w)) return null; + if (!Number.isInteger(h) || h < 2 || h > 60 || + !Number.isInteger(d) || d < 1 || h % d !== 0) return null; + return buildXmssMt(ots, h, d, w); + } + return null; +} + +function writeStatefulHash() { + const parts = []; + const keys = state.candidates.slice(1).map(c => c.key).filter(Boolean); + if (keys.join(',') !== DEFAULT_CAND_KEYS.join(',')) parts.push('cand=' + keys.join(',')); + if (HASH_X_KEYS.some(k => sweepState.X[k] !== SWEEP_DEFAULTS_X[k])) + parts.push('b=' + HASH_X_KEYS.map(k => sweepState.X[k]).join(',')); + if (HASH_W_KEYS.some(k => sweepState.W[k] !== SWEEP_DEFAULTS_W[k])) + parts.push('w=' + HASH_W_KEYS.map(k => sweepState.W[k]).join(',')); + if (sweepState.sort.col !== 'distance' || sweepState.sort.dir !== 'asc') + parts.push('sort=' + sweepState.sort.col + '.' + sweepState.sort.dir); + if (SIGNER_MODE !== 'cached') parts.push('m=' + SIGNER_MODE); + // Safari throws once a page exceeds its history-update quota. + try { + history.replaceState(null, '', + parts.length ? '#' + parts.join('&') : location.pathname + location.search); + } catch (err) { /* keep the page working */ } +} + +const HASH_WRITE_DELAY_MS = 250; +let statefulHashTimer = null; +function scheduleStatefulHashWrite() { + clearTimeout(statefulHashTimer); + statefulHashTimer = setTimeout(writeStatefulHash, HASH_WRITE_DELAY_MS); +} + +// A slider drag fires an input event per step. Rebuild the sweep table at most +// once per animation frame instead of once per event. +let sweepRenderQueued = false; +function requestSweepRender() { + if (sweepRenderQueued) return; + sweepRenderQueued = true; + requestAnimationFrame(() => { sweepRenderQueued = false; renderSweepTable(); }); +} + +function applyStatefulHash() { + const raw = location.hash.replace(/^#/, ''); + if (!raw) return; + const params = {}; + for (const part of raw.split('&')) { + const eq = part.indexOf('='); + if (eq > 0) params[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1)); + } + const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + if (params.b) { + const v = params.b.split(',').map(parseFloat); + if (v.length === 6 && v.every(isFinite)) { + sweepState.X.size = clamp(v[0], 0.10, 1.00); + sweepState.X.kg = clamp(v[1], 0.10, 1e15); + sweepState.X.sg = clamp(v[2], 0.10, 1e15); + sweepState.X.sv = clamp(v[3], 0.10, 1e15); + sweepState.X.cb = clamp(v[4], 0.10, 1e15); + sweepState.X.minQs = clamp(Math.round(v[5]), 0, 40); + } + } + if (params.w) { + const v = params.w.split(',').map(parseFloat); + if (v.length === 5 && v.every(isFinite)) + HASH_W_KEYS.forEach((k, i) => sweepState.W[k] = clamp(v[i], 0.10, 10.00)); + } + if (params.sort) { + const [col, dir] = params.sort.split('.'); + if (SWEEP_COLS.some(c => c.field === col) && ['asc', 'desc'].includes(dir)) + sweepState.sort = { col, dir }; + } + if (params.m === 'cold') SIGNER_MODE = 'cold'; + if (params.cand !== undefined) { + const seen = new Set(); + const cands = []; + for (const key of params.cand.split(',').filter(Boolean)) { + if (seen.has(key)) continue; + seen.add(key); + const c = candidateFromKey(key); + if (c) cands.push(c); + } + state.candidates = [state.candidates[0], ...cands]; + } +} + +function exportSweepCSV() { + const std = state.candidates[0].metrics; + const header = ['scheme', 'label', 'max_sigs', 'size_B', 'keygen_C', + 'siggen_cached_C', 'siggen_cold_C', 'state_B', + 'sigver_C', 'sigver_worst_C', 'rho', 'rho_worst', + 'rho_vs_schnorr', 'rho_worst_vs_schnorr', 'schnorr_rho', + 'signer', 'distance']; + const lines = [header.join(',')]; + for (const f of sweepState.lastSurvivors) { + const c = f.cached, m = c.metrics; + lines.push([c.scheme, '"' + c.label + '"', + m.qsLog2 !== null ? '2^' + m.qsLog2 : m.qsExact, + m.size, m.keygen, m.siggen, m.siggenCold, m.state !== undefined ? m.state : '', + m.sigver, m.sigverWorst, + (m.sigver / m.size).toFixed(6), (m.sigverWorst / m.size).toFixed(6), + (m.sigver / m.size / SCHNORR.cb64).toFixed(6), + (m.sigverWorst / m.size / SCHNORR.cb64).toFixed(6), + SCHNORR.cb64.toFixed(6), + SIGNER_MODE, + sweepDistance(m, std, sweepState.W).toFixed(6)].join(',')); + } + const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/csv' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `stateful_candidates_${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(a.href); +} + +function setupAddForm() { + const showBtn = document.getElementById('show-add'); + const form = document.getElementById('add-form'); + const cancelBtn = document.getElementById('cancel-add'); + const schemeSel = document.getElementById('f-scheme'); + const errBox = document.getElementById('f-error'); + + showBtn.addEventListener('click', () => { + form.hidden = false; + showBtn.hidden = true; + errBox.hidden = true; + }); + cancelBtn.addEventListener('click', () => { + form.hidden = true; + showBtn.hidden = false; + errBox.hidden = true; + }); + + function updateVisibility() { + const scheme = schemeSel.value; + form.querySelectorAll('[data-scheme]').forEach(el => { + el.style.display = (el.dataset.scheme === scheme) ? '' : 'none'; + }); + } + schemeSel.addEventListener('change', updateVisibility); + updateVisibility(); + + const hInput = document.getElementById('f-h'); + const dSel = document.getElementById('f-d'); + function refreshDOptions() { + const h = parseInt(hInput.value, 10); + const prev = parseInt(dSel.value, 10); + dSel.innerHTML = ''; + if (!Number.isInteger(h) || h < 2) return; + const divisors = []; + for (let d = 1; d <= h; d++) if (h % d === 0) divisors.push(d); + for (const d of divisors) { + const opt = document.createElement('option'); + opt.value = String(d); + opt.textContent = `${d} (h' = ${h / d})`; + dSel.appendChild(opt); + } + if (divisors.includes(prev)) dSel.value = String(prev); + else dSel.value = String(divisors[0] || 1); + } + hInput.addEventListener('input', refreshDOptions); + refreshDOptions(); + + form.addEventListener('submit', e => { + e.preventDefault(); + errBox.hidden = true; + const scheme = schemeSel.value; + const ots = document.getElementById('f-ots').value; + const w = parseInt(document.getElementById('f-w').value, 10); + + try { + let cand; + if (scheme === 'xmssmt') { + const h = parseInt(document.getElementById('f-h').value, 10); + const d = parseInt(document.getElementById('f-d').value, 10); + if (!Number.isInteger(h) || h < 2 || h > 60) throw new Error(`h must be an integer in [2, 60] (got ${h}).`); + if (!Number.isInteger(d) || d < 1) throw new Error(`d must be a positive integer (got ${d}).`); + if (h % d !== 0) throw new Error(`d (${d}) must divide h (${h}) evenly. Got h/d = ${(h/d).toFixed(3)}.`); + cand = buildXmssMt(ots, h, d, w); + } else { + cand = buildUxmss(ots, w); + if (cand.metrics.size <= 0) throw new Error(`UXMSS budget too tight for ${ots}/w=${w}: no signature fits under ${UXMSS_TARGET_SIZE} B.`); + } + state.candidates.push(cand); + render(); + form.hidden = true; + showBtn.hidden = false; + } catch (err) { + errBox.textContent = err.message; + errBox.hidden = false; + } + }); + + document.getElementById('reset-defaults').addEventListener('click', () => { + state.candidates = defaultCandidates(); + render(); + }); +} + +// render() is the single entry point invoked by every UI handler that +// mutates state candidates +function render() { + renderComparison(); + if (SWEEP_FACTORIES.length > 0) renderSweepTable(); + else scheduleStatefulHashWrite(); +} + +// Init +async function loadSiteData() { + try { + const resp = await fetch('data.json', { cache: 'no-cache' }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return await resp.json(); + } catch (e) { + console.warn('data.json unavailable (' + e.message + ') — computing candidates in-browser as fallback.'); + return null; + } +} + +function renderSchnorrNote() { + const s = SCHNORR; + const m = state.candidates[0].metrics; + const cb = m.sigver / m.size; + const src = s.live + ? `kappa.json${s.commit ? ` at secp256k1 ${s.commit}` : ''}${s.run ? `, ${s.run}` : ''}` + + `${s.machine ? `, ${s.machine}` : ''}${s.preliminary ? ', preliminary' : ''}` + : 'built-in value, the live kappa.json did not load'; + document.getElementById('about-schnorr').innerHTML = + `

The average ρ divided by the ρ of a BIP340 Schnorr verification, ${s.cb64.toFixed(2)} C/B ` + + `(${s.impl}, one 64-byte signature; ` + + `bip340-vs-sha256, ${src}). ` + + `SLH-DSA-128s has ρ = ${fmtRho(cb)} C/B, ${fmtRatio(cb / s.cb64)} Schnorr.

`; +} + +async function init() { + const [siteData, schnorr] = await Promise.all([loadSiteData(), loadSchnorrRef()]); + SITE_DATA = siteData; + SCHNORR = schnorr; + if (SITE_DATA) selfCheckStateful(SITE_DATA); + state.candidates = defaultCandidates(); + applyStatefulHash(); + document.getElementById('signer-mode').value = SIGNER_MODE; + document.getElementById('signer-mode').addEventListener('change', e => { + SIGNER_MODE = e.target.value === 'cold' ? 'cold' : 'cached'; + render(); + }); + setupAddForm(); + render(); // initial pass paints the comparison + renderSchnorrNote(); + buildSweepPool(); // sweep generation depends on the SLH baseline + computeSweepXMax(); // bound caps from the pool (w = 65536 needs big ones) + clampSweepXToMax(); + renderSweepFilters(); + renderSweepTable(); // first paint of the sweep table + + document.getElementById('share-btn').addEventListener('click', async e => { + writeStatefulHash(); + const btn = e.target; + try { + await navigator.clipboard.writeText(location.href); + btn.textContent = 'Link copied ✓'; + setTimeout(() => { btn.textContent = 'Copy shareable link'; }, 1500); + } catch (err) { + prompt('Copy this link:', location.href); + } + }); + document.getElementById('sweep-export').addEventListener('click', exportSweepCSV); +} +if (typeof document !== 'undefined') init(); diff --git a/site/js/stateless.js b/site/js/stateless.js new file mode 100644 index 0000000..a9f9bd9 --- /dev/null +++ b/site/js/stateless.js @@ -0,0 +1,1352 @@ +'use strict'; + +// ---------- Constants ---------- +// SLH-DSA-128s baseline (matches the numbers in the tech report) +const STD = { n: 16, h: 63, d: 7, k: 14, a: 12, w: 16 }; + +// SLH-DSA-compatible Winternitz choices: lg_w = log2(w) must divide 8 so +// that base_2b message digits stay byte-aligned (lg_w in {1, 2, 4, 8}). +const ALLOWED_W = [2, 4, 16, 256]; + +// Optional Winternitz extras, gated by a sidebar checkbox +const OPTIONAL_W = [32, 64, 128]; +const UNIVERSE_W = [...ALLOWED_W, ...OPTIONAL_W]; + +// ---------- Schnorr (BIP340) reference ---------- +const SCHNORR_KAPPA_URL = + 'https://raw.githubusercontent.com/mjthatch/bip340-vs-sha256/main/results/kappa.json'; +const SCHNORR_FALLBACK = { + cb64: 1.98, + impl: 'libsecp256k1', + commit: null, run: null, machine: null, preliminary: false, + live: false, +}; + +// ---------- Cost formulas ---------- +// WOTS+ chain components: { len1, len2, len } +// len1 = message chains, len2 = checksum chains, len = len1 + len2 +function wotsComponents(n, w) { + const log2w = Math.log2(w); + const len1 = Math.ceil(8 * n / log2w); + const len2 = Math.floor(Math.log2(len1 * (w - 1)) / log2w) + 1; + return { len1, len2, len: len1 + len2 }; +} + +// Convenience wrapper kept for any callers that only need total length +function wotsLen(n, w) { return wotsComponents(n, w).len; } + +// Worst-case WOTS-TW verification steps per hypertree layer. +// +// WOTS-TW has no constant-sum constraint (unlike WOTS+C), so the +// average is WRONG for worst-case analysis. +// +// Worst case: all len1 message digits = 0 → verifier walks w-1 steps +// on each message chain. The checksum C = len1*(w-1) is then fixed, +// and its base-w digit sum determines the remaining checksum work. +// +// worst_steps = len1*(w-1) + len2*(w-1) - digitSum(len1*(w-1), w) +// = len*(w-1) - digitSum(len1*(w-1), w) +// +// Reference: costs.sage compute_verification_time(), SPX branch. +function wotsVerifyWorstSteps(n, w) { + const { len1, len2 } = wotsComponents(n, w); + const C = len1 * (w - 1); + let ds = 0, rem = C; + while (rem > 0) { ds += rem % w; rem = Math.floor(rem / w); } + return len1 * (w - 1) + len2 * (w - 1) - ds; +} + +// Average WOTS-TW verification steps over `count` hypertree layers, +// rounded to the nearest integer (same as wotsTwMeanSteps in formulas.js +// and compute_wots_tw_mean_steps in costs.sage). +// +// The len1 message chains average (w-1)/2 steps each, but the len2 +// checksum chains do not: the checksum is concentrated around +// len1*(w-1)/2, so its leading digit is almost always 0 or 1 and the +// verifier walks most of that chain. Exactly, with S the sum of the len1 +// message digits, +// E[steps] = (w-1) * (len2 + sum_{j=1}^{len2-1} E[floor(S / w^j)]) +// (267.12 for w = 16, where (w-1)*len/2 would give 262). The expectations +// are E[floor(S / w^j)] = sum_{m>=1} P(S >= m*w^j), with the number of +// digit strings of sum <= t counted by inclusion-exclusion: +// sum_i (-1)^i * C(len1, i) * C(t - i*w + len1, len1). +function binomBig(n, k) { + if (k < 0n || k > n) return 0n; + if (k === 0n || k === n) return 1n; + if (k > n - k) k = n - k; + let num = 1n, den = 1n; + for (let i = 0n; i < k; i++) { num *= (n - i); den *= (i + 1n); } + return num / den; +} + +// Per (n, w): the mean steps of one signature as the exact fraction num / W. +const _meanStepsCache = new Map(); +function wotsVerifyMeanSteps(n, w, count) { + const key = n + ',' + w; + let mean = _meanStepsCache.get(key); + if (!mean) { + const { len1, len2 } = wotsComponents(n, w); + const L1 = BigInt(len1), wB = BigInt(w); + const top = L1 * (wB - 1n); + const W = wB ** L1; + const countLe = (t) => { + let total = 0n; + for (let i = 0n; i <= L1 && i * wB <= t; i++) { + const term = binomBig(L1, i) * binomBig(t - i * wB + L1, L1); + total += (i % 2n === 0n) ? term : -term; + } + return total; + }; + let acc = 0n; + for (let j = 1; j < len2; j++) { + const step = wB ** BigInt(j); + for (let m = 1n; m * step <= top; m++) acc += W - countLe(m * step - 1n); + } + mean = { num: BigInt(w - 1) * (BigInt(len2) * W + acc), W }; + _meanStepsCache.set(key, mean); + } + // count * num / W, rounded to the nearest integer + return Number((2n * BigInt(count) * mean.num + mean.W) / (2n * mean.W)); +} + +// Marginal compression-call count for a tweakable hash over n hash-sized +// values, under the cached convention (FIPS 205 SHA-2 layout): the +// PK.seed block is precomputed once per keypair, so each call costs only +// ADRS_c (22 B = 176 bits) + payload (128n bits) + padding (65 bits). +// Matches costs.sage (HASH_CONVENTION=cached, the default): +// compute_Th(n) = ceil((176 + 128*n + 65) / 512) +function computeTh(n) { + return Math.ceil((176 + 128 * n + 65) / 512); +} + +function metrics(p) { + const n = STD.n; // 16 bytes (128-bit security) + const hp = p.h / p.d; + const { len } = wotsComponents(n, p.w); + + // Compression costs: cached convention, matching costs.sage defaults: + // PK.seed midstate cached per keypair; a 2-child node hash is + // ADRS_c (22 B) + 32 B + padding = 497 bits -> 1 compression. + // Hmsg and PRFmsg for a 32-byte message and an empty context, as the + // SHRINCS reference implementation executes them: Hmsg is two nested + // SHA-256 calls of 2 compressions each (its input starts with the + // randomizer, so nothing is cacheable); PRFmsg is HMAC-SHA-256, 2 + 1 + // compressions with its two key-pad blocks cached. + const C_PRF = 1; // PRF generation + const C_Th1 = 1; // tweakable hash, 1 value (WOTS chain step / leaf) + const C_Th2 = 1; // tweakable hash, 2 values (internal tree node) + const C_Hmsg = 4; // Message hash + const C_PRFmsg = 3; // Message PRF + + // Exact compression counts for leaf/root compressions (matches costs.sage). + // computeTh(n) = ceil((128 + 96 + 128*n + 65) / 512): + // Thl = cost to compress len WOTS public values into one XMSS leaf + // Thk = cost to compress k FORS roots into one combined value + // Previously approximated as len and k respectively, which overstated both + // by ~25 and ~9 compressions for standard parameters, cascading into + // KeyGen (+12,800), SigGen (+89,609) and SigVer (+191) errors. + const Thl = computeTh(len); + const Thk = computeTh(p.k); + + // 1. Signature Size (Bytes) + // Rand (1*n) + FORS (k*(a+1)*n) + Hypertree Auth (h*n) + WOTS (d*len*n) + const size = n * (1 + p.k * (p.a + 1) + p.h + p.d * len); + + // 2. Cost of ONE XMSS Tree Layer (c_merkle) + const leavesPerTree = Math.pow(2, hp); + // Leaves: PRF + WOTS chains (w-1)*C_Th1 + leaf compression (Thl) + const wotsCost = (len * C_PRF) + (len * (p.w - 1) * C_Th1) + Thl; + // Internal nodes: (2^hp - 1) * C_Th2 + const c_merkle = (leavesPerTree * wotsCost) + ((leavesPerTree - 1) * C_Th2); + + // 3. Key Generation (Build top tree only) + const kg = c_merkle; + + // 4. Signature Generation (FORS + d * c_merkle) + const forsLeaves = Math.pow(2, p.a); + + // EXACT FORS COST: PRF + Leaf Hash (C_Th1) + Internal Nodes (C_Th2) + Root compress (Thk) + const c_fors_fixed = (p.k * forsLeaves * C_PRF) + + (p.k * forsLeaves * C_Th1) + + (p.k * (forsLeaves - 1) * C_Th2) + + Thk; + + const c_msg = C_Hmsg + C_PRFmsg; // Cost to hash message + const sg = c_fors_fixed + c_msg + (p.d * c_merkle); + + // 5a. Signature Verification — average case + // Exact expected chain steps for uniformly random digests, summed over + // the d layers (see wotsVerifyMeanSteps). Used for filter thresholds + // and distance scoring. + const c_wots_avg = wotsVerifyMeanSteps(n, p.w, p.d) * C_Th1 + p.d * Thl; + // FORS path verify: k leaves (C_Th1) + k*a internal nodes (C_Th2) + root (Thk) + const c_fts = (p.k * C_Th1) + (p.k * p.a * C_Th2) + Thk; + const sv = C_Hmsg + c_fts + c_wots_avg + (p.h * C_Th2); + + // 5b. Signature Verification — worst case (WOTS-TW) + // All len1 message digits = 0: verifier walks w-1 steps per message chain. + // Checksum C = len1*(w-1) is fixed; its base-w digit sum determines checksum work. + // See wotsVerifyWorstSteps(). Displayed alongside average for comparison. + const c_wots_worst = (wotsVerifyWorstSteps(n, p.w) * C_Th1) + Thl; + const sv_worst = C_Hmsg + c_fts + (p.d * c_wots_worst) + (p.h * C_Th2); + + // 6. Density (Compressions per byte) + // avg: based on average-case SV (used for filter threshold and distance scoring) + // worst: based on worst-case SV — upper bound on verifier work per byte transferred + const cb = sv / size; + const cb_worst = sv_worst / size; + + return { size, kg, sg, sv, sv_worst, cb, cb_worst }; +} + +const STD_M = metrics(STD); + +// ---------- Sweep ---------- +// Canonical grid — must match the report (Sec. 2) and slhdsa-2to40.sage defaults: +// h in [40, 50], d in [2, 25] with d | h, k in [6, 24], a in [8, 20], w in ALLOWED_W = {2, 4, 16, 256}. +// k >= 6 because with a <= 20 and h <= 50 any k <= 5 falls short of the +// 128-bit security floor at q_s = 2^40, so smaller k never survives anyway. +function sweep() { + const candidates = []; + for (let h = 40; h <= 50; h++) { + for (let d = 2; d <= 25; d++) { + if (h % d !== 0) continue; + for (let k = 6; k <= 24; k++) { + for (let a = 8; a <= 20; a++) { + for (const w of UNIVERSE_W) { + const p = { h, d, k, a, w }; + const m = metrics(p); + // Skip pathological/infinite candidates + if (!isFinite(m.kg) || !isFinite(m.sg)) continue; + candidates.push({ ...p, ...m }); + } + } + } + } + } + return candidates; +} + +// ---------- Security ---------- +// Port of compute_security from BlockstreamResearch/SPHINCS-Parameters/security.sage. +// Computes SLH-DSA bit security against FORS subset-forgery attacks for q_s signatures. +// All probabilities accumulated in log2-space to avoid double-precision underflow. +const _LOG2_E = 1 / Math.LN2; + +function _lgamma(z) { + if (z < 0.5) return Math.log(Math.PI / Math.sin(Math.PI * z)) - _lgamma(1 - z); + z -= 1; + const c = [ + 0.99999999999980993, 676.5203681218851, -1259.1392167224028, + 771.32342877765313, -176.61502916214059, 12.507343278686905, + -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7, + ]; + let x = c[0]; + for (let i = 1; i < 9; i++) x += c[i] / (z + i); + const t = z + 7.5; + return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x); +} + +function _log2Binom(n, k) { + if (k < 0 || k > n) return -Infinity; + if (k === 0 || k === n) return 0; + // For small k, direct sum (more accurate than lgamma(huge) − lgamma(huge)) + if (k <= 500) { + let num = 0, denom = 0; + for (let i = 0; i < k; i++) num += Math.log2(n - i); + for (let i = 2; i <= k; i++) denom += Math.log2(i); + return num - denom; + } + return (_lgamma(n + 1) - _lgamma(k + 1) - _lgamma(n - k + 1)) * _LOG2_E; +} + +function _log2OneMinusExp(x) { + if (x >= 0) return -Infinity; + return Math.log(-Math.expm1(x)) * _LOG2_E; +} + +function _log2SumExp(a, b) { + if (a === -Infinity) return b; + if (b === -Infinity) return a; + if (a < b) { const tmp = a; a = b; b = tmp; } + return a + Math.log1p(Math.pow(2, b - a)) * _LOG2_E; +} + +// Cap on the number of terms the forgery sum evaluates (see below). +const MAX_SUM_TERMS = 1 << 16; + +/** + * FORS subset-forgery exponent -log2(forgery probability) for + * q_s = 2^q_s_log2 signatures (uncapped). + * + * The sum runs over r, the number of signatures that land on one FORS + * instance: r ~ Binomial(q_s, 2^-h) with mean lambda = q_s / 2^h. Only + * the terms within `spread` of lambda matter: further out the binomial + * weight is below e^-450 of its peak, and the forgery factor + * (1 - (1 - 1/t)^r)^k only grows with r, so the low side can be skipped + * as well as the high side. The work is therefore O(sqrt(lambda)), not + * O(lambda): a tuple with few hypertree leaves (h = 14 gives + * lambda = 2^26) used to need 67 million terms and froze the page. + * MAX_SUM_TERMS bounds the work for even smaller h, where the summand + * is smooth on the scale of the stride and a strided sum is accurate + * far beyond the displayed precision. For the canonical grid + * (lambda <= 1) nothing changes: r starts at 1 with stride 1. + */ +function forsForgeryExponent(q_s_log2, h, k, a) { + const q_s = Math.pow(2, q_s_log2); + const t = Math.pow(2, a); + const log2_p = -h; + const p = Math.pow(2, -h); + const log2_one_minus_p = p < 1e-15 ? -p * _LOG2_E : Math.log1p(-p) * _LOG2_E; + const ln_one_minus_1_over_t = 1 / t < 1e-15 ? -1 / t : Math.log1p(-1 / t); + const lambda = Math.pow(2, q_s_log2 - h); + let log2_sigma = -Infinity; + const spread = 30 * Math.sqrt(lambda + 1) + 30; + const r_max = Math.min(q_s, Math.max(Math.ceil(lambda + spread), 200)); + const r_min = Math.max(1, Math.floor(lambda - spread)); + const stride = Math.max(1, Math.ceil((r_max - r_min + 1) / MAX_SUM_TERMS)); + const log2_stride = Math.log2(stride); + for (let r = r_min; r <= r_max; r += stride) { + const log2_C = _log2Binom(q_s, r); + if (!isFinite(log2_C)) continue; + const log2_qhp = log2_C + r * log2_p + (q_s - r) * log2_one_minus_p; + const log2_pf = k * _log2OneMinusExp(r * ln_one_minus_1_over_t); + const log2_contrib = log2_qhp + log2_pf + log2_stride; + if (isFinite(log2_contrib)) log2_sigma = _log2SumExp(log2_sigma, log2_contrib); + if (r > Math.max(lambda, 5) && log2_contrib < -500) break; + } + // sigma is a probability; rounding can leave it a hair above 1. + return Math.max(0, -log2_sigma); +} + +/** + * Bit security: the forgery exponent capped by the hash preimage bound. + * hashbytes=16 ⇒ 128-bit cap. + */ +function computeSecurityBits(q_s_log2, h, k, a, hashbytes = 16) { + return Math.min(8 * hashbytes, forsForgeryExponent(q_s_log2, h, k, a)); +} + +// Report's q_s = 2^40 +const Q_S_LOG2 = 40; + +// 128-bit security check using the real FORS forgery analysis +function passesSecurity(c) { + return computeSecurityBits(Q_S_LOG2, c.h, c.k, c.a) >= 128; +} + +// Combined initial bounds: security + size strictly less than SLH-DSA-128s +// (the report's Sage script uses MAX_SIZE = 7856, matching the FIPS 205 reference value) +const MAX_SIZE = 7856; +function passesInitial(c) { + return passesSecurity(c) && c.size < MAX_SIZE; +} + +// ---------- Canonical data (data.json) ---------- +// The pools are normally loaded from site/data.json, generated by +// export_site_data.sage: SPX from the same sweep CSV as the report, the +// W+C / FORS+C / PORS+FP variants computed by costs.sage. The formulas +// above stay for the custom-candidate panel (SPX model) and as a +// fallback, and are cross-checked against the loaded SPX pool at startup. +function poolFromData(data, scheme) { + const p = data.pools[scheme]; + const idx = Object.fromEntries(p.fields.map((f, i) => [f, i])); + // Guard against a stale data.json generated on an older grid: only the + // swept w universe survives (a freshly exported dataset drops none). + const rows = p.rows.filter(r => UNIVERSE_W.includes(r[idx.w])); + if (rows.length !== p.rows.length) { + console.warn(`pool ${scheme}: dropped ${p.rows.length - rows.length} rows with ` + + `w outside {${UNIVERSE_W.join(', ')}} — regenerate data.json (stale grid).`); + } + return rows.map(r => { + const c = { + h: r[idx.h], d: r[idx.d], k: r[idx.k], a: r[idx.a], w: r[idx.w], + swn: r[idx.swn], mmax: r[idx.mmax], + size: r[idx.size], kg: r[idx.kg], sg: r[idx.sg], + sv: r[idx.sv], sv_worst: r[idx.sv_worst], + }; + c.cb = c.sv / c.size; + c.cb_worst = c.sv_worst / c.size; + return c; + }); +} + +// Metric accessor honouring the worst-case mode: SigVer and ρ switch to +// their worst-case variants; size/kg/sg are message-independent. +function mkey(k) { + return state.useWorst && (k === 'sv' || k === 'cb') ? k + '_worst' : k; +} + +// ---------- Filter pipeline ---------- +const FILTER_STEPS = [ + { key: 'size', metric: 'size', label: 'Size' }, + { key: 'kg', metric: 'kg', label: 'KeyGen' }, + { key: 'sg', metric: 'sg', label: 'SigGen' }, + { key: 'sv', metric: 'sv', label: 'SigVer' }, + { key: 'cb', metric: 'cb', label: 'ρ' }, +]; + +// Returns an array: each element holds the survivors after step i, and which step eliminated each candidate +function runFilters(pool, X) { + const eliminatedAt = new Map(); // candidate -> step index (or null if survived all) + pool.forEach(c => eliminatedAt.set(c, null)); + + const stepCounts = [{ name: 'Initial pool', count: pool.length }]; + let cur = pool; + FILTER_STEPS.forEach((step, idx) => { + const mk = mkey(step.metric); + const stdVal = STD_M[mk]; + const next = []; + for (const c of cur) { + if (c[mk] <= X[step.key] * stdVal) { + next.push(c); + } else { + if (eliminatedAt.get(c) === null) eliminatedAt.set(c, idx); + } + } + cur = next; + const worstTag = (mk !== step.metric) ? ', worst' : ''; + stepCounts.push({ + name: `${step.label}${worstTag} ≤ ${fmtX(X[step.key])}×`, + count: cur.length + }); + }); + + return { stepCounts, survivors: cur, eliminatedAt }; +} + +// ---------- Distance ---------- +// Compute per-metric ratio range across the survivor pool so that +// weights have equal leverage regardless of filter tightness. +const METRIC_KEYS = ['size', 'kg', 'sg', 'sv', 'cb']; + +function computeRanges(survivors) { + const ranges = {}; + METRIC_KEYS.forEach(key => { + const mk = mkey(key); + const stdVal = STD_M[mk]; + if (!survivors.length) { ranges[key] = { min: 0, max: 1 }; return; } + const vals = survivors.map(c => c[mk] / stdVal); + const lo = Math.min(...vals), hi = Math.max(...vals); + ranges[key] = { min: lo, max: hi === lo ? lo + 1 : hi }; // avoid /0 + }); + return ranges; +} + +function weightedDistance(c, W) { + const sum = W.size + W.kg + W.sg + W.sv + W.cb; + const p = { + size: W.size / sum, kg: W.kg / sum, sg: W.sg / sum, + sv: W.sv / sum, cb: W.cb / sum + }; + const ranges = state.lastRanges; + let dist2 = 0; + METRIC_KEYS.forEach(key => { + const wKey = key; // same key names in W + const mk = mkey(key); + const ratio = c[mk] / STD_M[mk]; + const { min, max } = ranges[key]; + const norm = (ratio - min) / (max - min); // in [0,1] for survivors + dist2 += p[wKey] * norm * norm; + }); + return Math.sqrt(dist2); +} + +// ---------- State ---------- +const DEFAULTS_X = { size: 1.00, kg: 1.00, sg: 1.00, sv: 1.00, cb: 1.00 }; +const DEFAULTS_W = { size: 1.0, kg: 1.0, sg: 1.0, sv: 1.0, cb: 1.0 }; + +const state = { + candidates: [], + initialPool: [], + siteData: null, // parsed data.json (null in fallback mode) + schnorr: { ...SCHNORR_FALLBACK }, // BIP340 ρ reference (live after loadSchnorrRef) + pools: {}, // scheme -> candidate pool + scheme: 'SPX', + useWorst: false, // filter/rank on worst-case SigVer & ρ + showOptW: false, // include w = 32, 64, 128 (non-byte-aligned extras) + xMax: { kg: 20, sg: 20, sv: 20, cb: 20 }, + X: { ...DEFAULTS_X }, + W: { ...DEFAULTS_W }, + // Live filter results (refreshed every update) + lastEliminatedAt: null, + lastSurvivors: [], + lastWinner: null, + // Layout cache for hit-testing + scatterLayout: { pad: 12, w: 0, h: 0 }, + // Per-metric ratio ranges across current survivors (for normalisation) + lastRanges: Object.fromEntries(['size', 'kg', 'sg', 'sv', 'cb'].map(k => [k, { min: 0, max: 1 }])), +}; + +// Active Winternitz set: the SLH-DSA-compatible core plus any enabled optional extras (the extras exist in the SPX pool only). +function activeWSet() { + const act = [...ALLOWED_W]; + if (state.showOptW) act.push(...OPTIONAL_W); + return act.sort((a, b) => a - b); +} + +function rebuildInitialPool() { + const act = activeWSet(); + state.initialPool = (state.pools[state.scheme] || []).filter(c => act.includes(c.w)); +} + +// Round a ratio up to a 1-2-5 decade; 20x is the floor so the classic slider range never shrinks. +function niceCap(m) { + if (!isFinite(m) || m <= 20) return 20; + const e = Math.pow(10, Math.floor(Math.log10(m))); + for (const f of [1, 2, 5, 10]) if (f * e >= m) return f * e; + return 10 * e; +} + +// X-slider caps: the largest per-metric ratio present in ANY loaded pool +// (average and worst-case variants), so even the extreme candidates — +// e.g. the tiny-signature d = 2 family with keygen ratios in the tens of +// thousands — stay reachable. Never below 20. +function computeXMax() { + const maxes = { kg: 0, sg: 0, sv: 0, cb: 0 }; + for (const scheme of Object.keys(state.pools)) { + for (const c of state.pools[scheme]) { + if (c.kg / STD_M.kg > maxes.kg) maxes.kg = c.kg / STD_M.kg; + if (c.sg / STD_M.sg > maxes.sg) maxes.sg = c.sg / STD_M.sg; + maxes.sv = Math.max(maxes.sv, c.sv / STD_M.sv, c.sv_worst / STD_M.sv_worst); + maxes.cb = Math.max(maxes.cb, c.cb / STD_M.cb, c.cb_worst / STD_M.cb_worst); + } + } + state.xMax = { kg: niceCap(maxes.kg), sg: niceCap(maxes.sg), + sv: niceCap(maxes.sv), cb: niceCap(maxes.cb) }; +} + +function clampXToMax() { + for (const k of ['kg', 'sg', 'sv', 'cb']) { + if (state.X[k] > state.xMax[k]) state.X[k] = state.xMax[k]; + } +} + +// Tuples swept per w value: (h, d) pairs with d | h times the k and a ranges. +function consideredPerW(grid) { + const g = grid || { h: [40, 50], d: [2, 25], k: [6, 24], a: [8, 20] }; + let hd = 0; + for (let h = g.h[0]; h <= g.h[1]; h++) + for (let d = g.d[0]; d <= g.d[1]; d++) if (h % d === 0) hd++; + return hd * (g.k[1] - g.k[0] + 1) * (g.a[1] - g.a[0] + 1); +} + +// Stable seeded RNG for scatter positions +function mulberry32(seed) { + let s = seed | 0; + return () => { + s = (s + 0x6D2B79F5) | 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// ---------- Rendering ---------- + +// Log-position mapping for the cost-coefficient sliders: integer track +// positions 0..SLIDER_STEPS <-> X in [min, max], geometrically spaced. +// (~2% per tick — finer than the old 0.1 linear step near X = 1, and it +// reaches the pool's extreme ratios that a linear 0.1..20 track cannot.) +const SLIDER_STEPS = 1000; +function posToX(pos, min, max) { + return min * Math.pow(max / min, pos / SLIDER_STEPS); +} +function xToPos(x, min, max) { + const t = Math.log(x / min) / Math.log(max / min); + return Math.round(SLIDER_STEPS * Math.max(0, Math.min(1, t))); +} +function fmtX(v) { + // Keep the string short enough for the 92px value inputs: plain + // integers up to 6 digits, compact exponential beyond. + if (v < 100) return v.toFixed(2); + if (v < 1e6) return String(Math.round(v)); + return v.toExponential(1); +} + +function renderControls() { + const filterDefs = [ + { key: 'size', label: 'Size', min: 0.30, max: 1.00, step: 0.01, dp: 2 }, + { key: 'kg', label: 'KeyGen', min: 0.10, max: state.xMax.kg, log: true }, + { key: 'sg', label: 'SigGen', min: 0.10, max: state.xMax.sg, log: true }, + { key: 'sv', label: 'SigVer', min: 0.10, max: state.xMax.sv, log: true }, + { key: 'cb', label: 'ρ', min: 0.10, max: state.xMax.cb, log: true }, + ]; + document.getElementById('filter-controls').innerHTML = filterDefs.map(d => ` +
+ + +
+ `).join(''); + + const weightDefs = [ + { key: 'size', label: 'Size', min: 0.10, max: 20.00, step: 0.10, dp: 1 }, + { key: 'kg', label: 'KeyGen', min: 0.10, max: 20.00, step: 0.10, dp: 1 }, + { key: 'sg', label: 'SigGen', min: 0.10, max: 20.00, step: 0.10, dp: 1 }, + { key: 'sv', label: 'SigVer', min: 0.10, max: 20.00, step: 0.10, dp: 1 }, + { key: 'cb', label: 'ρ', min: 0.10, max: 20.00, step: 0.10, dp: 1 }, + ]; + document.getElementById('weight-controls').innerHTML = weightDefs.map(d => ` +
+ + +
+ `).join(''); + + // --- Wire bidirectional input <-> slider for both X and W --- + // Sliders flagged data-log store a 0..SLIDER_STEPS track position; the + // real range lives on the paired number input's min/max attributes. + function bindPair(group, store) { + // Slider -> store + number-input + document.querySelectorAll(`input[data-${group}]`).forEach(slider => { + slider.addEventListener('input', e => { + const key = e.target.dataset[group]; + const numInp = document.querySelector(`input[data-${group}-input="${key}"]`); + let v; + if (e.target.dataset.log) { + v = posToX(parseFloat(e.target.value), + parseFloat(numInp.min), parseFloat(numInp.max)); + numInp.value = fmtX(v); + } else { + v = parseFloat(e.target.value); + numInp.value = v.toFixed(parseInt(numInp.dataset.dp, 10)); + } + store[key] = v; + requestUpdate(); + }); + }); + // Number input -> store + slider + document.querySelectorAll(`input[data-${group}-input]`).forEach(inp => { + inp.addEventListener('input', e => { + const key = e.target.dataset[`${group}Input`]; + let v = parseFloat(e.target.value); + if (!isFinite(v)) return; + const min = parseFloat(e.target.min); + const max = parseFloat(e.target.max); + const clamped = Math.max(min, Math.min(max, v)); + store[key] = clamped; + const slider = document.querySelector(`input[data-${group}="${key}"]`); + slider.value = slider.dataset.log ? xToPos(clamped, min, max) : clamped; + requestUpdate(); + }); + // On blur, reformat to canonical precision and re-clamp the displayed value + inp.addEventListener('blur', e => { + const key = e.target.dataset[`${group}Input`]; + e.target.value = e.target.dataset.dp + ? store[key].toFixed(parseInt(e.target.dataset.dp, 10)) + : fmtX(store[key]); + }); + }); + } + + bindPair('coef', state.X); + bindPair('weight', state.W); + + document.getElementById('reset-btn').addEventListener('click', () => { + state.X = { ...DEFAULTS_X }; + state.W = { ...DEFAULTS_W }; + renderControls(); + update(); + }); +} + +function renderFunnel(stepCounts) { + const max = stepCounts[0].count || 1; + const html = stepCounts.map((s, i) => { + const widthPct = (s.count / max) * 100; + const prev = i > 0 ? stepCounts[i - 1].count : null; + const eliminated = prev !== null && s.count === 0 ? 'eliminated' : ''; + return ` +
+
${s.name}
+
+
${s.count.toLocaleString('en-US')}
+
+`; + }).join(''); + document.getElementById('funnel').innerHTML = html; +} + +// Colour scale: blue (low distance) → red (high distance) +function distanceColor(t) { + // t in [0, 1]; 0 = best (blue), 1 = worst (red) + // Use HSL: 220° (blue) → 0° (red), going through purple + t = Math.max(0, Math.min(1, t)); + const hue = 220 - 220 * t; + return `hsl(${hue}, 70%, 50%)`; +} + +const STEP_COLORS = ['#f87171', '#fb923c', '#facc15', '#a3e635', '#22d3ee']; + +function renderScatter(eliminatedAt, survivors, winner) { + const canvas = document.getElementById('scatter'); + // Set internal resolution from CSS size + const dpr = window.devicePixelRatio || 1; + const cssW = canvas.clientWidth || 800; + const cssH = 500; + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + canvas.style.height = cssH + 'px'; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, cssW, cssH); + + // Background + ctx.fillStyle = '#fafafa'; + ctx.fillRect(0, 0, cssW, cssH); + + const pad = 12; + const w = cssW - 2 * pad; + const h = cssH - 2 * pad; + // Cache layout for hit-testing + state.scatterLayout = { pad, w, h, cssW, cssH }; + + // Compute distance for survivors to drive colour + const dists = survivors.map(c => weightedDistance(c, state.W)); + const dmin = dists.length ? Math.min(...dists) : 0; + const dmax = dists.length ? Math.max(...dists) : 1; + const drange = (dmax - dmin) || 1; + + // Draw eliminated first (grey, small), then survivors (coloured), then winner + for (const c of state.initialPool) { + const step = eliminatedAt.get(c); + if (step === null) continue; // survived; draw later + const x = pad + c._sx * w; + const y = pad + c._sy * h; + ctx.fillStyle = '#d4d4d0'; + ctx.globalAlpha = 0.45; + ctx.beginPath(); + ctx.arc(x, y, 1.6, 0, 2 * Math.PI); + ctx.fill(); + } + ctx.globalAlpha = 1; + + for (let i = 0; i < survivors.length; i++) { + const c = survivors[i]; + const x = pad + c._sx * w; + const y = pad + c._sy * h; + const t = (dists[i] - dmin) / drange; + ctx.fillStyle = distanceColor(t); + ctx.beginPath(); + ctx.arc(x, y, 3.2, 0, 2 * Math.PI); + ctx.fill(); + } + + // Winner ring + if (winner) { + const x = pad + winner._sx * w; + const y = pad + winner._sy * h; + ctx.strokeStyle = '#1a1a1a'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(x, y, 8, 0, 2 * Math.PI); + ctx.stroke(); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(x, y, 6, 0, 2 * Math.PI); + ctx.stroke(); + } + + canvas.setAttribute('aria-label', `Candidate sieve: ${survivors.length.toLocaleString('en-US')} of ` + + `${state.initialPool.length.toLocaleString('en-US')} candidates within the bounds` + + (winner ? `, best (${winner.h}, ${winner.d}, ${winner.k}, ${winner.a}, ${winner.w})` : '')); + + // Legend + const legendHTML = ` +
filtered out
+
closest
+
middle
+
farthest
+
best
+ `; + document.getElementById('scatter-legend').innerHTML = legendHTML; +} + +function ratioClass(r, inverse = false) { + // r is target/std. inverse=true means lower is better (default for cost metrics) + if (inverse) { + if (r <= 0.8) return 'good'; + if (r <= 1.05) return 'warn'; + return 'bad'; + } + if (r >= 1.2) return 'good'; + if (r >= 0.95) return 'warn'; + return 'bad'; +} + +// Rows of the comparison tables: each metric next to its SLH-DSA-128s value. +function comparisonRows(c) { + const schnorr = state.schnorr.cb64; + const rows = [ + ['Size (B)', c.size, STD_M.size, fmtNum], + ['KeyGen (C)', c.kg, STD_M.kg, fmtNum], + ['SigGen (C)', c.sg, STD_M.sg, fmtNum], + ['SigVer (C)', c.sv, STD_M.sv, fmtNum], + ['SigVer, worst (C)', c.sv_worst, STD_M.sv_worst, fmtNum], + ['ρ (C/B)', c.cb, STD_M.cb, fmtRho], + ['ρ, worst (C/B)', c.cb_worst, STD_M.cb_worst, fmtRho], + ['ρ ÷ Schnorr', c.cb / schnorr, STD_M.cb / schnorr, fmtRatio], + ['ρ ÷ Schnorr, worst', c.cb_worst / schnorr, STD_M.cb_worst / schnorr, fmtRatio], + ]; + return rows.map(([name, value, std, fmtValue]) => { + const r = value / std; + return `${name}${fmtValue(value)}${fmtValue(std)}` + + `${fmtRatio(r)}`; + }).join(''); +} + +function comparisonTable(c, column) { + return ` + + ${comparisonRows(c)} +
Metric${column}SLH-DSA-128sRatio
`; +} + +function renderWinner(winner) { + const el = document.getElementById('winner'); + if (!winner) { + el.innerHTML = '
No candidate is within all bounds. Relax one.
'; + return; + } + const d = weightedDistance(winner, state.W); + el.innerHTML = ` +
+
+

(${winner.h}, ${winner.d}, ${winner.k}, ${winner.a}, ${winner.w})

+

Distance ${d.toFixed(4)} · h′ = ${winner.h / winner.d}

+ ${winner.swn ? `

Swn = ${winner.swn}${winner.mmax ? ` · mmax = ${winner.mmax}` : ''}

` : ''} +
+
${comparisonTable(winner, 'Winner')}
+
+ `; +} + +// ---------- Main update loop ---------- +function update() { + const { stepCounts, survivors, eliminatedAt } = runFilters(state.initialPool, state.X); + renderFunnel(stepCounts); + // Recompute normalisation ranges for this survivor pool + state.lastRanges = computeRanges(survivors); + let winner = null; + if (survivors.length > 0) { + winner = survivors[0]; + let bestD = weightedDistance(winner, state.W); + for (let i = 1; i < survivors.length; i++) { + const d = weightedDistance(survivors[i], state.W); + if (d < bestD) { bestD = d; winner = survivors[i]; } + } + } + state.lastEliminatedAt = eliminatedAt; + state.lastSurvivors = survivors; + state.lastWinner = winner; + renderScatter(eliminatedAt, survivors, winner); + renderWinner(winner); + scheduleHashWrite(); +} + +// A slider drag fires an input event per step. Recompute and redraw at +// most once per animation frame instead of once per event. +let updateQueued = false; +function requestUpdate() { + if (updateQueued) return; + updateQueued = true; + requestAnimationFrame(() => { updateQueued = false; update(); }); +} + +// ---------- Hover tooltip ---------- +function findNearestCandidate(mx, my) { + const { pad, w, h } = state.scatterLayout; + let best = null, bestD = 12 * 12; // 12px hit radius (squared) + for (const c of state.initialPool) { + const x = pad + c._sx * w; + const y = pad + c._sy * h; + const dx = mx - x, dy = my - y; + const d2 = dx * dx + dy * dy; + if (d2 < bestD) { bestD = d2; best = c; } + } + return best; +} + +function statusFor(c) { + if (c === state.lastWinner) return { cls: 'winner', text: '★ Best candidate' }; + const elimStep = state.lastEliminatedAt ? state.lastEliminatedAt.get(c) : null; + if (elimStep === null || elimStep === undefined) { + return { cls: 'survivor', text: 'Within all bounds' }; + } + return { cls: 'eliminated', text: `Outside the ${FILTER_STEPS[elimStep].label} bound` }; +} + +function renderTooltip(c, clientX, clientY) { + const tt = document.getElementById('tooltip'); + if (!c) { tt.style.display = 'none'; document.getElementById('hover-ring').style.display = 'none'; return; } + const st = statusFor(c); + const isSurvivor = st.cls === 'survivor' || st.cls === 'winner'; + const distHTML = isSurvivor + ? `
Distance ${weightedDistance(c, state.W).toFixed(4)}
` + : ''; + + tt.innerHTML = ` +

(${c.h}, ${c.d}, ${c.k}, ${c.a}, ${c.w})${c.swn ? ` Swn=${c.swn}${c.mmax ? `, mmax=${c.mmax}` : ''}` : ''}

+
${st.text}
+ + + ${comparisonRows(c)} +
candidateSLH-DSA-128sratio
+${distHTML} + `; + tt.style.display = 'block'; + + // Position: prefer to the right of the cursor, flip if off-screen + const ttRect = tt.getBoundingClientRect(); + const pad = 12; + let x = clientX + pad; + let y = clientY + pad; + if (x + ttRect.width > window.innerWidth - 8) x = clientX - ttRect.width - pad; + if (y + ttRect.height > window.innerHeight - 8) y = clientY - ttRect.height - pad; + tt.style.left = Math.max(8, x) + 'px'; + tt.style.top = Math.max(8, y) + 'px'; + + // Hover ring (positioned over the scatter) + const canvas = document.getElementById('scatter'); + const rect = canvas.getBoundingClientRect(); + const { pad: scPad, w, h } = state.scatterLayout; + const ring = document.getElementById('hover-ring'); + ring.style.left = (rect.left + scPad + c._sx * w) - canvas.parentElement.getBoundingClientRect().left + 'px'; + ring.style.top = (rect.top + scPad + c._sy * h) - canvas.parentElement.getBoundingClientRect().top + 'px'; + ring.style.display = 'block'; +} + +// ---------- Custom candidate panel ---------- +function validateCustom(p) { + const errors = []; + const warnings = []; + const isPosInt = v => Number.isFinite(v) && v > 0 && Math.floor(v) === v; + + if (!isPosInt(p.h)) errors.push('h must be a positive integer.'); + if (!isPosInt(p.d)) errors.push('d must be a positive integer.'); + if (!isPosInt(p.k)) errors.push('k must be a positive integer.'); + if (!isPosInt(p.a)) errors.push('a must be a positive integer.'); + if (!isPosInt(p.w) || p.w < 2) errors.push('w must be an integer ≥ 2.'); + + if (errors.length) return { valid: false, errors, warnings }; + + if (p.h % p.d !== 0) { + errors.push(`d must divide h (h/d = ${(p.h / p.d).toFixed(3)}).`); + } + if (!UNIVERSE_W.includes(p.w)) { + errors.push(`w must be one of ${UNIVERSE_W.join(', ')}.`); + } else if (OPTIONAL_W.includes(p.w)) { + warnings.push(`w = ${p.w} is exploratory: its digits do not fit whole bytes, as SLH-DSA requires.`); + } + + if (errors.length) return { valid: false, errors, warnings }; + + // Real security check (FORS subset-forgery analysis, q_s = 2^40) + const secBits = computeSecurityBits(Q_S_LOG2, p.h, p.k, p.a); + if (secBits < 128) { + warnings.push(`Below 128-bit security: ${secBits.toFixed(1)} bits at qs = 240.`); + } + + return { valid: true, errors: [], warnings, secBits }; +} + +function renderCustom() { + const p = { + h: parseInt(document.getElementById('cust-h').value, 10), + d: parseInt(document.getElementById('cust-d').value, 10), + k: parseInt(document.getElementById('cust-k').value, 10), + a: parseInt(document.getElementById('cust-a').value, 10), + w: parseInt(document.getElementById('cust-w').value, 10), + }; + + const v = validateCustom(p); + const statusEl = document.getElementById('custom-status'); + const resultEl = document.getElementById('custom-result'); + scheduleHashWrite(); + + if (!v.valid) { + statusEl.className = 'custom-status invalid'; + statusEl.innerHTML = `Invalid.
    ${v.errors.map(e => `
  • ${e}
  • `).join('')}
`; + resultEl.innerHTML = ''; + return; + } + + // Compute metrics + const m = metrics(p); + + // Guard against overflow: 2^(h/d) exceeds double precision for h' ≳ 1024, + // and the model stops being meaningful long before that. + if (!isFinite(m.kg) || !isFinite(m.sg)) { + statusEl.className = 'custom-status invalid'; + statusEl.innerHTML = `Out of range. h′ = ${p.h / p.d} is too large for this model; ` + + `reduce h or increase d.`; + resultEl.innerHTML = ''; + return; + } + + // Status banner + if (v.warnings.length) { + statusEl.className = 'custom-status warn'; + statusEl.innerHTML = `Valid, with notes:
    ${v.warnings.map(w => `
  • ${w}
  • `).join('')}
`; + } else { + statusEl.className = 'custom-status ok'; + const sameAsStd = p.h === STD.h && p.d === STD.d && p.k === STD.k && p.a === STD.a && p.w === STD.w; + statusEl.innerHTML = `Valid.${sameAsStd ? ' This is SLH-DSA-128s.' : ''}`; + } + + const hp = p.h / p.d; + const wotsL = wotsLen(STD.n, p.w); + const secRaw = forsForgeryExponent(Q_S_LOG2, p.h, p.k, p.a); + const rawTxt = secRaw > 300 ? '> 300' : secRaw.toFixed(1); + const secLabel = secRaw >= 128 + ? `128 bits ✓ ` + + `(${rawTxt} bits against FORS forgery, capped by the 2−128 preimage bound)` + : `${secRaw.toFixed(1)} bits`; + + resultEl.innerHTML = ` +
+
+

(${p.h}, ${p.d}, ${p.k}, ${p.a}, ${p.w})

+
+
Layer height h′ = ${hp}
+
WOTS chains: ${wotsL}
+
Security at qs = 240: ${secLabel}
+
+
+
${comparisonTable(m, 'Yours')}
+
+ `; +} + +function attachCustomCandidate() { + ['cust-h', 'cust-d', 'cust-k', 'cust-a', 'cust-w'].forEach(id => { + const el = document.getElementById(id); + el.addEventListener('input', renderCustom); + el.addEventListener('change', renderCustom); // `; }).join(''); - host.querySelectorAll('input[data-sw]').forEach(slider => { - slider.addEventListener('input', e => { - const key = e.target.dataset.sw; - const inp = host.querySelector(`input[data-sw-input="${key}"]`); - let v; - if (e.target.dataset.log) { - v = posToX(parseFloat(e.target.value), - parseFloat(inp.min), parseFloat(inp.max)); - inp.value = fmtX(v); - } else { - v = parseFloat(e.target.value); - inp.value = v.toFixed(parseInt(inp.dataset.dp, 10)); - } - store[key] = v; - requestSweepRender(); - }); - }); - host.querySelectorAll('input[data-sw-input]').forEach(inp => { - inp.addEventListener('input', e => { - const key = e.target.dataset.swInput; - const raw = parseFloat(e.target.value); - if (!isFinite(raw)) return; - const min = parseFloat(e.target.min), max = parseFloat(e.target.max); - const v = Math.max(min, Math.min(max, raw)); - store[key] = v; - const slider = host.querySelector(`input[data-sw="${key}"]`); - slider.value = slider.dataset.log ? xToPos(v, min, max) : v; - requestSweepRender(); - }); - inp.addEventListener('blur', e => { - const key = e.target.dataset.swInput; - e.target.value = e.target.dataset.dp - ? store[key].toFixed(parseInt(e.target.dataset.dp, 10)) - : fmtX(store[key]); - }); - }); + bindSliderPairs(host, store, requestSweepRender); } function renderSweepFilters() { @@ -588,7 +492,7 @@ function renderSweepTable() { if (candQs < minQsAbs) continue; survivors.push(f); } - sweepState.lastRanges = computeSweepRanges(survivors, std); + sweepState.lastRanges = ratioRanges(survivors.map(f => sweepRatios(f.cached.metrics, std))); const sortCol = SWEEP_COLS.find(c => c.field === sweepState.sort.col) || SWEEP_COLS[0]; const sign = sweepState.sort.dir === 'asc' ? 1 : -1; survivors.sort((a, b) => { @@ -598,7 +502,7 @@ function renderSweepTable() { return sign * (av < bv ? -1 : 1); }); sweepState.lastSurvivors = survivors; - scheduleStatefulHashWrite(); + scheduleHashWrite(); document.getElementById('sweep-info').innerHTML = `${survivors.length.toLocaleString('en-US')} of ` + @@ -717,7 +621,7 @@ function candidateFromKey(key) { return null; } -function writeStatefulHash() { +function writeHash() { const parts = []; const keys = state.candidates.slice(1).map(c => c.key).filter(Boolean); if (keys.join(',') !== DEFAULT_CAND_KEYS.join(',')) parts.push('cand=' + keys.join(',')); @@ -728,38 +632,14 @@ function writeStatefulHash() { if (sweepState.sort.col !== 'distance' || sweepState.sort.dir !== 'asc') parts.push('sort=' + sweepState.sort.col + '.' + sweepState.sort.dir); if (SIGNER_MODE !== 'cached') parts.push('m=' + SIGNER_MODE); - // Safari throws once a page exceeds its history-update quota. - try { - history.replaceState(null, '', - parts.length ? '#' + parts.join('&') : location.pathname + location.search); - } catch (err) { /* keep the page working */ } -} - -const HASH_WRITE_DELAY_MS = 250; -let statefulHashTimer = null; -function scheduleStatefulHashWrite() { - clearTimeout(statefulHashTimer); - statefulHashTimer = setTimeout(writeStatefulHash, HASH_WRITE_DELAY_MS); + replaceHash(parts); } -// A slider drag fires an input event per step. Rebuild the sweep table at most -// once per animation frame instead of once per event. -let sweepRenderQueued = false; -function requestSweepRender() { - if (sweepRenderQueued) return; - sweepRenderQueued = true; - requestAnimationFrame(() => { sweepRenderQueued = false; renderSweepTable(); }); -} +const scheduleHashWrite = hashWriter(writeHash); +const requestSweepRender = oncePerFrame(renderSweepTable); -function applyStatefulHash() { - const raw = location.hash.replace(/^#/, ''); - if (!raw) return; - const params = {}; - for (const part of raw.split('&')) { - const eq = part.indexOf('='); - if (eq > 0) params[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1)); - } - const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); +function applyHash() { + const params = readHash(); if (params.b) { const v = params.b.split(',').map(parseFloat); if (v.length === 6 && v.every(isFinite)) { @@ -816,12 +696,7 @@ function exportSweepCSV() { SIGNER_MODE, sweepDistance(m, std, sweepState.W).toFixed(6)].join(',')); } - const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/csv' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = `stateful_candidates_${new Date().toISOString().slice(0, 10)}.csv`; - a.click(); - URL.revokeObjectURL(a.href); + downloadCSV('stateful_candidates', lines); } function setupAddForm() { @@ -913,21 +788,10 @@ function setupAddForm() { function render() { renderComparison(); if (SWEEP_FACTORIES.length > 0) renderSweepTable(); - else scheduleStatefulHashWrite(); + else scheduleHashWrite(); } // Init -async function loadSiteData() { - try { - const resp = await fetch('data.json', { cache: 'no-cache' }); - if (!resp.ok) throw new Error('HTTP ' + resp.status); - return await resp.json(); - } catch (e) { - console.warn('data.json unavailable (' + e.message + ') — computing candidates in-browser as fallback.'); - return null; - } -} - function renderSchnorrNote() { const s = SCHNORR; const m = state.candidates[0].metrics; @@ -943,13 +807,13 @@ function renderSchnorrNote() { `SLH-DSA-128s has ρ = ${fmtRho(cb)} C/B, ${fmtRatio(cb / s.cb64)} Schnorr.

`; } -async function init() { +async function setup() { const [siteData, schnorr] = await Promise.all([loadSiteData(), loadSchnorrRef()]); SITE_DATA = siteData; SCHNORR = schnorr; if (SITE_DATA) selfCheckStateful(SITE_DATA); state.candidates = defaultCandidates(); - applyStatefulHash(); + applyHash(); document.getElementById('signer-mode').value = SIGNER_MODE; document.getElementById('signer-mode').addEventListener('change', e => { SIGNER_MODE = e.target.value === 'cold' ? 'cold' : 'cached'; @@ -964,17 +828,7 @@ async function init() { renderSweepFilters(); renderSweepTable(); // first paint of the sweep table - document.getElementById('share-btn').addEventListener('click', async e => { - writeStatefulHash(); - const btn = e.target; - try { - await navigator.clipboard.writeText(location.href); - btn.textContent = 'Link copied ✓'; - setTimeout(() => { btn.textContent = 'Copy shareable link'; }, 1500); - } catch (err) { - prompt('Copy this link:', location.href); - } - }); + bindShareButton(writeHash); document.getElementById('sweep-export').addEventListener('click', exportSweepCSV); } -if (typeof document !== 'undefined') init(); +if (typeof document !== 'undefined') setup(); diff --git a/site/js/stateless.js b/site/js/stateless.js index 15d593c..cc6e73a 100644 --- a/site/js/stateless.js +++ b/site/js/stateless.js @@ -66,7 +66,7 @@ function passesInitial(c) { // fallback, and are cross-checked against the loaded SPX pool at startup. function poolFromData(data, scheme) { const p = data.pools[scheme]; - const idx = Object.fromEntries(p.fields.map((f, i) => [f, i])); + const idx = fieldIndex(p); // Guard against a stale data.json generated on an older grid: only the // swept w universe survives (a freshly exported dataset drops none). const rows = p.rows.filter(r => UNIVERSE_W.includes(r[idx.w])); @@ -131,41 +131,15 @@ function runFilters(pool, X) { return { stepCounts, survivors: cur, eliminatedAt }; } -// ---------- Distance ---------- -// Compute per-metric ratio range across the survivor pool so that -// weights have equal leverage regardless of filter tightness. -const METRIC_KEYS = ['size', 'kg', 'sg', 'sv', 'cb']; - -function computeRanges(survivors) { - const ranges = {}; - METRIC_KEYS.forEach(key => { - const mk = mkey(key); - const stdVal = STD_M[mk]; - if (!survivors.length) { ranges[key] = { min: 0, max: 1 }; return; } - const vals = survivors.map(c => c[mk] / stdVal); - const lo = Math.min(...vals), hi = Math.max(...vals); - ranges[key] = { min: lo, max: hi === lo ? lo + 1 : hi }; // avoid /0 - }); - return ranges; +// Ratios to SLH-DSA-128s, with the worst-case SigVer and ρ in worst-case mode. +function ratiosOf(c) { + const r = {}; + for (const k of DISTANCE_KEYS) r[k] = c[mkey(k)] / STD_M[mkey(k)]; + return r; } -function weightedDistance(c, W) { - const sum = W.size + W.kg + W.sg + W.sv + W.cb; - const p = { - size: W.size / sum, kg: W.kg / sum, sg: W.sg / sum, - sv: W.sv / sum, cb: W.cb / sum - }; - const ranges = state.lastRanges; - let dist2 = 0; - METRIC_KEYS.forEach(key => { - const wKey = key; // same key names in W - const mk = mkey(key); - const ratio = c[mk] / STD_M[mk]; - const { min, max } = ranges[key]; - const norm = (ratio - min) / (max - min); // in [0,1] for survivors - dist2 += p[wKey] * norm * norm; - }); - return Math.sqrt(dist2); +function distanceOf(c, W) { + return weightedDistance(ratiosOf(c), state.lastRanges, W); } // ---------- State ---------- @@ -205,14 +179,6 @@ function rebuildInitialPool() { state.initialPool = (state.pools[state.scheme] || []).filter(c => act.includes(c.w)); } -// Round a ratio up to a 1-2-5 decade; 20x is the floor so the classic slider range never shrinks. -function niceCap(m) { - if (!isFinite(m) || m <= 20) return 20; - const e = Math.pow(10, Math.floor(Math.log10(m))); - for (const f of [1, 2, 5, 10]) if (f * e >= m) return f * e; - return 10 * e; -} - // X-slider caps: the largest per-metric ratio present in ANY loaded pool // (average and worst-case variants), so even the extreme candidates — // e.g. the tiny-signature d = 2 family with keygen ratios in the tens of @@ -260,26 +226,6 @@ function mulberry32(seed) { // ---------- Rendering ---------- -// Log-position mapping for the cost-coefficient sliders: integer track -// positions 0..SLIDER_STEPS <-> X in [min, max], geometrically spaced. -// (~2% per tick — finer than the old 0.1 linear step near X = 1, and it -// reaches the pool's extreme ratios that a linear 0.1..20 track cannot.) -const SLIDER_STEPS = 1000; -function posToX(pos, min, max) { - return min * Math.pow(max / min, pos / SLIDER_STEPS); -} -function xToPos(x, min, max) { - const t = Math.log(x / min) / Math.log(max / min); - return Math.round(SLIDER_STEPS * Math.max(0, Math.min(1, t))); -} -function fmtX(v) { - // Keep the string short enough for the 92px value inputs: plain - // integers up to 6 digits, compact exponential beyond. - if (v < 100) return v.toFixed(2); - if (v < 1e6) return String(Math.round(v)); - return v.toExponential(1); -} - function renderControls() { const filterDefs = [ { key: 'size', label: 'Size', min: 0.30, max: 1.00, step: 0.01, dp: 2 }, @@ -296,12 +242,12 @@ function renderControls() { id="X_${d.key}_val" min="${d.min}" max="${d.max}" step="${d.log ? 'any' : d.step}" value="${d.log ? fmtX(state.X[d.key]) : state.X[d.key].toFixed(d.dp)}" - data-coef-input="${d.key}"${d.log ? '' : ` data-dp="${d.dp}"`}> + data-key="${d.key}"${d.log ? '' : ` data-dp="${d.dp}"`}> + data-key="${d.key}"${d.log ? ' data-log="1"' : ''}> `).join(''); @@ -320,61 +266,15 @@ function renderControls() { id="W_${d.key}_val" min="${d.min}" max="${d.max}" step="${d.step}" value="${state.W[d.key].toFixed(d.dp)}" - data-weight-input="${d.key}" data-dp="${d.dp}"> + data-key="${d.key}" data-dp="${d.dp}"> + value="${state.W[d.key]}" data-key="${d.key}"> `).join(''); - // --- Wire bidirectional input <-> slider for both X and W --- - // Sliders flagged data-log store a 0..SLIDER_STEPS track position; the - // real range lives on the paired number input's min/max attributes. - function bindPair(group, store) { - // Slider -> store + number-input - document.querySelectorAll(`input[data-${group}]`).forEach(slider => { - slider.addEventListener('input', e => { - const key = e.target.dataset[group]; - const numInp = document.querySelector(`input[data-${group}-input="${key}"]`); - let v; - if (e.target.dataset.log) { - v = posToX(parseFloat(e.target.value), - parseFloat(numInp.min), parseFloat(numInp.max)); - numInp.value = fmtX(v); - } else { - v = parseFloat(e.target.value); - numInp.value = v.toFixed(parseInt(numInp.dataset.dp, 10)); - } - store[key] = v; - requestUpdate(); - }); - }); - // Number input -> store + slider - document.querySelectorAll(`input[data-${group}-input]`).forEach(inp => { - inp.addEventListener('input', e => { - const key = e.target.dataset[`${group}Input`]; - let v = parseFloat(e.target.value); - if (!isFinite(v)) return; - const min = parseFloat(e.target.min); - const max = parseFloat(e.target.max); - const clamped = Math.max(min, Math.min(max, v)); - store[key] = clamped; - const slider = document.querySelector(`input[data-${group}="${key}"]`); - slider.value = slider.dataset.log ? xToPos(clamped, min, max) : clamped; - requestUpdate(); - }); - // On blur, reformat to canonical precision and re-clamp the displayed value - inp.addEventListener('blur', e => { - const key = e.target.dataset[`${group}Input`]; - e.target.value = e.target.dataset.dp - ? store[key].toFixed(parseInt(e.target.dataset.dp, 10)) - : fmtX(store[key]); - }); - }); - } - - bindPair('coef', state.X); - bindPair('weight', state.W); + bindSliderPairs(document.getElementById('filter-controls'), state.X, requestUpdate); + bindSliderPairs(document.getElementById('weight-controls'), state.W, requestUpdate); document.getElementById('reset-btn').addEventListener('click', () => { state.X = { ...DEFAULTS_X }; @@ -436,7 +336,7 @@ function renderScatter(eliminatedAt, survivors, winner) { state.scatterLayout = { pad, w, h, cssW, cssH }; // Compute distance for survivors to drive colour - const dists = survivors.map(c => weightedDistance(c, state.W)); + const dists = survivors.map(c => distanceOf(c, state.W)); const dmin = dists.length ? Math.min(...dists) : 0; const dmax = dists.length ? Math.max(...dists) : 1; const drange = (dmax - dmin) || 1; @@ -497,15 +397,10 @@ function renderScatter(eliminatedAt, survivors, winner) { document.getElementById('scatter-legend').innerHTML = legendHTML; } -function ratioClass(r, inverse = false) { - // r is target/std. inverse=true means lower is better (default for cost metrics) - if (inverse) { - if (r <= 0.8) return 'good'; - if (r <= 1.05) return 'warn'; - return 'bad'; - } - if (r >= 1.2) return 'good'; - if (r >= 0.95) return 'warn'; +// Colour of a ratio to SLH-DSA-128s; lower is better. +function ratioTone(r) { + if (r <= 0.8) return 'good'; + if (r <= 1.05) return 'warn'; return 'bad'; } @@ -526,7 +421,7 @@ function comparisonRows(c) { return rows.map(([name, value, std, fmtValue]) => { const r = value / std; return `${name}${fmtValue(value)}${fmtValue(std)}` + - `${fmtRatio(r)}`; + `${fmtRatio(r)}`; }).join(''); } @@ -543,7 +438,7 @@ function renderWinner(winner) { el.innerHTML = '
No candidate is within all bounds. Relax one.
'; return; } - const d = weightedDistance(winner, state.W); + const d = distanceOf(winner, state.W); el.innerHTML = `
@@ -561,13 +456,13 @@ function update() { const { stepCounts, survivors, eliminatedAt } = runFilters(state.initialPool, state.X); renderFunnel(stepCounts); // Recompute normalisation ranges for this survivor pool - state.lastRanges = computeRanges(survivors); + state.lastRanges = ratioRanges(survivors.map(ratiosOf)); let winner = null; if (survivors.length > 0) { winner = survivors[0]; - let bestD = weightedDistance(winner, state.W); + let bestD = distanceOf(winner, state.W); for (let i = 1; i < survivors.length; i++) { - const d = weightedDistance(survivors[i], state.W); + const d = distanceOf(survivors[i], state.W); if (d < bestD) { bestD = d; winner = survivors[i]; } } } @@ -579,14 +474,7 @@ function update() { scheduleHashWrite(); } -// A slider drag fires an input event per step. Recompute and redraw at -// most once per animation frame instead of once per event. -let updateQueued = false; -function requestUpdate() { - if (updateQueued) return; - updateQueued = true; - requestAnimationFrame(() => { updateQueued = false; update(); }); -} +const requestUpdate = oncePerFrame(update); // ---------- Hover tooltip ---------- function findNearestCandidate(mx, my) { @@ -617,7 +505,7 @@ function renderTooltip(c, clientX, clientY) { const st = statusFor(c); const isSurvivor = st.cls === 'survivor' || st.cls === 'winner'; const distHTML = isSurvivor - ? `
Distance ${weightedDistance(c, state.W).toFixed(4)}
` + ? `
Distance ${distanceOf(c, state.W).toFixed(4)}
` : ''; tt.innerHTML = ` @@ -782,17 +670,6 @@ function attachScatterHover() { } // ---------- Setup ---------- -async function loadSiteData() { - try { - const resp = await fetch('data.json', { cache: 'no-cache' }); - if (!resp.ok) throw new Error('HTTP ' + resp.status); - return await resp.json(); - } catch (e) { - console.warn('data.json unavailable (' + e.message + ') — computing the pool in-browser as fallback.'); - return null; - } -} - // Cross-check the loaded data against the local formulas: the baseline // plus a sample of pool rows. Any divergence is a bug in one of the two // implementations — log it loudly. @@ -896,35 +773,13 @@ function writeHash() { if (state.showOptW) parts.push('ow=1'); const cust = CUSTOM_IDS.map(id => document.getElementById(id).value).join(','); if (cust !== CUSTOM_DEFAULTS) parts.push('c=' + cust); - const url = parts.length - ? '#' + parts.join('&') - : location.pathname + location.search; - // Safari throws once a page exceeds its history-update quota. - try { history.replaceState(null, '', url); } catch (err) { /* keep the page working */ } + replaceHash(parts); } -// The address bar mirrors the state, but not on every slider step: -// browsers rate-limit history.replaceState (Chromium silently drops -// calls beyond about 200 per 10 s, which left the URL stale after a long -// drag; Safari throws), and every call is a same-document navigation the -// browser has to process in the middle of the drag. One write, shortly -// after the last change, is enough. -const HASH_WRITE_DELAY_MS = 250; -let hashTimer = null; -function scheduleHashWrite() { - clearTimeout(hashTimer); - hashTimer = setTimeout(writeHash, HASH_WRITE_DELAY_MS); -} +const scheduleHashWrite = hashWriter(writeHash); function applyHash() { - const raw = location.hash.replace(/^#/, ''); - if (!raw) return; - const params = {}; - for (const part of raw.split('&')) { - const eq = part.indexOf('='); - if (eq > 0) params[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1)); - } - const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + const params = readHash(); const setVec = (str, store, isX) => { const vals = (str || '').split(',').map(parseFloat); if (vals.length !== 5 || !vals.every(isFinite)) return; @@ -965,14 +820,9 @@ function exportSurvivorsCSV() { (c.cb / SCHNORR.cb64).toFixed(6), (c.cb_worst / SCHNORR.cb64).toFixed(6), SCHNORR.cb64.toFixed(6), - weightedDistance(c, state.W).toFixed(6)].join(',')); + distanceOf(c, state.W).toFixed(6)].join(',')); } - const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/csv' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = `survivors_${SCHEME_IDS[state.scheme]}_${new Date().toISOString().slice(0, 10)}.csv`; - a.click(); - URL.revokeObjectURL(a.href); + downloadCSV(`survivors_${SCHEME_IDS[state.scheme]}`, lines); } async function setup() { @@ -1018,17 +868,7 @@ async function setup() { renderInfoStrip(); update(); }); - document.getElementById('share-btn').addEventListener('click', async e => { - writeHash(); - const btn = e.target; - try { - await navigator.clipboard.writeText(location.href); - btn.textContent = 'Link copied ✓'; - setTimeout(() => { btn.textContent = 'Copy shareable link'; }, 1500); - } catch (err) { - prompt('Copy this link:', location.href); - } - }); + bindShareButton(writeHash); document.getElementById('export-btn').addEventListener('click', exportSurvivorsCSV); // Re-render the scatter when the canvas CSS size changes diff --git a/site/js/ui.js b/site/js/ui.js index f9509eb..766f593 100644 --- a/site/js/ui.js +++ b/site/js/ui.js @@ -1,4 +1,5 @@ -// Number formats, popovers and the glossary. +// Helpers shared by the three site pages: number formats, popovers, sliders, +// the address bar, data.json and the glossary. 'use strict'; // Number formats @@ -71,6 +72,153 @@ function infoButton(id, label) { addEventListener('scroll', placeOpen, true); })(); +// Sliders + +function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); +} + +// A log-scale slider holds a track position 0..SLIDER_STEPS that maps +// geometrically onto min..max. +const SLIDER_STEPS = 1000; + +function posToX(pos, min, max) { + return min * Math.pow(max / min, pos / SLIDER_STEPS); +} + +function xToPos(x, min, max) { + const t = Math.log(x / min) / Math.log(max / min); + return Math.round(SLIDER_STEPS * clamp(t, 0, 1)); +} + +// Short enough for the number inputs: two decimals below 100, integers below +// a million, exponential above. +function fmtX(v) { + if (v < 100) return v.toFixed(2); + if (v < 1e6) return String(Math.round(v)); + return v.toExponential(1); +} + +// Slider caps: the ratio rounded up to 1, 2 or 5 times a power of ten, at least 20. +function niceCap(m) { + if (!isFinite(m) || m <= 20) return 20; + const e = Math.pow(10, Math.floor(Math.log10(m))); + for (const f of [1, 2, 5, 10]) if (f * e >= m) return f * e; + return 10 * e; +} + +// Keeps store[key] in step with each range input in host and the number input +// with the same data-key. A range with data-log is a log-scale slider over the +// number input's min..max; a number input with data-dp shows that many decimals. +function bindSliderPairs(host, store, onChange) { + for (const slider of host.querySelectorAll('input[type="range"][data-key]')) { + const key = slider.dataset.key; + const num = host.querySelector(`input[type="number"][data-key="${key}"]`); + const show = v => { + num.value = num.dataset.dp ? v.toFixed(parseInt(num.dataset.dp, 10)) : fmtX(v); + }; + slider.addEventListener('input', () => { + const v = slider.dataset.log + ? posToX(parseFloat(slider.value), parseFloat(num.min), parseFloat(num.max)) + : parseFloat(slider.value); + store[key] = v; + show(v); + onChange(); + }); + num.addEventListener('input', () => { + const v = parseFloat(num.value); + if (!isFinite(v)) return; + const min = parseFloat(num.min), max = parseFloat(num.max); + store[key] = clamp(v, min, max); + slider.value = slider.dataset.log ? xToPos(store[key], min, max) : store[key]; + onChange(); + }); + num.addEventListener('blur', () => show(store[key])); + } +} + +// A slider drag fires an input event per step: run fn at most once per frame. +function oncePerFrame(fn) { + let queued = false; + return () => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { queued = false; fn(); }); + }; +} + +// Address bar + +// The #key=value&... parameters. +function readHash() { + const params = {}; + for (const part of location.hash.replace(/^#/, '').split('&')) { + const eq = part.indexOf('='); + if (eq > 0) params[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1)); + } + return params; +} + +function replaceHash(parts) { + const url = parts.length ? '#' + parts.join('&') : location.pathname + location.search; + // Safari throws once a page exceeds its history-update quota. + try { history.replaceState(null, '', url); } catch (err) { /* keep the page working */ } +} + +// Browsers rate-limit history.replaceState (Chromium drops calls beyond about +// 200 per 10 s), so the address bar is written once, shortly after the last +// change, rather than on every slider step. +const HASH_WRITE_DELAY_MS = 250; + +function hashWriter(write) { + let timer = null; + return () => { + clearTimeout(timer); + timer = setTimeout(write, HASH_WRITE_DELAY_MS); + }; +} + +function bindShareButton(writeHash) { + document.getElementById('share-btn').addEventListener('click', async e => { + writeHash(); + const btn = e.target; + try { + await navigator.clipboard.writeText(location.href); + btn.textContent = 'Link copied ✓'; + setTimeout(() => { btn.textContent = 'Copy shareable link'; }, 1500); + } catch (err) { + prompt('Copy this link:', location.href); + } + }); +} + +// Data + +async function loadSiteData() { + try { + const resp = await fetch('data.json', { cache: 'no-cache' }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return await resp.json(); + } catch (e) { + console.warn('data.json unavailable (' + e.message + ')'); + return null; + } +} + +// Column of each field in a data.json table. +function fieldIndex(table) { + return Object.fromEntries(table.fields.map((f, i) => [f, i])); +} + +function downloadCSV(name, lines) { + const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/csv' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${name}_${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(a.href); +} + // Glossary if (typeof document !== 'undefined') document.body.insertAdjacentHTML('beforeend', ` From ccf1b0a6dd640083a1668d979d8c5b6a68685567 Mon Sep 17 00:00:00 2001 From: mjthatch Date: Thu, 12 Nov 2026 16:27:17 +0000 Subject: [PATCH 04/16] stateless.html: bind the reset button once renderControls added a click listener to the reset button on every call, and a reset calls renderControls, so each reset doubled the work of the next one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QabhpThuWQEq1AZRZo24vm --- site/js/stateless.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/site/js/stateless.js b/site/js/stateless.js index cc6e73a..cff71df 100644 --- a/site/js/stateless.js +++ b/site/js/stateless.js @@ -275,13 +275,6 @@ function renderControls() { bindSliderPairs(document.getElementById('filter-controls'), state.X, requestUpdate); bindSliderPairs(document.getElementById('weight-controls'), state.W, requestUpdate); - - document.getElementById('reset-btn').addEventListener('click', () => { - state.X = { ...DEFAULTS_X }; - state.W = { ...DEFAULTS_W }; - renderControls(); - update(); - }); } function renderFunnel(stepCounts) { @@ -868,6 +861,12 @@ async function setup() { renderInfoStrip(); update(); }); + document.getElementById('reset-btn').addEventListener('click', () => { + state.X = { ...DEFAULTS_X }; + state.W = { ...DEFAULTS_W }; + renderControls(); + update(); + }); bindShareButton(writeHash); document.getElementById('export-btn').addEventListener('click', exportSurvivorsCSV); From ec7a87908bd21a6f885011e6e2578d0fba20d587 Mon Sep 17 00:00:00 2001 From: mjthatch Date: Thu, 12 Nov 2026 16:30:53 +0000 Subject: [PATCH 05/16] stateless.html: remove the in-browser fallback sweep The pool comes only from data.json. Without it the page says so and your own candidate still works. test_site_index becomes test_site_explorer and keeps the metric checks; the pool count is still checked against data.json. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QabhpThuWQEq1AZRZo24vm --- export_site_data.sage | 9 +++-- site/js/stateless.js | 86 ++++++++++------------------------------ site/stateless.html | 1 - tests/test_regression.py | 5 +-- 4 files changed, 28 insertions(+), 73 deletions(-) diff --git a/export_site_data.sage b/export_site_data.sage index b3a5b18..461140a 100644 --- a/export_site_data.sage +++ b/export_site_data.sage @@ -14,9 +14,10 @@ Single source of truth: - stateful grid, UXMSS variants, SLH baseline : computed by loading stateful.sage -The pages load data.json at startup and fall back to in-browser computation -(SPX only) if the file is unavailable; they also self-check the loaded -numbers against their local formulas and log any divergence to the console. +The pages load data.json at startup (without it the explorer has no pool and +the stateful page computes its candidates in the browser) and self-check the +loaded numbers against their local formulas, logging any divergence to the +console. Usage: sage export_site_data.sage # all schemes (a few minutes) @@ -58,7 +59,7 @@ for s in schemes: assert s in ALL_SCHEMES, "unknown scheme: %s" % s # Canonical stateless grid (report Sec. 2; must match slhdsa-2to40.sage -# defaults and the explorer's fallback sweep). +# defaults). GRID = {'h': [40, 50], 'd': [2, 25], 'k': [6, 24], 'a': [8, 20], 'w': [2, 4, 16, 256], 'q_s_log2': 40, 'max_size': 7856} # Optional Winternitz extras for the SPX pool diff --git a/site/js/stateless.js b/site/js/stateless.js index cff71df..1c555c1 100644 --- a/site/js/stateless.js +++ b/site/js/stateless.js @@ -20,50 +20,12 @@ function metrics(p) { const STD_M = metrics(SLH); -// ---------- Sweep ---------- -// Canonical grid — must match the report (Sec. 2) and slhdsa-2to40.sage defaults: -// h in [40, 50], d in [2, 25] with d | h, k in [6, 24], a in [8, 20], w in ALLOWED_W = {2, 4, 16, 256}. -// k >= 6 because with a <= 20 and h <= 50 any k <= 5 falls short of the -// 128-bit security floor at q_s = 2^40, so smaller k never survives anyway. -function sweep() { - const candidates = []; - for (let h = 40; h <= 50; h++) { - for (let d = 2; d <= 25; d++) { - if (h % d !== 0) continue; - for (let k = 6; k <= 24; k++) { - for (let a = 8; a <= 20; a++) { - for (const w of UNIVERSE_W) { - const p = { h, d, k, a, w }; - const m = metrics(p); - // Skip pathological/infinite candidates - if (!isFinite(m.kg) || !isFinite(m.sg)) continue; - candidates.push({ ...p, ...m }); - } - } - } - } - } - return candidates; -} - -// 128-bit security check using the real FORS forgery analysis -function passesSecurity(c) { - return computeSecurityBits(Q_S_LOG2, c.h, c.k, c.a) >= 128; -} - -// Combined initial bounds: security + size strictly less than SLH-DSA-128s -// (the report's Sage script uses MAX_SIZE = 7856, matching the FIPS 205 reference value) -const MAX_SIZE = 7856; -function passesInitial(c) { - return passesSecurity(c) && c.size < MAX_SIZE; -} - // ---------- Canonical data (data.json) ---------- // The pools are normally loaded from site/data.json, generated by // export_site_data.sage: SPX from the same sweep CSV as the report, the -// W+C / FORS+C / PORS+FP variants computed by costs.sage. The formulas -// above stay for the custom-candidate panel (SPX model) and as a -// fallback, and are cross-checked against the loaded SPX pool at startup. +// W+C / FORS+C / PORS+FP variants computed by costs.sage. The model computes +// the custom-candidate panel (SPX) and is cross-checked against the loaded +// SPX pool at startup. function poolFromData(data, scheme) { const p = data.pools[scheme]; const idx = fieldIndex(p); @@ -147,9 +109,8 @@ const DEFAULTS_X = { size: 1.00, kg: 1.00, sg: 1.00, sv: 1.00, cb: 1.00 }; const DEFAULTS_W = { size: 1.0, kg: 1.0, sg: 1.0, sv: 1.0, cb: 1.0 }; const state = { - candidates: [], initialPool: [], - siteData: null, // parsed data.json (null in fallback mode) + siteData: null, // parsed data.json, null if it did not load pools: {}, // scheme -> candidate pool scheme: 'SPX', useWorst: false, // filter/rank on worst-case SigVer & ρ @@ -204,8 +165,7 @@ function clampXToMax() { } // Tuples swept per w value: (h, d) pairs with d | h times the k and a ranges. -function consideredPerW(grid) { - const g = grid || { h: [40, 50], d: [2, 25], k: [6, 24], a: [8, 20] }; +function consideredPerW(g) { let hd = 0; for (let h = g.h[0]; h <= g.h[1]; h++) for (let d = g.d[0]; d <= g.d[1]; d++) if (h % d === 0) hd++; @@ -446,6 +406,10 @@ function renderWinner(winner) { // ---------- Main update loop ---------- function update() { + if (!state.siteData) { + document.getElementById('winner').innerHTML = '
No candidate pool.
'; + return; + } const { stepCounts, survivors, eliminatedAt } = runFilters(state.initialPool, state.X); renderFunnel(stepCounts); // Recompute normalisation ranges for this survivor pool @@ -692,16 +656,11 @@ const SCHEME_FROM_ID = Object.fromEntries(Object.entries(SCHEME_IDS).map(([k, v] function renderSchemeButtons() { const host = document.getElementById('scheme-controls'); - const schemes = state.siteData ? state.siteData.meta.schemes : ['SPX']; + const schemes = state.siteData ? state.siteData.meta.schemes : []; host.innerHTML = schemes.map(s => ``).join(''); host.querySelectorAll('button').forEach(b => b.addEventListener('click', () => activateScheme(b.dataset.scheme))); - if (!state.siteData) { - const hint = document.getElementById('scheme-hint'); - hint.textContent = 'Only SPX: data.json did not load.'; - hint.hidden = false; - } } function schnorrNote() { @@ -719,23 +678,26 @@ function schnorrNote() { function renderInfoStrip() { const d = state.siteData; + if (!d) { + document.getElementById('initial-info').textContent = 'No candidate pool: data.json did not load.'; + document.getElementById('about-pool').innerHTML = schnorrNote(); + return; + } const pool = state.initialPool; const act = activeWSet(); const wtxt = state.scheme === 'SPX' ? `w = {${act.join(', ')}}` : `(w, Swn) ∈ {(16, 240), (256, 2040)}`; - const swept = d - ? (state.scheme === 'SPX' ? consideredPerW(d.meta.grid) * act.length : d.pools[state.scheme].considered) - : consideredPerW(null) * act.length; + const swept = state.scheme === 'SPX' + ? consideredPerW(d.meta.grid) * act.length + : d.pools[state.scheme].considered; document.getElementById('initial-info').innerHTML = `Scheme ${SCHEME_LABELS[state.scheme]} · ` + `${swept.toLocaleString('en-US')} tuples swept · ` + `${pool.length.toLocaleString('en-US')} pass the initial bounds`; - const source = d - ? `

Pool: data.json, generated ${d.meta.generated} from commit ${d.meta.commit}. ` + - `Security from the FORS and PORS+FP subset-forgery analysis in ` + - `security.sage.

` - : '

Pool computed in the browser, because data.json did not load.

'; + const source = `

Pool: data.json, generated ${d.meta.generated} from commit ${d.meta.commit}. ` + + `Security from the FORS and PORS+FP subset-forgery analysis in ` + + `security.sage.

`; document.getElementById('about-pool').innerHTML = `

Initial bounds: at least 128-bit security at qs = 240, ` + `size below ${fmtNum(STD_M.size)} B, ${wtxt}.

` + source + schnorrNote(); @@ -830,12 +792,6 @@ async function setup() { state.pools[s] = pool; }); selfCheckData(data, state.pools['SPX']); - } else { - state.candidates = sweep(); - const pool = state.candidates.filter(passesInitial); - const rand = mulberry32(69421); - for (const c of pool) { c._sx = rand(); c._sy = rand(); } - state.pools['SPX'] = pool; } computeXMax(); // slider caps from the loaded pools applyHash(); // may switch scheme, X/W, worst mode, w extras, custom tuple diff --git a/site/stateless.html b/site/stateless.html index 8aacca2..dd3bfc4 100644 --- a/site/stateless.html +++ b/site/stateless.html @@ -601,7 +601,6 @@

Scheme

-

Winternitz parameter

diff --git a/tests/test_regression.py b/tests/test_regression.py index 2791664..5fe4484 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -169,12 +169,12 @@ def _run_node(js): return json.loads(p.stdout.strip().splitlines()[-1]) -def test_site_index(): +def test_site_explorer(): js = _page_js('stateless.html') tuples = [list(_tuple_of(k)) for k in FIX['stateless']] js += f""" const _tuples = {json.dumps(tuples)}; -const out = {{ pool: sweep().filter(passesInitial).length, metrics: {{}} }}; +const out = {{ metrics: {{}} }}; for (const t of _tuples) {{ const m = metrics({{h:t[0], d:t[1], k:t[2], a:t[3], w:t[4]}}); out.metrics[t.join(',')] = [m.size, m.kg, m.sg, m.sv, m.sv_worst]; @@ -184,7 +184,6 @@ def test_site_index(): out = _run_node(js) if out is None: return - assert out['pool'] == FIX['site_pool_count'], out['pool'] for key, f in FIX['stateless'].items(): got = out['metrics'][key] want = [f['size'], f['kg'], f['sg'], f['sv'], f['sv_worst']] From ea14bcdf9cb95e97e2024ddcba273c30c6c276a4 Mon Sep 17 00:00:00 2001 From: mjthatch Date: Thu, 12 Nov 2026 17:03:32 +0000 Subject: [PATCH 06/16] site: all styles in style.css with one set of colour tokens The pages keep only markup: their - +

SHRINCS pinned parameters

@@ -176,7 +30,7 @@

Stateless

(h, d, k, a, w) = (45, 5, 10, 13, 16)

-
+
@@ -195,7 +49,7 @@

Stateful, XMSS