#!/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 | | --batch the conformance contract (../aere-conformance) # python3 reputation.py --facts --from --to --window the reputation, as canonical JSON # python3 reputation.py --many a list of { facts, params }: one line each, the canonical reputation or FORM: 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: 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 | | --batch | --facts --from --to --window \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)