aere-quantum/device-operation/reputation.py

325 lines
15 KiB
Python

#!/usr/bin/env python3
# AERE Device Reputation, the second implementation (roadmap 26, 2026-10-20). Python 3, standard library only, written from the formula in
# README.md ("The reputation formula"), not translated line by line from reputation.mjs. Integers only: the same input gives the same text.
#
# python3 reputation.py --capabilities | <vector.json> | --batch <list.json> the conformance contract (../aere-conformance)
# python3 reputation.py --facts <facts.json> --from <ms> --to <ms> --window <seconds> the reputation, as canonical JSON
# python3 reputation.py --many <list.json> a list of { facts, params }: one line each, the canonical reputation or FORM: <reason>
import hashlib
import json
import json.decoder
import re
import sys
KIND = 'aere-device-reputation'
FORMULA = 'aere-device-reputation/1'
FACTS_KIND = 'aere-device-operation-facts'
STATES = ['operating', 'idle', 'fault', 'maintenance']
VERDICTS = ['VALID', 'PARTIAL', 'INVALID']
MAX_RECORDS, MAX_KEYS, MAX_EQUIVOCATIONS, MAX_CHAINS, MAX_WINDOWS = 100000, 16, 4096, 64, 100000
MAX_TIME = 2 ** 52
MAX_WINDOW_S = 31622400
SAFE = 2 ** 53 - 1
ARTIFACT = 'device-reputation'
RULE_IDS = ['reputation.form', 'reputation.figures']
H32 = re.compile(r'0x[0-9a-f]{64}')
DEV = re.compile(r'aere-device:[0-9a-f]{40}')
AERE_ID = re.compile(r'aere-id:[0-9a-f]{40}')
NUME = re.compile(r'[a-z][a-z0-9-]{0,63}')
NUMBER = re.compile(r'-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?')
WS = re.compile(r'[ \t\n\r]*')
def num(s):
# a JSON number as JavaScript reads it: ONE double, the nearest to the text. A whole safe number is that integer (JSON.parse('1.0') is 1);
# any other stays a float: 9007199254740993 is the double 2^53, a whole number of 5000 digits is Infinity. Python's int() would read
# the digits exactly, and refuses more than 4300 of them: a text JavaScript judges would not even be read.
f = float(s)
return int(f) if f.is_integer() and abs(f) <= SAFE else f
def loads(s):
# JSON as JavaScript's JSON.parse reads it, WITHOUT recursion: json.loads recurses once per level and stops with a RecursionError on a
# text nested some thousands deep, which JSON.parse reads (and then judges). The grammar is RFC 8259's, as JSON.parse's: whitespace is
# space, tab, line feed and carriage return; NaN and Infinity are not JSON; a key written twice keeps its last value; the strings are
# read by the standard library's own scanner, strict (no raw control character). Every number goes through num().
end = len(s)
stack = [] # the containers being filled: [object or list, the key whose value comes next (objects)]
def fail(i, what):
raise ValueError('not JSON: %s at %d' % (what, i))
def ws(i):
return WS.match(s, i).end()
def key(i): # a key and its colon, at i: the position of the value after them
if i >= end or s[i] != '"':
fail(i, 'a key')
k, i = json.decoder.scanstring(s, i + 1, True)
i = ws(i)
if i >= end or s[i] != ':':
fail(i, 'a colon')
return k, ws(i + 1)
i = ws(0)
while True:
if i >= end:
fail(i, 'a value')
c = s[i]
if c == '{':
i = ws(i + 1)
if i < end and s[i] == '}':
val, i = {}, i + 1
else:
k, i = key(i)
stack.append([{}, k])
continue
elif c == '[':
i = ws(i + 1)
if i < end and s[i] == ']':
val, i = [], i + 1
else:
stack.append([[], None])
continue
elif c == '"':
val, i = json.decoder.scanstring(s, i + 1, True)
elif s.startswith('true', i):
val, i = True, i + 4
elif s.startswith('false', i):
val, i = False, i + 5
elif s.startswith('null', i):
val, i = None, i + 4
else:
m = NUMBER.match(s, i)
if not m:
fail(i, 'a value')
val, i = num(m.group()), m.end()
# a whole value: it goes into the container on top; each container that closes after it is the value of the one beneath
while True:
if not stack:
i = ws(i)
if i != end:
fail(i, 'the end of the text')
return val
top = stack[-1]
if isinstance(top[0], dict):
top[0][top[1]] = val
else:
top[0].append(val)
i = ws(i)
if i < end and s[i] == ',':
i = ws(i + 1)
if isinstance(top[0], dict):
top[1], i = key(i)
break
if i >= end or s[i] != ('}' if isinstance(top[0], dict) else ']'):
fail(i, 'a comma or the end of a container')
i += 1
val = stack.pop()[0]
def canonical(v):
# sorted keys, no spaces; every string this function hashes is ASCII (the form check requires it)
return json.dumps(v, sort_keys=True, separators=(',', ':'), ensure_ascii=True, allow_nan=False)
def is_int(x, lo=0):
return isinstance(x, int) and not isinstance(x, bool) and lo <= x <= SAFE
def is_obj(x):
return isinstance(x, dict)
def exact(o, keys):
return is_obj(o) and len(o) == len(keys) and all(k in o for k in keys)
def string(x, rx):
return isinstance(x, str) and rx.fullmatch(x) is not None
def increasing(a):
return all(a[i - 1] < a[i] for i in range(1, len(a)))
def facts_form(f):
if not exact(f, ['v', 'kind', 'verdict', 'rules', 'unjudged', 'manufacturer', 'keys', 'records', 'uncounted', 'equivocations', 'retirements', 'chains']):
return 'facts: members'
if not (is_int(f['v']) and f['v'] == 1 and f['kind'] == FACTS_KIND):
return 'facts: kind'
if f['verdict'] not in VERDICTS or not isinstance(f['verdict'], str):
return 'facts: verdict'
for k in ('rules', 'unjudged'):
a = f[k]
if not (isinstance(a, list) and len(a) <= 64 and all(string(x, NUME) for x in a) and increasing(a)):
return 'facts: ' + k
if not (f['manufacturer'] is None or string(f['manufacturer'], AERE_ID)):
return 'facts: manufacturer'
if not (isinstance(f['keys'], list) and len(f['keys']) <= MAX_KEYS and all(string(x, DEV) for x in f['keys'])):
return 'facts: keys'
R = f['records']
if not (isinstance(R, list) and len(R) <= MAX_RECORDS):
return 'facts: records'
for i, r in enumerate(R):
if not exact(r, ['seq', 'hash', 'at', 'lo', 'hi', 'state', 'counter']):
return 'facts: record members'
if not (is_int(r['seq']) and string(r['hash'], H32) and is_int(r['at']) and r['at'] <= MAX_TIME
and (r['lo'] is None or (is_int(r['lo']) and r['lo'] <= MAX_TIME)) and (r['hi'] is None or (is_int(r['hi']) and r['hi'] <= MAX_TIME))
and isinstance(r['state'], str) and r['state'] in STATES and is_int(r['counter'])):
return 'facts: record form'
if i and not (R[i - 1]['seq'] < r['seq'] and R[i - 1]['at'] <= r['at'] and R[i - 1]['counter'] <= r['counter']):
return 'facts: record order'
if not is_int(f['uncounted']):
return 'facts: uncounted'
E = f['equivocations']
if not (isinstance(E, list) and len(E) <= MAX_EQUIVOCATIONS and all(exact(e, ['seq', 'a', 'b']) and is_int(e['seq']) and string(e['a'], H32) and string(e['b'], H32) and e['a'] != e['b'] for e in E)):
return 'facts: equivocations'
if not increasing([(e['seq'], e['a'], e['b']) for e in E]):
return 'facts: equivocation order'
T = f['retirements']
if not (isinstance(T, list) and len(T) <= MAX_KEYS and all(exact(x, ['deviceId', 'at']) and string(x['deviceId'], DEV) and (x['at'] is None or (is_int(x['at']) and x['at'] <= MAX_TIME)) for x in T)):
return 'facts: retirements'
C = f['chains']
if not (exact(C, ['anchors', 'beacons']) and all(isinstance(C[k], list) and len(C[k]) <= MAX_CHAINS and all(is_int(x, 1) for x in C[k]) and increasing(C[k]) for k in ('anchors', 'beacons'))):
return 'facts: chains'
return None
def params_form(p):
if not exact(p, ['from', 'to', 'windowS']):
return 'params: members'
if not (is_int(p['from']) and is_int(p['to']) and p['from'] < p['to'] and p['to'] <= MAX_TIME):
return 'params: from, to'
if not (is_int(p['windowS'], 1) and p['windowS'] <= MAX_WINDOW_S):
return 'params: windowS'
w = p['windowS'] * 1000
if (p['to'] - p['from'] + w - 1) // w > MAX_WINDOWS:
return 'params: windows'
return None
def reputation(facts, params):
e = facts_form(facts) or params_form(params)
if e:
raise ValueError(e)
start, end, w = params['from'], params['to'], params['windowS'] * 1000
n = -(-(end - start) // w) # ceil((to - from) / W)
first_claimed = {} # window -> lowest seq of a record dated in it
first_witnessed = {} # window -> lowest seq of a record whose witnessed interval lies in it
by_state = {s: 0 for s in STATES}
in_range = 0
for r in sorted(facts['records'], key=lambda x: x['seq']):
if start <= r['at'] < end:
in_range += 1
by_state[r['state']] += 1
first_claimed.setdefault((r['at'] - start) // w, r['seq'])
lo, hi = r['lo'], r['hi']
if lo is not None and hi is not None and start <= lo <= hi < end:
i = (lo - start) // w
if hi < min(start + (i + 1) * w, end):
first_witnessed.setdefault(i, r['seq'])
gaps, run = [], None
for i in range(n):
if i in first_claimed:
if run is not None:
gaps.append([run, i - run])
run = None
elif run is None:
run = i
if run is not None:
gaps.append([run, n - run])
ats = [r['at'] for r in facts['records']]
his = [r['hi'] for r in facts['records'] if r['hi'] is not None]
first_at = min(ats) if ats else None
hi_min = min(his) if his else None
evidence = [[i, first_claimed.get(i), first_witnessed.get(i)] for i in range(n) if i in first_claimed or i in first_witnessed]
return {
'v': 1, 'kind': KIND, 'formula': FORMULA,
'factsHash': '0x' + hashlib.sha256(canonical(facts).encode('ascii')).hexdigest(),
'params': {'from': start, 'to': end, 'windowS': params['windowS']},
'verdict': facts['verdict'], 'rules': facts['rules'], 'unjudged': facts['unjudged'], 'manufacturer': facts['manufacturer'], 'keys': facts['keys'], 'chains': facts['chains'],
'records': {'inRange': in_range, 'byState': by_state, 'uncounted': facts['uncounted']},
'availability': {'windows': n, 'claimed': len(first_claimed), 'witnessed': len(first_witnessed),
'claimedPerMille': len(first_claimed) * 1000 // n, 'witnessedPerMille': len(first_witnessed) * 1000 // n},
'gaps': {'count': len(gaps), 'longestWindows': max([g[1] for g in gaps], default=0), 'list': gaps},
'equivocations': {'count': len(facts['equivocations']), 'list': [[x['seq'], x['a'], x['b']] for x in facts['equivocations']]},
'retirements': {'count': len(facts['retirements']), 'list': [[x['deviceId'], x['at']] for x in facts['retirements']]},
'age': {'claimedS': (end - first_at) // 1000 if first_at is not None and first_at < end else None,
'witnessedS': (end - hi_min) // 1000 if hi_min is not None and hi_min < end else None},
'evidence': {'windows': evidence},
}
def check(inp):
if not exact(inp, ['facts', 'params', 'claimed']):
return 'INVALID', ['form'], 'the input is not exactly { facts, params, claimed }'
e = facts_form(inp['facts']) or params_form(inp['params'])
if e:
return 'INVALID', ['form'], e
try:
same = canonical(inp['claimed']) == canonical(reputation(inp['facts'], inp['params']))
except (ValueError, TypeError, OverflowError, RecursionError):
# a claim that cannot be written as canonical JSON (Infinity, or nested deeper than the encoder goes) is not what the formula gives,
# whose text is a few levels deep: JavaScript's canonical() throws on the same claims, and its check says the same
same = False
if same:
return 'VALID', [], 'the stated reputation is what the formula gives'
return 'INVALID', ['figures'], 'the stated reputation is not what the formula gives for these facts and parameters'
def verdict(inp):
v, rules, reason = check(inp)
failed = ['reputation.' + r for r in rules]
nm = lambda level: {'level': level, 'state': 'NOT_MEASURED', 'rules': [], 'cause': 'not-judged', 'reason': 'the reputation carries no signature and no notarization of its own'}
return {'v': 1, 'kind': 'aere-conformance-verdict', 'artifact': ARTIFACT, 'verdict': v,
'levels': [{'level': 'integrity', 'state': 'FAILED' if failed else 'PASSED', 'rules': failed, 'reason': reason}, nm('authenticity'), nm('anchoring'), nm('finality')],
'failed': failed}
def read(path):
with open(path, 'r', encoding='utf-8') as f:
return loads(f.read())
def main(a):
if a[:1] == ['--capabilities']:
print(json.dumps({'name': 'aere-device-reputation (Python, reputation.py)', 'artifacts': [ARTIFACT], 'rules': RULE_IDS, 'online': False, 'batch': True}))
elif a[:1] == ['--batch'] and len(a) > 1:
for e in read(a[1]):
v = read(e['file'])
print(json.dumps(verdict(v['input']) if v.get('artifact') == ARTIFACT else {'error': 'not a ' + ARTIFACT + ' vector'}))
elif a[:1] == ['--many'] and len(a) > 1:
# a list of { facts, params }: one line each, the canonical reputation or FORM: <reason>
for e in read(a[1]):
try:
print(canonical(reputation(e['facts'], e['params'])))
except ValueError as x:
print('FORM: ' + str(x))
elif '--facts' in a:
o = lambda n: a[a.index(n) + 1]
print(canonical(reputation(read(o('--facts')), {'from': int(o('--from')), 'to': int(o('--to')), 'windowS': int(o('--window'))})))
elif a and not a[0].startswith('--'):
v = read(a[0])
if v.get('artifact') != ARTIFACT:
raise ValueError('not a ' + ARTIFACT + ' vector')
print(json.dumps(verdict(v['input'])))
else:
sys.stderr.write('usage: python3 reputation.py --capabilities | <vector.json> | --batch <list.json> | --facts <facts.json> --from <ms> --to <ms> --window <seconds>\n')
return 3
return 0
if __name__ == '__main__':
# 2026-10-21 (the stranger pass): the same BYTES as `operation-cli.mjs reputation --canonical` on every system. Python's text-mode
# stdout turns '\n' into '\r\n' on Windows, Node's console.log writes '\n' everywhere; without this the two outputs differ by the
# line ending on Windows, and only there.
sys.stdout.reconfigure(newline='\n')
try:
sys.exit(main(sys.argv[1:]))
except (ValueError, OSError, KeyError, TypeError) as e:
sys.stderr.write('reputation: ' + str(e) + '\n')
sys.exit(3)