diff --git a/README.md b/README.md
index 247e553..c33e455 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,6 @@ Scripts for exploring SPHINCS+ parameter tradeoffs, computing security levels, s
- [utils/](utils/) helper scripts for searching and visualising the parameter space
- [tests/](tests/) regression suite, run `python3 tests/test_regression.py`
- [document/](document/) LaTeX source for the accompanying technical report
-- [site/](site/) interactive pages hosted on GitHub Pages: the SHRINCS pinned-parameters main page ([index.html](site/index.html)), stateless parameter search over all report schemes (SPX / W+C / FORS+C / PORS+FP) with worst-case mode, shareable URL state and CSV export ([stateless.html](site/stateless.html)), and stateful scheme comparison ([stateful.html](site/stateful.html)); the stateful cost model shared by index and the stateful page lives in [formulas.js](site/formulas.js). The report PDF is not tracked in git: CI compiles [document/](document/) and deploys it as `site/report.pdf` (also attached to releases on tags)
+- [site/](site/) interactive pages hosted on GitHub Pages: the SHRINCS pinned-parameters main page ([index.html](site/index.html)), stateless parameter search over all report schemes (SPX / W+C / FORS+C / PORS+FP) with worst-case mode, shareable URL state and CSV export ([stateless.html](site/stateless.html)), and stateful scheme comparison ([stateful.html](site/stateful.html)); the cost and security model shared by the three pages lives in [js/model.js](site/js/model.js). The report PDF is not tracked in git: CI compiles [document/](document/) and deploys it as `site/report.pdf` (also attached to releases on tags)
Contributing: run `make check-local` before pushing.
\ No newline at end of file
diff --git a/docs/model.md b/docs/model.md
new file mode 100644
index 0000000..28aab9f
--- /dev/null
+++ b/docs/model.md
@@ -0,0 +1,187 @@
+# Cost and security model
+
+Sizes are in bytes and costs in SHA-256 compressions (C). The hash output is n = 16 bytes.
+The pages compute the model in `site/js/model.js`, the Sage scripts in `costs.sage`,
+`stateful.sage` and `security.sage`, and `tests/model.py` ports it to check both.
+
+## Hash calls
+
+Hashing follows the FIPS 205 SHA-2 instantiation as the SHRINCS reference implementation runs it:
+the block that holds PK.seed is compressed once per key and its state reused, so each call pays
+only for the rest of its input. A tweakable hash over m values of n bytes takes
+
+ Th(m) = ceil((176 + 128·m + 65) / 512)
+
+compressions: a 22-byte compressed address, the values, and the SHA-256 padding (one bit and a
+64-bit length). A chain step, a chain step with the WOTS+C counter, a tree node and a PRF call each
+fit in one block, so `C_Th1 = C_Th1c = C_Th2 = C_PRF = 1`. Compressing the l chain ends of a WOTS
+key into a leaf takes Th(l), and the k FORS roots Th(k).
+
+For a 32-byte message and an empty context:
+
+- H_msg is two nested SHA-256 calls of 2 compressions each. Its input starts with the randomizer R,
+ so no state can be reused: `C_Hmsg = 4`.
+- PRF_msg is HMAC-SHA-256, two SHA-256 calls of 3 and 2 compressions. Their first blocks, the fixed
+ key pads, are reused like the PK.seed block: `C_PRFmsg = 3`.
+
+## WOTS
+
+A WOTS key has l1 = ceil(8n / log2 w) message chains. WOTS-TW, the variant in SLH-DSA, adds
+l2 = floor(log_w(l1·(w − 1))) + 1 checksum chains (FIPS 205); WOTS+C has none and grinds a counter
+instead. With l chains, a public key costs
+
+ pk = l·PRF + l·(w − 1)·Th1 + Th(l)
+
+### WOTS-TW verification
+
+A message digit m costs the verifier w − 1 − m chain steps, so the message chains average
+(w − 1)/2 steps each. The checksum chains do not: the checksum C = l1·(w − 1) − S, with S the sum
+of the message digits, is concentrated around l1·(w − 1)/2, so its leading base-w digit is almost
+always 0 or 1 and the verifier walks most of that chain. With digit_sum(x) = x − (w − 1)·Σ_{j≥1}
+floor(x / w^j), and C distributed like S (map every digit m to w − 1 − m),
+
+ E[steps] = l1·(w − 1)/2 + l2·(w − 1) − E[digit_sum(C)]
+ = (w − 1)·(l2 + Σ_{j=1}^{l2−1} E[floor(S / w^j)])
+
+where E[floor(S / w^j)] = Σ_{m≥1} P(S ≥ m·w^j), and the number of digit strings with sum at most t
+is Σ_i (−1)^i·C(l1, i)·C(t − i·w + l1, l1). The code evaluates this with exact integers and rounds
+the total for all d layers of a signature once, to the nearest integer. For w = 16 the mean is
+267.12 steps rather than the 262 of (w − 1)·l/2; for w = 256 it is 2415.03 rather than 2295.
+
+The verifier walks the most when every message digit is 0. The checksum is then l1·(w − 1), so
+
+ worst = l1·(w − 1) + l2·(w − 1) − digit_sum(l1·(w − 1))
+
+Any other message saves at least as many steps on the message chains as it can cost on the
+checksum chains.
+
+The signer walks the part of each chain the verifier does not: l PRF calls and
+l·(w − 1) − E[steps] chain steps.
+
+### WOTS+C
+
+The signer grinds a counter until the l = l1 digits of the digest sum to S_wn, which makes
+verification cost the same for every message. Of the w^l digit strings,
+
+ ν = Σ_{j=0}^{l} (−1)^j·C(l, j)·C(S_wn + l − j·w − 1, l − 1)
+
+have that sum, so signing takes ceil(w^l / ν) attempts of `C_Th1c` on average, then l PRF calls and
+S_wn chain steps. Verification takes l·(w − 1) − S_wn chain steps, one `C_Th1c` and Th(l).
+
+SHRINCS stores the counter in 2 bytes: 2^16 counters miss the target sum with probability below
+2^−126 for every w ≤ 256. w = 65536 needs about 137,000 attempts on average and keeps a 4-byte
+counter.
+
+## SLH-DSA
+
+For (h, d, k, a, w), the hypertree has d layers of trees of height h′ = h/d.
+
+ size = n·(1 + k·(a + 1) + h + d·l)
+ tree = 2^h′·pk + (2^h′ − 1)·Th2
+ KeyGen = tree
+ SigGen = FORS + H_msg + PRF_msg + d·tree
+ FORS = k·2^a·(PRF + Th1) + k·(2^a − 1)·Th2 + Th(k)
+ SigVer = H_msg + k·Th1 + k·a·Th2 + Th(k) + d WOTS-TW verifications + h·Th2
+
+The size counts the randomizer, k FORS leaves with a authentication nodes each, the h nodes of the
+hypertree authentication path and d WOTS signatures: 7,856 B for SLH-DSA-128s, (63, 7, 14, 12, 16).
+KeyGen builds the top tree; a signature rebuilds one tree per layer. SigVer takes the mean of
+[WOTS-TW verification](#wots-tw-verification), SigVer worst its worst case in every layer. A SHRINCS
+stateless signature adds a one-byte indicator.
+
+## FORS security
+
+The stateless schemes are evaluated at q_s = 2^40 signatures. Each signature uses one of 2^h FORS
+instances, so the number r of signatures that land on a given instance follows Binomial(q_s, 2^−h).
+After r signatures each of the k trees of t = 2^a leaves has revealed up to r leaves, and a forger
+needs the leaf it must open revealed in every tree:
+
+ P(forgery | r) = (1 − (1 − 1/t)^r)^k
+ P(forgery) = Σ_r C(q_s, r)·2^(−h·r)·(1 − 2^−h)^(q_s − r)·P(forgery | r)
+ security = −log2(max(2^(−8n), P(forgery)))
+
+so 128 bits at most. Unlike the SPHINCS+ parameter script, this uses the exact forgery factor
+instead of the bound (r/t)^k, and the maximum of the two attacks instead of their sum, since a
+query cannot serve both. `security.sage` also covers PORS+FP.
+
+The page sums in log2 to avoid underflow. Only r within 30·sqrt(λ + 1) + 30 of the mean
+λ = q_s / 2^h is summed: further out the binomial weight is below e^−450 of its peak. That keeps
+the work at O(sqrt(λ)); when even that exceeds 2^16 terms, the sum takes every s-th term and
+weights it by s. For the parameter grid λ ≤ 1, and every term from r = 1 is summed.
+
+## Stateful schemes
+
+Every stateful scheme uses the SHRINCS stateful signature layout, so sizes compare like for like:
+
+ indicator (1 B) | R (16 B) | leaf index | per layer: [counter] + l·n chain values + h′·n auth path
+
+The leaf index takes max(1, ceil(h / 8)) bytes in XMSS and XMSS-MT. In UXMSS it takes
+ceil(min(depth, 64) / 8) bytes for a leaf at that depth: SHRINCS sizes the field by the depth of
+the signing leaf.
+
+### XMSS and XMSS-MT
+
+d layers of trees of height h′ = h/d give 2^h signatures; XMSS is d = 1.
+
+ KeyGen = 2^h′·pk + (2^h′ − 1)·Th2 the top tree
+ SigVer = H_msg + d·(WOTS verification + h′·Th2)
+
+A cached signer keeps a BDS traversal state for every layer (Buchmann, Dahmen and Schneider,
+"Merkle Tree Traversal Revisited", PQCrypto 2008) and the WOTS signatures of the upper layers, so
+it computes one WOTS signature and one BDS update per layer. K is 2 for even h′ and 3 for odd, the
+smallest K ≥ 2 with h′ − K even. An update takes at most (h′ − K)/2 + 1 leaf computations and
+floor(3·(h′ − K − 1)/2) + 1 node hashes; the long-run average is about one leaf computation lower.
+
+ SigGen, cached = H_msg + PRF_msg + WOTS signature + d·(leaves·pk + hashes·Th2)
+ State = d·(h′ + floor(h′/2) + 2^K − K − 1 + (h′ − K) + (h′ − K − 1))·n + (d − 1)·l·n
+
+The state counts what the SHRINCS signer caches hold: the authentication path, the keep nodes,
+the retained nodes, one completed node per treehash instance and their stack of partial results,
+plus the upper-layer WOTS signatures.
+
+A cold signer keeps no state and rebuilds the d trees for every signature. In the top tree it needs
+only the authentication path, so it skips the signing leaf and the h′ nodes on its path to the root:
+
+ SigGen, cold = H_msg + PRF_msg + d·(tree + WOTS signature) − pk − h′·Th2
+
+### UXMSS
+
+The SHRINCS unbalanced tree has a spine of height hsf with hsf + 1 leaves. The q-th signature
+(q = 1 is the first) carries min(q, hsf) authentication nodes, so each signature is one node longer
+than the one before. hsf is the largest value up to 255 for which the last signature stays below
+5,712 B, the size of the report's candidate 2, (45, 5, 8, 16, 16).
+
+ size(q) = 1 + 16 + index + counter + l·n + min(q, hsf)·n
+ KeyGen = (hsf + 1)·pk + hsf·Th2
+ SigVer = H_msg + WOTS verification + min(q, hsf)·Th2
+ State = (hsf + 1)·n
+
+A cached signer keeps the hsf + 1 leaf public keys. Every authentication node is one of them,
+except the sibling of the signing leaf on the spine, which it recombines from the cached leaves
+below it with hsf − q node hashes (none for the last two signatures):
+
+ SigGen, cached = H_msg + PRF_msg + WOTS signature + (hsf − min(q, hsf))·Th2
+
+A cold signer first rebuilds the hsf other leaves. Its first signature, the most expensive, takes
+SigGen, cached for q = 1, plus hsf·pk.
+
+## Distance
+
+The explorer and the stateful page rank the candidates within the bounds by the report's
+metric-normalised weighted distance (Section 3, Metric-Normalized Weighted Distance). For each
+metric i (size, KeyGen, SigGen, SigVer and ρ), X_i is the candidate's ratio to SLH-DSA-128s,
+rescaled over the candidates within the bounds, and W_i is its weight:
+
+ X̂_i = (X_i − min_i) / (max_i − min_i) max_i = min_i + 1 if all X_i are equal
+ d = sqrt(Σ_i p_i·X̂_i²), p_i = W_i / Σ_j W_j
+
+Because every X̂_i spans [0, 1], a weight sets its metric's share of the distance however loose the
+bounds are. The explorer's worst-case mode uses the worst-case SigVer and ρ; on the stateful page
+SigGen follows the signer mode.
+
+## Schnorr reference
+
+ρ ÷ Schnorr divides ρ by the compressions per byte of a BIP340 Schnorr verification with
+libsecp256k1 for a 64-byte signature, read from `results/kappa.json` of
+[bip340-vs-sha256](https://github.com/mjthatch/bip340-vs-sha256) when a page loads. If that fails,
+the pages use 1.98 C/B.
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/index.html b/site/index.html
index 6ac4701..c0a2cbc 100644
--- a/site/index.html
+++ b/site/index.html
@@ -8,155 +8,9 @@
-
-
+
SHRINCS pinned parameters
@@ -176,7 +30,7 @@ Stateless Candidate 1 of the report. The size includes the one-byte SHRINCS indicator.
(h, d, k, a, w) = (45, 5, 10, 13, 16)
-
+
@@ -195,7 +49,7 @@ Stateful, XMSS
-
+
@@ -208,7 +62,7 @@ Stateful, UXMSS WOTS+C, w = 64
-
+
@@ -218,130 +72,8 @@ Stateful, UXMSS
-
-
+
+
diff --git a/site/js/index.js b/site/js/index.js
new file mode 100644
index 0000000..e893641
--- /dev/null
+++ b/site/js/index.js
@@ -0,0 +1,118 @@
+// The pinned parameters page: the stateless parameters of the report's
+// candidate 1 and the stateful XMSS and UXMSS cards.
+'use strict';
+
+// Explorer links that point here (#s=…&x=…&w=…&wc=…&c=…) belong to
+// stateless.html. Under node, where the tests run, there is no location.
+if (typeof location !== 'undefined' && location.hash &&
+ /(^#|[#&])(s|x|w|wc|c)=/.test(location.hash)) {
+ location.replace('stateless.html' + location.hash);
+}
+
+const PINNED = { h: 45, d: 5, k: 10, a: 13, w: 16 }; // the report's candidate 1
+const XMSS_W = 64; // the stateful cards use WOTS+C with w = 64
+const XMSS_H_MAX = 20;
+
+// A SHRINCS stateless signature is the SLH-DSA signature behind the one-byte
+// indicator: 5,777 B for the pinned parameters.
+function shrincsStatelessSize(p) { return slhMetrics(p).size + IND_SIZE; }
+
+function row(name, value) {
+ return `${name} ${value} `;
+}
+
+function renderStateless() {
+ const m = slhMetrics(PINNED);
+ const size = shrincsStatelessSize(PINNED);
+ const cb = m.sv / size, cbW = m.svWorst / size;
+ document.getElementById('stateless-table').innerHTML = [
+ row('Max signatures', fmtPow2(40)),
+ row('Size (B)', fmtNum(size)),
+ row('KeyGen (C)', fmtNum(m.kg)),
+ row('SigGen (C)', fmtNum(m.sg)),
+ row('SigVer (C)', fmtNum(m.sv)),
+ row('SigVer, worst (C)', fmtNum(m.svWorst)),
+ row('ρ (C/B)', fmtRho(cb)),
+ row('ρ, worst (C/B)', fmtRho(cbW)),
+ row('ρ ÷ Schnorr, worst', fmtRatio(cbW / SCHNORR.cb64)),
+ ].join('');
+}
+
+function renderXmss() {
+ const h = state.xmssH;
+ const swn = WOTS_C_SWN[XMSS_W];
+ const size = xmssMtSize(h, 1, XMSS_W, 'WC');
+ const kg = xmssMtKeygenC(h, 1, XMSS_W, 'WC');
+ const sg = xmssMtSignBdsC(h, 1, XMSS_W, swn, 'WC');
+ const sgCold = xmssMtSignColdC(h, 1, XMSS_W, swn, 'WC');
+ const st = xmssMtStateBytes(h, 1, XMSS_W, 'WC');
+ const sv = xmssMtVerifyC(h, 1, XMSS_W, swn, 'WC');
+ const cb = sv / size;
+ document.getElementById('xmss-tuple').textContent = `WOTS+C, w = ${XMSS_W}, h = ${h}`;
+ document.getElementById('xmss-table').innerHTML = [
+ row('Max signatures', fmtPow2(h)),
+ row('Size (B)', fmtNum(size)),
+ row('KeyGen (C)', fmtNum(kg)),
+ row('SigGen, cached (C)', fmtNum(sg)),
+ row('SigGen, cold (C)', fmtNum(sgCold)),
+ row('State (B)', fmtNum(st)),
+ row('SigVer (C)', fmtNum(sv)),
+ row('ρ (C/B)', fmtRho(cb)),
+ row('ρ ÷ Schnorr', fmtRatio(cb / SCHNORR.cb64)),
+ ].join('');
+ const link = document.getElementById('xmss-link');
+ link.href = `stateful.html#cand=xmssmt-WC-${XMSS_W}-${h}-1,uxmss-WC-${XMSS_W}`;
+}
+
+function renderUxmss() {
+ const swn = WOTS_C_SWN[XMSS_W];
+ const hsf = findMaxHsf(XMSS_W, 'WC', UXMSS_TARGET_SIZE);
+ const sz1 = uxmssSize(1, hsf, XMSS_W, 'WC');
+ const szMax = uxmssSize(hsf, hsf, XMSS_W, 'WC');
+ const kg = uxmssKeygenC(hsf, XMSS_W, 'WC');
+ const sg1 = uxmssSignC(1, hsf, XMSS_W, swn, 'WC');
+ const sgLast = uxmssSignC(hsf + 1, hsf, XMSS_W, swn, 'WC');
+ const sgCold = uxmssSignColdC(hsf, XMSS_W, swn, 'WC');
+ const sv1 = uxmssVerifyC(1, hsf, XMSS_W, swn, 'WC');
+ const svMax = uxmssVerifyC(hsf, hsf, XMSS_W, swn, 'WC');
+ const cb1 = sv1 / sz1, cbMax = svMax / szMax;
+ document.getElementById('uxmss-table').innerHTML = [
+ row('Max signatures', fmtNum(hsf + 1)),
+ row('Size (B)', `${fmtNum(sz1)} → ${fmtNum(szMax)}`),
+ row('KeyGen (C)', fmtNum(kg)),
+ row('SigGen, cached (C)', `${fmtNum(sg1)} → ${fmtNum(sgLast)}`),
+ row('SigGen, cold (C)', fmtNum(sgCold)),
+ row('State (B)', fmtNum(uxmssStateBytes(hsf))),
+ row('SigVer (C)', `${fmtNum(sv1)} → ${fmtNum(svMax)}`),
+ row('ρ (C/B)', `${fmtRho(cb1)} → ${fmtRho(cbMax)}`),
+ row('ρ ÷ Schnorr', `${fmtRatio(cb1 / SCHNORR.cb64)} → ${fmtRatio(cbMax / SCHNORR.cb64)}`),
+ ].join('');
+}
+
+const state = { xmssH: 20 };
+
+async function setup() {
+ SCHNORR = await loadSchnorrRef();
+ renderStateless();
+ renderXmss();
+ renderUxmss();
+
+ const slider = document.getElementById('xmss-h');
+ const num = document.getElementById('xmss-h-val');
+ slider.addEventListener('input', () => {
+ state.xmssH = parseInt(slider.value, 10);
+ num.value = state.xmssH;
+ renderXmss();
+ });
+ num.addEventListener('input', () => {
+ const v = parseInt(num.value, 10);
+ if (!Number.isInteger(v)) return;
+ state.xmssH = Math.max(2, Math.min(XMSS_H_MAX, v));
+ slider.value = state.xmssH;
+ renderXmss();
+ });
+ num.addEventListener('blur', () => { num.value = state.xmssH; });
+}
+
+// The tests load this file under node, without a document.
+if (typeof document !== 'undefined') setup();
diff --git a/site/formulas.js b/site/js/model.js
similarity index 56%
rename from site/formulas.js
rename to site/js/model.js
index aeb0a15..54adb18 100644
--- a/site/formulas.js
+++ b/site/js/model.js
@@ -1,47 +1,36 @@
-// Shared cost-model layer for the three site pages.
+// The model behind the three site pages: sizes in bytes, costs in SHA-256
+// compressions (C), security in bits. docs/model.md derives every formula.
'use strict';
-// ---------- Cost-model constants ----------
-
-const N = 16;
-
-const R_SIZE = 16;
+const N = 16; // hash output, bytes
+const R_SIZE = 16; // signature randomizer, bytes
// SHRINCS puts a one-byte indicator in front of every signature (the tree
// height for the stateless path, the leaf height for the stateful path).
const IND_SIZE = 1;
-const C_Th1 = 1;
-
-const C_Th1c = 1;
-
-const C_Th2 = 1;
-
-// Message hash and message PRF for a 32-byte message and an empty context,
-// as executed by the SHRINCS reference implementation:
-// H_msg = two nested SHA-256 calls of 2 compressions each (its input
-// starts with the randomizer R, so nothing can be cached);
-// PRF_msg = HMAC-SHA-256, 2 + 1 compressions once its two key-pad blocks
-// are cached like the PK.seed block.
-const C_Hmsg = 4;
-
-const C_PRFmsg = 3;
-
+// Compressions per hash call (docs/model.md, Hash calls).
+const C_Th1 = 1; // chain step or leaf
+const C_Th1c = 1; // chain step with the WOTS+C counter
+const C_Th2 = 1; // tree node
+const C_Hmsg = 4; // message hash, 32-byte message
+const C_PRFmsg = 3; // message PRF
const C_PRF = 1;
+// Target digit sum S_wn of WOTS+C for each w.
const WOTS_C_SWN = { 16: 240, 32: 403, 64: 693, 128: 1206, 256: 2040, 65536: 262140 };
+// UXMSS signatures stay below the size of the report's candidate 2.
const UXMSS_TARGET_SIZE = 5712;
-const SLH = { h: 63, d: 7, k: 14, a: 12, w: 16 };
-
-const HSF_MAX = 255;
+const SLH = { h: 63, d: 7, k: 14, a: 12, w: 16 }; // SLH-DSA-128s
+const HSF_MAX = 255; // the UXMSS tree height fits one byte
-// ---------- Helpers ----------
-
+// Bytes of the leaf index in a hypertree of height h.
function idxBytes(h) { return Math.max(1, Math.ceil(h / 8)); }
+// Compressions of a tweakable hash over n hash values.
function computeTh(n) { return Math.ceil((176 + 128 * n + 65) / 512); }
// WOTS+C grinding counter. SHRINCS stores it in 2 bytes: 2^16 counters miss
@@ -49,8 +38,7 @@ function computeTh(n) { return Math.ceil((176 + 128 * n + 65) / 512); }
// needs about 137,000 attempts on average and keeps a 4-byte counter.
function counterBytes(w) { return w <= 256 ? 2 : 4; }
-
-// ---------- WOTS-TW average verification ----------
+// WOTS-TW verification, mean (docs/model.md, WOTS-TW verification)
function binomBig(n, k) {
if (k < 0n || k > n) return 0n;
@@ -61,11 +49,8 @@ function binomBig(n, k) {
return num / den;
}
-// w^l1 * sum_{j=1}^{l2-1} E[floor(S / w^j)] as an exact BigInt, where S is
-// the sum of l1 independent uniform base-w digits. Uses
-// E[floor(S / w^j)] = sum_{m>=1} P(S >= m*w^j)
-// and the inclusion-exclusion count of digit strings with sum <= t:
-// sum_i (-1)^i * C(l1, i) * C(t - i*w + l1, l1).
+// w^l1 * sum_{j=1}^{l2-1} E[floor(S / w^j)] as an exact BigInt, with S the sum
+// of l1 uniform base-w digits.
const _wotsTwFloorCache = new Map();
function wotsTwFloorSum(l1, l2, w) {
@@ -91,16 +76,8 @@ function wotsTwFloorSum(l1, l2, w) {
return acc;
}
-// Mean chain steps to verify `count` WOTS-TW signatures on uniformly random
-// digests, rounded to the nearest integer (compute_wots_tw_mean_steps in
-// costs.sage).
-//
-// The l1 message chains average (w-1)/2 steps each, but the l2 checksum
-// chains do not: the checksum is concentrated around l1*(w-1)/2, so its
-// leading digit is almost always 0 or 1 and the verifier walks most of that
-// chain. Exactly,
-// E[steps] = (w-1) * (l2 + sum_{j=1}^{l2-1} E[floor(S / w^j)])
-// (267.12 for w = 16, where (w-1)*len/2 would give 262).
+// Mean chain steps to verify `count` WOTS-TW signatures on random digests,
+// rounded once to the nearest integer.
function wotsTwMeanSteps(w, count = 1) {
const l1 = Math.ceil(8 * N / Math.log2(w));
const l2 = Math.floor(Math.log2(l1 * (w - 1)) / Math.log2(w)) + 1;
@@ -109,13 +86,12 @@ function wotsTwMeanSteps(w, count = 1) {
return Number((2n * num + W) / (2n * W)); // round to nearest
}
+// WOTS (docs/model.md, WOTS)
-// ---------- WOTS family ----------
-
+// Chains of a WOTS key: l1 for WOTS+C, l1 + l2 for WOTS-TW (FIPS 205).
function wotsL(w, ots) {
const l1 = Math.ceil(8 * N / Math.log2(w));
if (ots === 'WC') return l1;
- // FIPS 205 form: len2 = floor(log_w(len1*(w-1))) + 1
const l2 = Math.floor(Math.log2(l1 * (w - 1)) / Math.log2(w)) + 1;
return l1 + l2;
}
@@ -129,6 +105,7 @@ function wotsPkC(w, ots) {
return l * C_PRF + l * (w - 1) * C_Th1 + wotsThl(l, ots);
}
+// Digit strings of length l in base w that sum to swn.
function computeNu(l, swn, w) {
const lB = BigInt(l), swnB = BigInt(swn), wB = BigInt(w);
let nu = 0n;
@@ -156,6 +133,7 @@ function wotsSignC(w, swn, ots) {
return l * C_PRF + (l * (w - 1) - wotsTwMeanSteps(w)) * C_Th1;
}
+// Worst case: every message digit is 0.
function wotsVerifyWorstSteps(w) {
const l1 = Math.ceil(8 * N / Math.log2(w));
const l2 = Math.floor(Math.log2(l1 * (w - 1)) / Math.log2(w)) + 1;
@@ -174,8 +152,7 @@ function wotsVerifyC(w, swn, ots, worstCase, count = 1) {
return wotsTwMeanSteps(w, count) * C_Th1 + count * Thl;
}
-
-// ---------- XMSS / XMSS-MT ----------
+// XMSS and XMSS-MT (docs/model.md, XMSS and XMSS-MT)
function treeSizePerLayer(hPrime, w, ots) {
const l = wotsL(w, ots);
@@ -184,7 +161,6 @@ function treeSizePerLayer(hPrime, w, ots) {
}
function treeKeygenC(hPrime, w, ots) {
- // 2^h' WOTS keypairs + 2^h' − 1 internal hashes
const pk = wotsPkC(w, ots);
const sz = Math.pow(2, hPrime);
return sz * pk + (sz - 1) * C_Th2;
@@ -194,13 +170,8 @@ function treeVerifyC(hPrime, w, swn, ots, worstCase, count = 1) {
return wotsVerifyC(w, swn, ots, worstCase, count) + count * hPrime * C_Th2;
}
-// BDS tree traversal (Buchmann, Dahmen, Schneider 2008) for a tree of height
-// h' with parameter K (K >= 2, h' - K even): per signature at most
-// (h'-K)/2 + 1 leaf computations and 3(h'-K-1)/2 + 1 node hashes. The
-// long-run average is about one leaf computation below the bound. The state
-// is counted as for the SHRINCS signer caches: h' authentication nodes,
-// floor(h'/2) keep nodes, 2^K - K - 1 retained nodes, h' - K completed
-// treehash nodes and a stack of h' - K - 1 partial results.
+// BDS traversal (Buchmann, Dahmen, Schneider 2008): the parameter K, the
+// leaves and node hashes per signature, and the nodes of the state.
function bdsK(hPrime) {
if (hPrime < 2) return hPrime;
return hPrime % 2 === 0 ? 2 : 3;
@@ -218,7 +189,7 @@ function bdsStateNodes(hPrime) {
+ Math.max(0, hPrime - k) + Math.max(0, hPrime - k - 1);
}
-// Signature layout (SHRINCS stateful path), used for every scheme here:
+// SHRINCS stateful layout, used for every stateful scheme:
// indicator | R | leaf index | per layer: [counter] + WOTS chains + auth path
function xmssMtSize(h, d, w, ots) {
const hPrime = h / d;
@@ -226,12 +197,13 @@ function xmssMtSize(h, d, w, ots) {
}
function xmssMtKeygenC(h, d, w, ots) {
- // Build only the top tree (others are derived during signing).
+ // The top tree; the signer builds the lower ones.
return treeKeygenC(h / d, w, ots);
}
function xmssMtSignBdsC(h, d, w, swn, ots) {
- // Cached signer: one BDS update per layer and signature (upper bound).
+ // Cached signer, upper bound: the upper-layer WOTS signatures are cached,
+ // so one WOTS signature and one BDS update per layer.
const hPrime = h / d;
const pk = wotsPkC(w, ots);
const cSg = wotsSignC(w, swn, ots);
@@ -240,17 +212,16 @@ function xmssMtSignBdsC(h, d, w, swn, ots) {
}
function xmssMtSignColdC(h, d, w, swn, ots) {
- // State-minimized signer: rebuild d trees of height h' per signature. The
- // top tree only needs its authentication path: the other 2^h' - 1 leaves
- // and every internal node except the h' on the signing leaf's own path.
+ // Cold signer: rebuilds the d trees. In the top tree it skips the signing
+ // leaf and the h' nodes on its path to the root.
const hPrime = h / d;
return C_Hmsg + C_PRFmsg + d * (treeKeygenC(hPrime, w, ots) + wotsSignC(w, swn, ots))
- wotsPkC(w, ots) - hPrime * C_Th2;
}
function xmssMtStateBytes(h, d, w, ots) {
- // Cached-signer state: the BDS state of every layer plus the cached
- // upper-layer WOTS signatures.
+ // Cached signer: the BDS state of every layer and the upper-layer WOTS
+ // signatures.
const hPrime = h / d;
return d * bdsStateNodes(hPrime) * N + (d - 1) * wotsL(w, ots) * N;
}
@@ -259,17 +230,15 @@ function xmssMtVerifyC(h, d, w, swn, ots, worstCase) {
return C_Hmsg + treeVerifyC(h / d, w, swn, ots, worstCase, d);
}
+// UXMSS, the SHRINCS unbalanced tree (docs/model.md, UXMSS)
-// ---------- UXMSS (SHRINCS left-leaning) ----------
-
-// Leaf-index width for a leaf at this depth: ceil(min(depth, 64) / 8) bytes.
-// SHRINCS sizes the field by the depth of the signing leaf, also in the
-// unbalanced tree, where the index is only ever 0 or 1.
+// Bytes of the leaf index for a leaf at this depth. SHRINCS sizes the field
+// by the depth also in the unbalanced tree, where the index is only 0 or 1.
function uxmssIdxBytes(depth) {
return Math.max(1, Math.ceil(Math.min(depth, 64) / 8));
}
-// Size of the q-th UXMSS signature (q = 1 is the first).
+// Size of the q-th signature (q = 1 is the first).
function uxmssSize(q, hsf, w, ots) {
const l = wotsL(w, ots);
const ctr = (ots === 'WC') ? counterBytes(w) : 0;
@@ -277,7 +246,7 @@ function uxmssSize(q, hsf, w, ots) {
return IND_SIZE + R_SIZE + uxmssIdxBytes(depth) + ctr + l * N + depth * N;
}
-// Largest hsf <= HSF_MAX whose largest signature is strictly below targetSize.
+// Largest hsf <= HSF_MAX whose largest signature is below targetSize.
function findMaxHsf(w, ots, targetSize) {
for (let hsf = HSF_MAX; hsf > 0; hsf--) {
if (uxmssSize(hsf, hsf, w, ots) < targetSize) return hsf;
@@ -289,27 +258,29 @@ function uxmssKeygenC(hsf, w, ots) {
return (hsf + 1) * wotsPkC(w, ots) + hsf * C_Th2;
}
-// Cached signer, q-th signature: the hsf + 1 leaf public keys are kept as
-// state. Every authentication node is one of them except the sibling of the
-// signing leaf on the spine, which is recombined from the cached leaves below
-// it with hsf - q node hashes (none for the last two signatures).
+// Cached signer, q-th signature: it keeps the leaf public keys and recombines
+// the spine sibling of the signing leaf from them.
function uxmssSignC(q, hsf, w, swn, ots) {
return C_Hmsg + C_PRFmsg + wotsSignC(w, swn, ots)
+ (hsf - Math.min(q, hsf)) * C_Th2;
}
-// State-minimal signer, first signature (the most expensive one): rebuild
-// the hsf other leaves, then proceed like the cached signer.
+// Cold signer, first signature, the most expensive: it rebuilds the hsf
+// other leaves first.
function uxmssSignColdC(hsf, w, swn, ots) {
return uxmssSignC(1, hsf, w, swn, ots) + hsf * wotsPkC(w, ots);
}
+// Cached-signer state: the hsf + 1 leaf public keys.
+function uxmssStateBytes(hsf) {
+ return (hsf + 1) * N;
+}
+
function uxmssVerifyC(q, hsf, w, swn, ots, worstCase) {
return C_Hmsg + wotsVerifyC(w, swn, ots, worstCase) + Math.min(q, hsf) * C_Th2;
}
-
-// ---------- SLH-DSA-128s baseline (SPX, for reference rows) ----------
+// SLH-DSA (docs/model.md, SLH-DSA)
function slhMetrics(p) {
const hp = p.h / p.d;
@@ -319,24 +290,21 @@ function slhMetrics(p) {
const Thl = computeTh(len);
const Thk = computeTh(p.k);
- // R (1·n) + FORS (k·(a+1)·n) + hypertree auth (h·n) + WOTS (d·len·n) — 7856 B for 128s
+ // randomizer, FORS, hypertree authentication path, WOTS signatures
const size = N * (1 + p.k * (p.a + 1) + p.h + p.d * len);
- // One XMSS layer: 2^h' WOTS keypairs (PRF + chains + leaf compress) + internal nodes
+ // one hypertree layer
const leaves = Math.pow(2, hp);
const wotsCost = len * C_PRF + len * (p.w - 1) * C_Th1 + Thl;
const cMerkle = leaves * wotsCost + (leaves - 1) * C_Th2;
const kg = cMerkle;
- // FORS: k·2^a leaves (PRF + Th1) + k·(2^a − 1) internal nodes + root compress
const forsLeaves = Math.pow(2, p.a);
const cFors = p.k * forsLeaves * C_PRF + p.k * forsLeaves * C_Th1
+ p.k * (forsLeaves - 1) * C_Th2 + Thk;
const sg = cFors + C_Hmsg + C_PRFmsg + p.d * cMerkle;
- // Verification: Hmsg + FORS paths + d WOTS verifies + h auth-path nodes
const cFts = p.k * C_Th1 + p.k * p.a * C_Th2 + Thk;
- // Average case: exact expected chain steps over all d layers, see wotsTwMeanSteps.
const cWotsAvg = wotsTwMeanSteps(p.w, p.d) * C_Th1 + p.d * Thl;
const sv = C_Hmsg + cFts + cWotsAvg + p.h * C_Th2;
const cWotsWorst = wotsVerifyWorstSteps(p.w) * C_Th1 + Thl;
@@ -345,9 +313,127 @@ function slhMetrics(p) {
return { size, kg, sg, sv, svWorst };
}
+// FORS security (docs/model.md, FORS security), summed in log2 to avoid
+// underflow.
+
+const _LOG2_E = 1 / Math.LN2;
+
+// ln Γ(z), Lanczos approximation.
+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;
+}
+
+// log2(1 - e^x)
+function _log2OneMinusExp(x) {
+ if (x >= 0) return -Infinity;
+ return Math.log(-Math.expm1(x)) * _LOG2_E;
+}
+
+// log2(2^a + 2^b)
+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;
+}
+
+// Beyond this many terms the forgery sum is strided; the summand is smooth on
+// that scale, so the sum stays accurate far beyond the displayed precision.
+const MAX_SUM_TERMS = 1 << 16;
+
+// -log2 of the FORS forgery probability after 2^q_s_log2 signatures, uncapped.
+// Only r within `spread` of the mean lambda = q_s / 2^h is summed, so the work
+// grows with sqrt(lambda); the custom candidate accepts any h.
+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);
+}
+
+// Bits of security: the forgery exponent, capped at 8 * hashbytes by preimage
+// search.
+function computeSecurityBits(q_s_log2, h, k, a, hashbytes = 16) {
+ return Math.min(8 * hashbytes, forsForgeryExponent(q_s_log2, h, k, a));
+}
-// ---------- Schnorr reference ----------
+// The stateless schemes are evaluated at 2^40 signatures.
+const Q_S_LOG2 = 40;
+
+// Distance, the report's metric-normalised weighted distance (docs/model.md,
+// Distance)
+
+const DISTANCE_KEYS = ['size', 'kg', 'sg', 'sv', 'cb'];
+
+// Per-metric min and max of a list of ratio sets; a flat metric spans [min, min + 1].
+function ratioRanges(ratioSets) {
+ const ranges = {};
+ for (const k of DISTANCE_KEYS) {
+ let min = Infinity, max = -Infinity;
+ for (const r of ratioSets) {
+ if (r[k] < min) min = r[k];
+ if (r[k] > max) max = r[k];
+ }
+ ranges[k] = isFinite(min) ? { min, max: max === min ? min + 1 : max } : { min: 0, max: 1 };
+ }
+ return ranges;
+}
+
+function weightedDistance(ratios, ranges, W) {
+ const sum = W.size + W.kg + W.sg + W.sv + W.cb;
+ let d2 = 0;
+ for (const k of DISTANCE_KEYS) {
+ const norm = (ratios[k] - ranges[k].min) / (ranges[k].max - ranges[k].min);
+ d2 += (W[k] / sum) * norm * norm;
+ }
+ return Math.sqrt(d2);
+}
+// ρ of a BIP340 Schnorr verification (libsecp256k1, 64-byte signature) from
+// bip340-vs-sha256; the built-in value stands in when it does not load.
const SCHNORR_KAPPA_URL =
'https://raw.githubusercontent.com/mjthatch/bip340-vs-sha256/main/results/kappa.json';
diff --git a/site/js/stateful.js b/site/js/stateful.js
new file mode 100644
index 0000000..23ba285
--- /dev/null
+++ b/site/js/stateful.js
@@ -0,0 +1,822 @@
+// The Stateful Comparison: SLH-DSA-128s against XMSS, XMSS-MT and UXMSS
+// candidates, and the list of all stateful candidates within the bounds.
+'use strict';
+
+// A candidate is { id, key, label, scheme, canRemove, metrics }, plus hsf for
+// UXMSS. The key rebuilds it from the address bar.
+let nextId = 1;
+function makeId() { return 'c' + (nextId++); }
+
+function otsShort(ots) { return ots === 'WC' ? 'WOTS+C' : 'WOTS'; }
+
+function xmssLabel(ots, h, d, w) {
+ return d > 1 ? `XMSS-MT (${otsShort(ots)}, w = ${w}, h = ${h}, d = ${d})`
+ : `XMSS (${otsShort(ots)}, w = ${w}, h = ${h})`;
+}
+
+function buildSLH() {
+ const s = SITE_DATA ? SITE_DATA.stateful.slh : null;
+ const m = slhMetrics(SLH);
+ return {
+ id: makeId(),
+ label: 'SLH-DSA-128s',
+ scheme: 'SLH-DSA',
+ canRemove: false,
+ metrics: {
+ size: s ? s.size : m.size,
+ keygen: s ? s.kg : m.kg,
+ siggen: s ? s.sg : m.sg,
+ siggenCold: s ? s.sg : m.sg,
+ sigver: s ? s.sv : m.sv,
+ sigverWorst: s ? s.sv_worst : m.svWorst,
+ qsLog2: s ? s.qs_log2 : 64,
+ qsExact: null,
+ },
+ };
+}
+
+function buildXmssMt(ots, h, d, w) {
+ const swn = (ots === 'WC') ? WOTS_C_SWN[w] : 0;
+ return {
+ id: makeId(),
+ key: `xmssmt-${ots}-${w}-${h}-${d}`,
+ label: xmssLabel(ots, h, d, w),
+ scheme: d > 1 ? 'XMSS-MT' : 'XMSS',
+ canRemove: true,
+ metrics: {
+ size: xmssMtSize(h, d, w, ots),
+ keygen: xmssMtKeygenC(h, d, w, ots),
+ siggen: xmssMtSignBdsC(h, d, w, swn, ots),
+ siggenCold: xmssMtSignColdC(h, d, w, swn, ots),
+ state: xmssMtStateBytes(h, d, w, ots),
+ sigver: xmssMtVerifyC(h, d, w, swn, ots, false),
+ sigverWorst: xmssMtVerifyC(h, d, w, swn, ots, true),
+ qsLog2: h,
+ qsExact: null,
+ },
+ };
+}
+
+function buildUxmss(ots, w) {
+ const swn = (ots === 'WC') ? WOTS_C_SWN[w] : 0;
+ const hsf = findMaxHsf(w, ots, UXMSS_TARGET_SIZE);
+ return {
+ id: makeId(),
+ key: `uxmss-${ots}-${w}`,
+ label: `UXMSS (${otsShort(ots)}, w = ${w})`,
+ scheme: 'UXMSS',
+ canRemove: true,
+ hsf,
+ metrics: {
+ size: uxmssSize(hsf, hsf, w, ots),
+ sizeQ1: uxmssSize(1, hsf, w, ots),
+ keygen: uxmssKeygenC(hsf, w, ots),
+ siggen: uxmssSignC(1, hsf, w, swn, ots),
+ siggenCold: uxmssSignColdC(hsf, w, swn, ots),
+ state: uxmssStateBytes(hsf),
+ sigver: uxmssVerifyC(hsf, hsf, w, swn, ots, false),
+ sigverWorst: uxmssVerifyC(hsf, hsf, w, swn, ots, true),
+ qsLog2: null,
+ qsExact: hsf + 1,
+ },
+ };
+}
+
+// data.json (export_site_data.sage) feeds the candidate list; without it the
+// model computes the list in the browser.
+let SITE_DATA = null;
+
+// 'cached' or 'cold'; changes SigGen only.
+let SIGNER_MODE = 'cached';
+function sigGenOf(m) { return SIGNER_MODE === 'cold' ? m.siggenCold : m.siggen; }
+
+function candFromXmssMtRec(rec, idx) {
+ const [ots, h, d, w] = [rec[idx.ots], rec[idx.h], rec[idx.d], rec[idx.w]];
+ return {
+ id: makeId(),
+ key: `xmssmt-${ots}-${w}-${h}-${d}`,
+ label: xmssLabel(ots, h, d, w),
+ scheme: d > 1 ? 'XMSS-MT' : 'XMSS',
+ canRemove: true,
+ metrics: {
+ size: rec[idx.size], keygen: rec[idx.kg], siggen: rec[idx.sg],
+ siggenCold: rec[idx.sg_cold], state: rec[idx.state],
+ sigver: rec[idx.sv], sigverWorst: rec[idx.sv_worst],
+ qsLog2: h, qsExact: null,
+ },
+ };
+}
+
+function candFromUxmssRec(rec, idx) {
+ const [ots, w, hsf] = [rec[idx.ots], rec[idx.w], rec[idx.hsf]];
+ return {
+ id: makeId(),
+ key: `uxmss-${ots}-${w}`,
+ label: `UXMSS (${otsShort(ots)}, w = ${w})`,
+ scheme: 'UXMSS',
+ canRemove: true,
+ hsf,
+ metrics: {
+ size: rec[idx.sz_max], sizeQ1: rec[idx.sz_q1],
+ keygen: rec[idx.kg], siggen: rec[idx.sg],
+ siggenCold: rec[idx.sg_cold], state: rec[idx.state],
+ sigver: rec[idx.sv_max], sigverWorst: rec[idx.sv_max_worst],
+ qsLog2: null, qsExact: hsf + 1,
+ },
+ };
+}
+
+// Checks data.json against the model on SLH-DSA-128s, a sample of the XMSS-MT
+// rows and every UXMSS row; a mismatch is a bug in one of the two.
+function selfCheckStateful(data) {
+ let bad = 0;
+ const s = data.stateful.slh, m = slhMetrics(SLH);
+ if (s.size !== m.size || s.kg !== m.kg || s.sg !== m.sg ||
+ s.sv !== m.sv || s.sv_worst !== m.svWorst) {
+ bad++; console.error('data.json SLH baseline disagrees with local formulas:', s, m);
+ }
+ const xi = fieldIndex(data.stateful.xmssmt);
+ const xrows = data.stateful.xmssmt.rows;
+ const step = Math.max(1, Math.floor(xrows.length / 20));
+ for (let i = 0; i < xrows.length; i += step) {
+ const r = xrows[i];
+ const c = buildXmssMt(r[xi.ots], r[xi.h], r[xi.d], r[xi.w]).metrics;
+ if (c.size !== r[xi.size] || c.keygen !== r[xi.kg] || c.siggen !== r[xi.sg] ||
+ c.siggenCold !== r[xi.sg_cold] || c.state !== r[xi.state] ||
+ c.sigver !== r[xi.sv] || c.sigverWorst !== r[xi.sv_worst]) {
+ bad++; console.error('data.json XMSS-MT row disagrees with local formulas:', r, c);
+ }
+ }
+ const ui = fieldIndex(data.stateful.uxmss);
+ for (const r of data.stateful.uxmss.rows) {
+ const c = buildUxmss(r[ui.ots], r[ui.w]).metrics;
+ if (c.size !== r[ui.sz_max] || c.sizeQ1 !== r[ui.sz_q1] || c.keygen !== r[ui.kg] ||
+ c.siggen !== r[ui.sg] || c.siggenCold !== r[ui.sg_cold] || c.state !== r[ui.state] ||
+ c.sigver !== r[ui.sv_max] || c.sigverWorst !== r[ui.sv_max_worst]) {
+ bad++; console.error('data.json UXMSS row disagrees with local formulas:', r, c);
+ }
+ }
+ if (bad === 0) console.log('data.json self-check passed (SLH baseline, XMSS-MT sample, UXMSS variants).');
+ return bad === 0;
+}
+
+function defaultCandidates() {
+ return [
+ buildSLH(),
+ buildUxmss('TW', 16),
+ buildUxmss('WC', 16),
+ buildXmssMt('TW', 10, 1, 16),
+ buildXmssMt('WC', 10, 1, 16),
+ buildXmssMt('TW', 20, 1, 16),
+ buildXmssMt('WC', 20, 1, 16),
+ ];
+}
+
+// UXMSS signatures grow with the index: cells show first → last; ratios,
+// bounds and sorting use the last.
+function sizeCell(m) {
+ if (m.sizeQ1 !== undefined && m.sizeQ1 !== m.size) return `${fmtNum(m.sizeQ1)} → ${fmtNum(m.size)}`;
+ return fmtNum(m.size);
+}
+
+function ratioNote(target, std) {
+ if (target === std || !isFinite(target) || !isFinite(std)) return '';
+ return fmtRatio(target / std);
+}
+
+// ρ for SigVer sv, divided by `scale` (1, or the Schnorr ρ) and printed with `fmt`.
+function rhoCell(m, c, sv, scale, fmt) {
+ const cbMax = sv / m.size / scale;
+ if (c && c.hsf !== undefined && m.sizeQ1 !== undefined && m.sizeQ1 !== m.size) {
+ const cb1 = (sv - (c.hsf - 1) * C_Th2) / m.sizeQ1 / scale;
+ return `${fmt(cb1)} → ${fmt(cbMax)}`;
+ }
+ return fmt(cbMax);
+}
+
+function qsCell(c) {
+ return c.qsLog2 !== null ? fmtPow2(c.qsLog2) : fmtNum(c.qsExact);
+}
+
+const RHO = 'ρ ';
+
+const state = { candidates: [] }; // SLH-DSA-128s first
+
+const METRIC_COLS = [
+ { label: () => 'Size (B)' + infoButton('about-size', 'About the size'),
+ value: m => sizeCell(m), ratioField: 'size', lowerIsBetter: true },
+ { label: 'KeyGen (C)', value: m => fmtNum(m.keygen), ratioField: 'keygen', lowerIsBetter: true },
+ { label: () => `SigGen, ${SIGNER_MODE} (C)` + infoButton('about-siggen', 'About SigGen'),
+ value: m => fmtNum(sigGenOf(m)), ratioFn: m => sigGenOf(m), lowerIsBetter: true },
+ { label: () => 'State (B)' + infoButton('about-state', 'About the state'),
+ value: m => (SIGNER_MODE === 'cold' || m.state === undefined) ? '—' : fmtNum(m.state) },
+ { label: 'SigVer (C)', value: m => fmtNum(m.sigver), ratioField: 'sigver', lowerIsBetter: true },
+ { label: `${RHO} (C/B)`, value: (m, c) => rhoCell(m, c, m.sigver, 1, fmtRho), ratioFn: m => (m.sigver / m.size), lowerIsBetter: true },
+ { label: `${RHO}, worst (C/B)`,
+ value: (m, c) => rhoCell(m, c, m.sigverWorst, 1, fmtRho), ratioFn: m => (m.sigverWorst / m.size), lowerIsBetter: true },
+ { label: () => `${RHO} ÷ Schnorr` + infoButton('about-schnorr', 'About ρ ÷ Schnorr'),
+ value: (m, c) => rhoCell(m, c, m.sigver, SCHNORR.cb64, fmtRatio) },
+ { label: 'Max signatures', value: (_, c) => qsCell(c.metrics) },
+];
+
+function ratioClass(ratio, lowerIsBetter) {
+ if (ratio === 1) return '';
+ if (lowerIsBetter) return ratio < 0.95 ? 'gain' : (ratio > 1.05 ? 'regress' : '');
+ return ratio > 1.05 ? 'gain' : (ratio < 0.95 ? 'regress' : '');
+}
+
+function renderComparison() {
+ const std = state.candidates[0].metrics;
+ const table = document.getElementById('compare-table');
+
+ const thead = document.createElement('thead');
+ let hr = 'Candidate ';
+ for (const col of METRIC_COLS) {
+ const lbl = typeof col.label === 'function' ? col.label() : col.label;
+ hr += `${lbl} `;
+ }
+ hr += ' ';
+ thead.innerHTML = hr;
+
+ const tbody = document.createElement('tbody');
+ for (let i = 0; i < state.candidates.length; i++) {
+ const c = state.candidates[i];
+ const m = c.metrics;
+ const isStd = (i === 0);
+
+ let row = ``;
+ row += `${c.label} `;
+
+ for (const col of METRIC_COLS) {
+ let cell = col.value(m, c);
+ if (!isStd) {
+ let target = null, stdVal = null;
+ if (col.ratioField) {
+ target = m[col.ratioField];
+ stdVal = std[col.ratioField];
+ } else if (col.ratioFn) {
+ target = col.ratioFn(m);
+ stdVal = col.ratioFn(std);
+ }
+ if (target !== null && isFinite(target) && isFinite(stdVal) && stdVal > 0) {
+ const ratio = target / stdVal;
+ const cls = ratioClass(ratio, col.lowerIsBetter);
+ cell += `${ratioNote(target, stdVal)} `;
+ }
+ }
+ row += `${cell} `;
+ }
+
+ if (c.canRemove) {
+ row += `× `;
+ } 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();
+ });
+ });
+}
+
+// The candidate list: every candidate smaller than SLH-DSA-128s, with a
+// factory that adds a fresh copy to the comparison.
+const SWEEP_FACTORIES = [];
+
+function buildSweepPool() {
+ const stdSize = state.candidates[0].metrics.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;
+ 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;
+ 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,
+ });
+ }
+ }
+}
+
+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: [], // in display order, for the CSV
+};
+
+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 },
+];
+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 },
+];
+
+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 sweepDistance(m, std, W) {
+ return weightedDistance(sweepRatios(m, std), sweepState.lastRanges, W);
+}
+
+// Bound slider caps: the largest ratio of each metric in the list, in both
+// signer modes and verification cases, so every candidate stays 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-key="${f.key}" data-log="1"`
+ : `min="${f.min}" max="${f.max}" step="${f.step}"
+ value="${store[f.key]}" data-key="${f.key}"`;
+ return `
+
+
+ ${f.label}
+
+
+
+
+ `;
+ }).join('');
+
+ bindSliderPairs(host, store, requestSweepRender);
+}
+
+function renderSweepFilters() {
+ renderSweepFilterGroup('sweep-bounds', 'X', SWEEP_BOUND_FILTERS);
+ renderSweepFilterGroup('sweep-weights', 'W', SWEEP_WEIGHT_FILTERS);
+}
+
+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 = 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) => {
+ 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;
+ scheduleHashWrite();
+
+ 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);
+ // One innerHTML assignment, so the rows are parsed 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(); // disables its + button
+ });
+ });
+
+ 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();
+ });
+ });
+}
+
+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'];
+
+// The candidate of a 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 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(','));
+ 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);
+ replaceHash(parts);
+}
+
+const scheduleHashWrite = hashWriter(writeHash);
+const requestSweepRender = oncePerFrame(renderSweepTable);
+
+function applyHash() {
+ const params = readHash();
+ 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(','));
+ }
+ downloadCSV('stateful_candidates', lines);
+}
+
+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();
+ });
+}
+
+// Called after every change of state.candidates.
+function render() {
+ renderComparison();
+ if (SWEEP_FACTORIES.length > 0) renderSweepTable();
+ else scheduleHashWrite();
+}
+
+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 setup() {
+ const [siteData, schnorr] = await Promise.all([loadSiteData(), loadSchnorrRef()]);
+ SITE_DATA = siteData;
+ SCHNORR = schnorr;
+ if (SITE_DATA) selfCheckStateful(SITE_DATA);
+ state.candidates = defaultCandidates();
+ applyHash();
+ 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();
+ renderSchnorrNote();
+ buildSweepPool(); // needs the SLH-DSA-128s candidate
+ computeSweepXMax();
+ clampSweepXToMax();
+ renderSweepFilters();
+ renderSweepTable();
+
+ bindShareButton(writeHash);
+ document.getElementById('sweep-export').addEventListener('click', exportSweepCSV);
+}
+// The tests load this file under node, without a document.
+if (typeof document !== 'undefined') setup();
diff --git a/site/js/stateless.js b/site/js/stateless.js
new file mode 100644
index 0000000..ab55e2e
--- /dev/null
+++ b/site/js/stateless.js
@@ -0,0 +1,811 @@
+// The Parameter Explorer: filters the candidate pools of data.json by bounds
+// relative to SLH-DSA-128s, ranks what is left by distance, and compares a
+// candidate of your own.
+'use strict';
+
+// SLH-DSA-compatible Winternitz parameters: log2(w) divides 8, so message
+// digits stay byte-aligned.
+const ALLOWED_W = [2, 4, 16, 256];
+
+// Exploratory w; a checkbox adds them to the SPX pool.
+const OPTIONAL_W = [32, 64, 128];
+const UNIVERSE_W = [...ALLOWED_W, ...OPTIONAL_W];
+
+// Explorer rows use data.json's field names and carry ρ.
+function metrics(p) {
+ const m = slhMetrics(p);
+ return {
+ size: m.size, kg: m.kg, sg: m.sg, sv: m.sv, sv_worst: m.svWorst,
+ cb: m.sv / m.size, cb_worst: m.svWorst / m.size,
+ };
+}
+
+const STD_M = metrics(SLH);
+
+// The pool of a scheme, from data.json (export_site_data.sage).
+function poolFromData(data, scheme) {
+ const p = data.pools[scheme];
+ const idx = fieldIndex(p);
+ // A data.json made for another grid can hold other w.
+ 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;
+ });
+}
+
+// Field of metric k: worst-case mode switches SigVer and ρ to their worst case.
+function mkey(k) {
+ return state.useWorst && (k === 'sv' || k === 'cb') ? k + '_worst' : k;
+}
+
+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: 'ρ' },
+];
+
+// Applies the bounds X in turn. Returns the count after each step, the
+// survivors, and the step that removed each candidate (null if none did).
+function runFilters(pool, X) {
+ const eliminatedAt = new Map();
+ 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 };
+}
+
+// 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 distanceOf(c, W) {
+ return weightedDistance(ratiosOf(c), state.lastRanges, W);
+}
+
+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 = {
+ initialPool: [],
+ siteData: null, // parsed data.json, null if it did not load
+ pools: {}, // scheme -> candidate pool
+ scheme: 'SPX',
+ useWorst: false, // bounds and ranking on worst-case SigVer and ρ
+ showOptW: false, // include OPTIONAL_W
+ xMax: { kg: 20, sg: 20, sv: 20, cb: 20 },
+ X: { ...DEFAULTS_X },
+ W: { ...DEFAULTS_W },
+ // results of the last update
+ lastEliminatedAt: null,
+ lastSurvivors: [],
+ lastWinner: null,
+ // scatter geometry, for hit-testing
+ scatterLayout: { pad: 12, w: 0, h: 0 },
+ // ratio ranges over the survivors, for the distance
+ lastRanges: Object.fromEntries(['size', 'kg', 'sg', 'sv', 'cb'].map(k => [k, { min: 0, max: 1 }])),
+};
+
+// Winternitz parameters in the pool: OPTIONAL_W 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));
+}
+
+// Bound slider caps: the largest ratio of each metric in any pool, average or
+// worst case, so every candidate stays reachable (KeyGen ratios of the d = 2
+// candidates reach tens of thousands).
+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(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++;
+ return hd * (g.k[1] - g.k[0] + 1) * (g.a[1] - g.a[0] + 1);
+}
+
+// Seeded random numbers, so the dots keep their places between visits.
+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;
+ };
+}
+
+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 => `
+
+
+ ${d.label}
+
+
+
+
+ `).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 => `
+
+
+ ${d.label}
+
+
+
+
+ `).join('');
+
+ bindSliderPairs(document.getElementById('filter-controls'), state.X, requestUpdate);
+ bindSliderPairs(document.getElementById('weight-controls'), state.W, requestUpdate);
+}
+
+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 of a distance scaled to [0, 1]: blue for the closest, through purple
+// to red.
+function distanceColor(t) {
+ 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');
+ // Canvas pixels follow the device pixel ratio.
+ 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);
+
+ ctx.fillStyle = '#fafafa';
+ ctx.fillRect(0, 0, cssW, cssH);
+
+ const pad = 12;
+ const w = cssW - 2 * pad;
+ const h = cssH - 2 * pad;
+ state.scatterLayout = { pad, w, h, cssW, cssH };
+
+ 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;
+
+ // Grey dots first, so the coloured ones and the winner stay on top.
+ for (const c of state.initialPool) {
+ const step = eliminatedAt.get(c);
+ if (step === null) continue;
+ 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();
+ }
+
+ 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})` : ''));
+
+ const legendHTML = `
+ filtered out
+ closest
+ middle
+ farthest
+ best
+ `;
+ document.getElementById('scatter-legend').innerHTML = legendHTML;
+}
+
+// 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';
+}
+
+// Rows of the comparison tables: each metric next to its SLH-DSA-128s value.
+function comparisonRows(c) {
+ const schnorr = 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 `
+ Metric ${column} SLH-DSA-128s Ratio
+ ${comparisonRows(c)}
+
`;
+}
+
+function renderWinner(winner) {
+ const el = document.getElementById('winner');
+ if (!winner) {
+ el.innerHTML = 'No candidate is within all bounds. Relax one.
';
+ return;
+ }
+ const d = distanceOf(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')}
+
+ `;
+}
+
+// Filters, ranks and redraws after any change of the bounds, weights or pool.
+function update() {
+ if (!state.siteData) {
+ document.getElementById('winner').innerHTML = 'No candidate pool.
';
+ return;
+ }
+ const { stepCounts, survivors, eliminatedAt } = runFilters(state.initialPool, state.X);
+ renderFunnel(stepCounts);
+ state.lastRanges = ratioRanges(survivors.map(ratiosOf));
+ let winner = null;
+ if (survivors.length > 0) {
+ winner = survivors[0];
+ let bestD = distanceOf(winner, state.W);
+ for (let i = 1; i < survivors.length; i++) {
+ const d = distanceOf(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();
+}
+
+const requestUpdate = oncePerFrame(update);
+
+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 ${distanceOf(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}
+
+ candidate SLH-DSA-128s ratio
+ ${comparisonRows(c)}
+
+${distHTML}
+ `;
+ tt.style.display = 'block';
+
+ // Right of and below the cursor, flipped where that leaves the window.
+ 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';
+
+ 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';
+}
+
+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 };
+
+ 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;
+ }
+
+ const m = metrics(p);
+
+ // 2^(h/d) overflows a double for h' above about 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;
+ }
+
+ 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 === SLH.h && p.d === SLH.d && p.k === SLH.k && p.a === SLH.a && p.w === SLH.w;
+ statusEl.innerHTML = `Valid. ${sameAsStd ? ' This is SLH-DSA-128s.' : ''}`;
+ }
+
+ const hp = p.h / p.d;
+ const chains = wotsL(p.w, 'TW');
+ 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: ${chains}
+
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); // the w select
+ });
+ renderCustom();
+}
+
+function attachScatterHover() {
+ const canvas = document.getElementById('scatter');
+ let last = null;
+ canvas.addEventListener('mousemove', (e) => {
+ const rect = canvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ const c = findNearestCandidate(mx, my);
+ if (c !== last || c) {
+ last = c;
+ renderTooltip(c, e.clientX, e.clientY);
+ }
+ });
+ canvas.addEventListener('mouseleave', () => {
+ last = null;
+ renderTooltip(null);
+ });
+}
+
+// Checks data.json against the model on the baseline and a sample of rows; a
+// mismatch is a bug in one of the two.
+function selfCheckData(data, pool) {
+ const KEYS = ['size', 'kg', 'sg', 'sv', 'sv_worst'];
+ let bad = 0;
+ const b = data.baseline;
+ if (KEYS.some(k => STD_M[k] !== b[k])) {
+ bad++; console.error('data.json baseline disagrees with local formulas:', b, STD_M);
+ }
+ const step = Math.max(1, Math.floor(pool.length / 25));
+ for (let i = 0; i < pool.length; i += step) {
+ const c = pool[i];
+ const m = metrics(c);
+ if (KEYS.some(k => m[k] !== c[k])) {
+ bad++; console.error('data.json row disagrees with local formulas:', c, m);
+ }
+ }
+ if (bad === 0) console.log('data.json self-check passed (baseline + sampled rows match local formulas).');
+ return bad === 0;
+}
+
+const SCHEME_LABELS = { 'SPX': 'SPX', 'W+C': 'W+C', 'W+C_F+C': 'FORS+C', 'W+C_P+FP': 'PORS+FP' };
+const SCHEME_IDS = { 'SPX': 'spx', 'W+C': 'wc', 'W+C_F+C': 'fc', 'W+C_P+FP': 'pfp' };
+const SCHEME_FROM_ID = Object.fromEntries(Object.entries(SCHEME_IDS).map(([k, v]) => [v, k]));
+
+function renderSchemeButtons() {
+ const host = document.getElementById('scheme-controls');
+ const schemes = state.siteData ? state.siteData.meta.schemes : [];
+ host.innerHTML = schemes.map(s =>
+ `${SCHEME_LABELS[s]} `).join('');
+ host.querySelectorAll('button').forEach(b =>
+ b.addEventListener('click', () => activateScheme(b.dataset.scheme)));
+}
+
+function schnorrNote() {
+ const s = SCHNORR;
+ 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';
+ return `Schnorr reference: a BIP340 verification takes ${s.cb64.toFixed(2)} C/B ` +
+ `(${s.impl}, one 64-byte signature; ` +
+ `bip340-vs-sha256 , ${src}). ` +
+ `SLH-DSA-128s has ρ = ${fmtRho(STD_M.cb)} C/B, ${fmtRatio(STD_M.cb / s.cb64)} Schnorr ` +
+ `(${fmtRatio(STD_M.cb_worst / s.cb64)} worst).
`;
+}
+
+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 = 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 = `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();
+}
+
+function activateScheme(scheme) {
+ if (!state.pools[scheme] || scheme === state.scheme) return;
+ state.scheme = scheme;
+ rebuildInitialPool();
+ renderSchemeButtons();
+ renderInfoStrip();
+ update();
+}
+
+const HASH_KEYS = ['size', 'kg', 'sg', 'sv', 'cb'];
+const CUSTOM_IDS = ['cust-h', 'cust-d', 'cust-k', 'cust-a', 'cust-w'];
+const CUSTOM_DEFAULTS = '63,7,14,12,16';
+
+function writeHash() {
+ const parts = [];
+ if (state.scheme !== 'SPX') parts.push('s=' + SCHEME_IDS[state.scheme]);
+ if (HASH_KEYS.some(k => state.X[k] !== DEFAULTS_X[k]))
+ parts.push('x=' + HASH_KEYS.map(k => state.X[k]).join(','));
+ if (HASH_KEYS.some(k => state.W[k] !== DEFAULTS_W[k]))
+ parts.push('w=' + HASH_KEYS.map(k => state.W[k]).join(','));
+ if (state.useWorst) parts.push('wc=1');
+ 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);
+ replaceHash(parts);
+}
+
+const scheduleHashWrite = hashWriter(writeHash);
+
+function applyHash() {
+ const params = readHash();
+ const setVec = (str, store, isX) => {
+ const vals = (str || '').split(',').map(parseFloat);
+ if (vals.length !== 5 || !vals.every(isFinite)) return;
+ // Bounds accept the whole pool range here; clampXToMax lowers them to the
+ // slider caps next. Weights stay within 20.
+ HASH_KEYS.forEach((k, i) => {
+ store[k] = (k === 'size' && isX)
+ ? clamp(vals[i], 0.30, 1.00)
+ : clamp(vals[i], 0.10, isX ? 1e9 : 20.00);
+ });
+ };
+ if (params.s && SCHEME_FROM_ID[params.s] && state.pools[SCHEME_FROM_ID[params.s]]) {
+ state.scheme = SCHEME_FROM_ID[params.s];
+ }
+ if (params.x) setVec(params.x, state.X, true);
+ if (params.w) setVec(params.w, state.W, false);
+ if (params.wc === '1') state.useWorst = true;
+ if (params.ow === '1') state.showOptW = true;
+ if (params.c) {
+ const vals = params.c.split(',').map(v => parseInt(v, 10));
+ if (vals.length === 5 && vals.every(Number.isFinite)) {
+ if (!UNIVERSE_W.includes(vals[4])) vals[4] = 16;
+ CUSTOM_IDS.forEach((id, i) => document.getElementById(id).value = vals[i]);
+ }
+ }
+}
+
+function exportSurvivorsCSV() {
+ const header = ['scheme', 'h', 'd', 'k', 'a', 'w', 'swn', 'mmax', 'size_B',
+ 'keygen_C', 'siggen_C', 'sigver_C', 'sigver_worst_C',
+ 'rho', 'rho_worst', 'rho_vs_schnorr', 'rho_worst_vs_schnorr', 'schnorr_rho', 'distance'];
+ const lines = [header.join(',')];
+ for (const c of state.lastSurvivors) {
+ lines.push([state.scheme, c.h, c.d, c.k, c.a, c.w, c.swn || 0, c.mmax || 0,
+ c.size, c.kg, c.sg, c.sv, c.sv_worst,
+ c.cb.toFixed(6), c.cb_worst.toFixed(6),
+ (c.cb / SCHNORR.cb64).toFixed(6),
+ (c.cb_worst / SCHNORR.cb64).toFixed(6),
+ SCHNORR.cb64.toFixed(6),
+ distanceOf(c, state.W).toFixed(6)].join(','));
+ }
+ downloadCSV(`survivors_${SCHEME_IDS[state.scheme]}`, lines);
+}
+
+async function setup() {
+ const [data, schnorr] = await Promise.all([loadSiteData(), loadSchnorrRef()]);
+ state.siteData = data;
+ SCHNORR = schnorr;
+ if (data) {
+ data.meta.schemes.forEach((s, si) => {
+ const pool = poolFromData(data, s);
+ const rand = mulberry32(69421 + si); // stable layout per scheme
+ for (const c of pool) { c._sx = rand(); c._sy = rand(); }
+ state.pools[s] = pool;
+ });
+ selfCheckData(data, state.pools['SPX']);
+ }
+ computeXMax(); // slider caps from the loaded pools
+ applyHash(); // may switch scheme, X/W, worst mode, w extras, custom tuple
+ clampXToMax();
+ rebuildInitialPool();
+ renderControls();
+ document.getElementById('worst-toggle').checked = state.useWorst;
+ document.getElementById('optw-toggle').checked = state.showOptW;
+ document.querySelectorAll('[data-std]').forEach(el => { el.innerHTML = fmtNum(STD_M[el.dataset.std]); });
+ renderSchemeButtons();
+ renderInfoStrip();
+ update();
+ attachScatterHover();
+ attachCustomCandidate();
+
+ document.getElementById('worst-toggle').addEventListener('change', e => {
+ state.useWorst = e.target.checked;
+ update();
+ });
+ document.getElementById('optw-toggle').addEventListener('change', e => {
+ state.showOptW = e.target.checked;
+ rebuildInitialPool();
+ 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);
+
+ // The canvas follows its width.
+ window.addEventListener('resize', () => {
+ if (state.lastEliminatedAt) {
+ renderScatter(state.lastEliminatedAt, state.lastSurvivors, state.lastWinner);
+ }
+ });
+}
+
+// The tests load this file under node, without a document.
+if (typeof document !== 'undefined') setup();
diff --git a/site/js/ui.js b/site/js/ui.js
index 1deb126..8d0c307 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
@@ -40,6 +41,7 @@ function infoButton(id, label) {
}
(function () {
+ if (typeof document === 'undefined') return; // under node, in the tests
if (!('popover' in HTMLElement.prototype)) return; // older browsers show the content inline
let opener = null;
document.addEventListener('click', e => {
@@ -70,9 +72,156 @@ 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
-document.body.insertAdjacentHTML('beforeend', `
+if (typeof document !== 'undefined') document.body.insertAdjacentHTML('beforeend', `
Glossary
diff --git a/site/stateful.html b/site/stateful.html
index 3acba48..a063dd8 100644
--- a/site/stateful.html
+++ b/site/stateful.html
@@ -6,301 +6,8 @@
SHRINCS Stateful Comparison
-
-
+
- Comparison
+ Comparison
Ratios under each value compare with SLH-DSA-128s.
Sizes follow the SHRINCS stateful layout: a one-byte indicator, a 16-byte randomizer and the leaf index,
@@ -427,993 +134,8 @@
All stateful candidates
-
-
+
+
diff --git a/site/stateless.html b/site/stateless.html
index 0d732ec..7c14fb7 100644
--- a/site/stateless.html
+++ b/site/stateless.html
@@ -8,572 +8,6 @@
-
@@ -601,7 +35,6 @@ Scheme PORS+FP : WOTS+C, and PORS+FP, a single tree with Octopus authentication.
-
Winternitz parameter i
@@ -610,7 +43,7 @@ Winternitz parameter
- include w = 32, 64, 128 (non-aligned)
+ include w = 32, 64, 128 (non-aligned)
Bounds
@@ -700,1360 +133,8 @@ Compare your own candidate
-
+
+
diff --git a/site/style.css b/site/style.css
index b34c2a4..02d433c 100644
--- a/site/style.css
+++ b/site/style.css
@@ -1,16 +1,61 @@
-/* Shared by the three site pages. Colours come from each page's :root. */
+/* Styles of the three site pages: shared rules first, then one section per page.
+ A rule that would also reach another page is scoped by its page's body class. */
+
+:root {
+ --bg: #f7f7f5;
+ --panel: #ffffff;
+ --border: #e5e5e0;
+ --text: #1a1a1a;
+ --text-soft: #6b6b6b;
+ --text-mute: #9a9a92;
+ --accent: #2563eb;
+ --accent-soft: #dbeafe;
+ --good: #16a34a;
+ --good-soft: #dcfce7;
+ --warn: #d97706;
+ --warn-soft: #fef3c7;
+ --bad: #dc2626;
+ --bad-soft: #fee2e2;
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ padding: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.5;
+}
+
+body { padding: 24px; }
+
+header, main { max-width: 1280px; margin: 0 auto; }
+header { margin-bottom: 20px; }
+
+h1 { margin: 0 0 6px; font-size: 22px; font-weight: 600; letter-spacing: -0.01em; }
+
+.tagline { margin: 0; color: var(--text-soft); font-size: 14px; }
-/* Page header */
.crumbs { margin-bottom: 4px; font-size: 12px; color: var(--text-mute); }
-.crumbs a { color: var(--accent); text-decoration: none; }
-.crumbs a:hover { text-decoration: underline; }
+
+.crumbs a, .tagline a, .card-link a { color: var(--accent); text-decoration: none; }
+.crumbs a:hover, .tagline a:hover, .card-link a:hover { text-decoration: underline; }
+
.text-btn {
padding: 0; border: 0; background: none; font: inherit;
color: var(--accent); cursor: pointer;
}
.text-btn:hover { text-decoration: underline; }
-/* info button */
+/* Names whose case matters, inside uppercase headings */
+.keep-case { text-transform: none; }
+
+.nowrap { white-space: nowrap; }
+
+/* Info button */
.info {
display: inline-grid; place-items: center;
width: 15px; height: 15px; margin-left: 5px; padding: 0;
@@ -50,5 +95,599 @@
.pop dd { margin-bottom: 6px; }
}
-/* Names whose case matters */
-.keep-case { text-transform: none; }
+/* Panels and cards */
+.panel, .card {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 16px;
+}
+.panel + .panel { margin-top: 16px; }
+.panel h2, .card h2 {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-soft);
+ font-weight: 600;
+}
+.panel h2 { margin: 0 0 8px; }
+.card h2 { margin: 0 0 2px; }
+.panel .section-title { margin-top: 20px; }
+.panel .hint { margin: -4px 0 12px; color: var(--text-mute); font-size: 12px; }
+
+/* Sliders, each with a number input */
+.control label, .h-control label {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ font-size: 13px;
+ font-family: var(--mono);
+ margin-bottom: 2px;
+}
+.control label .name, .h-control label .name { color: var(--text); }
+input[type="range"] { width: 100%; accent-color: var(--accent); }
+.val-input {
+ padding: 2px 6px;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ background: transparent;
+ font-family: var(--mono);
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--accent);
+ text-align: right;
+ -moz-appearance: textfield;
+}
+.val-input:hover { border-color: var(--border); }
+.val-input:focus { outline: none; border-color: var(--accent); background: var(--accent-soft); }
+.val-input::-webkit-inner-spin-button,
+.val-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
+
+
+/* Pinned parameters (index.html) */
+
+.page-index header, .page-index main { max-width: 820px; }
+.page-index header { margin-bottom: 16px; }
+.page-index h1 { font-size: 24px; }
+
+.cards { display: grid; grid-template-columns: 1fr; gap: 16px; }
+
+.card .tuple {
+ margin: 2px 0;
+ font-family: var(--mono);
+ font-size: 17px;
+ font-weight: 600;
+ color: var(--accent);
+}
+
+.card table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12.5px; }
+.card td { padding: 4px; border-top: 1px solid var(--border); }
+.card tr:first-child td { border-top: none; }
+.card td:first-child { color: var(--text-soft); }
+.card td:nth-child(2) { text-align: right; font-weight: 500; white-space: nowrap; }
+
+.h-control { margin: 0 0 10px; max-width: 420px; }
+.h-control .val-input { width: 56px; }
+
+.card-link {
+ margin-top: 12px;
+ padding-top: 10px;
+ border-top: 1px solid var(--border);
+ font-size: 13px;
+}
+
+
+/* Parameter Explorer (stateless.html) */
+
+.layout {
+ display: grid;
+ grid-template-columns: 320px 1fr;
+ gap: 16px;
+}
+
+.control { margin-bottom: 10px; }
+.control .val-input { width: 92px; }
+
+.reset-btn {
+ margin-top: 8px;
+ padding: 6px 12px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--text-soft);
+ width: 100%;
+}
+.reset-btn:hover { background: var(--accent-soft); color: var(--accent); }
+
+.seg {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 8px;
+}
+.seg button {
+ flex: 1 1 auto;
+ padding: 5px 8px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ font-family: var(--mono);
+ color: var(--text-soft);
+}
+.seg button:hover { border-color: var(--accent); color: var(--accent); }
+.seg button.active {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+ font-weight: 600;
+}
+
+.mode-toggle {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-family: var(--mono);
+ color: var(--text-soft);
+ margin: 6px 0 2px;
+ cursor: pointer;
+ user-select: none;
+}
+.mode-toggle input { accent-color: var(--accent); margin: 0; }
+.mode-row { display: flex; align-items: center; }
+
+.funnel-row {
+ display: grid;
+ grid-template-columns: 200px 1fr 80px;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 4px;
+ font-size: 13px;
+ font-family: var(--mono);
+}
+.funnel-row .label { color: var(--text-soft); }
+.funnel-row .bar {
+ height: 18px;
+ background: var(--accent-soft);
+ border-radius: 3px;
+ transition: width 0.2s ease;
+}
+.funnel-row .count { text-align: right; color: var(--text); font-weight: 500; }
+.funnel-row.eliminated .bar { background: var(--bad-soft); opacity: 0.6; }
+
+.scatter-wrap { position: relative; }
+#scatter {
+ width: 100%;
+ height: auto;
+ display: block;
+ border-radius: 4px;
+ cursor: crosshair;
+}
+#hover-ring {
+ position: absolute;
+ width: 16px;
+ height: 16px;
+ border: 2px solid #1a1a1a;
+ border-radius: 50%;
+ pointer-events: none;
+ transform: translate(-50%, -50%);
+ display: none;
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.9);
+}
+
+#tooltip {
+ position: fixed;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 10px 12px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
+ pointer-events: none;
+ font-size: 12px;
+ z-index: 100;
+ min-width: 280px;
+ max-width: 340px;
+ display: none;
+}
+#tooltip .tt-tuple {
+ font-family: var(--mono);
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--accent);
+ margin: 0 0 4px;
+}
+#tooltip .tt-swn { font-size: 11px; color: var(--text-soft); }
+#tooltip .tt-status {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 8px;
+}
+#tooltip .tt-status.survivor { color: var(--good); }
+#tooltip .tt-status.eliminated { color: var(--bad); }
+#tooltip .tt-status.winner { color: var(--accent); }
+#tooltip table {
+ width: 100%;
+ border-collapse: collapse;
+ font-family: var(--mono);
+ font-size: 11px;
+}
+#tooltip td { padding: 2px 4px; }
+#tooltip td:first-child { color: var(--text-soft); }
+#tooltip td:nth-child(2),
+#tooltip td:nth-child(3) { text-align: right; }
+#tooltip td.ratio { text-align: right; font-weight: 600; }
+#tooltip td.ratio.good { color: var(--good); }
+#tooltip td.ratio.warn { color: var(--warn); }
+#tooltip td.ratio.bad { color: var(--bad); }
+#tooltip .tt-distance {
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid var(--border);
+ color: var(--text-soft);
+ font-size: 11px;
+}
+#tooltip .tt-distance strong { color: var(--text); font-family: var(--mono); }
+
+.scatter-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 10px;
+ font-size: 11px;
+ color: var(--text-soft);
+}
+.scatter-legend .item { display: flex; align-items: center; gap: 4px; }
+.scatter-legend .swatch {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ display: inline-block;
+}
+
+.winner-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+.winner-tuple {
+ font-family: var(--mono);
+ font-size: 18px;
+ color: var(--accent);
+ font-weight: 600;
+ margin: 0 0 4px;
+}
+.winner-distance { color: var(--text-soft); font-size: 12px; margin: 0; }
+
+table.metrics {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+ margin-top: 8px;
+}
+table.metrics th,
+table.metrics td {
+ padding: 6px 8px;
+ text-align: right;
+ border-bottom: 1px solid var(--border);
+ font-family: var(--mono);
+}
+table.metrics th:first-child,
+table.metrics td:first-child {
+ text-align: left;
+ color: var(--text-soft);
+}
+table.metrics th {
+ color: var(--text-soft);
+ font-weight: 500;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+/* Ratio to SLH-DSA-128s in the comparison tables */
+td.ratio { font-size: 12px; }
+td.ratio.good { color: var(--good); }
+td.ratio.warn { color: var(--warn); }
+td.ratio.bad { color: var(--bad); }
+
+.empty-state {
+ text-align: center;
+ color: var(--text-mute);
+ padding: 24px;
+ font-style: italic;
+}
+
+.custom-inputs {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ margin-bottom: 12px;
+}
+.custom-inputs .field { display: flex; flex-direction: column; gap: 4px; }
+.custom-inputs .field label { font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
+.custom-inputs .field input,
+.custom-inputs .field select {
+ width: 72px;
+ padding: 6px 8px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ font-family: var(--mono);
+ font-size: 14px;
+ text-align: right;
+ background: var(--panel);
+ color: var(--text);
+}
+.custom-inputs .field input:focus,
+.custom-inputs .field select:focus { outline: none; border-color: var(--accent); }
+
+.custom-status {
+ padding: 8px 12px;
+ border-radius: 4px;
+ margin-bottom: 12px;
+ font-size: 13px;
+}
+.custom-status.ok { background: var(--good-soft); color: var(--good); }
+.custom-status.warn { background: var(--warn-soft); color: var(--warn); }
+.custom-status.invalid { background: var(--bad-soft); color: var(--bad); }
+.custom-status ul { margin: 4px 0 0 18px; padding: 0; font-size: 12px; }
+
+.custom-notes { margin-top: 10px; font-size: 12px; color: var(--text-soft); }
+.custom-notes div { margin: 2px 0; }
+.custom-notes .sec-ok { color: var(--good); }
+.custom-notes .sec-low { color: var(--bad); }
+.custom-notes .sec-detail { color: var(--text-mute); font-size: 11px; }
+
+.info-strip {
+ background: var(--accent-soft);
+ color: var(--accent);
+ padding: 8px 12px;
+ border-radius: 4px;
+ font-size: 12px;
+ margin-bottom: 16px;
+}
+
+@media (max-width: 900px) {
+ .layout { grid-template-columns: 1fr; }
+ .winner-grid { grid-template-columns: 1fr; }
+}
+
+
+/* Stateful Comparison (stateful.html) */
+
+/* Comparison table */
+.compare-wrap { overflow-x: auto; margin: 0 -4px; padding: 0 4px; }
+table.compare {
+ border-collapse: collapse;
+ font-family: var(--mono);
+ font-size: 12px;
+ min-width: 100%;
+}
+table.compare th, table.compare td {
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--border);
+ text-align: right;
+ white-space: nowrap;
+ vertical-align: top;
+}
+table.compare thead th {
+ white-space: normal;
+ vertical-align: bottom;
+ color: var(--text-soft);
+ font-weight: 600;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ background: var(--bg);
+ border-bottom: 2px solid var(--border);
+}
+/* First column: candidate name */
+table.compare th.cand-col, table.compare td.cand-col {
+ text-align: left;
+ position: sticky;
+ left: 0;
+ background: var(--panel);
+ z-index: 2;
+ border-right: 1px solid var(--border);
+ min-width: 180px;
+}
+table.compare thead th.cand-col { background: var(--bg); }
+/* A row with a note instead of a candidate */
+table.compare th.note, table.compare td.note {
+ text-align: center;
+ color: var(--text-mute);
+ font-style: italic;
+}
+table.compare th.note { padding: 24px; }
+.cand-title { font-size: 12px; font-weight: 600; display: block; color: var(--text); }
+/* Last column: actions */
+table.compare th.act-col, table.compare td.act-col {
+ text-align: center;
+ width: 40px;
+ min-width: 40px;
+}
+.row-remove {
+ background: transparent; border: none; color: var(--text-mute);
+ cursor: pointer; font-size: 16px; line-height: 1; padding: 2px 6px;
+ border-radius: 3px;
+}
+.row-remove:hover { background: var(--bad-soft); color: var(--bad); }
+table.compare tr.baseline td { background: var(--accent-soft); }
+table.compare tr.baseline td.cand-col { background: var(--accent-soft); }
+table.compare tr:not(.baseline):hover td { background: var(--bg); }
+table.compare tr:not(.baseline):hover td.cand-col { background: var(--bg); }
+
+/* Ratio to SLH-DSA-128s, under each value */
+span.ratio { font-size: 10px; color: var(--text-mute); display: block; margin-top: 1px; }
+span.ratio.gain { color: var(--good); }
+span.ratio.regress { color: var(--bad); }
+
+/* Add candidate panel */
+.actions { margin-bottom: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
+.btn {
+ padding: 6px 12px;
+ background: var(--accent);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 13px;
+ font-family: inherit;
+}
+.btn:hover { filter: brightness(1.05); }
+.btn.btn-ghost { background: var(--bg); color: var(--text); border: 1px solid var(--border); }
+.btn.btn-ghost:hover { background: var(--accent-soft); color: var(--accent); }
+
+.add-form[hidden] { display: none; }
+.add-form {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 12px;
+ align-items: end;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 14px;
+ margin-top: 10px;
+}
+.add-form label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-soft); font-family: var(--mono); }
+.add-form select, .add-form input {
+ padding: 6px 8px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ font-family: var(--mono);
+ font-size: 13px;
+ background: var(--panel);
+}
+.add-form select:focus, .add-form input:focus { outline: none; border-color: var(--accent); }
+.add-form .add-form-actions { grid-column: 1 / -1; display: flex; gap: 8px; justify-content: flex-end; }
+.add-form .form-error {
+ grid-column: 1 / -1;
+ background: var(--bad-soft);
+ color: var(--bad);
+ padding: 6px 10px;
+ border-radius: 4px;
+ font-size: 12px;
+}
+
+/* Bounds and weights of the candidate list */
+.filter-section { margin-bottom: 10px; }
+.filter-section:last-of-type { margin-bottom: 12px; }
+.filter-section-label {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-soft);
+ margin-bottom: 6px;
+}
+.filter-section-hint {
+ text-transform: none;
+ letter-spacing: 0;
+ font-weight: 400;
+ color: var(--text-mute);
+ margin-left: 6px;
+ font-size: 11px;
+}
+details.filter-collapsible > summary {
+ cursor: pointer;
+ list-style: none;
+ display: flex;
+ align-items: baseline;
+ gap: 4px;
+ padding: 2px 0;
+ user-select: none;
+ -webkit-user-select: none;
+}
+details.filter-collapsible > summary::-webkit-details-marker { display: none; }
+details.filter-collapsible > summary:hover { color: var(--accent); }
+details.filter-collapsible > summary .chev {
+ display: inline-block;
+ transition: transform 0.15s ease;
+ font-size: 9px;
+ color: var(--text-mute);
+}
+details.filter-collapsible[open] > summary .chev { transform: rotate(90deg); }
+details.filter-collapsible[open] > .filter-grid { margin-top: 4px; }
+.filter-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 14px;
+ margin-bottom: 12px;
+}
+.filter-grid .ctrl { display: flex; flex-direction: column; gap: 4px; }
+.filter-grid .ctrl label {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ font-family: var(--mono);
+ font-size: 12px;
+}
+.filter-grid .ctrl label .name { color: var(--text-soft); }
+.filter-grid .val-input { width: 60px; padding: 1px 4px; font-size: 12px; }
+
+.sweep-info { color: var(--text-soft); font-size: 12px; margin-bottom: 10px; }
+.sweep-info strong { color: var(--text); }
+
+/* Sort headers of the candidate list */
+table.compare th.sortable {
+ cursor: pointer;
+ user-select: none;
+ -webkit-user-select: none;
+}
+table.compare th.sortable:hover { color: var(--accent); }
+table.compare th.sortable.active { color: var(--accent); }
+.sort-arrow {
+ display: inline-block;
+ margin-left: 4px;
+ font-size: 10px;
+ opacity: 0.7;
+}
+
+.row-add {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--text-soft);
+ cursor: pointer;
+ font-size: 13px;
+ line-height: 1;
+ padding: 2px 8px;
+ border-radius: 3px;
+}
+.row-add:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
+.row-add:disabled { opacity: 0.4; cursor: not-allowed; }
+.row-add:disabled:hover { background: transparent; color: var(--text-soft); border-color: var(--border); }
+
+.signer-mode {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-family: var(--mono);
+ color: var(--text-soft);
+}
+.signer-mode select {
+ padding: 5px 8px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ font-family: var(--mono);
+ font-size: 12px;
+ background: var(--panel);
+}
+.signer-mode select:focus { outline: none; border-color: var(--accent); }
+
+@media (max-width: 600px) {
+ body.page-stateful { padding: 12px; }
+ .page-stateful h1 { font-size: 18px; }
+ .page-stateful .panel { padding: 12px; }
+ table.compare th, table.compare td { padding: 6px 8px; font-size: 11px; }
+}
diff --git a/tests/synth_generated.py b/tests/synth_generated.py
index 8466a90..1b10780 100644
--- a/tests/synth_generated.py
+++ b/tests/synth_generated.py
@@ -47,9 +47,8 @@ def security_from_page():
if not shutil.which('node'):
sys.exit('node is required (the security analysis runs from the page JS)')
html = open(os.path.join(ROOT, 'site', 'stateless.html'), encoding='utf-8').read()
- m = re.search(r"'use strict';(.*?)// ---------- State ----------", html, re.S)
- assert m, 'stateless.html extraction marker missing'
- js = m.group(1) + """
+ srcs = re.findall(r'', html)
+ js = '\n'.join(open(os.path.join(ROOT, 'site', s), encoding='utf-8').read() for s in srcs) + """
const out = { grid: {}, std: computeSecurityBits(64, 63, 14, 12) };
for (let h = %d; h <= %d; h++)
for (let k = %d; k <= %d; k++)
diff --git a/tests/test_regression.py b/tests/test_regression.py
index e605397..5fe4484 100644
--- a/tests/test_regression.py
+++ b/tests/test_regression.py
@@ -152,14 +152,11 @@ def test_csv_standard_row():
# 3. Site pages (node)
# ---------------------------------------------------------------------------
-def _extract_js(page, end_marker):
- html = open(os.path.join(ROOT, 'site', page)).read()
- m = re.search(r"'use strict';(.*?)" + re.escape(end_marker), html, re.S)
- assert m, f'extraction markers not found in {page} — update the test'
- js = m.group(1)
- if '', html)
+ return '\n'.join(open(os.path.join(ROOT, 'site', s), encoding='utf-8').read() for s in srcs)
def _run_node(js):
@@ -172,12 +169,12 @@ def _run_node(js):
return json.loads(p.stdout.strip().splitlines()[-1])
-def test_site_index():
- js = _extract_js('stateless.html', '// ---------- State ----------')
+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];
@@ -187,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']]
@@ -198,7 +194,7 @@ def test_site_security_small_trees():
"""The custom-candidate panel accepts any (h, k, a), so the forgery sum
must stay cheap when the hypertree has few leaves (lambda = q_s / 2^h
huge): h = 14 used to take about 11 s per evaluation and froze the page."""
- js = _extract_js('stateless.html', '// ---------- State ----------')
+ js = _page_js('stateless.html')
js += """
const t0 = Date.now();
const out = {
@@ -225,7 +221,7 @@ def test_site_security_small_trees():
def test_site_main():
"""index.html (SHRINCS main): pinned cards must match the reference model."""
- js = _extract_js('index.html', '// ---------- Render ----------')
+ js = _page_js('index.html')
js += """
const out = {};
out.pinned = (m => [m.size, m.kg, m.sg, m.sv, m.svWorst])(slhMetrics(PINNED));
@@ -283,7 +279,7 @@ def test_site_main():
def test_site_stateful():
- js = _extract_js('stateful.html', '// Shared slider/number-input renderer')
+ js = _page_js('stateful.html')
js += """
const out = { slh: null, uxmss: {}, xmssmt: {} };
const s = buildSLH().metrics;
@@ -474,8 +470,9 @@ def scan(name, js, base_line):
html = open(path, encoding='utf-8').read()
for m in re.finditer(r'', html, re.S):
scan(os.path.basename(path), m.group(1), html[:m.start(1)].count('\n') + 1)
- for path in sorted(glob.glob(os.path.join(ROOT, 'site', '*.js'))):
- scan(os.path.basename(path), open(path, encoding='utf-8').read(), 1)
+ for path in sorted(glob.glob(os.path.join(ROOT, 'site', '*.js')) +
+ glob.glob(os.path.join(ROOT, 'site', 'js', '*.js'))):
+ scan(os.path.relpath(path, os.path.join(ROOT, 'site')), open(path, encoding='utf-8').read(), 1)
assert not offenders, f'site-JS hazards: {offenders}'
@@ -568,7 +565,7 @@ def test_site_data_integration():
return
data_path = os.path.join(ROOT, 'site', 'data.json')
# stateless.html: poolFromData must reproduce the pool; STD_M must equal baseline
- js = _extract_js('stateless.html', '// ---------- State ----------')
+ js = _page_js('stateless.html')
js += f"""
const data = JSON.parse(require('fs').readFileSync({json.dumps(data_path)}, 'utf8'));
const pool = poolFromData(data, 'SPX');
@@ -594,7 +591,7 @@ def test_site_data_integration():
if out is not None:
assert out['ok'] and out['n'] == FIX['site_pool_count'], out
# stateful.html: the page's own self-check must pass on the shipped data
- js2 = _extract_js('stateful.html', '// Shared slider/number-input renderer')
+ js2 = _page_js('stateful.html')
js2 += f"""
SITE_DATA = JSON.parse(require('fs').readFileSync({json.dumps(data_path)}, 'utf8'));
const okCheck = selfCheckStateful(SITE_DATA);