163 lines
18 KiB
JavaScript
163 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
// genereaza-vectori.mjs: the vectors of the reputation, in the form of the conformance suite (../aere-conformance: the same vector, the
|
|
// same index, the same one-operation mutations), so that any implementation of the formula, in any language, is run against them with
|
|
// the suite's own runner:
|
|
// node ../aere-conformance/conformance.mjs --vectors vectori --impl "node reputation.mjs"
|
|
// node ../aere-conformance/conformance.mjs --vectors vectori --impl "python3 reputation.py"
|
|
//
|
|
// No vector is typed by hand. fixturi/sursa-vectori.json holds operation logs made ONCE by the producers (`--proaspat`: lume-operation.mjs,
|
|
// fresh keys, discarded in that call; synthetic blocks and the readings a verifier would make, said so in the file), with the verifier's
|
|
// options for each. From it every vector is DERIVED: the operation verifier gives the facts, the formula gives the reputation (VALID), and
|
|
// one operation on a VALID input gives each INVALID one, checked here to be refused for exactly the rule it was made for.
|
|
// node genereaza-vectori.mjs --proaspat makes the source anew, then the vectors (every vector changes: the records are signed anew)
|
|
// node genereaza-vectori.mjs derives vectori/ from the source
|
|
// node genereaza-vectori.mjs --verifica derives and compares byte for byte: LA ZI (exit 0), DIFERIT (exit 1)
|
|
import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url';
|
|
import * as O from './operation.mjs';
|
|
import { reputation, conformanceVerdict, ARTIFACT, RULE_IDS, MAX_TIME } from './reputation.mjs';
|
|
import { lume, ORA } from './lume-operation.mjs';
|
|
|
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
|
export const SURSA = path.join(AICI, 'fixturi', 'sursa-vectori.json'), DIR = path.join(AICI, 'vectori');
|
|
const sha256 = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex');
|
|
const clona = (x) => JSON.parse(JSON.stringify(x));
|
|
const text = (v) => JSON.stringify(v, null, 1) + '\n';
|
|
|
|
/** One operation (set, add, remove) at one JSON path of a vector's input: the mutation form of the conformance suite. */
|
|
export function aplica(input, op) {
|
|
const c = clona(input); let o = c; const p = op.path;
|
|
for (const k of p.slice(0, -1)) { if (o == null || !(k in Object(o))) throw new Error('mutation path not in the base: ' + p.join('.')); o = o[k]; }
|
|
const k = p[p.length - 1];
|
|
if (op.op === 'set') { if (!(k in Object(o))) throw new Error('set: nothing at ' + p.join('.')); o[k] = clona(op.value); }
|
|
else if (op.op === 'add') { if (k in Object(o)) throw new Error('add: something already at ' + p.join('.')); o[k] = clona(op.value); }
|
|
else if (op.op === 'remove') { if (!(k in Object(o))) throw new Error('remove: nothing at ' + p.join('.')); if (Array.isArray(o)) o.splice(k, 1); else delete o[k]; }
|
|
else throw new Error('unknown op ' + op.op);
|
|
return c;
|
|
}
|
|
|
|
function proaspat() {
|
|
const W = lume(), sc = [];
|
|
const add = (name, title, j, o = {}) => sc.push({ name, title, log: j.log, options: W.optiuni(j, o) });
|
|
const j1 = W.jurnal();
|
|
add('honest', '24 hourly records, a rotation to a second key after record 12, four anchors read, every block read', j1);
|
|
add('sparse', 'records in hours 0-3, 8-10 and 20-23 only, a rotation after the sixth, three anchors', W.jurnal({ ore: [0, 1, 2, 3, 8, 9, 10, 20, 21, 22, 23], rotireLa: 5, ancoreLa: [3, 6, 10] }));
|
|
add('equivocation', 'the honest log, and beside it a second record the first key signed for position 8', j1, { otherRecords: [W.resemneaza(j1.log.records[8], W.dev1, (st) => { st.state = 'fault'; })] });
|
|
add('retired', 'the honest log; the manufacturer retired the first key half an hour after the rotation', j1, { statusRecords: [W.stare(W.M1, 'retired', W.timpul(12) + 1800000)] });
|
|
add('witnessed', '24 hourly records, each anchored and read ten minutes after it, each naming a block read two minutes before it', W.jurnal({ ancoreLa: Array.from({ length: 24 }, (_, i) => i) }));
|
|
add('unwitnessed','24 hourly records with no anchor and no block named: the time is the device\'s word', W.jurnal({ ancoreLa: [], dupa: false }));
|
|
const recs = [...j1.log.records]; recs[15] = W.resemneaza(recs[15], W.dev2, (st) => { st.counter = 50; });
|
|
add('refused', 'the honest log with record 15 signed again with a counter lower than the one before', { ...j1, log: { ...j1.log, records: recs } });
|
|
add('empty', 'a log that carries no record', { log: O.newLog({ manufacture: W.M1 }), readings: [], beacons: [] });
|
|
return { v: 1, kind: 'aere-device-operation-vector-source', made: new Date().toISOString(), T0: W.T0,
|
|
note: 'made once by genereaza-vectori.mjs --proaspat: every key was made and discarded in that call; the blocks are synthetic (the hash of a block is the sha256 of its number) and the readings of blocks and anchors stand for the readings a verifier makes on a chain',
|
|
scenarios: sc };
|
|
}
|
|
|
|
export function construieste(sursa = JSON.parse(fs.readFileSync(SURSA, 'utf8'))) {
|
|
const T0 = sursa.T0, V = [], baza = new Map();
|
|
// 2026-10-20 (enrolment in the conformance suite): `quarter` puts the upper bound of an anchored record (its anchor read ten minutes after
|
|
// it, at hour + 15 min) EXACTLY at the end of the window its lower bound falls in, so it is not witnessed there; `cut` ends the range at the
|
|
// very time of record 12, which is then not in it. Before them no vector told `<` from `<=` at either boundary (a second implementation with
|
|
// `<=` passed all 87): measured by planting it
|
|
const PARAMS = { h1: { from: T0, to: T0 + 24 * ORA, windowS: 3600 }, half: { from: T0, to: T0 + 24 * ORA, windowS: 1800 }, shift: { from: T0 + 6 * ORA, to: T0 + 30 * ORA, windowS: 7200 },
|
|
before: { from: T0 - 6 * ORA, to: T0 + 12 * ORA, windowS: 3600 }, day: { from: T0, to: T0 + 24 * ORA, windowS: 86400 },
|
|
quarter: { from: T0, to: T0 + 24 * ORA, windowS: 900 }, cut: { from: T0, to: T0 + 12 * ORA + 300000, windowS: 3600 } };
|
|
const expected = (failed) => ({ verdict: failed.length ? 'INVALID' : 'VALID', failed, levels: { integrity: failed.length ? 'FAILED' : 'PASSED', authenticity: 'NOT_MEASURED', anchoring: 'NOT_MEASURED', finality: 'NOT_MEASURED' } });
|
|
const pune = (v) => { const c = conformanceVerdict(v.input); if (c.verdict !== v.expected.verdict || c.failed.join() !== v.expected.failed.join()) throw new Error(`vector ${v.id}: the formula gives ${c.verdict} [${c.failed}], the vector expects ${v.expected.verdict} [${v.expected.failed}]`); V.push(v); };
|
|
for (const s of sursa.scenarios) {
|
|
const r = O.verifyOperation(s.log, s.options);
|
|
for (const [p, params] of Object.entries(PARAMS)) {
|
|
const id = `${ARTIFACT}/${s.name}.${p}.valid`, input = { facts: r.facts, params, claimed: reputation(r.facts, params) };
|
|
baza.set(id, input);
|
|
pune({ v: 1, kind: 'aere-conformance-vector', id, artifact: ARTIFACT, title: `${s.title}; windows of ${params.windowS} s from ${new Date(params.from).toISOString()} to ${new Date(params.to).toISOString()}`,
|
|
source: `fixturi/sursa-vectori.json, scenario ${s.name}: the operation verifier gives ${r.verdict} (${r.facts.records.length} record(s) counted), the formula gives the reputation`, requires: [], exercises: RULE_IDS, input, expected: expected([]) });
|
|
}
|
|
}
|
|
const invalid = (nume, title, din, op, regula) => {
|
|
const id = `${ARTIFACT}/${din}.${nume}`, b = `${ARTIFACT}/${din}.valid`;
|
|
pune({ v: 1, kind: 'aere-conformance-vector', id, artifact: ARTIFACT, title, source: `derived from ${b}`, requires: [], exercises: RULE_IDS, base: b, mutation: op, input: aplica(baza.get(b), op), expected: expected([regula]) });
|
|
};
|
|
const H = baza.get(`${ARTIFACT}/honest.h1.valid`), alt = (h) => '0x' + (h[2] === '0' ? '1' : '0') + h.slice(3);
|
|
const fig = (nume, title, din, op) => invalid(nume, title, din, op, 'reputation.figures');
|
|
fig('claimed-windows', 'one more window said to be covered than the records show', 'honest.h1', { op: 'set', path: ['claimed', 'availability', 'claimed'], value: H.claimed.availability.claimed + 1 });
|
|
fig('witnessed-permille', 'the witnessed availability said one per mille higher', 'honest.h1', { op: 'set', path: ['claimed', 'availability', 'witnessedPerMille'], value: H.claimed.availability.witnessedPerMille + 1 });
|
|
fig('gap-count', 'one gap more than the windows show', 'honest.h1', { op: 'set', path: ['claimed', 'gaps', 'count'], value: H.claimed.gaps.count + 1 });
|
|
fig('fault-count', 'one fault record more than the log holds', 'honest.h1', { op: 'set', path: ['claimed', 'records', 'byState', 'fault'], value: H.claimed.records.byState.fault + 1 });
|
|
fig('age', 'the age said one second longer', 'honest.h1', { op: 'set', path: ['claimed', 'age', 'claimedS'], value: H.claimed.age.claimedS + 1 });
|
|
fig('witnessed-age', 'the witnessed age left out', 'honest.h1', { op: 'set', path: ['claimed', 'age', 'witnessedS'], value: null });
|
|
fig('facts-hash', 'the reputation names other facts', 'honest.h1', { op: 'set', path: ['claimed', 'factsHash'], value: alt(H.claimed.factsHash) });
|
|
fig('evidence', 'a window said to be covered by another record', 'honest.h1', { op: 'set', path: ['claimed', 'evidence', 'windows', 0, 1], value: H.claimed.evidence.windows[0][1] + 1 });
|
|
fig('verdict', 'the verdict of the log said otherwise', 'honest.h1', { op: 'set', path: ['claimed', 'verdict'], value: H.claimed.verdict === 'VALID' ? 'PARTIAL' : 'VALID' });
|
|
fig('formula', 'another version of the formula named', 'honest.h1', { op: 'set', path: ['claimed', 'formula'], value: 'aere-device-reputation/2' });
|
|
fig('no-evidence', 'the figures without the evidence they come from', 'honest.h1', { op: 'remove', path: ['claimed', 'evidence'] });
|
|
fig('score', 'one opaque number added beside the figures', 'honest.h1', { op: 'add', path: ['claimed', 'score'], value: 97 });
|
|
const E = baza.get(`${ARTIFACT}/equivocation.h1.valid`), R = baza.get(`${ARTIFACT}/retired.h1.valid`), S = baza.get(`${ARTIFACT}/sparse.h1.valid`), U = baza.get(`${ARTIFACT}/unwitnessed.h1.valid`);
|
|
fig('no-equivocation', 'a log with a proof of equivocation said to have none', 'equivocation.h1', { op: 'set', path: ['claimed', 'equivocations', 'count'], value: 0 });
|
|
fig('equivocation-list', 'the proof of equivocation left out of the list', 'equivocation.h1', { op: 'remove', path: ['claimed', 'equivocations', 'list', 0] });
|
|
fig('no-retirement', 'a retired key said not to be', 'retired.h1', { op: 'set', path: ['claimed', 'retirements', 'list'], value: [] });
|
|
fig('uncounted', 'one record fewer said not to be counted', 'retired.h1', { op: 'set', path: ['claimed', 'records', 'uncounted'], value: R.claimed.records.uncounted - 1 });
|
|
fig('gap-length', 'the first gap said one window longer', 'sparse.h1', { op: 'set', path: ['claimed', 'gaps', 'list', 0, 1], value: S.claimed.gaps.list[0][1] + 1 });
|
|
fig('longest-gap', 'the longest gap said one window shorter', 'sparse.h1', { op: 'set', path: ['claimed', 'gaps', 'longestWindows'], value: S.claimed.gaps.longestWindows - 1 });
|
|
fig('witnessed', 'a log with no witness said to be witnessed in one window', 'unwitnessed.h1', { op: 'set', path: ['claimed', 'availability', 'witnessed'], value: 1 });
|
|
const form = (nume, title, din, op) => invalid(nume, title, din, op, 'reputation.form');
|
|
const r0 = H.facts.records[0];
|
|
form('facts-kind', 'facts of another kind', 'honest.h1', { op: 'set', path: ['facts', 'kind'], value: 'aere-device-operation-fact' });
|
|
form('facts-version', 'facts of another version', 'honest.h1', { op: 'set', path: ['facts', 'v'], value: 2 });
|
|
form('facts-version-bool', 'a version written as true (a boolean is not the number 1, in any language)', 'honest.h1', { op: 'set', path: ['facts', 'v'], value: true });
|
|
form('facts-verdict', 'a verdict that is not one of the three', 'honest.h1', { op: 'set', path: ['facts', 'verdict'], value: 'OK' });
|
|
form('rules-unsorted', 'the rules that refused, not in order', 'honest.h1', { op: 'set', path: ['facts', 'rules'], value: ['record-time', 'form'] });
|
|
form('unjudged-name', 'a cause that is not a name', 'honest.h1', { op: 'set', path: ['facts', 'unjudged'], value: ['Not Judged'] });
|
|
form('manufacturer', 'a manufacturer that is not an aere-id', 'honest.h1', { op: 'set', path: ['facts', 'manufacturer'], value: 'aere-id:xyz' });
|
|
form('key', 'a key that is not a device id', 'honest.h1', { op: 'set', path: ['facts', 'keys', 0], value: 'aere-device:' + 'G'.repeat(40) });
|
|
form('record-hash', 'a record hash in upper case', 'honest.h1', { op: 'set', path: ['facts', 'records', 0, 'hash'], value: r0.hash.toUpperCase().replace('0X', '0x') });
|
|
form('record-order', 'a record dated before the one before it', 'honest.h1', { op: 'set', path: ['facts', 'records', 1, 'at'], value: r0.at - 1 });
|
|
form('record-seq', 'two records with seq that does not grow', 'honest.h1', { op: 'set', path: ['facts', 'records', 2, 'seq'], value: 1 });
|
|
form('record-state', 'a state that is not one of the four', 'honest.h1', { op: 'set', path: ['facts', 'records', 0, 'state'], value: 'broken' });
|
|
form('record-lo', 'a lower bound below zero', 'honest.h1', { op: 'set', path: ['facts', 'records', 0, 'lo'], value: -1 });
|
|
form('record-counter-bool', 'a counter written as true', 'honest.h1', { op: 'set', path: ['facts', 'records', 0, 'counter'], value: true });
|
|
form('record-at-text', 'a time written as text', 'honest.h1', { op: 'set', path: ['facts', 'records', 0, 'at'], value: String(r0.at) });
|
|
form('record-at-bound', 'a time past the bound that keeps every sum exact', 'honest.h1', { op: 'set', path: ['facts', 'records', 0, 'at'], value: MAX_TIME + 1 });
|
|
form('record-missing-member', 'a record without its upper bound member', 'honest.h1', { op: 'remove', path: ['facts', 'records', 0, 'hi'] });
|
|
form('record-member-more', 'a record with a member more', 'honest.h1', { op: 'add', path: ['facts', 'records', 0, 'note'], value: 'x' });
|
|
form('uncounted', 'a negative number of records not counted', 'honest.h1', { op: 'set', path: ['facts', 'uncounted'], value: -1 });
|
|
form('chains', 'a chain id listed twice', 'honest.h1', { op: 'set', path: ['facts', 'chains', 'anchors'], value: [28001, 28001] });
|
|
form('equivocation-same', 'a proof of equivocation that names one record twice', 'equivocation.h1', { op: 'set', path: ['facts', 'equivocations', 0, 'b'], value: E.facts.equivocations[0].a });
|
|
form('retirement-at', 'a retirement time that is not a whole number', 'retired.h1', { op: 'set', path: ['facts', 'retirements', 0, 'at'], value: 1.5 });
|
|
form('params-order', 'from not before to', 'honest.h1', { op: 'set', path: ['params', 'from'], value: H.params.to });
|
|
form('params-window-zero', 'a window of zero seconds', 'honest.h1', { op: 'set', path: ['params', 'windowS'], value: 0 });
|
|
form('params-window-fraction', 'a window of a second and a half', 'honest.h1', { op: 'set', path: ['params', 'windowS'], value: 1.5 });
|
|
form('params-windows', 'more windows than are counted', 'honest.h1', { op: 'set', path: ['params'], value: { from: T0, to: T0 + 30 * ORA, windowS: 1 } });
|
|
form('params-member-more', 'parameters with a member more', 'honest.h1', { op: 'add', path: ['params', 'tz'], value: 'UTC' });
|
|
form('input-member-more', 'an input with a member more', 'honest.h1', { op: 'add', path: ['extra'], value: 1 });
|
|
return V;
|
|
}
|
|
|
|
export function fisiere(vectori) {
|
|
const out = new Map(); const reguli = {};
|
|
for (const v of vectori) { if (out.has(v.id + '.json')) throw new Error('two vectors with the id ' + v.id); out.set(v.id + '.json', text(v)); for (const r of v.expected.failed) (reguli[r] || (reguli[r] = [])).push(v.id); }
|
|
const artifacte = {}; for (const v of vectori) artifacte[v.artifact] = (artifacte[v.artifact] || 0) + 1;
|
|
const index = { v: 1, kind: 'aere-conformance-vector-index', count: vectori.length, artifacts: artifacte, offline: vectori.length, online: 0,
|
|
rules: Object.fromEntries(Object.keys(reguli).sort().map((r) => [r, reguli[r]])), vectors: vectori.map((v) => ({ id: v.id, artifact: v.artifact, verdict: v.expected.verdict, requires: v.requires, sha256: sha256(Buffer.from(out.get(v.id + '.json'), 'utf8')) })) };
|
|
out.set('INDEX.json', text(index)); return out;
|
|
}
|
|
const peDisc = (d, pre = '') => (fs.existsSync(d) ? fs.readdirSync(d, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? peDisc(path.join(d, e.name), pre + e.name + '/') : [pre + e.name])) : []);
|
|
/** compares what the source derives now with vectori/ on disk: the list of differences (empty: up to date) */
|
|
export function diferente(out = fisiere(construieste()), dir = DIR) {
|
|
const d = [];
|
|
for (const [f, t] of out) { const p = path.join(dir, f); if (!fs.existsSync(p) || fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n') !== t) d.push('DIFERIT: ' + f); }
|
|
for (const f of peDisc(dir)) if (!out.has(f)) d.push('STRAIN: ' + f);
|
|
return d;
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const a = process.argv.slice(2);
|
|
if (a.includes('--proaspat')) { fs.mkdirSync(path.dirname(SURSA), { recursive: true }); fs.writeFileSync(SURSA, text(proaspat())); console.log('written ' + path.relative(AICI, SURSA)); }
|
|
const out = fisiere(construieste());
|
|
if (a.includes('--verifica')) { const d = diferente(out); for (const x of d) console.log(x); console.log(d.length ? `DIFERIT (${d.length})` : `LA ZI (${out.size - 1} vectors + INDEX.json)`); process.exitCode = d.length ? 1 : 0; }
|
|
else {
|
|
for (const f of peDisc(DIR)) if (!out.has(f)) fs.rmSync(path.join(DIR, f));
|
|
for (const [f, t] of out) { const p = path.join(DIR, f); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, t); }
|
|
const idx = JSON.parse(out.get('INDEX.json')); console.log(`written ${idx.count} vectors: ` + Object.entries(idx.rules).map(([k, v]) => `${k} ${v.length}`).join(', ') + `, ${idx.vectors.filter((v) => v.verdict === 'VALID').length} VALID`);
|
|
}
|
|
}
|