stable_abstraction.py (15858B)
1 #!/usr/bin/env python3 2 """Opt-in native stable abstraction probes (Haskell only). 3 4 python3 test/stable_abstraction.py result/bin/tricu --directory /tmp/tricu-stable 5 No default-gate integration. Compilation, complete-tree forcing, publication, 6 and source-free application are checked separately. Divergence is bounded only. 7 """ 8 import argparse 9 import hashlib 10 import json 11 from pathlib import Path 12 import re 13 import subprocess 14 import tempfile 15 import time 16 from process_rss import run_guarded 17 18 ROOT = Path(__file__).resolve().parent.parent 19 20 21 def main(): 22 parser = argparse.ArgumentParser(description=__doc__) 23 parser.add_argument('tricu', type=Path) 24 parser.add_argument('--directory', type=Path) 25 parser.add_argument('--case', action='append') 26 parser.add_argument('--case-group', choices=['core', 'sequences'], 27 help='bounded shards; both groups cover the full corpus') 28 parser.add_argument('--generation', type=Path, help='exercise self-built stable exports, with artifact-only wrapper preparation') 29 parser.add_argument('--timeout', type=float, default=120) 30 parser.add_argument('--memory-mib', type=int, default=8192) 31 parser.add_argument('--wall-seconds', type=float, default=1500) 32 parser.add_argument('--divergence-seconds', type=float, default=3) 33 args = parser.parse_args() 34 if not 0 < args.memory_mib <= 65536 or not 0 < args.wall_seconds <= 7200: 35 parser.error("resource guards exceed supported bounds") 36 tricu = str(args.tricu.resolve()) 37 root = args.directory.resolve() if args.directory else Path(tempfile.mkdtemp(prefix='tricu-stable-')) 38 root.mkdir(parents=True, exist_ok=True) 39 store = root / 'store' 40 41 deadline = time.monotonic() + args.wall_seconds 42 events = [] 43 def run(command, seconds=None): 44 limit = min(seconds or args.timeout, deadline-time.monotonic()) 45 if limit <= 0: 46 raise RuntimeError('whole stable-abstraction job deadline') 47 argv = [tricu, *map(str, command), '+RTS', '-M'+str(args.memory_mib)+'m', '-RTS'] 48 started = time.monotonic() 49 try: 50 output, peak = run_guarded(argv, memory_mib=args.memory_mib, seconds=limit) 51 except RuntimeError as error: 52 if str(error) == 'wall-time guard exceeded' and seconds == args.divergence_seconds and limit == seconds: 53 events.append(dict(command=argv, expected_divergence_timeout=True, seconds=time.monotonic()-started)) 54 (root/'resources.json').write_text(json.dumps(events, indent=2)+'\n') 55 raise subprocess.TimeoutExpired(argv, limit) from error 56 raise 57 events.append(dict(command=argv, seconds=time.monotonic()-started, peak_kib=peak)) 58 (root/'resources.json').write_text(json.dumps(events, indent=2)+'\n') 59 return output.strip().encode() 60 61 tool_pin = None 62 facades = {} 63 def compile(source, names, output): 64 if args.generation: 65 text = Path(source).read_text() 66 imports, uses, body = [], [], [] 67 for line in text.splitlines(): 68 if line.startswith('!import "'): 69 _, encoded, namespace = line.split() 70 dependency = json.loads(encoded) 71 if dependency not in facades: 72 alias = 'support' + str(len(facades)) 73 if len(dependency) == 64: 74 pin = dependency 75 else: 76 cas = args.generation.resolve() / 'cas' 77 module = dependency.replace('.', '_') 78 manifest = (cas / 'cache/source-build' / module).read_text().split('\t')[2] 79 bundle = root / (alias + '.arboricx') 80 run(['arboricx', 'export', '--store', cas, '--module', manifest, 81 '--output', bundle], 300) 82 pin, _ = imported(bundle) 83 (root / (alias + '.tri')).write_text('!export * from ' + pin + '\n') 84 facades[dependency] = alias 85 alias = facades[dependency] 86 uses.append('!import ' + alias + (' *' if namespace == '!Local' else ' as ' + namespace)) 87 else: 88 body.append(line) 89 translated = root / 'portable-wrapper.tri' 90 translated.write_text('\n'.join(imports + uses + ['!export ' + n for n in names] + body) + '\n') 91 return run(['arboricx', 'compile', '--file', translated, '--store', store, 92 '--toolchain', tool_pin, '--tool-store', root / 'tools', 93 '--compile-timeout', '900', '--output', output, 94 '+RTS', '-N1', '-RTS'], 930) 95 return run(['arboricx', 'compile', '--file', source, '--store', store, 96 *[a for name in names for a in ('--name', name)], '--output', output]) 97 98 def imported(bundle, destination=store, indexed=True): 99 reply = run(['arboricx', 'import', '--file', bundle, '--store', destination, 100 *(['--indexed'] if indexed else ['--flat'])]) 101 pin = re.search(rb'Created module alias .* -> ([0-9a-f]{64})', reply)[1].decode() 102 manifest = (destination / 'objects' / pin[:3] / pin).read_text() 103 return pin, pin + ':' + manifest.splitlines()[1].split('\t')[1] 104 105 start = time.monotonic() 106 compiler = root / 'compiler.arboricx' 107 # A small preparation cache only; every specimen still gets a fresh compile. 108 fingerprint = hashlib.sha256(str(Path(tricu).resolve()).encode()) 109 for path in sorted((ROOT / 'lib').rglob('*.tri')) + [ROOT / 'test/stableRunner.tri']: 110 fingerprint.update(str(path.relative_to(ROOT)).encode()) 111 fingerprint.update(path.read_bytes()) 112 stamp = root / 'tools.sha256' 113 reuse = stamp.exists() and stamp.read_text() == fingerprint.hexdigest() 114 if args.generation: 115 generation = args.generation.resolve() 116 tool_pin, _ = imported(generation / 'build-stable.arboricx', root / 'tools', True) 117 cas = generation / 'cas' 118 manifest = (cas / 'cache/source-build/source_unit').read_text().split('\t')[2] 119 exports = {r.split('\t')[1]: r.split('\t')[3] for r in 120 (cas / 'objects' / manifest[:3] / manifest).read_text().splitlines()[1:]} 121 run(['arboricx', 'export', '--store', cas, '--target', exports['compileStableModuleDefinitions'], 122 '--name', 'compileStableModuleDefinitions', '--output', compiler], 300) 123 fingerprint.update(compiler.read_bytes()) 124 reuse = stamp.exists() and stamp.read_text() == fingerprint.hexdigest() 125 elif not reuse or not compiler.exists(): 126 compile(ROOT / 'lib/source/unit.tri', ['compileStableModuleDefinitions'], compiler) 127 compiler_pin, _ = imported(compiler) 128 runner_source = root / 'runner.tri' 129 runner_source.write_text((ROOT / 'test/stableRunner.tri').read_text().replace('STABLE_COMPILER_MANIFEST', compiler_pin)) 130 runner = root / 'runner.arboricx' 131 if not reuse or not runner.exists(): 132 compile(runner_source, ['constructionReady', 'diagnosticMatches'], runner) 133 runner_pin, _ = imported(runner, indexed=True) 134 evaluator = root / 'evaluator.arboricx' 135 if not reuse or not evaluator.exists(): 136 compile(runner_source, ['matchesPublished'], evaluator) 137 stamp.write_text(fingerprint.hexdigest()) 138 print(f'preparation={time.monotonic()-start:.2f}s directory={root}', flush=True) 139 140 environment = '[' + ' '.join(f'(pair "{name}" {name})' for name in 141 ['true', 'false', 'pair', 'triage', 'succ', 'pred', 'equal?', 'lazyBool', 142 'lazyList', 'fst', 'snd', 'add', 'sub', 'length', 'reverse', 'append', 143 'matchBool', 'isZero_?', 'not?', 'or?', 'ok', 'err']) + \ 144 ' (pair "parsePair" (P.parserThen P.parserAny P.parserAny))' + \ 145 ' (pair "parserAny" P.parserAny) (pair "parserPure" P.parserPure)' + \ 146 ' (pair "parserMatchReply" P.parserMatchReply)]' 147 # None means invocation must time out; construction/publication must still pass. 148 cases = [ 149 ('identity', 'main x = x\n', '[23]', '23'), 150 ('closed-body', 'q a = a a\nmain ignored = q q\n', '[t]', None), 151 ('ignored-divergent-argument', 'q a = a a\nmain input = (ignored : succ 22) (q q)\n', '[t]', '23'), 152 ('direct-unselected', 'q a = a a\nmain flag = matchBool (q q) (succ 22) flag\n', '[false]', '23'), 153 ('unselected', 'q a = a a\nmain flag = lazyBool (_ : q q) (_ : succ 22) flag\n', '[false]', '23'), 154 ('selected', 'q a = a a\nmain flag = lazyBool (_ : q q) (_ : succ 22) flag\n', '[true]', None), 155 ('recursive-loop', 'main input = main input\n', '[t]', None), 156 ('recursive-count', 'count xs = lazyList (_ : 0) (head tail : succ (count tail)) xs\nmain xs = count xs\n', '[[1 2 3 4]]', '4'), 157 ('capture', 'main saved ignored = fresh : pair saved fresh\n', '[11 t 12]', '(pair 11 12)'), 158 ('returned-closed-body', 'q a = a a\nmake saved = ignored : q q\nmain = make 11\n', '[t]', None), 159 ('partial-known', 'choose saved ignored = saved\nmain = choose 23\n', '[t]', '23'), 160 ('wrong-output-control', 'main x = x\n', '[23]', '24'), 161 ('parameter-shadows-self', 'main main = main\n', '[23]', '23'), 162 ('local-shadows-self', 'main input = let main x = succ x in main input\n', '[22]', '23'), 163 ('pattern-shadows-self', 'pick [pick | rest] = pick\npick input = 0\nmain = pick [23]\n', '[]', '23'), 164 ('recursive-lambda', 'count = (xs : lazyList (_ : 0) (head tail : succ (count tail)) xs)\nmain = count [1 2]\n', '[]', '2'), 165 ('recursive-value', 'main = main\n', '[]', '"recursive function requires parameters"'), 166 ('mutual-cycle', 'f x = g x\ng x = f x\nmain = f\n', '[]', '"acyclic"'), 167 ('missing-name', 'main x = missing x\n', '[]', '"missing"'), 168 ('import-shadow', 'succ x = x\nmain = succ\n', '[]', '"succ"'), 169 ] 170 slice_text = (ROOT / 'test/stableSlice.tri').read_text() 171 repeat_text = (ROOT / 'test/stableRepeat.tri').read_text() 172 for label, text, result in [('empty', '', ''), ('short', 'a', ''), 173 ('normal', 'abcdefg', 'cde'), ('unicode', 'abλ雪🙂z', 'λ雪🙂')]: 174 # Author scalar cells explicitly to keep this slicing oracle independent 175 # of source-literal decoding, which has separate UTF-8 controls. 176 scalars = lambda value: '[' + ' '.join(str(ord(ch)) for ch in value) + ']' 177 cases.append(('slice-' + label, slice_text, 178 '[' + scalars(text) + ']', scalars(result))) 179 for label, arguments, expected in [('empty', '[[]]', '(ok [] [])'), 180 ('pairs', '[[1 2 3 4 5 6]]', '(ok [2 4 6] [])'), 181 ('truncated', '[[1 2 3]]', '(err (pair 1 (pair t (pair t (pair t t)))) [])')]: 182 cases.append(('repeat-' + label, repeat_text, arguments, expected)) 183 cases.append(('repeat-no-progress', repeat_text.replace('repeat parsePair input []', 184 'repeat (parserPure 23) input []'), '[[1]]', '(err "non-consuming parser" [1])')) 185 if args.case_group: 186 cases = [c for c in cases if c[0].startswith(('slice-', 'repeat-')) == 187 (args.case_group == 'sequences')] 188 selected_names = [c[0] for c in cases] 189 if args.case: 190 missing = set(args.case) - {c[0] for c in cases} 191 if missing: 192 raise ValueError(missing) 193 cases = [c for c in cases if c[0] in args.case] 194 results = [] 195 programs = {} 196 for name, text, arguments, expected in cases: 197 start = time.monotonic() 198 source = root / 'fixture.tri' 199 source.write_text('!import "prelude" !Local\n!import "parser" P\nfixture = ' + 200 f'pair {environment} (pair {json.dumps(text)} (pair {arguments} {expected or "t"}))\n') 201 fixture = root / 'fixture.arboricx' 202 compile(source, ['fixture'], fixture) 203 fixture_pin, fixture_tree = imported(fixture) 204 if name in {'recursive-value', 'mutual-cycle', 'missing-name', 'import-shadow'}: 205 answer = run(['arboricx', 'run', '--store', store, '--module', runner_pin, 206 '--name', 'diagnosticMatches', '--arg-export', fixture_tree, '--format', 'tree']) 207 assert answer == b't t', (name, 'diagnostic', answer) 208 results.append(dict(name=name, outcome='diagnostic pass')) 209 (root / 'results.json').write_text(json.dumps(results, indent=2) + '\n') 210 print(f'{name}: diagnostic pass', flush=True) 211 continue 212 key = text 213 reused = key in programs 214 construction_start = time.monotonic() 215 if not reused: 216 answer = run(['arboricx', 'run', '--store', store, '--module', runner_pin, 217 '--name', 'constructionReady', '--arg-export', fixture_tree, '--format', 'tree']) 218 assert answer == b't t', (name, 'complete construction', answer) 219 driver = root / 'driver.tri' 220 driver.write_text(f'!import "{compiler_pin}" E\n!import "{fixture_pin}" F\n' 221 '!import "prelude" !Local\n!import "source.resolve" !Local\n' 222 'compiled = E.compileStableModuleDefinitions (fst F.fixture) (fst (snd F.fixture))\n' 223 'main = lazyResult (_ _ : t) (defs _ : lazyMaybe (_ : t) (fn : fn) ' 224 '(sourceFindExternal defs "main")) compiled\n') 225 bundle = root / (name + '.arboricx') 226 compilation = compile(driver, ['main'], bundle).decode() 227 programs[key] = (bundle, compilation) 228 else: 229 bundle, compilation = programs[key] 230 construction_seconds = time.monotonic() - construction_start 231 fresh = Path(tempfile.mkdtemp(prefix='published-' + name + '-', dir=root)) 232 _, executable = imported(bundle, fresh) 233 evaluator_pin, _ = imported(evaluator, fresh, indexed=True) 234 _, published_fixture = imported(fixture, fresh) 235 command = ['arboricx', 'run', '--store', fresh, '--module', evaluator_pin, 236 '--name', 'matchesPublished', '--arg-export', executable, 237 '--arg-export', published_fixture, '--format', 'tree'] 238 if expected is None: 239 try: 240 answer = run(command, args.divergence_seconds) 241 except subprocess.TimeoutExpired: 242 pass 243 else: 244 raise AssertionError((name, 'expected bounded timeout', answer)) 245 outcome = 'invocation timeout' 246 else: 247 answer = run(command) 248 assert answer == (b't' if name == 'wrong-output-control' else b't t'), (name, answer) 249 outcome = 'application pass' 250 elapsed = time.monotonic() - start 251 results.append(dict(name=name, outcome=outcome, seconds=elapsed, 252 bytes=bundle.stat().st_size, compilation=compilation, 253 construction_seconds=construction_seconds, reused_program=reused)) 254 (root / 'results.json').write_text(json.dumps(results, indent=2) + '\n') 255 provenance = 'reused checked program' if reused else 'full construction/publication pass' 256 print(f'{name}: {provenance}; {outcome}; {elapsed:.2f}s; {bundle.stat().st_size} bytes', flush=True) 257 (root/'complete.json').write_text(json.dumps(dict( 258 complete=True, case_group=args.case_group, selected_names=[c[0] for c in cases], 259 group_names=selected_names, completed_names=[r['name'] for r in results], 260 compiler_sha256=hashlib.sha256(compiler.read_bytes()).hexdigest(), 261 harness_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), 262 memory_mib=args.memory_mib, wall_seconds=args.wall_seconds, 263 ), indent=2)+'\n') 264 265 266 if __name__ == '__main__': 267 main()