143 lines
13 KiB
JavaScript
143 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
// AERE Device Reputation (roadmap 26, 2026-10-20): a DETERMINISTIC function, published with its formula (README.md, "The reputation
|
|
// formula"), over the facts the operation verifier derived from a log (operation.mjs, `facts`). It returns the figures AND the evidence
|
|
// they come from (the record of each window, the gaps, the proofs of equivocation, the retirements), never one opaque number. It is not a
|
|
// token, it is not traded, and it is written on no chain as a value: anyone who holds the log recomputes it.
|
|
//
|
|
// Integers only. Every time is a whole number of milliseconds since 1970, every division is an exact integer division (idiv), and the
|
|
// bounds below keep each value a safe integer, so a second implementation in any language gives the same text for the same input. The
|
|
// second implementation is reputation.py (Python, standard library only); the vectors in vectori/ are run against both.
|
|
//
|
|
// node reputation.mjs --capabilities | <vector.json> | --batch <list.json> the conformance contract (../aere-conformance)
|
|
// node reputation.mjs --facts <facts.json> --from <time> --to <time> --window <seconds> [--json]
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { canonical } from '../identity/identity.mjs';
|
|
|
|
export const KIND = 'aere-device-reputation';
|
|
export const FORMULA = 'aere-device-reputation/1';
|
|
export const FACTS_KIND = 'aere-device-operation-facts';
|
|
export const STATES = ['operating', 'idle', 'fault', 'maintenance'];
|
|
export const VERDICTS = ['VALID', 'PARTIAL', 'INVALID'];
|
|
export const MAX_RECORDS = 100000, MAX_KEYS = 16, MAX_EQUIVOCATIONS = 4096, MAX_CHAINS = 64, MAX_WINDOWS = 100000;
|
|
export const MAX_TIME = 2 ** 52; // ms (the year 142,000): with it every sum below stays a safe integer
|
|
export const MAX_WINDOW_S = 31622400; // 366 days
|
|
export const REGULI = ['form', 'figures'];
|
|
const H32 = /^0x[0-9a-f]{64}$/, DEV = /^aere-device:[0-9a-f]{40}$/, AERE_ID = /^aere-id:[0-9a-f]{40}$/, NUME = /^[a-z][a-z0-9-]{0,63}$/;
|
|
const ob = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
|
|
const exact = (o, chei) => ob(o) && Object.keys(o).length === chei.length && chei.every((k) => Object.prototype.hasOwnProperty.call(o, k));
|
|
const intreg = (x, min = 0) => Number.isSafeInteger(x) && x >= min;
|
|
const nulSauIntreg = (x) => x === null || intreg(x);
|
|
const sir = (x, re) => typeof x === 'string' && re.test(x);
|
|
const crescator = (a, strict = true) => a.every((x, i) => i === 0 || (strict ? a[i - 1] < x : a[i - 1] <= x));
|
|
/** a / b rounded down, for whole a >= 0 and b > 0: exact (a % b is exact, and a - a % b is a multiple of b). */
|
|
const idiv = (a, b) => (a - (a % b)) / b;
|
|
const sha256 = (t) => '0x' + crypto.createHash('sha256').update(Buffer.from(t, 'utf8')).digest('hex');
|
|
|
|
/** Why the facts are not of the form this function reads, or null when they are. */
|
|
export function factsForm(f) {
|
|
if (!exact(f, ['v', 'kind', 'verdict', 'rules', 'unjudged', 'manufacturer', 'keys', 'records', 'uncounted', 'equivocations', 'retirements', 'chains'])) return 'facts: not exactly the members v, kind, verdict, rules, unjudged, manufacturer, keys, records, uncounted, equivocations, retirements, chains';
|
|
if (f.v !== 1 || f.kind !== FACTS_KIND) return `facts: not ${FACTS_KIND} version 1`;
|
|
if (!VERDICTS.includes(f.verdict)) return 'facts: verdict is VALID, PARTIAL or INVALID';
|
|
for (const k of ['rules', 'unjudged']) if (!(Array.isArray(f[k]) && f[k].length <= 64 && f[k].every((x) => sir(x, NUME)) && crescator(f[k]))) return `facts: ${k} is a sorted list of distinct names`;
|
|
if (!(f.manufacturer === null || sir(f.manufacturer, AERE_ID))) return 'facts: manufacturer is an aere-id or null';
|
|
if (!(Array.isArray(f.keys) && f.keys.length <= MAX_KEYS && f.keys.every((x) => sir(x, DEV)))) return `facts: keys is a list of at most ${MAX_KEYS} device ids`;
|
|
const R = f.records;
|
|
if (!(Array.isArray(R) && R.length <= MAX_RECORDS)) return `facts: records is a list of at most ${MAX_RECORDS}`;
|
|
for (const [i, r] of R.entries()) {
|
|
if (!exact(r, ['seq', 'hash', 'at', 'lo', 'hi', 'state', 'counter'])) return `facts: record ${i} is not exactly seq, hash, at, lo, hi, state, counter`;
|
|
if (!(intreg(r.seq) && sir(r.hash, H32) && intreg(r.at) && r.at <= MAX_TIME && nulSauIntreg(r.lo) && nulSauIntreg(r.hi) && (r.lo === null || r.lo <= MAX_TIME) && (r.hi === null || r.hi <= MAX_TIME) && STATES.includes(r.state) && intreg(r.counter))) return `facts: record ${i} has a member of another form`;
|
|
if (i && !(R[i - 1].seq < r.seq && R[i - 1].at <= r.at && R[i - 1].counter <= r.counter)) return `facts: record ${i} does not follow the one before it (seq grows, at and counter do not go down)`;
|
|
}
|
|
if (!intreg(f.uncounted)) return 'facts: uncounted is a whole number';
|
|
const E = f.equivocations;
|
|
if (!(Array.isArray(E) && E.length <= MAX_EQUIVOCATIONS && E.every((e) => exact(e, ['seq', 'a', 'b']) && intreg(e.seq) && sir(e.a, H32) && sir(e.b, H32) && e.a !== e.b))) return 'facts: equivocations is a list of { seq, a, b } with two different record hashes';
|
|
if (!crescator(E.map((e) => [String(e.seq).padStart(16, '0'), e.a, e.b].join(' ')))) return 'facts: equivocations are sorted by seq, then a, then b, and distinct';
|
|
if (!(Array.isArray(f.retirements) && f.retirements.length <= MAX_KEYS && f.retirements.every((x) => exact(x, ['deviceId', 'at']) && sir(x.deviceId, DEV) && (x.at === null || (intreg(x.at) && x.at <= MAX_TIME))))) return 'facts: retirements is a list of { deviceId, at }';
|
|
if (!(exact(f.chains, ['anchors', 'beacons']) && ['anchors', 'beacons'].every((k) => Array.isArray(f.chains[k]) && f.chains[k].length <= MAX_CHAINS && f.chains[k].every((x) => intreg(x, 1)) && crescator(f.chains[k])))) return 'facts: chains is { anchors, beacons }, each a sorted list of chain ids';
|
|
return null;
|
|
}
|
|
/** Why the parameters are not of the form this function reads, or null when they are. */
|
|
export function paramsForm(p) {
|
|
if (!exact(p, ['from', 'to', 'windowS'])) return 'params: not exactly the members from, to, windowS';
|
|
if (!(intreg(p.from) && intreg(p.to) && p.from < p.to && p.to <= MAX_TIME)) return `params: from and to are milliseconds since 1970, from before to, to at most ${MAX_TIME}`;
|
|
if (!(intreg(p.windowS, 1) && p.windowS <= MAX_WINDOW_S)) return `params: windowS is a whole number of seconds from 1 to ${MAX_WINDOW_S}`;
|
|
if (idiv(p.to - p.from + p.windowS * 1000 - 1, p.windowS * 1000) > MAX_WINDOWS) return `params: at most ${MAX_WINDOWS} windows`;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The reputation of a machine over [from, to), in windows of windowS seconds, from the facts of its operation log. See README.md for the
|
|
* formula in words; this is it in code. Throws an error that begins with 'aere-device-reputation:' when the input is not of its form.
|
|
*/
|
|
export function reputation(facts, params) {
|
|
const e = factsForm(facts) || paramsForm(params); if (e) throw new Error('aere-device-reputation: ' + e);
|
|
const { from, to, windowS } = params, W = windowS * 1000;
|
|
const n = idiv(to - from + W - 1, W);
|
|
const claimed = new Array(n).fill(null), witnessed = new Array(n).fill(null);
|
|
const byState = { operating: 0, idle: 0, fault: 0, maintenance: 0 };
|
|
let inRange = 0;
|
|
for (const r of facts.records) { // in order of seq: the first record of a window is the one with the lowest seq
|
|
if (r.at >= from && r.at < to) { inRange++; byState[r.state]++; const i = idiv(r.at - from, W); if (claimed[i] === null) claimed[i] = r.seq; }
|
|
if (r.lo !== null && r.hi !== null && r.lo >= from && r.lo <= r.hi && r.hi < to) {
|
|
const i = idiv(r.lo - from, W), fin = Math.min(from + (i + 1) * W, to);
|
|
if (r.hi < fin && witnessed[i] === null) witnessed[i] = r.seq;
|
|
}
|
|
}
|
|
const gaps = [];
|
|
for (let i = 0; i < n;) { if (claimed[i] !== null) { i++; continue; } let j = i; while (j < n && claimed[j] === null) j++; gaps.push([i, j - i]); i = j; }
|
|
const cc = claimed.filter((x) => x !== null).length, wc = witnessed.filter((x) => x !== null).length;
|
|
const primul = facts.records.length ? facts.records[0].at : null;
|
|
const hiMin = facts.records.reduce((m, r) => (r.hi !== null && (m === null || r.hi < m) ? r.hi : m), null);
|
|
const evidence = [];
|
|
for (let i = 0; i < n; i++) if (claimed[i] !== null || witnessed[i] !== null) evidence.push([i, claimed[i], witnessed[i]]);
|
|
return { v: 1, kind: KIND, formula: FORMULA, factsHash: sha256(canonical(facts)), params: { from, to, windowS },
|
|
verdict: facts.verdict, rules: facts.rules, unjudged: facts.unjudged, manufacturer: facts.manufacturer, keys: facts.keys, chains: facts.chains,
|
|
records: { inRange, byState, uncounted: facts.uncounted },
|
|
availability: { windows: n, claimed: cc, witnessed: wc, claimedPerMille: idiv(cc * 1000, n), witnessedPerMille: idiv(wc * 1000, n) },
|
|
gaps: { count: gaps.length, longestWindows: gaps.reduce((m, g) => Math.max(m, g[1]), 0), list: gaps },
|
|
equivocations: { count: facts.equivocations.length, list: facts.equivocations.map((x) => [x.seq, x.a, x.b]) },
|
|
retirements: { count: facts.retirements.length, list: facts.retirements.map((x) => [x.deviceId, x.at]) },
|
|
age: { claimedS: primul !== null && primul < to ? idiv(to - primul, 1000) : null, witnessedS: hiMin !== null && hiMin < to ? idiv(to - hiMin, 1000) : null },
|
|
evidence: { windows: evidence } };
|
|
}
|
|
|
|
/**
|
|
* A reputation as somebody states it, checked: does it reproduce from the facts and the parameters? { verdict: VALID | INVALID, rules,
|
|
* reason }. INVALID `form`: the facts or the parameters are not of the form; INVALID `figures`: the statement is not what the formula gives.
|
|
*/
|
|
// Each check of a rule carries a PAZA marker (2026-10-20): the conformance suite (../aere-conformance) derives its rule table from them and
|
|
// plants each rule out in a copy (the marked check made true) to show that the vectors which expect the rule, and only they, then fail.
|
|
export function checkReputation(input) {
|
|
if (!(exact(input, ['facts', 'params', 'claimed']) /*PAZA:form*/)) return { verdict: 'INVALID', rules: ['form'], reason: 'the input is not exactly { facts, params, claimed }' };
|
|
const e = factsForm(input.facts) || paramsForm(input.params); if (!(e === null /*PAZA:form*/)) return { verdict: 'INVALID', rules: ['form'], reason: e };
|
|
let egal = false; try { egal = canonical(input.claimed) === canonical(reputation(input.facts, input.params)); } catch { egal = false; }
|
|
return (egal /*PAZA:figures*/) ? { verdict: 'VALID', rules: [], reason: 'the stated reputation is what the formula gives' } : { verdict: 'INVALID', rules: ['figures'], reason: 'the stated reputation is not what the formula gives for these facts and parameters' };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- the conformance contract (../aere-conformance/conformance.mjs)
|
|
export const ARTIFACT = 'device-reputation';
|
|
export const RULE_IDS = REGULI.map((r) => 'reputation.' + r);
|
|
export function conformanceVerdict(input) {
|
|
const c = checkReputation(input), failed = c.rules.map((r) => 'reputation.' + r);
|
|
const nm = (level) => ({ level, state: 'NOT_MEASURED', rules: [], cause: 'not-judged', reason: 'the reputation is a computation over facts: it carries no signature and no notarization of its own' });
|
|
return { v: 1, kind: 'aere-conformance-verdict', artifact: ARTIFACT, verdict: c.verdict,
|
|
levels: [{ level: 'integrity', state: failed.length ? 'FAILED' : 'PASSED', rules: failed, reason: c.reason }, nm('authenticity'), nm('anchoring'), nm('finality')], failed };
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const a = process.argv.slice(2); const opt = (n) => { const i = a.indexOf(n); return i >= 0 ? a[i + 1] : undefined; };
|
|
const timp = (s) => (/^\d+$/.test(String(s)) ? Number(s) : Date.parse(s));
|
|
try {
|
|
if (a[0] === '--capabilities') console.log(JSON.stringify({ name: 'aere-device-reputation (JavaScript, reputation.mjs)', artifacts: [ARTIFACT], rules: RULE_IDS, online: false, batch: true }));
|
|
else if (a[0] === '--batch') { for (const e of JSON.parse(fs.readFileSync(a[1], 'utf8'))) { const v = JSON.parse(fs.readFileSync(e.file, 'utf8')); console.log(JSON.stringify(v.artifact === ARTIFACT ? conformanceVerdict(v.input) : { error: 'not a ' + ARTIFACT + ' vector' })); } }
|
|
else if (opt('--facts')) {
|
|
const r = reputation(JSON.parse(fs.readFileSync(opt('--facts'), 'utf8')), { from: timp(opt('--from')), to: timp(opt('--to')), windowS: Number(opt('--window')) });
|
|
console.log(a.includes('--json') ? JSON.stringify(r, null, 1) : canonical(r));
|
|
} else if (a[0] && !a[0].startsWith('--')) { const v = JSON.parse(fs.readFileSync(a[0], 'utf8')); if (v.artifact !== ARTIFACT) throw new Error('not a ' + ARTIFACT + ' vector'); console.log(JSON.stringify(conformanceVerdict(v.input))); }
|
|
else { console.error('usage: node reputation.mjs --capabilities | <vector.json> | --batch <list.json> | --facts <facts.json> --from <time> --to <time> --window <seconds> [--json]'); process.exitCode = 3; }
|
|
} catch (e) { console.error('reputation: ' + e.message); process.exitCode = 3; }
|
|
}
|