Branch site-truth-2026-10-06 = master9519a95+ merge of ledger-text (the 45 conceded sentences of the 5 October close) + cherry-picks 6141e01, 8c5b02f (the bounty strikes) and1a238bc(review round 4 reddit) + this commit. The hero chip line follows the owner's pick of 16:25 UTC (draft (a) of docs/plans/counter-asic-3-status.md section 6 with the owner's last sentence). Every number carries its source beside it. No em dashes. Site build, identity-check.sh (0 hits over 224 files) and the private-string sweep of the built pages pass. Changed sentences, before and after (lines are master's where the sentence existed): site/index.html:343 hero before: A custom chip gains under 2x, and the model is public. after: The strongest recompute chip we can price, holding the whole 256 MiB cache on-die, reaches under 1x per chip against an RTX 5090. A memory-controller chip that stores the whole dataset reaches 1.2x per chip and, in our model, 5x to 9x per joule; the Ethash chips of this class reached 2.1x to 4.8x. The lever against it, program work in the latency shadow, is measured and in its gates: it brings the chip to about 2x. The model is public: the numbers. before: An NVIDIA card with 24 GB or more proves every block and gets paid for it; AMD and Apple cards mine. after: Every NVIDIA card from 8 GB proves and is paid for it; 12 GB and up mine and prove; 24 GB on the stock server (eleven rented cards, RTX 3060 to RTX 5090, 6 October 2026, docs/analysis/prover-tiers-real-cards.md). AMD and Apple cards mine. site/index.html:372 before: miners active after: vote keys active in 10 min (a card runs several) site/index.html:400 and the chain scene script before: the "proven" and "locked" counters counted the simulation while the scene carried the live label after: in live mode the counters read the observer (proven = blocks in the window every shard of which is verified, "not active" while the proving layer is off; locked = locked checkpoints in the window) and the simulated shard sparks stop site/index.html:484 before: One click. The card mines; an NVIDIA card with 24 GB proves too. after: One click. The card mines; every NVIDIA card from 8 GB proves, 12 GB and up mine and prove. site/index.html:492 and site/miner.html:266 before: (the app is still labelled Igneum Miner until 0.3.6) after: (the app window still says Igneum Miner at 0.3.13) site/litepaper.html:301 abstract before: A custom chip gains under 2x, and the model is public; the claim is tested by paid independent cryptanalysis and the public benchmark. after: draft (a) as on the hero, then the sources (chip-model-v3.md section 5; the Ethash rows of asic-resistance-history.md; Counter ASIC 3.0 item 8), then "The model is public; the claim is tested by paid independent cryptanalysis and the public benchmark." site/litepaper.html:396 before: About one a second after: About one a second with the full fleet; 0.65 a second over the hour to 16:00 UTC on 6 Oct 2026 with one PC off (the live page's hour count, 2,325 blocks) site/litepaper.html:400 before: version 0.3.5; 0.3.6 staged after: version 0.3.13 (6 Oct 2026); the app window still says Igneum Miner site/litepaper.html:412 added after "not on maths or bandwidth.": Measured: an RTX 5090 hashes at 95 GB/s of useful 4-byte loads against 1,638 GB/s of sequential writes (engineering log, the RTX 5090 entries). site/litepaper.html:424 before: It claims the gain is small, the response takes a week, and both are measured. after: It states the gain its own model finds, the response takes a week, and both are measured. site/litepaper.html:440 (vs RandomX, Useful work) before: NVIDIA cards with 24 GB or more prove every block and sell proofs to other chains; AMD and Apple cards mine, ... after: Every NVIDIA card from 8 GB proves; 12 GB and up mine and prove; 24 GB on the stock server (eleven rented cards, RTX 3060 to RTX 5090, 6 October 2026, docs/analysis/prover-tiers-real-cards.md); they sell proofs to other chains. AMD and Apple cards mine, ... site/litepaper.html:452 before: Proving needs an NVIDIA card with 24 GB or more (32 GB until the fee switch of 6 October 2026; ...). AMD and Apple cards mine. A prover for them lands when a zkVM ships one. after: Proving: every NVIDIA card from 8 GB proves; 12 GB and up mine and prove; 24 GB on the stock server. Measured on eleven rented cards ...: the RTX 3060 (12 GB) mines at 23.78 MH/s and proves the v1 shard beside its miner at an 8.9 GB peak in 37.5 s; the RTX 4060 (8 GB) proves it alone at 7.4 GB in 18.4 s; the RTX 4090 (24 GB) proves it on the stock SP1 server in 5.6 s at 17.4 GB. The patched server that fits the smaller cards is not yet in the shipped app. AMD and Apple cards mine. A prover for them lands when a zkVM ships one. site/litepaper.html:454 before: The old 12 GB gate on the roadmap is withdrawn until a prover build with a smaller floor is measured. after: ... was withdrawn on 5 October until a prover build with a smaller floor was measured; on 6 October a patched server proved the same shard at 7.4 to 8.0 GB alone on eleven rented cards from the RTX 3060 to the RTX 5090 (the real-card table), so the gate returns as measured and the patched server is not yet in the shipped app. site/litepaper.html:562 (Hardware) before: 24 GB proves full shards (measured on a 32 GB card's allocation; a 24 GB card has not run it yet) and 32 GB mines and proves on one card, measured 5 October 2026 on this prover build (a 12 GB card does not prove on it: the GPU prover's floor is 13.9 GB). after: Every NVIDIA card from 8 GB proves; 12 GB and up mine and prove; 24 GB on the stock server (eleven rented cards, RTX 3060 to RTX 5090, 6 October 2026, docs/analysis/prover-tiers-real-cards.md). site/litepaper.html:645 (Governance, admin keys) appended: On the devnet the activation heights and one execution-state restart (6 October 2026) reach every node through the signed update manifest, so on the devnet the release key acts as the operator; the sentence above holds for mainnet consensus only once that path is closed, and the public testnet terms will say which parameters still travel that way. site/litepaper.html, Questions miners ask added: the entry "Don't ASICs make a chain safer?" (three parts and a five-row table; the rental row cites the fleet's bench entry that lands tonight and the 50-miner wave row reads "measurement tonight") site/litepaper.html:733 (What Igneum does not claim, the chip item) before: ... approximate; the claim is under 2x, the margin is stated on the numbers page, and the next lever is named there. No hash has stayed free of chips forever; Igneum does not claim to. Monero's seven years without a public chip are precedent, not proof. after: ... approximate. The same model, drawn out to the chip that stores the dataset (6 October 2026): draft (a) with its sources. No hash has stayed free of chips forever; Igneum does not claim to. Monero's seven years without a public chip are precedent, not proof, and a small prize: they say nothing about the price of a chip with the 256 MB cache on its die, and that price is a cost model, not a measurement. site/litepaper.html, the four bounty sentences (301, 424, 684, 686): struck as on ca2-coord 6141e01 and 8c5b02f site/litepaper.html, the 45 sentences of the 5 October close: as on ledger-texta795b02(merged) site/miner.html:7, 13, 21 (meta) before: and an NVIDIA card with 24 GB proves. after: and every NVIDIA card from 8 GB proves. site/miner.html:252 before: Install. Start. The card mines. An NVIDIA card with 24 GB proves too. after: Install. Start. The card mines. Every NVIDIA card from 8 GB proves; 12 GB and up mine and prove. site/miner.html:308 before: ... and, on an NVIDIA card with 24 GB or more, the prover. AMD and Apple cards mine; after: ... and, on an NVIDIA card, the prover: every NVIDIA card from 8 GB proves; 12 GB and up mine and prove; 24 GB on the stock server (eleven rented cards, ...); the patched server for the smaller cards is not yet in the shipped app. AMD and Apple cards mine; site/evidence.html before: hand-kept, "30 claims · 4 Oct 2026", row 21 "unwritten" after: generated by site/build.mjs from docs/evidence.md (31 rows, the counts, the date), scrubbed as /bench is docs/evidence.md row 17 before: The chip resistance target: a chip gains under 2x over a GPU after: The chip resistance claim: the strongest recompute chip under 1x per chip against an RTX 5090; the stored-dataset chip 1.2x per chip and 5x to 9x per joule in the model (2.1x to 4.8x by the Ethash precedent); the latency-shadow lever, measured and in its gates, brings it to about 2x docs/evidence.md the card-lifetime row numbered 22 under "What moved on 5 October" becomes row 31 of the table; "Hetzner VMs" becomes "cloud VMs", "recommended to the project lead" becomes "recommended to the owner" docs/fud-ledger.md, docs/fud-fixes.md taken from fud-closec6b0bad(167 entries); two lines reworded for the identity check (the log key, the log intake) site/ledger.html new: every ledger entry with the critic's words, what was done, the status and the date, the count table on top (53 conceded, 54 fixed or built, 27 closed by rule or decided, 13 answered with evidence, 13 by design, 7 open); generated by tools/ledger-page.mjs site/vercel.json the /ledger redirect removed site/partials/footer.html "Ledger: every criticism" added under Read (so every page's footer changed) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
415 lines
31 KiB
JavaScript
415 lines
31 KiB
JavaScript
// Igneum site build: renders the engineering log from the repository's markdown into a branded
|
|
// page and merges the log's dated entries into the journey feed. The FUD ledger is internal and
|
|
// is never rendered here.
|
|
// Runs on every deploy (Vercel build command) and locally with `node build.mjs`.
|
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
import { scrubBench } from './scrub.mjs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const repo = join(here, '..');
|
|
const docs = join(repo, 'docs');
|
|
|
|
function esc(s) { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
function inline(s) {
|
|
s = esc(s);
|
|
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
s = s.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '<a href="$2" rel="noopener">$1</a>');
|
|
return s;
|
|
}
|
|
function md(src) {
|
|
const lines = src.split('\n'); const out = []; let i = 0; let toc = [];
|
|
const slug = t => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
while (i < lines.length) {
|
|
const l = lines[i];
|
|
if (/^```/.test(l)) { const buf = []; i++; while (i < lines.length && !/^```/.test(lines[i])) buf.push(lines[i++]); i++; out.push('<pre>' + esc(buf.join('\n')) + '</pre>'); continue; }
|
|
const h = /^(#{1,4})\s+(.*)$/.exec(l);
|
|
if (h) { const lvl = h[1].length; const t = h[2].trim(); const id = slug(t); if (lvl <= 2) toc.push({ lvl, t, id }); out.push(`<h${lvl} id="${id}">${inline(t)}</h${lvl}>`); i++; continue; }
|
|
if (/^\|/.test(l)) {
|
|
const rows = []; while (i < lines.length && /^\|/.test(lines[i])) rows.push(lines[i++]);
|
|
const cells = r => r.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
|
|
const head = cells(rows[0]); const body = rows.slice(1).filter(r => !/^\|\s*-{2,}/.test(r));
|
|
out.push('<div class="tbl"><table><thead><tr>' + head.map(c => `<th>${inline(c)}</th>`).join('') + '</tr></thead><tbody>' + body.map(r => '<tr>' + cells(r).map(c => `<td>${inline(c)}</td>`).join('') + '</tr>').join('') + '</tbody></table></div>');
|
|
continue;
|
|
}
|
|
if (/^\s*[-*]\s+/.test(l)) { const items = []; while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*[-*]\s+/, '')); out.push('<ul>' + items.map(x => `<li>${inline(x)}</li>`).join('') + '</ul>'); continue; }
|
|
if (/^\s*\d+\.\s+/.test(l)) { const items = []; while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*\d+\.\s+/, '')); out.push('<ol>' + items.map(x => `<li>${inline(x)}</li>`).join('') + '</ol>'); continue; }
|
|
if (/^\s*$/.test(l)) { i++; continue; }
|
|
const buf = []; while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#|\||```|\s*[-*]\s|\s*\d+\.\s)/.test(lines[i])) buf.push(lines[i++]);
|
|
out.push('<p>' + inline(buf.join(' ')) + '</p>');
|
|
}
|
|
return { html: out.join('\n'), toc };
|
|
}
|
|
|
|
// Shared chrome: fonts and tokens (head), the nav bar and the footer live once in site/partials/ and are injected into every
|
|
// page between HTML comment markers (<!-- head:start --> ... <!-- head:end -->, nav, footer; journey on the homepage).
|
|
// The committed pages carry the injected copy, so they are right with or without a build; the build keeps them in step.
|
|
const partial = name => readFileSync(join(here, 'partials', name), 'utf8').trim();
|
|
const HEAD = partial('head.html'), NAV = partial('nav.html'), FOOT = partial('footer.html');
|
|
function navFor(active) {
|
|
if (!active) return NAV;
|
|
const re = new RegExp(`(<a href="[^"]*" data-nav="${active}")`);
|
|
if (!re.test(NAV)) throw new Error(`nav: no link with data-nav="${active}"`);
|
|
return NAV.replace(re, '$1 aria-current="page"');
|
|
}
|
|
function inject(html, name, content, file) {
|
|
const re = new RegExp(`<!-- ${name}:start -->[\\s\\S]*?<!-- ${name}:end -->`);
|
|
if (!re.test(html)) throw new Error(`${file}: missing <!-- ${name}:start --> ... <!-- ${name}:end --> markers`);
|
|
return html.replace(re, () => `<!-- ${name}:start -->\n${content}\n<!-- ${name}:end -->`);
|
|
}
|
|
const ORIGIN = 'https://igneum.network';
|
|
const svgIcon = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Crect width='1024' height='1024' fill='%230C0C0E'/%3E%3Cg transform='translate(166.95 166.95) scale(6.901)'%3E%3Cpolygon points='50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34' fill='%23F2541B'/%3E%3Cpolygon points='50,42 59,58 50,82 41,58' fill='%230C0C0E'/%3E%3C/g%3E%3C/svg%3E";
|
|
// Search and share metadata. Same set as the hand-written pages (index, litepaper, live); keep them in step.
|
|
function meta(title, desc, path) {
|
|
const url = ORIGIN + path; const t = esc(title); const d = esc(desc);
|
|
return `<title>${t}</title>
|
|
<meta name="description" content="${d}">
|
|
<link rel="canonical" href="${url}">
|
|
<meta name="theme-color" content="#0C0C0E">
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:site_name" content="Igneum">
|
|
<meta property="og:title" content="${t}">
|
|
<meta property="og:description" content="${d}">
|
|
<meta property="og:url" content="${url}">
|
|
<meta property="og:image" content="${ORIGIN}/og.png?v=3">
|
|
<meta property="og:image:width" content="1200">
|
|
<meta property="og:image:height" content="630">
|
|
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:title" content="${t}">
|
|
<meta name="twitter:description" content="${d}">
|
|
<meta name="twitter:image" content="${ORIGIN}/og.png?v=3">
|
|
<meta name="twitter:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
|
<link rel="icon" href="${svgIcon}" type="image/svg+xml">
|
|
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
|
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
|
<link rel="icon" href="/icon-192.png" type="image/png" sizes="192x192">
|
|
<link rel="icon" href="/icon-512.png" type="image/png" sizes="512x512">
|
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
|
<link rel="manifest" href="/site.webmanifest">`;
|
|
}
|
|
function page(title, desc, bodyHtml, toc, note, { path = '/bench', heading = title, active = 'bench' } = {}) {
|
|
const h2s = toc.filter(t => t.lvl === 2);
|
|
const nav = h2s.map(t => `<li><a href="#${t.id}">${esc(t.t)}</a></li>`).join('');
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
${meta(title, desc, path)}
|
|
<!-- head:start -->
|
|
${HEAD}
|
|
<!-- head:end -->
|
|
<style>
|
|
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;
|
|
/* one system for every page: container, gutter, section rhythm, card and tile boxes, heading scale (same tokens as the homepage) */
|
|
--max:1200px;--gutter:clamp(16px,4vw,32px);--sec:clamp(56px,8vw,96px);--head:clamp(40px,6vw,64px);
|
|
--card-pad:clamp(18px,3vw,28px);--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:24px;--gap-tile:12px;
|
|
--fs-h1:clamp(32px,5.5vw,56px);--fs-h2:clamp(28px,4.2vw,44px);--fs-h3:clamp(18px,2vw,22px);--fs-tile:clamp(20px,2.2vw,26px)}
|
|
*{box-sizing:border-box}body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--f-sans);font-size:16px;line-height:1.6;padding:0;-webkit-font-smoothing:antialiased}
|
|
a{color:var(--ember);text-decoration:none}a:hover{text-decoration:underline}
|
|
[id]{scroll-margin-top:calc(var(--nav-h) + 16px)}
|
|
.wrap{max-width:var(--max);margin:0 auto;padding-inline:var(--gutter)}
|
|
.eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
|
|
h1{font-family:var(--f-display);font-weight:900;font-size:var(--fs-h1);line-height:1.05;margin:12px 0 12px}
|
|
.head{padding-top:var(--head)}
|
|
.note{color:var(--ash);font-size:15px;max-width:72ch;margin:0 0 32px}
|
|
.layout{display:grid;grid-template-columns:minmax(0,1fr);gap:32px}
|
|
@media(min-width:960px){.layout{grid-template-columns:260px minmax(0,1fr);gap:56px}}
|
|
.toc{align-self:start;font-size:14px;border:1px solid var(--line);border-radius:var(--tile-r);background:var(--graphite);padding:8px}
|
|
.toc .lbl{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);padding:6px 10px 8px}
|
|
.toc ol{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:1px;counter-reset:s;max-height:40vh;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin}
|
|
@media(min-width:960px){.toc{position:sticky;top:calc(var(--nav-h) + 20px)}.toc ol{max-height:calc(100vh - var(--nav-h) - 96px)}}
|
|
.toc a{display:flex;gap:10px;padding:7px 10px;border-radius:8px;color:var(--ink-2);counter-increment:s;line-height:1.35}
|
|
.toc a::before{content:counter(s,decimal-leading-zero);font-family:var(--f-mono);font-size:11px;color:var(--ash);min-width:20px;padding-top:2px}
|
|
.toc a:hover{background:var(--obsidian);text-decoration:none;color:var(--bone)}
|
|
article{max-width:78ch;min-width:0}
|
|
article h1{display:none}
|
|
article h2{font-family:var(--f-display);font-weight:700;font-size:var(--fs-h3);line-height:1.3;margin:44px 0 12px;padding-top:24px;border-top:1px solid var(--line)}
|
|
article h2:first-of-type{margin-top:0;padding-top:0;border-top:0}
|
|
article h3{font-family:var(--f-display);font-weight:500;font-size:16px;margin:26px 0 8px}
|
|
article h4{font-size:15px;margin:20px 0 6px;color:var(--molten)}
|
|
article p{margin:0 0 14px}article ul,article ol{margin:0 0 14px;padding-left:22px}article li{margin-bottom:6px}
|
|
article p,article li,article h2,article h3{overflow-wrap:anywhere}
|
|
code{font-family:var(--f-mono);font-size:.92em;background:var(--graphite);padding:1px 5px;border-radius:4px;overflow-wrap:anywhere}
|
|
pre{margin:0 0 16px;padding:16px 18px;background:var(--graphite);border-radius:var(--card-r);font-family:var(--f-mono);font-size:13px;line-height:1.55;overflow-x:auto;-webkit-overflow-scrolling:touch}
|
|
pre code{background:none;padding:0;font-size:inherit}
|
|
.tbl{overflow-x:auto;margin:18px 0 24px;border:1px solid var(--line);border-radius:var(--card-r);background:var(--graphite);-webkit-overflow-scrolling:touch}
|
|
table{border-collapse:collapse;width:100%;min-width:560px;font-size:15px}th,td{padding:12px 14px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line)}
|
|
th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500;background:var(--obsidian)}tr:last-child td{border-bottom:0}
|
|
.gen{color:var(--ash);font-size:13px;margin:40px 0 0;max-width:72ch}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- nav:start -->
|
|
${navFor(active)}
|
|
<!-- nav:end -->
|
|
<main id="main" class="wrap">
|
|
<div class="head">
|
|
<div class="eyebrow">${h2s.length} entries, newest at the bottom</div>
|
|
<h1>${esc(heading)}</h1>
|
|
<p class="note">${note}</p>
|
|
</div>
|
|
<div class="layout">
|
|
<nav class="toc" aria-label="Contents"><div class="lbl">Contents</div><ol>${nav}</ol></nav>
|
|
<article>${bodyHtml}</article>
|
|
</div>
|
|
<p class="gen">Generated from the repository at build time. Times are UTC. Machine names are model names.</p>
|
|
</main>
|
|
<!-- footer:start -->
|
|
${FOOT}
|
|
<!-- footer:end -->
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
let built = [];
|
|
if (existsSync(join(docs, 'bench-log.md'))) {
|
|
// the public /bench page renders a SCRUBBED copy (site/scrub.mjs; the build fails on any private string, R4.6.1)
|
|
const src = scrubBench(readFileSync(join(docs, 'bench-log.md'), 'utf8'));
|
|
const { html, toc } = md(src);
|
|
writeFileSync(join(here, 'bench.html'), page('Igneum engineering log', 'Every Igneum benchmark and test, with the hardware and the commands that produced it. Prototype numbers are labelled as such.', html, toc,
|
|
'Every measurement the project has made, newest at the bottom, written by the people and agents who ran it, with the commands and hardware. Prototype numbers are not mining numbers and say so.',
|
|
{ path: '/bench', heading: 'Engineering log' }));
|
|
built.push('bench.html');
|
|
// journey feed: dated headings become log entries. `short` is the homepage line (one clause, under 72 characters);
|
|
// SHORT holds hand-written lines keyed by the start of the heading, the rule below covers the rest.
|
|
const SHORT = [
|
|
['Generator version 2 adopted', 'Generator v2 adopted: every hash does 128 distinct reads'],
|
|
['Sim/economy', 'Economy simulation: mining versus proving under stress'],
|
|
['Difficulty rule under attack', 'Difficulty rule attacked seven ways'],
|
|
['Difficulty rule: timestamp attack fixed', 'Timestamp attack on the difficulty rule fixed'],
|
|
['Devnet-v4 integration', 'Devnet v4: nine branches merged into one node'],
|
|
['Per-identity hash rate', 'RTX 5090 hash-rate decay diagnosed: a miner bug, fixed'],
|
|
['Igneum-node devnet v2', 'Finality rule v2 live on a four-miner test network'],
|
|
['Execution layer devnet v3', 'EVM execution layer: identical state on three nodes'],
|
|
['Weak-program census', 'Census of 400,000 programs: redundant loads found'],
|
|
['First machine in the United States', 'First machine in the United States: a laptop on an integrated GPU, synced and voting'],
|
|
['Shard proving on the RTX 5090', 'Shard layer on the RTX 5090: a full shard proven in 10.9 s, a block aggregated in 2.2 s'],
|
|
['Proving v0 on the RTX 5090', 'First GPU proof of an Igneum block: 1.4 s on an RTX 5090'],
|
|
['Proving v0', 'First SP1 proof of an Igneum block, on a laptop CPU'],
|
|
['Windows node package', 'Windows node package: cross-compiled, two-peer sync'],
|
|
['R3.26 / M15', 'Cache-build attack closed: 10.6 s of rebuilds to 14 ms'],
|
|
['Difficulty controller', 'Igneum dual-lane difficulty rule built and simulated'],
|
|
['Devnet started four months', 'Devnet started four months ahead of plan'],
|
|
['First blocks mined over the network', 'First blocks from a Windows PC: RTX 5090 and AMD'],
|
|
['First devnet blocks on the real lottery', 'First devnet blocks on the real lottery hash'],
|
|
['Igneum-node devnet v0', 'Devnet v0: three nodes at one block per second'],
|
|
['Proto-opencl', 'OpenCL worker built and proven without AMD silicon'],
|
|
['AMD gfx1036', 'AMD integrated GPU runs the hash through OpenCL'],
|
|
['RTX 5090 through NVIDIA OpenCL', 'RTX 5090 through NVIDIA OpenCL'],
|
|
['RTX 5090 first run', 'RTX 5090 first run'],
|
|
['RTX 5090, memory-hard', 'RTX 5090 on the memory-hard dataset'],
|
|
['RTX 5090 ran two generated programs', 'RTX 5090 matches Apple silicon hash for hash, 192 of 192'],
|
|
['Fuzzing', 'Fuzzing: 1.3 million hashes, zero GPU to CPU mismatches'],
|
|
['Second hostile review', 'Second hostile review: two fatal flaws found and fixed'],
|
|
['First prototype of the random-program', 'First prototype of the lottery hash on Apple silicon'],
|
|
['Igneum named', 'Igneum named. Litepaper, brand and wallet designs published'],
|
|
['Finality floor', 'Finality floor raised to two thirds of all weight'],
|
|
['First hourly program swap on the live devnet', 'First live hourly swap: no pause on Mac, NVIDIA or AMD'],
|
|
['First finality lock on the live devnet', 'First live finality lock: 77.4% of weight, 17 voters'],
|
|
['First machine on the Igneum Miner app', 'First machine on the one-click app: a 5090 at 118 MH/s'],
|
|
['Difficulty rule v2 activated on the live devnet', 'Difficulty rule v2 switched on the live devnet by height, no fresh chain'],
|
|
['First outside machine on the devnet', 'First outside machine: a friend\'s Mac mining through the app'],
|
|
['Devnet v4 cut-over', 'Devnet v4 live: generator v2, two-thirds floor, fresh chain'],
|
|
];
|
|
function shortTitle(t) {
|
|
for (const [k, v] of SHORT) if (t.startsWith(k)) return v;
|
|
let s = t.split(/[:;]/)[0].trim();
|
|
if (s.length < 16) s = t.split(/[,;]/).slice(0, 2).join(',').trim();
|
|
if (s.length > 72) s = s.slice(0, 69).replace(/\s+\S*$/, '') + '…';
|
|
return s.replace(/[.,]$/, '');
|
|
}
|
|
const entries = [];
|
|
for (const m of src.matchAll(/^##+\s+(\d{1,2}\s+\w+\s+\d{4})[,:]?\s*(.*)$/gm)) {
|
|
const d = new Date(m[1] + ' 12:00:00 UTC'); if (isNaN(d)) continue;
|
|
const iso = d.toISOString().slice(0, 10);
|
|
// "(night), ledger M30: ..." loses its bracket and then its leading comma
|
|
const text = m[2].replace(/\(.*?\)/g, '').replace(/^\s*[,:;]\s*/, '').replace(/\s+/g, ' ').trim();
|
|
if (text) { const t = text.charAt(0).toUpperCase() + text.slice(1); entries.push({ date: iso, text: t, short: shortTitle(t) }); }
|
|
}
|
|
const jp = join(here, 'journey.json');
|
|
const j = JSON.parse(readFileSync(jp, 'utf8'));
|
|
for (const e of j.log) { const t = e.text.replace(/^\s*[,:;]\s*/, ''); e.text = t.charAt(0).toUpperCase() + t.slice(1); } // entries stored before the leading-comma fix
|
|
const seen = new Set(j.log.map(e => e.date + '|' + e.text));
|
|
for (const e of entries.reverse()) { const k = e.date + '|' + e.text; if (!seen.has(k)) { j.log.unshift(e); seen.add(k); } }
|
|
for (const e of j.log) e.short = shortTitle(e.text);
|
|
j.log.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
|
j.log = j.log.slice(0, 40);
|
|
j.updated = j.log[0] ? j.log[0].date : j.updated;
|
|
writeFileSync(jp, JSON.stringify(j, null, 2) + '\n');
|
|
built.push('journey.json (' + j.log.length + ' entries)');
|
|
}
|
|
|
|
// The hand-written pages: shared head, nav (with the active link marked) and footer injected in place; the homepage also
|
|
// gets journey.json inlined so the phases and the log render without a fetch and without a layout shift.
|
|
const journey = JSON.parse(readFileSync(join(here, 'journey.json'), 'utf8'));
|
|
// The miner's product name lives HERE and nowhere else (the project lead, 5 October 2026: Ember). Pages carry
|
|
// <span data-product="full|name|app|caption">…</span> and the build stamps every span from this table, so a rename is one
|
|
// edit. `full` is the first mention on a page and the headings where the brand matters, `name` every mention after that,
|
|
// `app` what the bundle, the installer and the window title still say (renaming them touches the over-the-air path),
|
|
// `caption` the line under a screenshot of the app window; drop the caption text once the app itself is relabelled.
|
|
const PRODUCT = {
|
|
full: 'Igneum Ember',
|
|
name: 'Ember',
|
|
app: 'Igneum Miner',
|
|
caption: 'Igneum Ember (the app is still labelled Igneum Miner until 0.3.6)',
|
|
};
|
|
function stampProduct(html, file) {
|
|
return html.replace(/<span data-product="([a-z]+)">[^<]*<\/span>/g, (m, k) => {
|
|
if (!(k in PRODUCT)) throw new Error(`${file}: unknown data-product="${k}"`);
|
|
return `<span data-product="${k}">${esc(PRODUCT[k])}</span>`;
|
|
});
|
|
}
|
|
// Downloads (5 October 2026, testnet launch). The buttons link the PUBLIC downloads path, which carries no token:
|
|
// https://dl.igneum.network/public/<alias>, four aliases that packaging/ota/publish-public.sh rewrites to the current
|
|
// files. The version and the size on every button come from the index that script writes next to the signed manifests
|
|
// (dl/public/igneum-downloads.json), read here at build time; site/downloads.json is the last copy that was read, so a
|
|
// build without network (CI, a laptop offline) still stamps real numbers, and a successful read refreshes it.
|
|
// Pages carry: <a data-dl="miner-windows|miner-mac|miner-hive|wallet-mac"> (href stamped), <span data-dl-meta="key">
|
|
// (version and size), <code data-dl-url="key"> (the versioned URL, for the HiveOS Flight Sheet) and <span data-dl-sha="key">.
|
|
// TESTNET_OPEN: the one flag of the go. While false every download section keeps its "Public testnet: not yet open" line
|
|
// (elements marked data-testnet-notice); true removes them.
|
|
const TESTNET_OPEN = false;
|
|
const DL_HOST = 'https://dl.igneum.network';
|
|
const DL_SNAPSHOT = join(here, 'downloads.json');
|
|
async function loadDownloads() {
|
|
const snapshot = existsSync(DL_SNAPSHOT) ? JSON.parse(readFileSync(DL_SNAPSHOT, 'utf8')) : { files: {} };
|
|
if (process.env.SITE_DOWNLOADS_OFFLINE) return { ...snapshot, source: 'snapshot (offline)' };
|
|
try {
|
|
const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 8000);
|
|
const r = await fetch(`${DL_HOST}/dl/public/igneum-downloads.json`, { signal: ctl.signal, headers: { 'Cache-Control': 'no-cache' } });
|
|
clearTimeout(t);
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
const live = await r.json();
|
|
if (!live || typeof live.files !== 'object') throw new Error('no files object');
|
|
for (const [k, f] of Object.entries(live.files)) if (!/^\/public\/[a-z0-9.-]+$/.test(f.alias) || !/^\d+\.\d+\.\d+$/.test(String(f.version)) || !(f.size > 0)) throw new Error(`bad entry ${k}`);
|
|
writeFileSync(DL_SNAPSHOT, JSON.stringify(live, null, 2) + '\n');
|
|
return { ...live, source: 'live' };
|
|
} catch (e) {
|
|
console.warn(`downloads: using the snapshot (${e.message})`);
|
|
return { ...snapshot, source: 'snapshot' };
|
|
}
|
|
}
|
|
const fmtMB = n => (n / 1e6).toLocaleString('en-GB', { maximumFractionDigits: 1, minimumFractionDigits: 1 }) + ' MB';
|
|
function stampDownloads(html, file, dl) {
|
|
const f = dl.files || {};
|
|
html = html.replace(/<a([^>]*)\sdata-dl="([a-z-]+)"([^>]*)>/g, (m, pre, key, post) => {
|
|
const attrs = (pre + post).replace(/\shref="[^"]*"/, '').replace(/\sdata-dl-missing(="[^"]*")?/, '').replace(/\saria-disabled="[^"]*"/, '');
|
|
if (!f[key]) return `<a data-dl="${key}" href="#get" data-dl-missing aria-disabled="true"${attrs}>`;
|
|
return `<a data-dl="${key}" href="${DL_HOST}${f[key].alias}"${attrs}>`;
|
|
});
|
|
// the stamped elements keep their other attributes (class, style); only the text between the tags is replaced
|
|
html = html.replace(/<span([^>]*\sdata-dl-meta="([a-z-]+)"[^>]*)>[^<]*<\/span>/g, (m, attrs, key) => `<span${attrs}>${f[key] ? `v${esc(String(f[key].version))} · ${fmtMB(f[key].size)}` : 'not published yet'}</span>`);
|
|
html = html.replace(/<code([^>]*\sdata-dl-url="([a-z-]+)"[^>]*)>[^<]*<\/code>/g, (m, attrs, key) => `<code${attrs}>${f[key] ? `${DL_HOST}${esc(f[key].path)}` : '(not published yet)'}</code>`);
|
|
html = html.replace(/<span([^>]*\sdata-dl-sha="([a-z-]+)"[^>]*)>[^<]*<\/span>/g, (m, attrs, key) => `<span${attrs}>${f[key] ? esc(String(f[key].sha256)) : 'not published yet'}</span>`);
|
|
if (TESTNET_OPEN) html = html.replace(/\n?[ \t]*<p data-testnet-notice[^>]*>[\s\S]*?<\/p>/g, '');
|
|
for (const key of ['miner-windows', 'miner-mac', 'miner-hive', 'wallet-mac']) if (html.includes(`data-dl="${key}"`) && !f[key]) console.warn(`${file}: ${key} has no published file yet; its button points at #get`);
|
|
return html;
|
|
}
|
|
const downloads = await loadDownloads();
|
|
built.push(`downloads (${downloads.source}: ${Object.keys(downloads.files || {}).join(', ') || 'none'})`);
|
|
|
|
// the evidence page (/evidence): the claims table, the counts and the date are rendered from docs/evidence.md (6 October 2026);
|
|
// the page's prose stays in site/evidence.html, the rows are judgement edited in the markdown and follow from there.
|
|
function renderEvidence(html) {
|
|
const mdp = join(docs, 'evidence.md');
|
|
if (!existsSync(mdp)) return html;
|
|
const src = readFileSync(mdp, 'utf8');
|
|
const start = src.indexOf('\n| # | Claim |'); const end = src.indexOf('\n## Count by status');
|
|
if (start < 0 || end < 0) throw new Error('evidence.md: claims table not found');
|
|
const rows = src.slice(start, end).split('\n').filter(l => /^\| \d+ \|/.test(l));
|
|
const LABELS = ['designed', 'implemented', 'tested by the team', 'reproduced externally', 'reviewed independently'];
|
|
const inl = t => esc(t.trim()).replace(/`([^`]+)`/g, (m, c) => `<code>${c}</code>`);
|
|
const cells = l => l.replace(/^\| /, '').replace(/ \|$/, '').split(' | ');
|
|
const counts = Object.fromEntries(LABELS.map(k => [k, 0]));
|
|
const tr = rows.map(l => {
|
|
const c = cells(l); if (c.length !== 8) throw new Error(`evidence.md: row ${c[0]} has ${c.length} cells`);
|
|
const [n, claim, where, status, ver, test, result, iv] = c;
|
|
const idx = LABELS.findIndex(k => status.toLowerCase().includes(k)); if (idx < 0) throw new Error(`evidence.md: row ${n} status "${status}"`);
|
|
counts[LABELS[idx]]++;
|
|
return `<tr data-status="${LABELS[idx]}"><td class="n">${n}</td><td class="claim">${inl(claim)}<div class="where">${inl(where)}</div></td><td><span class="st st-${idx}">${inl(status)}</span></td><td class="mono">${inl(ver)}</td><td>${inl(test)}</td><td>${inl(result)}</td><td class="iv">${inl(iv)}</td></tr>`;
|
|
}).join('\n');
|
|
// the same scrub as /bench: local-time stamps to UTC and the private-string check (the build fails on any hit)
|
|
const trs = scrubBench(tr);
|
|
html = html.replace(/(<table id="claims">[\s\S]*?<tbody>)[\s\S]*?(<\/tbody>)/, (m, a, b) => `${a}\n${trs}\n${b}`);
|
|
for (const k of LABELS) {
|
|
html = html.replace(new RegExp(`(<div class="label"><div class="k">${k}</div><div class="v">)\\d+(</div>)`), `$1${counts[k]}$2`);
|
|
html = html.replace(new RegExp(`(data-filter="${k}"><b>)\\d+(</b>)`), `$1${counts[k]}$2`);
|
|
}
|
|
html = html.replace(/(data-filter=""><b>)\d+(<\/b>)/, `$1${rows.length}$2`);
|
|
const d = new Date(); const MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
const day = `${d.getUTCDate()} ${MON[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
const dayLong = `${d.getUTCDate()} ${['January','February','March','April','May','June','July','August','September','October','November','December'][d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
html = html.replace(/<div class="eyebrow">\d+ claims · 5 labels · [^<]*<\/div>/, `<div class="eyebrow">${rows.length} claims · 5 labels · ${day}</div>`);
|
|
html = html.replace(/<p class="asof">Statuses are honest as of [^<]*<\/p>/, `<p class="asof">Statuses are honest as of ${dayLong}, the day this page was generated from docs/evidence.md, and change only through that file.</p>`);
|
|
return html;
|
|
}
|
|
|
|
const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['wallet.html', 'wallet'], ['metamask.html', ''], ['faucet.html', ''], ['404.html', ''],
|
|
// the DAG explorer (5 Oct 2026): /explorer, /block/<hash> and /address/<addr> (vercel.json rewrites the last two)
|
|
['explorer.html', 'live'], ['block.html', 'live'], ['address.html', 'live']];
|
|
for (const [file, active] of PAGES) {
|
|
const p = join(here, file);
|
|
if (!existsSync(p)) throw new Error(`missing page ${file}`);
|
|
let html = readFileSync(p, 'utf8');
|
|
html = inject(html, 'head', HEAD, file);
|
|
html = inject(html, 'nav', navFor(active), file);
|
|
html = inject(html, 'footer', FOOT, file);
|
|
html = stampProduct(html, file);
|
|
html = stampDownloads(html, file, downloads);
|
|
if (file === 'evidence.html') html = renderEvidence(html);
|
|
if (file === 'index.html') html = inject(html, 'journey', `<script type="application/json" id="journey-data">${JSON.stringify(journey).replace(/</g, '\\u003c')}</script>`, file);
|
|
writeFileSync(p, html);
|
|
built.push(file);
|
|
}
|
|
|
|
// the public bench table (/miners): one row per card, generator version and miner version, from site/miner-bench.json
|
|
// (the bench-log numbers that exist today, and rows later jobs append); rendered through the same scrub as /bench
|
|
{
|
|
const bj = JSON.parse(readFileSync(join(here, 'miner-bench.json'), 'utf8'));
|
|
const rows = bj.rows.slice().sort((a, b) => (a.card < b.card ? -1 : a.card > b.card ? 1 : a.generator < b.generator ? -1 : a.generator > b.generator ? 1 : b.mh_s - a.mh_s));
|
|
const fmt = (n) => Number(n).toLocaleString('en-GB', { maximumFractionDigits: 1 });
|
|
const cell = (r) => [
|
|
r.card, r.generator, fmt(r.mh_s), r.mh_per_w == null ? 'not measured' : fmt(r.mh_per_w), r.miner, r.date, r.source, r.by + (r.note ? '. ' + r.note : ''),
|
|
];
|
|
const table = '<div class="tbl"><table><thead><tr>' + ['Card', 'Generator', 'Best MH/s', 'MH per watt', 'Miner', 'Date', 'Source', 'Who measured it'].map(h => `<th>${h}</th>`).join('') + '</tr></thead><tbody>' +
|
|
rows.map(r => '<tr>' + cell(r).map(c => `<td>${esc(String(c))}</td>`).join('') + '</tr>').join('') + '</tbody></table></div>';
|
|
// Ember Tune's fleet priors (site/miner-priors.json, tools/tuning.mjs --priors --site): one row per card model,
|
|
// driver major and program class; a row under the sample floor shows its count and no point
|
|
const pj = JSON.parse(readFileSync(join(here, 'miner-priors.json'), 'utf8'));
|
|
const prows = (pj.rows || []).slice().sort((a, b) => (b.samples - a.samples) || (a.card < b.card ? -1 : 1));
|
|
const pcell = r => [
|
|
r.card, r.driver_major, r.class, r.samples + (r.machines ? ' from ' + r.machines + ' machine' + (r.machines === 1 ? '' : 's') : ''),
|
|
r.prior ? (r.clock_mhz ? fmt(r.clock_mhz) + ' MHz at ' + r.power_pct + '%' : r.power_pct + '%, clock unlocked') : 'under the floor (' + pj.min_samples + ' needed)',
|
|
r.mh_per_w == null ? 'not yet' : Number(r.mh_per_w).toFixed(3) + (r.spread_pct != null ? ' (spread ' + r.spread_pct + '%)' : ''),
|
|
r.mh_s == null ? '' : fmt(r.mh_s) + ' MH/s at ' + fmt(r.watts) + ' W',
|
|
r.untuned_mh_per_w == null ? 'not measured' : Number(r.untuned_mh_per_w).toFixed(3) + (r.gain_pct != null ? ' (' + (r.gain_pct >= 0 ? '+' : '') + r.gain_pct + '%)' : ''),
|
|
];
|
|
const ptable = prows.length
|
|
? '<div class="tbl"><table><thead><tr>' + ['Card', 'Driver', 'Program class', 'Samples', 'Tuned point', 'MH per watt', 'Rate and draw', 'Untuned MH per watt (gain)'].map(h => `<th>${h}</th>`).join('') + '</tr></thead><tbody>' +
|
|
prows.map(r => '<tr>' + pcell(r).map(c => `<td>${esc(String(c))}</td>`).join('') + '</tr>').join('') + '</tbody></table></div>'
|
|
: '<p>No tune reports yet. The first rows appear once five machines with the same card model have reported.</p>';
|
|
const body = scrubBench([
|
|
'<h2 id="table">The table</h2>',
|
|
'<p>One row per card, generator version and miner version. The rate is the best one measured. Integrated GPUs are not listed. Prototype rows are bench numbers from before the devnet and say so in the miner column.</p>',
|
|
table,
|
|
'<h2 id="how">How a row gets here</h2>',
|
|
'<p>Every row names the engineering log entry or the job it came from. "Measured by the team" means our own hardware and our own log. "Reported by the fleet" means a machine we do not own, read from the status lines its miner uploads.</p>',
|
|
'<p>MH per watt needs the card\'s power draw during the run. The app reads it on NVIDIA cards through the driver. Rows get the figure when a run records it.</p>',
|
|
'<p>There is no other Igneum miner to compare with yet, so this table compares cards, not miners. The app that produces these rows: <a href="/miner">the miner page</a>.</p>',
|
|
`<p>Rows: ${rows.length}. Source file: <code>site/miner-bench.json</code> in the repository.</p>`,
|
|
'<h2 id="priors">Fleet tuning priors</h2>',
|
|
'<p>Ember Tune runs on every card the app mines with: the power limit and the core clock are stepped on the live program and the card keeps the point with the best MH per watt within 1% of its top rate. Every finished tune is reported back without anything that identifies the owner, and the fleet\'s median point per card model, driver major and program class comes back down inside the signed update manifest as the starting point for the next card of that model. A model needs ' + pj.min_samples + ' reports before its prior is used.</p>',
|
|
ptable,
|
|
`<p>Rows: ${prows.length}${pj.generated ? ', generated ' + pj.generated : ''}. Source file: <code>site/miner-priors.json</code> in the repository, written from the fleet records by <code>tools/tuning.mjs --priors --site</code>.</p>`,
|
|
].join('\n'));
|
|
const toc = [{ lvl: 2, t: 'The table', id: 'table' }, { lvl: 2, t: 'How a row gets here', id: 'how' }, { lvl: 2, t: 'Fleet tuning priors', id: 'priors' }];
|
|
writeFileSync(join(here, 'miners.html'), page('Igneum GPU bench table', 'Measured Igneum hash rates per GPU: card, generator version, best MH/s, MH per watt where measured, miner version, date and the log entry each number came from.', body, toc,
|
|
'Measured hash rates per card on the Igneum lottery hash, with the generator version, the miner version, the date and the log entry behind each number.',
|
|
{ path: '/miners', heading: 'GPU bench table', active: 'miner' }));
|
|
built.push('miners.html (' + rows.length + ' rows)');
|
|
}
|
|
console.log('built: ' + built.join(', '));
|