#!/usr/bin/env python3 """Classify every external the extraction declares. For each gen//_Template.lean, Aeneas states what the extracted Rust needs from outside. Each such name must be provided by exactly one of: MODEL — declared in the hand-written sibling gen//.lean: an assumption, which the axiom gate and the per-certificate cones then govern; PROVEN — resolved to a real definition in the proven corpus, because a module of this repository declares it (namespace-aware). Anything else is drift: the extraction asks for something this repository does not provide. ──────────────────────────────────────────────────────────────────────────── WHY THIS FILE WAS REWRITTEN — 2026-08-01, round-7 external review The first version matched declarations with a LINE-ORIENTED regex requiring the keyword and the name on the same physical line, and it did not strip comments. Both assumptions are false about Lean, and false about Aeneas's own output. Three of the four forks contain, verbatim: axiom curve25519_dalek.edwards.EdwardsPoint.Insts.CoreOpsArithNegEdwardsPoint.neg : curve25519_dalek.edwards.EdwardsPoint -> Result ... The old pattern matched nothing there, so that declaration was SILENTLY DROPPED: no MODEL row, no PROVEN row, and no failure. Every committed MODEL-CORRESPONDENCE.txt was missing it, and every button passed green over the incomplete table. A reviewer separately showed that a definition appearing only inside a `/- ... -/` comment was read as a real declaration, so the scanner could also report PROVEN for a name Lean resolves to an axiom. The lesson is not "write a better regex". It is that this scanner was FAIL-OPEN: input it could not parse produced silence instead of a stop. A gate that drops what it cannot read is worse than no gate, because the button prints green across the gap and the gap is invisible in the diff. This version therefore: · strips comments first, including NESTED `/- ... -/` blocks, which Lean has and which a non-greedy match would close at the first inner `-/`; · allows a declaration's name to appear on a later line than its keyword; · tracks `namespace` / `section` / `end` over the stripped text; · FAILS CLOSED — every declaration keyword must yield a name, or the scanner exits non-zero naming file and line. Nothing is dropped, ever. WHAT IT STILL IS NOT. This is a source scanner, not a semantic Lean query. It cannot see `export`, aliases, or how Lean actually resolves a name at elaboration. A PROVEN row is documentary evidence about the extraction boundary; it is NOT a Lean-checked fact, and the trust documents must not claim it is. What the estate relies on for soundness is kernel-side and environment-derived — Phase 2b's axiom gate, Phase 2c's inventory, and the exact per-certificate cones of Phase 3/3b — none of which consult this file. ──────────────────────────────────────────────────────────────────────────── """ import re import sys import os import glob KEYWORDS = ('axiom', 'def', 'abbrev', 'opaque', 'structure', 'inductive', 'instance', 'theorem', 'lemma') # A declaration keyword opening a logical line, after any attributes and # modifiers. The NAME is deliberately NOT part of this pattern: it may sit on a # later line, which is precisely the case the previous scanner dropped. KW = re.compile( r'^[ \t]*(?:@\[[^\]]*\][ \t\n]*)*' r'(?:private |protected |noncomputable |unsafe |partial |scoped |local )*' r'(' + '|'.join(KEYWORDS) + r')(?=[ \t\n])', re.M) IDENT = re.compile(r"[ \t\n]*([A-Za-z_][A-Za-z0-9_.'!?]*)") NS = re.compile( r"^[ \t]*(namespace|section|end)(?:[ \t]+([A-Za-z_][A-Za-z0-9_.']*))?[ \t]*$", re.M) class ScanError(Exception): """Raised when a declaration cannot be parsed. Never swallowed.""" def strip_comments(text): """Remove Lean comments, preserving newlines so line numbers stay true. Block comments NEST in Lean, so this needs a depth counter: a non-greedy `/-.*?-/` would close the outer block at the first inner `-/` and leave the tail of a nested comment looking like source. """ out, i, n, depth = [], 0, len(text), 0 while i < n: if text.startswith('/-', i): depth += 1 out.append(' ') i += 2 continue if text.startswith('-/', i): if depth: depth -= 1 out.append(' ') i += 2 continue if depth: out.append('\n' if text[i] == '\n' else ' ') i += 1 continue if text.startswith('--', i): j = text.find('\n', i) if j < 0: out.append(' ' * (n - i)) break out.append(' ' * (j - i)) i = j continue out.append(text[i]) i += 1 return ''.join(out) def declared(path): """Fully-qualified names declared in one file. Raises ScanError on any declaration keyword whose name cannot be read. """ raw = open(path, encoding='utf-8', errors='replace').read() text = strip_comments(raw) # Scope events by offset, so each declaration can be placed in its stack. events = [(m.start(), m.group(1), m.group(2)) for m in NS.finditer(text)] names = set() for m in KW.finditer(text): im = IDENT.match(text, m.end()) if not im: line = text.count('\n', 0, m.start()) + 1 raise ScanError( "%s:%d: `%s` with no parseable name. This scanner fails closed:" " it will not drop a declaration it cannot read." % (path, line, m.group(1))) stack = [] for off, kind, arg in events: if off > m.start(): break if kind in ('namespace', 'section'): stack.append(arg) elif stack: stack.pop() prefix = [p for p in stack if p] names.add('.'.join(prefix + [im.group(1)]) if prefix else im.group(1)) return names def main(root): gen = os.path.join(root, 'gen') templates = sorted(glob.glob(os.path.join(gen, '*', '*_Template.lean'))) # The proven corpus: every generated module that is neither a template nor # a hand-written model. These are the files Aeneas produced from Rust. models = {t.replace('_Template', '') for t in templates} corpus = set() for f in sorted(glob.glob(os.path.join(gen, '*', '*.lean'))): if f in models or f.endswith('_Template.lean'): continue corpus |= declared(f) rows, unresolved = [], [] for t in templates: model = t.replace('_Template', '') rel = os.path.relpath(t, gen).replace('_Template.lean', '') tnames = declared(t) mnames = declared(model) if os.path.exists(model) else set() for n in sorted(tnames): if n in mnames: rows.append(f'{rel}|{n}|MODEL') elif n in corpus: rows.append(f'{rel}|{n}|PROVEN') else: rows.append(f'{rel}|{n}|UNRESOLVED') unresolved.append(f'{rel}|{n}') for n in sorted(mnames - tnames): rows.append(f'{rel}|{n}|EXTRA') print('\n'.join(rows)) print(f'CORRESPONDENCE-COUNT|{len(rows)}') return 1 if unresolved else 0 def emit_names(root): """Every name the EXTRACTION asks for, as `|`. Template discovery is unavoidably textual: the template is not imported (it would clash with the model, which declares the same names), so no Lean environment contains it. That is why `declared()` fails closed — this list is the input to the semantic phase, and a name missing here is a name nothing will ever check. """ gen = os.path.join(root, 'gen') for t in sorted(glob.glob(os.path.join(gen, '*', '*_Template.lean'))): rel = os.path.relpath(t, gen).replace('_Template.lean', '') for n in sorted(declared(t)): print(f'{rel}|{n}') return 0 if __name__ == '__main__': try: if len(sys.argv) > 2 and sys.argv[1] == '--names': sys.exit(emit_names(sys.argv[2])) sys.exit(main(sys.argv[1])) except ScanError as e: # Fail closed and loudly. Never degrade to a partial table. print('MODEL CORRESPONDENCE SCAN FAILED: %s' % e, file=sys.stderr) sys.exit(2)