Merge live-shards-8 ba503056 into master (gate: green on ba503056, recorded by tools/ci/pre-push.sh; landed on the box mirror)
This commit is contained in:
commit
52c6ac59c7
12 changed files with 305 additions and 31 deletions
|
|
@ -1,20 +1,150 @@
|
|||
{
|
||||
"version": 1,
|
||||
"doc": "scene/feed-contract.md: the one JSON shape the chain scene reads, from the observer's /api/live and from the miner app's api/live alike",
|
||||
"top": ["ok", "now", "state", "blocks", "miners", "events", "finality", "proving"],
|
||||
"top_extensions": ["partial"],
|
||||
"state": ["stale", "age_s", "network", "node_version", "height", "block_count", "header_count", "blue_score", "difficulty", "hashes_per_second_estimate", "peers", "mempool", "blocks_60s", "blocks_per_second_60s", "blocks_per_minute", "miners_10m", "observer_started_at", "observer_lag_s", "queue_depth", "updated_at"],
|
||||
"state_extensions": ["source", "you_blocks", "node"],
|
||||
"block": ["hash", "number", "blue_score", "daa", "ts", "rx", "parents", "chain", "miner", "color", "locked", "final", "shards", "proven"],
|
||||
"miner": ["id", "blocks", "share", "last_seen", "engine"],
|
||||
"event": ["ts", "kind", "text"],
|
||||
"finality": ["supported", "active", "next_index", "latest_locked_index", "latest_locked_hash", "latest_locked_blue_score", "params", "weights", "checkpoints"],
|
||||
"checkpoint": ["index", "hash", "blue_score", "daa", "state", "signed", "active", "total", "fraction_active", "fraction_total", "votes", "voters", "locked_at", "updated_at"],
|
||||
"shard": ["i", "n", "state", "prover", "lag", "payout", "pgas"],
|
||||
"proving_unsupported": ["supported", "reason"],
|
||||
"proving_supported": ["supported", "active", "activation_daa", "tip_daa", "verifier", "pool", "paid_shards_total", "shard_budget_pgas", "blocks_10m", "blocks_fully_proven_10m", "shards_proven_10m", "shards_paid_10m", "median_proof_lag_s", "provers_10m"],
|
||||
"color": ["blue", "red", "pending"],
|
||||
"shard_state": ["planned", "proving", "verified", "paid"],
|
||||
"checkpoint_state": ["pending", "locked"],
|
||||
"doc": "scene/feed-contract.md: the one JSON shape the chain scene reads, from the observer's /api/live and from the miner app's api/live alike 8 October 2026: proving carries its own health beside the counts (stale, stale_reason, status_age_s, tip_lag_daa, records_10m, last_change_at; site/lib/proving-health.mjs): the observer's node can stop crediting payouts while the DAG feed stays live, and the page says so instead of showing a frozen paid total as fresh.",
|
||||
"top": [
|
||||
"ok",
|
||||
"now",
|
||||
"state",
|
||||
"blocks",
|
||||
"miners",
|
||||
"events",
|
||||
"finality",
|
||||
"proving"
|
||||
],
|
||||
"top_extensions": [
|
||||
"partial"
|
||||
],
|
||||
"state": [
|
||||
"stale",
|
||||
"age_s",
|
||||
"network",
|
||||
"node_version",
|
||||
"height",
|
||||
"block_count",
|
||||
"header_count",
|
||||
"blue_score",
|
||||
"difficulty",
|
||||
"hashes_per_second_estimate",
|
||||
"peers",
|
||||
"mempool",
|
||||
"blocks_60s",
|
||||
"blocks_per_second_60s",
|
||||
"blocks_per_minute",
|
||||
"miners_10m",
|
||||
"observer_started_at",
|
||||
"observer_lag_s",
|
||||
"queue_depth",
|
||||
"updated_at"
|
||||
],
|
||||
"state_extensions": [
|
||||
"source",
|
||||
"you_blocks",
|
||||
"node"
|
||||
],
|
||||
"block": [
|
||||
"hash",
|
||||
"number",
|
||||
"blue_score",
|
||||
"daa",
|
||||
"ts",
|
||||
"rx",
|
||||
"parents",
|
||||
"chain",
|
||||
"miner",
|
||||
"color",
|
||||
"locked",
|
||||
"final",
|
||||
"shards",
|
||||
"proven"
|
||||
],
|
||||
"miner": [
|
||||
"id",
|
||||
"blocks",
|
||||
"share",
|
||||
"last_seen",
|
||||
"engine"
|
||||
],
|
||||
"event": [
|
||||
"ts",
|
||||
"kind",
|
||||
"text"
|
||||
],
|
||||
"finality": [
|
||||
"supported",
|
||||
"active",
|
||||
"next_index",
|
||||
"latest_locked_index",
|
||||
"latest_locked_hash",
|
||||
"latest_locked_blue_score",
|
||||
"params",
|
||||
"weights",
|
||||
"checkpoints"
|
||||
],
|
||||
"checkpoint": [
|
||||
"index",
|
||||
"hash",
|
||||
"blue_score",
|
||||
"daa",
|
||||
"state",
|
||||
"signed",
|
||||
"active",
|
||||
"total",
|
||||
"fraction_active",
|
||||
"fraction_total",
|
||||
"votes",
|
||||
"voters",
|
||||
"locked_at",
|
||||
"updated_at"
|
||||
],
|
||||
"shard": [
|
||||
"i",
|
||||
"n",
|
||||
"state",
|
||||
"prover",
|
||||
"lag",
|
||||
"payout",
|
||||
"pgas"
|
||||
],
|
||||
"proving_unsupported": [
|
||||
"supported",
|
||||
"reason"
|
||||
],
|
||||
"proving_supported": [
|
||||
"supported",
|
||||
"active",
|
||||
"activation_daa",
|
||||
"tip_daa",
|
||||
"verifier",
|
||||
"pool",
|
||||
"paid_shards_total",
|
||||
"shard_budget_pgas",
|
||||
"blocks_10m",
|
||||
"blocks_fully_proven_10m",
|
||||
"shards_proven_10m",
|
||||
"shards_paid_10m",
|
||||
"median_proof_lag_s",
|
||||
"provers_10m",
|
||||
"stale",
|
||||
"stale_reason",
|
||||
"status_age_s",
|
||||
"tip_lag_daa",
|
||||
"records_10m",
|
||||
"last_change_at"
|
||||
],
|
||||
"color": [
|
||||
"blue",
|
||||
"red",
|
||||
"pending"
|
||||
],
|
||||
"shard_state": [
|
||||
"planned",
|
||||
"proving",
|
||||
"verified",
|
||||
"paid"
|
||||
],
|
||||
"checkpoint_state": [
|
||||
"pending",
|
||||
"locked"
|
||||
],
|
||||
"miner_id": "^[0-9a-f]{8}$"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +1,7 @@
|
|||
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and
|
||||
* app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here.
|
||||
* 2.0.7 (8 Oct 2026): a chain block whose plan is known (block.number set, carried as plan on the normalised block) and empty reads
|
||||
* "no shards in this block"; the loading and late sentences stay for a plan the observer has not read; the fixed sentence of 2.0.5 is gone.
|
||||
* 2.0.6 (7 Oct 2026): IgneumDag.shardWords(block, nowMs) is the one shard sentence (tooltip, /live inspector, app inspector); the
|
||||
* empty case reads from the block's facts (loading, excluded, not yet ordered, off the chain) instead of one fixed sentence.
|
||||
* 2.0.5 (key-22, 7 Oct 2026): IgneumDag.legend({mine}) and renderLegend(el,{mine}) are the one key both pages render; the app's
|
||||
|
|
@ -46,14 +48,14 @@
|
|||
function cloneBlock(b) {
|
||||
return {hash:b.hash,ts:b.ts,blue_score:b.blue_score,blue:b.blue_score,daa:b.daa,
|
||||
parents:b.parents.slice(),chain:b.chain,color:b.color,miner:b.miner,locked:b.locked,
|
||||
final:b.final,proven:b.proven,shards:b.shards.map(function(s){return {state:s.state};})};
|
||||
final:b.final,proven:b.proven,plan:b.plan,shards:b.shards.map(function(s){return {state:s.state};})};
|
||||
}
|
||||
function normalize(b) {
|
||||
if (!b || typeof b.hash !== 'string' || !b.hash.length || b.hash.length>256 || !finite(b.ts) || b.ts<0) return null;
|
||||
return {hash:b.hash,ts:b.ts,blue_score:num(b.blue_score),daa:num(b.daa),
|
||||
parents:Array.isArray(b.parents)?Array.from(new Set(b.parents.filter(function(p){return typeof p==='string' && p!==b.hash;}))).slice(0,128):[],
|
||||
chain:b.chain===true,color:['blue','red','pending'].includes(b.color)?b.color:'pending',
|
||||
miner:typeof b.miner==='string'?b.miner.slice(0,256):'unknown',locked:b.locked===true,final:b.final===true,proven:b.proven===true,
|
||||
miner:typeof b.miner==='string'?b.miner.slice(0,256):'unknown',locked:b.locked===true,final:b.final===true,proven:b.proven===true,plan:finite(b.number),
|
||||
shards:Array.isArray(b.shards)?b.shards.slice(0,256).map(function(s){return {state:s && ['planned','proving','verified','paid'].includes(s.state)?s.state:'unknown'};}):[]};
|
||||
}
|
||||
function mount(canvas, opts) {
|
||||
|
|
@ -393,6 +395,7 @@
|
|||
if(b.color==='red')return {summary:'excluded, nothing to prove',items:[],state:'excluded'};
|
||||
if(b.color!=='blue')return {summary:'not yet ordered',items:[],state:'pending'};
|
||||
if(!b.chain)return {summary:'off the selected chain, no shards planned',items:[],state:'none'};
|
||||
if(b.plan===true)return {summary:'no shards in this block',items:[],state:'none'};
|
||||
if(age<10)return {summary:'shard plan loading',items:[],state:'loading'};
|
||||
return {summary:'no shard plan after '+ageWord(age),items:[],state:'late'};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
// 7 October 2026 evening by the founder word) reads another observer's tables (empty = the first devnet's, fintest_live_*, provtest_live_*).
|
||||
// Zero dependencies: Neon's HTTP SQL endpoint over Node's built-in fetch.
|
||||
|
||||
import { provingHealth } from '../lib/proving-health.mjs';
|
||||
|
||||
const STALE_AFTER_S = 30;
|
||||
const T = (process.env.LIVE_TABLE_PREFIX === undefined ? 'dn3_' : process.env.LIVE_TABLE_PREFIX).replace(/[^a-z0-9_]/gi, '');
|
||||
|
||||
|
|
@ -45,11 +47,12 @@ export default async function handler(req, res) {
|
|||
// ?summary=1: the counts of the window alone (blocks, chain blocks, proven, shards by state), for a tile that reads a long
|
||||
// window every 10 s while the scene keeps its short one (7 October 2026); no block rows, no miners, no events
|
||||
const summary = q.get('summary') === '1';
|
||||
const [head, blockRows, minerRows, checkpointRows, proofRows] = await Promise.all([
|
||||
const [head, blockRows, minerRows, checkpointRows, proofRows, provingRows] = await Promise.all([
|
||||
sql(`SELECT now() AS now,
|
||||
(SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state,
|
||||
(SELECT json_agg(e) FROM (SELECT ts, kind, text FROM ${T}live_events ORDER BY ts DESC LIMIT 30) e) AS events,
|
||||
(SELECT max(number) FROM ${T}live_blocks WHERE number IS NOT NULL AND received_at > now() - interval '10 minutes') AS height`),
|
||||
(SELECT max(number) FROM ${T}live_blocks WHERE number IS NOT NULL AND received_at > now() - interval '10 minutes') AS height,
|
||||
(SELECT max(daa_score) FROM ${T}live_blocks WHERE received_at > now() - interval '2 minutes') AS newest_daa`),
|
||||
sql(`SELECT hash, blue_score, daa_score, timestamp_ms, parent_hashes, is_chain_block, vote_key_hash, received_at, color, number
|
||||
FROM ${T}live_blocks
|
||||
WHERE received_at > now() - ($1 || ' seconds')::interval
|
||||
|
|
@ -72,6 +75,10 @@ export default async function handler(req, res) {
|
|||
FROM ${T}live_proofs p
|
||||
WHERE p.block_hash IN (SELECT hash FROM ${T}live_blocks WHERE received_at > now() - ($1 || ' seconds')::interval ORDER BY received_at DESC LIMIT 1800)
|
||||
ORDER BY p.block_hash, p.shard`, [String(windowS)]).catch(() => []),
|
||||
// the proving feed's own health (8 October 2026, site/lib/proving-health.mjs): the records blocks carried in the last
|
||||
// 10 minutes against the last shard the observer's node credited; an older observer has no proofs table
|
||||
sql(`SELECT (SELECT coalesce(sum(proof_records), 0)::int FROM ${T}live_blocks WHERE received_at > now() - interval '10 minutes') AS records_10m,
|
||||
(SELECT max(updated_at) FROM ${T}live_proofs WHERE state IN ('verified', 'paid')) AS last_change`).catch(() => [{ records_10m: 0, last_change: null }]),
|
||||
]);
|
||||
|
||||
const now = new Date(head[0].now).getTime();
|
||||
|
|
@ -138,12 +145,17 @@ export default async function handler(req, res) {
|
|||
shardsOf.get(k).push({ i: num(r.shard), n: num(r.shards), state: r.state, prover: r.prover || null, lag: num(r.lag_daa), payout, pgas: num(r.pgas) });
|
||||
}
|
||||
const pv = (s && s.proving) || null;
|
||||
const proving = pv && pv.supported ? {
|
||||
const provingBase = pv && pv.supported ? {
|
||||
supported: true, active: !!pv.active, activation_daa: num(pv.activation_daa), tip_daa: num(pv.tip_daa), verifier: pv.verifier || null, pool: pv.pool || null,
|
||||
paid_shards_total: num(pv.paid_shards_total), shard_budget_pgas: num(pv.shard_budget_pgas),
|
||||
blocks_10m: num(pv.blocks_10m), blocks_fully_proven_10m: num(pv.blocks_fully_proven_10m), shards_proven_10m: num(pv.shards_proven_10m), shards_paid_10m: num(pv.shards_paid_10m),
|
||||
median_proof_lag_s: num(pv.median_proof_lag_s), provers_10m: num(pv.provers_10m),
|
||||
} : { supported: false, reason: pv ? (pv.reason || 'node without proving') : 'observer has not reported the proving layer' };
|
||||
// the feed's own staleness beside the counts: stale (bool) and stale_reason in words, status_age_s (the observer's last
|
||||
// status read, null on an older observer), tip_lag_daa, records_10m, last_change_at (the last shard credited)
|
||||
const ph = provingHealth({ proving: provingBase, newestDaa: num(head[0].newest_daa), records10m: provingRows[0] ? provingRows[0].records_10m : 0,
|
||||
lastChangeAt: provingRows[0] && provingRows[0].last_change ? new Date(provingRows[0].last_change).toISOString() : null, statusAt: pv && pv.status_at ? pv.status_at : null, now });
|
||||
const proving = provingBase.supported ? { ...provingBase, stale: ph.stale, stale_reason: ph.reason, status_age_s: ph.status_age_s, tip_lag_daa: ph.tip_lag_daa, records_10m: ph.records_10m, last_change_at: ph.last_change_at } : provingBase;
|
||||
|
||||
const blocks = blockRows.reverse().map(b => ({
|
||||
hash: short(b.hash),
|
||||
|
|
|
|||
46
site/lib/proving-health.mjs
Normal file
46
site/lib/proving-health.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// The proving feed's own staleness (8 October 2026). /api/live's "proven" and "paid" shard states come from the observer's
|
||||
// node (igneum_getProvingStatus and igneum_getProofRecords on its execution RPC), never from the chain directly. Three
|
||||
// ways that node can lie while the DAG feed stays live, each read here against what the chain itself shows:
|
||||
// 1. the status snapshot is old (the observer's last successful igneum_getProvingStatus read), PROVING_STATUS_STALE_S;
|
||||
// 2. the node's execution tip trails the newest block's DAA by more than PROVING_TIP_LAG_DAA (a stalled executor);
|
||||
// 3. blocks in the last 10 minutes carried proof records (the IGNP section the observer counts per block) and the
|
||||
// node credited no shard (no row moved to verified or paid) for PROVING_SILENCE_S: the "blind to records" class
|
||||
// (Devnet 3, 7 to 8 October 2026: the observer's node froze at 7,304 paid shards from 23:57Z while blocks carried
|
||||
// 8,457 records by 03:00Z and hub-1's node counted 11,861 paid by 08:56Z).
|
||||
// A quiet chain (no records carried) is not stale for lack of credits: there was nothing to credit.
|
||||
export const PROVING_STATUS_STALE_S = 60;
|
||||
export const PROVING_TIP_LAG_DAA = 300;
|
||||
export const PROVING_SILENCE_S = 600;
|
||||
|
||||
const ms = v => { if (v === null || v === undefined || v === '') return null; const t = typeof v === 'number' ? v : Date.parse(String(v).replace(' ', 'T')); return Number.isFinite(t) ? t : null; };
|
||||
function span(s) { s = Math.max(0, Math.round(s)); if (s < 60) return s + ' s'; const m = Math.floor(s / 60); if (m < 60) return m + ' min'; const h = Math.floor(m / 60); return h + ' h ' + (m % 60) + ' min'; }
|
||||
|
||||
/**
|
||||
* @param {object} a proving: the API's proving object (supported, tip_daa, ...); newestDaa: the newest block's DAA score in the
|
||||
* feed; records10m: proof records carried by blocks in the last 10 minutes; lastChangeAt: when a shard last moved to verified
|
||||
* or paid (null = never); statusAt: the observer's last successful status read (null on an older observer); now: ms.
|
||||
* @returns {{stale: boolean, reason: string, status_age_s: number|null, tip_lag_daa: number|null, records_10m: number, last_change_at: string|null}}
|
||||
*/
|
||||
export function provingHealth(a) {
|
||||
const now = a.now ?? Date.now(), pv = a.proving || null;
|
||||
const statusAt = ms(a.statusAt), lastChange = ms(a.lastChangeAt);
|
||||
const status_age_s = statusAt === null ? null : Math.round(Math.max(0, now - statusAt) / 100) / 10;
|
||||
const records_10m = Number(a.records10m) > 0 ? Math.round(Number(a.records10m)) : 0;
|
||||
const tipDaa = pv && pv.tip_daa !== null && pv.tip_daa !== undefined ? Number(pv.tip_daa) : null;
|
||||
const newest = a.newestDaa !== null && a.newestDaa !== undefined ? Number(a.newestDaa) : null;
|
||||
const tip_lag_daa = tipDaa !== null && newest !== null && Number.isFinite(tipDaa) && Number.isFinite(newest) ? Math.max(0, newest - tipDaa) : null;
|
||||
const last_change_at = lastChange === null ? null : (typeof a.lastChangeAt === 'string' && /T.*Z$/.test(a.lastChangeAt) ? a.lastChangeAt : new Date(lastChange).toISOString());
|
||||
const out = { stale: false, reason: '', status_age_s, tip_lag_daa, records_10m, last_change_at };
|
||||
if (!pv || !pv.supported) { out.stale = true; out.reason = (pv && pv.reason) || 'the observer has not reported the proving layer'; return out; }
|
||||
if (status_age_s !== null && status_age_s > PROVING_STATUS_STALE_S) { out.stale = true; out.reason = 'proving status last read ' + Math.round(status_age_s) + ' s ago'; return out; }
|
||||
if (tip_lag_daa !== null && tip_lag_daa > PROVING_TIP_LAG_DAA) { out.stale = true; out.reason = 'the observer\'s node is ' + tip_lag_daa + ' DAA behind the chain'; return out; }
|
||||
if (records_10m > 0) {
|
||||
const silence = lastChange === null ? null : (now - lastChange) / 1000;
|
||||
if (silence === null || silence > PROVING_SILENCE_S) {
|
||||
out.stale = true;
|
||||
out.reason = 'blocks carried ' + records_10m + ' proof record' + (records_10m === 1 ? '' : 's') + ' in the last 10 min and the observer\'s node credited none' + (silence === null ? ' (never)' : ' for ' + span(silence));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
64
site/lib/proving-health.test.mjs
Normal file
64
site/lib/proving-health.test.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// The proving feed's own staleness (8 October 2026, main's order: a stale snapshot must read as stale). The page's "proven"
|
||||
// states come from the observer's node, not from the chain directly; a node that stops crediting payouts (the "blind to
|
||||
// records" class: Devnet 3's observer node froze at 7,304 paid shards from 23:57Z on 7 October while blocks kept carrying
|
||||
// records and hub-1 counted 11,861 by 08:56Z) leaves a snapshot that still says supported, active and a paid total. Known-failed
|
||||
// first: that snapshot read as fresh before this module existed; it must read as stale, with the reason in words.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { provingHealth, PROVING_STATUS_STALE_S, PROVING_TIP_LAG_DAA, PROVING_SILENCE_S } from './proving-health.mjs';
|
||||
|
||||
const NOW = Date.parse('2026-10-08T08:56:47Z');
|
||||
const live = { supported: true, active: true, tip_daa: 57767, paid_shards_total: 7304 };
|
||||
const h = (over) => provingHealth({ proving: live, newestDaa: 57770, records10m: 12, lastChangeAt: new Date(NOW - 20_000).toISOString(), statusAt: new Date(NOW - 1_500).toISOString(), now: NOW, ...over });
|
||||
|
||||
test('the 8 October 2026 shape: records carried in the window, no shard credited for nine hours: stale, and the reason says so', () => {
|
||||
const r = h({ records10m: 48, lastChangeAt: '2026-10-07T23:57:25Z' });
|
||||
assert.equal(r.stale, true);
|
||||
assert.match(r.reason, /48 proof records? in the last 10 min/);
|
||||
assert.match(r.reason, /credited none/);
|
||||
assert.match(r.reason, /8 h 59 min/);
|
||||
assert.equal(r.records_10m, 48); assert.equal(r.last_change_at, '2026-10-07T23:57:25Z');
|
||||
});
|
||||
test('a snapshot the observer last read too long ago is stale, with its age', () => {
|
||||
const r = h({ statusAt: new Date(NOW - 95_000).toISOString() });
|
||||
assert.equal(r.stale, true); assert.match(r.reason, /last read 95 s ago/); assert.equal(r.status_age_s, 95);
|
||||
assert.equal(PROVING_STATUS_STALE_S, 60);
|
||||
});
|
||||
test('an observer node whose execution tip trails the chain by more than the allowance is stale', () => {
|
||||
const r = h({ newestDaa: 57767 + 400 });
|
||||
assert.equal(r.stale, true); assert.match(r.reason, /400 DAA behind the chain/); assert.equal(r.tip_lag_daa, 400);
|
||||
assert.equal(PROVING_TIP_LAG_DAA, 300);
|
||||
assert.equal(h({ newestDaa: 57767 + 300 }).stale, false, 'at the allowance it is fresh');
|
||||
});
|
||||
test('fresh: the status just read, the tip at the chain, records in the window and a credit 20 s ago', () => {
|
||||
const r = h({});
|
||||
assert.equal(r.stale, false); assert.equal(r.reason, ''); assert.equal(r.status_age_s, 1.5); assert.equal(r.tip_lag_daa, 3);
|
||||
});
|
||||
test('a quiet chain (no records carried in the window) is not stale for lack of credits', () => {
|
||||
assert.equal(h({ records10m: 0, lastChangeAt: '2026-10-07T23:57:25Z' }).stale, false);
|
||||
assert.equal(h({ records10m: 0, lastChangeAt: null }).stale, false);
|
||||
});
|
||||
test('records in the window and no credit ever: stale', () => {
|
||||
const r = h({ records10m: 5, lastChangeAt: null });
|
||||
assert.equal(r.stale, true); assert.match(r.reason, /credited none/); assert.match(r.reason, /never/);
|
||||
});
|
||||
test('a silence shorter than the allowance is fresh; longer is stale', () => {
|
||||
assert.equal(PROVING_SILENCE_S, 600);
|
||||
assert.equal(h({ records10m: 5, lastChangeAt: new Date(NOW - 599_000).toISOString() }).stale, false);
|
||||
assert.equal(h({ records10m: 5, lastChangeAt: new Date(NOW - 601_000).toISOString() }).stale, true);
|
||||
});
|
||||
test('an older observer without status_at is judged by the other rules only', () => {
|
||||
const r = h({ statusAt: null });
|
||||
assert.equal(r.stale, false); assert.equal(r.status_age_s, null);
|
||||
});
|
||||
test('an unsupported proving layer is stale with the observer\'s reason', () => {
|
||||
const r = h({ proving: { supported: false, reason: 'evm rpc unreachable (fetch failed)' } });
|
||||
assert.equal(r.stale, true); assert.equal(r.reason, 'evm rpc unreachable (fetch failed)');
|
||||
});
|
||||
test('the forbidden wording is gone from the site and the scene: a chain block with no shards is never "no shard plan yet"', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
for (const f of ['scene/live-dag.js', 'site/live-dag.js', 'site/live.html', 'site/live-steps.js', 'site/block.html']) {
|
||||
const src = readFileSync(new URL('../../' + f, import.meta.url), 'utf8');
|
||||
assert.equal(/no shard plan yet/i.test(src), false, f + ' says "no shard plan yet"');
|
||||
}
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and
|
||||
* app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here.
|
||||
* 2.0.7 (8 Oct 2026): a chain block whose plan is known (block.number set, carried as plan on the normalised block) and empty reads
|
||||
* "no shards in this block"; the loading and late sentences stay for a plan the observer has not read; the fixed sentence of 2.0.5 is gone.
|
||||
* 2.0.6 (7 Oct 2026): IgneumDag.shardWords(block, nowMs) is the one shard sentence (tooltip, /live inspector, app inspector); the
|
||||
* empty case reads from the block's facts (loading, excluded, not yet ordered, off the chain) instead of one fixed sentence.
|
||||
* 2.0.5 (key-22, 7 Oct 2026): IgneumDag.legend({mine}) and renderLegend(el,{mine}) are the one key both pages render; the app's
|
||||
|
|
@ -46,14 +48,14 @@
|
|||
function cloneBlock(b) {
|
||||
return {hash:b.hash,ts:b.ts,blue_score:b.blue_score,blue:b.blue_score,daa:b.daa,
|
||||
parents:b.parents.slice(),chain:b.chain,color:b.color,miner:b.miner,locked:b.locked,
|
||||
final:b.final,proven:b.proven,shards:b.shards.map(function(s){return {state:s.state};})};
|
||||
final:b.final,proven:b.proven,plan:b.plan,shards:b.shards.map(function(s){return {state:s.state};})};
|
||||
}
|
||||
function normalize(b) {
|
||||
if (!b || typeof b.hash !== 'string' || !b.hash.length || b.hash.length>256 || !finite(b.ts) || b.ts<0) return null;
|
||||
return {hash:b.hash,ts:b.ts,blue_score:num(b.blue_score),daa:num(b.daa),
|
||||
parents:Array.isArray(b.parents)?Array.from(new Set(b.parents.filter(function(p){return typeof p==='string' && p!==b.hash;}))).slice(0,128):[],
|
||||
chain:b.chain===true,color:['blue','red','pending'].includes(b.color)?b.color:'pending',
|
||||
miner:typeof b.miner==='string'?b.miner.slice(0,256):'unknown',locked:b.locked===true,final:b.final===true,proven:b.proven===true,
|
||||
miner:typeof b.miner==='string'?b.miner.slice(0,256):'unknown',locked:b.locked===true,final:b.final===true,proven:b.proven===true,plan:finite(b.number),
|
||||
shards:Array.isArray(b.shards)?b.shards.slice(0,256).map(function(s){return {state:s && ['planned','proving','verified','paid'].includes(s.state)?s.state:'unknown'};}):[]};
|
||||
}
|
||||
function mount(canvas, opts) {
|
||||
|
|
@ -393,6 +395,7 @@
|
|||
if(b.color==='red')return {summary:'excluded, nothing to prove',items:[],state:'excluded'};
|
||||
if(b.color!=='blue')return {summary:'not yet ordered',items:[],state:'pending'};
|
||||
if(!b.chain)return {summary:'off the selected chain, no shards planned',items:[],state:'none'};
|
||||
if(b.plan===true)return {summary:'no shards in this block',items:[],state:'none'};
|
||||
if(age<10)return {summary:'shard plan loading',items:[],state:'loading'};
|
||||
return {summary:'no shard plan after '+ageWord(age),items:[],state:'late'};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@
|
|||
.mstats .mv b{font:700 clamp(21px,2.2vw,29px)/1.1 var(--display);color:var(--bone);letter-spacing:-.04em}
|
||||
.mstats .mv b.own{color:var(--molten-text)}.mstats .mv b.dim{color:var(--ash)}
|
||||
.mstats .mv small{font:400 11px/1.4 var(--f-mono);color:var(--ash)}
|
||||
.mstats .mf{font:400 11px/1.4 var(--f-mono);color:var(--ash);overflow-wrap:anywhere}
|
||||
.mstats .mf{font:400 11px/1.4 var(--f-mono);color:var(--ash);overflow-wrap:anywhere}.mstats .mf.bad{color:var(--ember-text)}
|
||||
@media (max-width:900px){.mstats{grid-template-columns:repeat(2,minmax(0,1fr))}.mstats>div:nth-child(2){border-right:0}.mstats>div:nth-child(-n+2){border-bottom:var(--hair)}}
|
||||
.tabs{display:inline-flex;border:var(--hair);border-radius:6px;padding:3px;gap:2px;background:var(--graphite)}
|
||||
.tab{background:transparent;border:0;color:var(--ash);font:400 12px/1.2 var(--f-sans);padding:6px 10px;border-radius:4px;cursor:pointer}
|
||||
|
|
@ -376,7 +376,7 @@ details.tablebar summary{display:flex;align-items:center}
|
|||
<section class="panel" aria-label="The block graph">
|
||||
<div class="mstats">
|
||||
<div><div class="mk">Blocks in window</div><div class="mv"><b id="m-blocks">0</b><small id="m-win">last 90 s</small></div><div class="mf" id="m-keys">waiting for the observer</div></div>
|
||||
<div><div class="mk">Proven blocks</div><div class="mv"><b id="m-proven">0</b><small id="m-proven-pct">reading</small></div><div class="mf">of the last 10 minutes, every shard verified or paid</div></div>
|
||||
<div><div class="mk">Proven blocks</div><div class="mv"><b id="m-proven">0</b><small id="m-proven-pct">reading</small></div><div class="mf" id="m-proven-note">of the last 10 minutes, every shard verified or paid</div></div>
|
||||
<div><div class="mk">Latest checkpoint</div><div class="mv"><b id="m-cp">None</b><small id="m-cp-w"></small></div><div class="mf" id="m-cp-note">no checkpoint in the reply</div></div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
|
|
@ -622,7 +622,7 @@ details.tablebar summary{display:flex;align-items:center}
|
|||
$('i-hash').textContent=b.hash+(b.number?' · #'+fmtN(b.number):'');
|
||||
var sh=b.shards||[],done=sh.filter(function(x){return x.state==='verified'||x.state==='paid';}).length;
|
||||
var sw=window.IgneumDag&&IgneumDag.shardWords?IgneumDag.shardWords(b,Date.now()):{summary:sh.length?'':'no shard plan',items:sh.map(function(x){return {word:x.state};})},cap=function(s){return s.replace(/^./,function(c){return c.toUpperCase();});};
|
||||
$('i-shard-count').textContent=sh.length?done+' / '+sh.length+' complete':sw.summary;$('i-track').innerHTML=sh.map(function(x){return '<i class="'+esc(x.state)+'"></i>';}).join('');
|
||||
$('i-shard-count').textContent=(sh.length?done+' / '+sh.length+' complete':sw.summary)+(lastD&&lastD.proving&&lastD.proving.stale?' \u00b7 feed stale':'');$('i-track').innerHTML=sh.map(function(x){return '<i class="'+esc(x.state)+'"></i>';}).join('');
|
||||
$('i-shards').innerHTML=sh.length?sh.map(function(x,i){return '<li class="'+esc(x.state)+'"><span>Shard '+String(i+1).padStart(2,'0')+'</span><span>'+esc(cap(sw.items[i]?sw.items[i].word:x.state))+'</span></li>';}).join(''):'<li class="muted">'+esc(cap(sw.summary))+'.</li>';
|
||||
$('i-incl').textContent=(b.color==='blue'?'Included':b.color==='red'?'Excluded':'Pending')+(b.chain?' / selected chain':'');
|
||||
$('i-miner').textContent=b.miner+(mineB?' (you)':'');$('i-blue').textContent=fmtN(b.blue_score);$('i-daa').textContent=fmtN(b.daa);
|
||||
|
|
@ -655,7 +655,9 @@ details.tablebar summary{display:flex;align-items:center}
|
|||
function feed(d){if(!dag)return;dag.push(d);}
|
||||
if(window.IgneumDag)mountDag();else{var ms=document.querySelector('script[src="/live-dag.js"]');if(ms)ms.addEventListener('load',mountDag);}
|
||||
// the Proven tile: a 600 s window of its own, read every 10 s, so the number is never zero by the proof lag alone
|
||||
function provenTile(){fetch('/api/live?window=600&summary=1',{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){if(!d||!d.ok)return;$('m-proven').textContent=fmtN(d.proven);$('m-proven-pct').textContent=d.chain?Math.round(d.proven/d.chain*100)+'% of '+fmtN(d.chain)+' chain blocks':'no chain blocks';}).catch(function(){});}
|
||||
function provenTile(){fetch('/api/live?window=600&summary=1',{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){if(!d||!d.ok)return;$('m-proven').textContent=fmtN(d.proven);$('m-proven-pct').textContent=d.chain?Math.round(d.proven/d.chain*100)+'% of '+fmtN(d.chain)+' chain blocks':'no chain blocks';
|
||||
// the proving feed's own health (8 October 2026, /api/live proving.stale): a frozen paid total never reads as fresh
|
||||
var pvh=d.proving||{},pn=$('m-proven-note');if(pn){pn.textContent=pvh.supported===false?'proving feed off: '+(pvh.reason||'no proving layer reported'):pvh.stale?'proving feed stale: '+(pvh.stale_reason||'the observer\u2019s node is not crediting shards'):'of the last 10 minutes, every shard verified or paid'+(pvh.last_change_at?' \u00b7 last credit '+rel(Date.now()-new Date(pvh.last_change_at).getTime()):'');pn.classList.toggle('bad',!!(pvh.stale||pvh.supported===false));}}).catch(function(){});}
|
||||
provenTile();setInterval(provenTile,10000);
|
||||
if(!window.IgneumProof){var ps=document.querySelector('script[src="/proof-core.js"]');if(ps)ps.addEventListener('load',function(){if(dag&&!proof)proof=IgneumProof.mount($('proof'));});}
|
||||
document.querySelectorAll('.tab[data-filter]').forEach(function(t){t.addEventListener('click',function(){document.querySelectorAll('.tab[data-filter]').forEach(function(x){x.classList.toggle('on',x===t);x.setAttribute('aria-pressed',x===t?'true':'false');});if(dag)dag.setFilter(t.getAttribute('data-filter'));});});
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ tree_checks() {
|
|||
run "chain scene: the live feed contract (the recorded reply validates; a rewritten miner, a float now, a stray key refused)" node --test tools/scene/feed-contract.test.mjs tools/scene/legend.test.mjs tools/scene/shard-words.test.mjs
|
||||
run "chain scene parity: one recorded feed through the home fold, /live and the app's Inspect view on build-2, three frames each pixel-equal apart from the app's own-key overlay (a changed token fails first; skipped with no box and no Playwright; one retry)" bash tools/ci/retry-once.sh scene-parity bash tools/scene/parity-remote.sh
|
||||
run "no text overlaps: every served page at 390 to 1600 px, light and dark, the hero at each step (self-test first; IGNEUM_OVERLAP_APPS=1 adds the miner and wallet UIs)" overlap_sweep
|
||||
run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/lib/money.test.mjs site/lib/leaderboard.test.mjs site/api/public-stats.test.mjs
|
||||
run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/lib/money.test.mjs site/lib/leaderboard.test.mjs site/api/public-stats.test.mjs site/lib/proving-health.test.mjs
|
||||
run "ship tool self-test" node tools/ship-app.mjs --self-test
|
||||
run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs
|
||||
run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ Created on start if missing.
|
|||
| `live_events` | one per event, kept 7 days | `ts`, `kind`, `text`. Kinds: `observer`, `miner_seen`, `miner_quiet`, `miner_back`, `peer_joined`, `peer_left`, `difficulty` (step over 5%), `checkpoint_locked`. |
|
||||
| `live_checkpoints` | one per checkpoint index, kept 7 days | `index`, `hash`, `blue_score`, `daa_score`, `state` (proposed, certified, locked), `signed_weight`, `active_weight`, `total_weight`, `fraction_active`, `fraction_total`, `votes_seen`, `voters`, `aggregators` (key hashes whose sortition proof made them aggregators), `locked_at`, `first_seen_at`, `updated_at`. |
|
||||
| `live_proofs` | one per planned shard of a chain block, kept `LIVE_RETAIN_HOURS` | `block_hash`, `shard`, `shards` (in the plan), `block_number`, `block_daa`, `block_ts`, `pgas`, `state` (planned, proving, verified, paid), `prover` (id8), `verified`, `carried_by`, `carrier_number`, `carrier_daa`, `lag_daa`, `payout_wei`, `received_at`, `updated_at`. Primary key `(block_hash, shard)`, index on `received_at`. |
|
||||
| `live_state.proving` | jsonb, updated every 2 s | `supported` (false with `reason` when the node has no proving RPCs or the endpoint is unreachable), `active`, `activation_daa`, `tip_daa`, `verifier`, `pool` (entries, pending, verified, failed), `paid_shards_total`, `shard_budget_pgas`, `blocks_10m`, `blocks_fully_proven_10m`, `shards_proven_10m`, `shards_paid_10m`, `median_proof_lag_s` (median `lag_daa` of the last 10 minutes; the devnet targets one DAA step per second), `provers_10m`, `open_blocks`, `pending_plans`, `evm_rpc`. |
|
||||
| `live_state.proving` | jsonb, updated every 2 s | `supported` (false with `reason` when the node has no proving RPCs or the endpoint is unreachable), `active`, `activation_daa`, `tip_daa`, `verifier`, `pool` (entries, pending, verified, failed), `paid_shards_total`, `shard_budget_pgas`, `blocks_10m`, `blocks_fully_proven_10m`, `shards_proven_10m`, `shards_paid_10m`, `median_proof_lag_s` (median `lag_daa` of the last 10 minutes; the devnet targets one DAA step per second), `provers_10m`, `open_blocks`, `pending_plans`, `evm_rpc`, `status_at` (when `igneum_getProvingStatus` last answered; the API reads an old one as stale). |
|
||||
| `live_state.vendor_share` | jsonb, updated every 60 s (`tools/observer/vendor-share.mjs`, Counter ASIC 3.0 item 7) | `at`, `window_s` (600), `network_hps`, `fleet_reported` (`by_vendor` {nvidia, amd, apple, intel, unknown} with `mhs`, `workers`, `share`; `total_mhs`, `workers`, `machines`, `rows` per worker with label, machine id8, card, mhs, ages), `chain_attributed` (`by_vendor` with `blue_blocks`, `miners`, `share`, `mhs`; `blue_blocks_total`, `mapped_keys`, `unknown_miner_ids`), `by_vendor` (the two readings side by side), `coverage` (1 minus the unknown share). Fleet-reported = the sum of `now=MH/s wall` from each intake-reporting worker's newest STATUS line in the window (our fleet only); chain-attributed = each vote key's share of the window's blue blocks times the network estimate, vendor from the worker that logged the key, else unknown. `docs/benchmarks/repro.md` section 8. |
|
||||
| `live_state.finality` | jsonb, updated every 2 s | `params`, `chain_id`, `next_index`, `finality_active`, `latest_locked_index`, `latest_locked_hash`, `latest_locked_blue_score`, `weights` (`total_weight`, `active_weight`, `voters`, `keys[]` with `id`, `blocks`, `voter`, `participation`, `stripped_until_daa`, `revealed`). |
|
||||
|
||||
|
|
|
|||
|
|
@ -863,6 +863,7 @@ const provers = new Set(); // prover id8s seen this run (one event each)
|
|||
let provingSupported = null; // null unknown, false = no RPC, true
|
||||
let provingRecheckAt = 0;
|
||||
let provingStatus = null; // the last igneum_getProvingStatus answer
|
||||
let provingStatusAt = 0; // when it was read (ms); live_state.proving.status_at, the API's status_age_s
|
||||
let provingReason = null; // why supported is false
|
||||
let provingWasActive = null;
|
||||
let plansBusy = false, recordsBusy = false, firstPaidSeen = false;
|
||||
|
|
@ -887,7 +888,7 @@ async function provingTick() {
|
|||
try {
|
||||
const s = await evm('igneum_getProvingStatus');
|
||||
if (provingSupported !== true) log(`proving RPCs available at ${EVM} (activation ${s.activationDaa === null ? 'not set' : hx(s.activationDaa)}, verifier ${s.verifier})`);
|
||||
provingSupported = true; provingReason = null; provingStatus = s;
|
||||
provingSupported = true; provingReason = null; provingStatus = s; provingStatusAt = Date.now();
|
||||
if (provingWasActive === false && s.active) recordEvent('proving', `Proving layer active: payouts start at DAA ${hx(s.activationDaa)}`);
|
||||
provingWasActive = !!s.active;
|
||||
} catch (e) {
|
||||
|
|
@ -1017,6 +1018,8 @@ async function provingState() {
|
|||
// DAA score of the carrying chain block minus the proven block's; the devnet targets one DAA step per second
|
||||
median_proof_lag_s: st.median_lag === null || st.median_lag === undefined ? null : Math.round(st.median_lag * 10) / 10,
|
||||
provers_10m: st.provers ?? 0, open_blocks: openBlocks.size, pending_plans: pendingPlans.size, evm_rpc: EVM,
|
||||
// 8 October 2026: when the status above was read, so the API can call an old snapshot stale (site/lib/proving-health.mjs)
|
||||
status_at: provingStatusAt ? new Date(provingStatusAt).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,17 @@ test('the four empty cases (known-failed on 2.0.5): loading under 10 s, excluded
|
|||
for (const s of [Dag.shardWords(block({ ts: NOW - 4_000 }), NOW), Dag.shardWords(block({ color: 'red' }), NOW)]) assert.deepEqual(s.items, []);
|
||||
assert.equal(/no shard plan yet/.test(src), false, 'the old words are gone from the scene');
|
||||
});
|
||||
// 8 October 2026 (main's order: the honest empty wording): a chain block whose plan the observer has read (block.number set, the
|
||||
// normalised block carries plan: true) and which has no shards reads "no shards in this block", whatever its age; a block whose plan
|
||||
// is not read keeps the loading and late sentences. Known-failed on 2.0.6, which had no plan fact and said "no shard plan after 42 s".
|
||||
test('a chain block with a known, empty plan says "no shards in this block"; an unread plan stays loading or late', () => {
|
||||
assert.equal(Dag.shardWords(block({ plan: true }), NOW).summary, 'no shards in this block');
|
||||
assert.equal(Dag.shardWords(block({ plan: true, ts: NOW - 4_000 }), NOW).summary, 'no shards in this block');
|
||||
assert.equal(Dag.shardWords(block({ plan: false }), NOW).summary, 'no shard plan after 42 s');
|
||||
assert.equal(Dag.shardWords(block({ plan: true, chain: false }), NOW).summary, 'off the selected chain, no shards planned');
|
||||
// the plan fact comes from the feed's number: a normalised block with number 25181 carries plan true, one with null carries false
|
||||
assert.equal(/plan:finite\(b\.number\)/.test(src), true, 'normalize() reads plan from block.number');
|
||||
});
|
||||
|
||||
test('a planned shard with no prover is awaiting one, with the block age; assigned, proving, verified and paid as before', (t) => {
|
||||
const sh = [{ i: 0, n: 4, state: 'planned', prover: null }, { i: 1, n: 4, state: 'planned', prover: 'faa34a1a' }, { i: 2, n: 4, state: 'proving', prover: 'b2' }, { i: 3, n: 4, state: 'paid', prover: 'c3', payout: 4.5532 }];
|
||||
|
|
|
|||
Loading…
Reference in a new issue