tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

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()