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