tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

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