Live proving feed: the page says when its shard states are stale, and a chain block with an empty plan reads "no shards in this block" (8 October 2026, main's order: Josh wants to watch proving and shards in real time; a stale snapshot must read as stale). Reading of the day: /api/live's proven and paid states come from the Devnet 3 observer's own node on igneum-build-1 (igneum-observer-node-dn3, EVM 26850), not from hub-1; that node stopped crediting payouts at 23:57Z on 7 October (paidShards frozen at 7,304 while blocks carried 8,457 proof records by 03:00Z and hub-1 counted 11,861 by 08:56Z), and the page kept showing the paid total as fresh. site/lib/proving-health.mjs (tests known-failed first: "Cannot find module", then 10 green on build-2): stale when the observer's last status read is over 60 s old (status_at, new in the observer's live_state.proving), when the node's tip trails the newest block by over 300 DAA, or when blocks carried proof records in the last 10 minutes and no shard moved to verified or paid for 10 minutes (the blind-to-records class); a quiet chain is not stale for lack of credits. /api/live carries stale, stale_reason, status_age_s, tip_lag_daa, records_10m and last_change_at on proving (two more indexed readings; scene/feed-contract.json lists the keys; the recorded fixture carries them). /live: the Proven tile's line reads "proving feed stale: ..." in ember, else "... last credit N s ago"; the inspector's count adds "feed stale". Scene 2.0.7: 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"; loading and late stay for an unread plan (shard-words test, known-failed on 2.0.6). Gate: the health test joins the site unit-test line.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
1895ce44d6
commit
b6ec58ad85
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