diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 0da8168..b005652 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -58,6 +58,66 @@ Each invocation prints one line per phase (DSL build → emit → compile → load → run) with wall-clock timings — useful when diagnosing where time is going on your box. +## Twelve-dataset DOOP corpus + +`examples/doop_benchmark.py` prepares twelve real DaCapo +23.11-MR2-chopin applications from the +[published FlowLog facts](https://huggingface.co/datasets/NemoYuu/flowlog_benchmark/tree/main/dataset/csv). +`examples/doop_suite/datasets.json` pins the corpus revision, archive SHA-256, +archive size, and the source of the upstream reference cardinalities. +These are fresh Chopin datasets, not aliases for the older five local datasets. + +The following **local scheduling tiers** use upstream reference `VarPointsTo` +cardinality, not input size or measured SRDatalog results. They are not official +DOOP dataset editions. H2O, for example, has substantially more raw input than +Jython but a much smaller upstream points-to result. + +| Tier | Reference VPT rows | Applications | +|---|---:|---| +| small | < 15 million | xalan, zxing, biojava, pmd, sunflow | +| medium | 15–<30 million | h2o, spring | +| large | 30–<100 million | batik, eclipse, fop, h2 | +| xlarge | >= 100 million | jython | + +```bash +python examples/doop_benchmark.py list +python examples/doop_benchmark.py fetch --all --root /path/to/doop-data +python examples/doop_benchmark.py prepare --all --root /path/to/doop-data + +# Select named applications or a workload tier instead: +python examples/doop_benchmark.py prepare --dataset xalan jython \ + --root /path/to/doop-data +python examples/doop_benchmark.py prepare --tier medium \ + --root /path/to/doop-data +``` + +Python 3.10+ and the external `sort` command are required for preparation. +Downloading all archives requires approximately 1.74 GB; the complete raw facts +require approximately 28.3 GB before normalized inputs, dictionaries, build +caches or result exports. Keep all data outside the source checkout. +`--archive-cache DIR` optionally reuses a read-only archive cache after checksum +verification. `DOOP_SORT_TMPDIR` can select an existing scratch directory. + +Preparation preserves the complete `MainClass` set and uses one shared symbol +dictionary per dataset. It derives all 39 declared integer TSV input files, +including descriptors and heap types, then materializes each projected relation +as a set with `sort -u`; it never samples rows or adds synthetic roots. +Required files, arities, signed-int32 numeric domains, and functional attributes +needed by the normalization are checked explicitly. +The instantiated program currently uses 37 of those inputs and has 37 derived +relations; `Var_DeclaringMethod` and `isVirtualMethodInvocation_Insn` are declared +but unused. Preparation reports all declared files; execution reports the +actually consumed input rows and bytes separately. + +Each `prepared/APP/` contains the input CSV files (tab-delimited despite their +extension), `meta.json`, `str2num.json`, and `manifest.json`. The manifest records +raw/prepared hashes, prepared rows and bytes, entrypoints, and provenance. +The status `prepared_not_engine_validated` deliberately does not claim query +correctness. An incomplete preparation is not published; existing prepared +datasets are verified rather than overwritten. Do not reuse another dataset's +interned constants or compare old-generation facts under the same application +name without recording that change. + ## Regenerating from Nim When upstream Nim sources change, regenerate every benchmark with: diff --git a/examples/doop_benchmark.py b/examples/doop_benchmark.py new file mode 100755 index 0000000..e4fe6ad --- /dev/null +++ b/examples/doop_benchmark.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Prepare and inspect the pinned twelve-application DOOP benchmark suite.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from doop_suite.catalog import ( + extract_archive, + fetch_archive, + load_catalog, + select_datasets, + verify_prepared, + verify_raw, +) + + +def selection(parser: argparse.ArgumentParser, *, required: bool = True) -> None: + group = parser.add_mutually_exclusive_group(required=required) + group.add_argument('--all', action='store_true', help='Select all twelve datasets') + group.add_argument('--dataset', nargs='+', metavar='NAME', help='Select named datasets') + group.add_argument('--tier', choices=list(load_catalog()['tiers'])) + + +def prepare_dataset(dataset: dict, root: Path, archive_cache: Path | None = None) -> dict: + from doop_suite.prepare import prepare + + output = root / 'prepared' / dataset['name'] + if output.exists(): + return verify_prepared(output, dataset) + archive = fetch_archive(dataset, root, archive_cache) + raw = root / 'raw' / dataset['name'] + if raw.exists(): + verify_raw(archive, dataset, raw) + else: + raw = extract_archive(archive, dataset, root) + catalog = load_catalog() + provenance = dict( + dataset, + corpus=catalog['corpus'], + revision=catalog['revision'], + repository=catalog['repository'], + reference_source=catalog['reference_source'], + ) + return prepare(raw, output, provenance=provenance) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + listing = commands.add_parser('list', help='Show tiers, names and upstream reference sizes') + selection(listing, required=False) + listing.add_argument('--json', action='store_true') + for command in ('fetch', 'prepare'): + action = commands.add_parser( + command, + help='Download pinned archives' + if command == 'fetch' + else 'Download and normalize complete input relation sets', + ) + selection(action) + action.add_argument('--root', type=Path, required=True, help='External data/artifact directory') + action.add_argument('--archive-cache', type=Path, help='Optional read-only existing zip cache') + args = parser.parse_args(argv) + try: + datasets = select_datasets(args.dataset, args.tier) + if args.command == 'list': + if args.json: + print(json.dumps(datasets, indent=2)) + else: + print( + 'Local tiers use upstream reference VarPointsTo rows, NOT input size or local results.' + ) + print(f'{"DATASET":12} {"TIER":8} {"REFERENCE VPT":>15} {"ARCHIVE MB":>12}') + for item in datasets: + print( + f'{item["name"]:12} {item["tier"]:8} {item["reference_vpt_rows"]:>15,}' + f' {item["archive_bytes"] / 1_000_000:>12.1f}' + ) + return 0 + root = args.root.resolve() + for dataset in datasets: + print(f'[{args.command}] {dataset["name"]}', flush=True) + if args.command == 'fetch': + archive = fetch_archive(dataset, root, args.archive_cache) + print( + json.dumps({'dataset': dataset['name'], 'archive': str(archive), 'verified': True}), + flush=True, + ) + else: + manifest = prepare_dataset(dataset, root, args.archive_cache) + print( + json.dumps( + { + 'dataset': dataset['name'], + 'status': manifest['status'], + 'prepared_input_rows': sum(r['rows'] for r in manifest['relations'].values()), + 'prepared_input_bytes': sum(r['bytes'] for r in manifest['relations'].values()), + 'directory': str(root / 'prepared' / dataset['name']), + } + ), + flush=True, + ) + return 0 + except (OSError, ValueError, RuntimeError) as error: + print(f'[error] {error}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/examples/doop_suite/__init__.py b/examples/doop_suite/__init__.py new file mode 100644 index 0000000..cfa1d0d --- /dev/null +++ b/examples/doop_suite/__init__.py @@ -0,0 +1 @@ +"""Reproducible DOOP datasets and opt-in benchmark execution.""" diff --git a/examples/doop_suite/catalog.py b/examples/doop_suite/catalog.py new file mode 100644 index 0000000..f550f4c --- /dev/null +++ b/examples/doop_suite/catalog.py @@ -0,0 +1,155 @@ +"""Pinned public DOOP facts; dataset identity is independent of workload tier.""" + +from __future__ import annotations + +import hashlib +import json +import shutil +import stat +import tempfile +import urllib.request +import zipfile +import zlib +from pathlib import Path, PurePosixPath + +CATALOG = Path(__file__).with_name('datasets.json') + + +def digest(path: Path) -> str: + result = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b''): + result.update(chunk) + return result.hexdigest() + + +def load_catalog() -> dict: + return json.loads(CATALOG.read_text()) + + +def select_datasets(names: list[str] | None = None, tier: str | None = None) -> list[dict]: + catalog = load_catalog() + datasets = catalog['datasets'] + known = {row['name'] for row in datasets} + if names and (unknown := set(names) - known): + raise ValueError(f'Unknown DOOP datasets: {sorted(unknown)}') + if tier is not None and tier not in catalog['tiers']: + raise ValueError(f'Unknown DOOP tier: {tier}') + selected = [ + row + for row in datasets + if (not names or row['name'] in names) and (tier is None or row['tier'] == tier) + ] + if not selected: + raise ValueError('Selection contains no datasets') + return selected + + +def check_archive(path: Path, dataset: dict) -> None: + if path.stat().st_size != dataset['archive_bytes']: + raise ValueError(f'Archive size mismatch: {path}') + if digest(path) != dataset['archive_sha256']: + raise ValueError(f'Archive SHA256 mismatch: {path}') + + +def fetch_archive(dataset: dict, root: Path, archive_cache: Path | None = None) -> Path: + """Reuse only byte-verified archives; never modify a supplied external cache.""" + root = root.resolve() + name = dataset['name'] + '.zip' + if archive_cache is not None and (cached := archive_cache / name).is_file(): + check_archive(cached, dataset) + return cached.resolve() + downloads = root / 'downloads' + downloads.mkdir(parents=True, exist_ok=True) + target = downloads / name + if target.exists(): + check_archive(target, dataset) + return target + with tempfile.TemporaryDirectory(prefix=f'.{dataset["name"]}-', dir=downloads) as temporary: + partial = Path(temporary) / name + request = urllib.request.Request(dataset['url'], headers={'User-Agent': 'srdatalog-doop-suite'}) + with urllib.request.urlopen(request, timeout=120) as response, partial.open('xb') as stream: + shutil.copyfileobj(response, stream, length=8 * 1024 * 1024) + check_archive(partial, dataset) + partial.rename(target) + return target + + +def verify_raw(archive: Path, dataset: dict, directory: Path) -> None: + """Check a reused extraction against the pinned archive's member CRCs.""" + check_archive(archive, dataset) + expected = set() + with zipfile.ZipFile(archive) as source: + for entry in source.infolist(): + if entry.is_dir(): + continue + name = PurePosixPath(entry.filename) + if len(name.parts) != 2 or name.parts[0] != dataset['name'] or name.suffix != '.facts': + raise ValueError(f'Unexpected DOOP archive member: {entry.filename}') + path = directory / name.name + expected.add(name.name) + if path.is_symlink() or path.stat().st_size != entry.file_size: + raise ValueError(f'Extracted fact size/type mismatch: {path}') + crc = 0 + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b''): + crc = zlib.crc32(chunk, crc) + if crc != entry.CRC: + raise ValueError(f'Extracted facts changed: {path}') + if {path.name for path in directory.iterdir()} != expected: + raise ValueError(f'Unexpected or missing extracted facts in {directory}') + + +def extract_archive(archive: Path, dataset: dict, root: Path) -> Path: + """Extract a verified archive into a new directory, rejecting links and traversal.""" + check_archive(archive, dataset) + raw = root.resolve() / 'raw' + raw.mkdir(parents=True, exist_ok=True) + target = raw / dataset['name'] + if target.exists(): + raise FileExistsError(f'Refusing to overwrite extracted facts: {target}') + with tempfile.TemporaryDirectory(prefix=f'.{dataset["name"]}-', dir=raw) as temporary: + staging = Path(temporary) + with zipfile.ZipFile(archive) as source: + seen = set() + for entry in source.infolist(): + name = PurePosixPath(entry.filename) + mode = entry.external_attr >> 16 + if ( + name.is_absolute() + or '..' in name.parts + or '\\' in entry.filename + or not name.parts + or name.parts[0] != dataset['name'] + or stat.S_ISLNK(mode) + or entry.filename in seen + ): + raise ValueError(f'Unsafe or duplicate archive member: {entry.filename}') + seen.add(entry.filename) + destination = staging.joinpath(*name.parts) + if entry.is_dir(): + destination.mkdir(parents=True, exist_ok=True) + else: + if len(name.parts) != 2 or name.suffix != '.facts': + raise ValueError(f'Unexpected DOOP archive member: {entry.filename}') + destination.parent.mkdir(parents=True, exist_ok=True) + with source.open(entry) as stream, destination.open('xb') as output: + shutil.copyfileobj(stream, output, length=8 * 1024 * 1024) + if not (staging / dataset['name'] / 'MainClass.facts').is_file(): + raise ValueError('Archive has no MainClass.facts') + (staging / dataset['name']).rename(target) + return target + + +def verify_prepared(directory: Path, dataset: dict) -> dict: + """Reused prepared inputs must retain their exact provenance, metadata and tuples.""" + manifest = json.loads((directory / 'manifest.json').read_text()) + if manifest.get('provenance', {}).get('archive_sha256') != dataset['archive_sha256']: + raise ValueError(f'Prepared dataset has different archive provenance: {directory}') + if json.loads((directory / 'meta.json').read_text()) != manifest['meta']: + raise ValueError(f'Prepared metadata changed: {directory}') + for name, info in manifest['relations'].items(): + path = directory / (name + '.csv') + if path.stat().st_size != info['bytes'] or digest(path) != info['sha256']: + raise ValueError(f'Prepared input changed: {path}') + return manifest diff --git a/examples/doop_suite/datasets.json b/examples/doop_suite/datasets.json new file mode 100644 index 0000000..11908aa --- /dev/null +++ b/examples/doop_suite/datasets.json @@ -0,0 +1,126 @@ +{ + "schema_version": 1, + "suite": "doop-chopin-12", + "corpus": "DaCapo 23.11-MR2-chopin", + "repository": "NemoYuu/flowlog_benchmark", + "revision": "da9e91b3ff75d94604f57ba2b21ef3aa97e241ec", + "reference_source": "https://github.com/flowlog-rs/flowlog-bench/blob/caa5f4afb630f8c275f3ea541f628f9c58245a8d/docs/data/doop-data-prep.md", + "tier_metric": "upstream_reference_VarPointsTo_rows", + "tier_note": "Local scheduling tiers based on upstream FlowLog results, not measured SRDatalog outputs, input bytes, or official DOOP size editions.", + "tiers": { + "small": { + "min_rows": 0, + "max_rows_exclusive": 15000000 + }, + "medium": { + "min_rows": 15000000, + "max_rows_exclusive": 30000000 + }, + "large": { + "min_rows": 30000000, + "max_rows_exclusive": 100000000 + }, + "xlarge": { + "min_rows": 100000000, + "max_rows_exclusive": null + } + }, + "datasets": [ + { + "name": "xalan", + "tier": "small", + "reference_vpt_rows": 2741586, + "archive_bytes": 105440689, + "archive_sha256": "fb80819fb66153b4370325cafb632ee5649917ff7e2d5b6645d6e360c61dd11f", + "url": "https://huggingface.co/datasets/NemoYuu/flowlog_benchmark/resolve/da9e91b3ff75d94604f57ba2b21ef3aa97e241ec/dataset/csv/xalan.zip" + }, + { + "name": "zxing", + "tier": "small", + "reference_vpt_rows": 4024065, + "archive_bytes": 72025076, + "archive_sha256": "154593343fefd18306d4098ba9f6286947b134b56ebcf83d8e8eae368d5867e7", + "url": "https://huggingface.co/datasets/NemoYuu/flowlog_benchmark/resolve/da9e91b3ff75d94604f57ba2b21ef3aa97e241ec/dataset/csv/zxing.zip" + }, + { + "name": "biojava", + "tier": "small", + "reference_vpt_rows": 4901635, + "archive_bytes": 173260481, + "archive_sha256": "6e81c27378dd151de6415ab49bb62ced9f8e70bfb69d9cd59df0a57c02091cde", + "url": "https://huggingface.co/datasets/NemoYuu/flowlog_benchmark/resolve/da9e91b3ff75d94604f57ba2b21ef3aa97e241ec/dataset/csv/biojava.zip" + }, + { + "name": "pmd", + "tier": "small", + "reference_vpt_rows": 8511281, + "archive_bytes": 86623764, + "archive_sha256": "8825defd6500c31ac1fbc2c5b82d8f46da6a7dbd3d395b1b7de245fef4d230c9", + "url": "https://huggingface.co/datasets/NemoYuu/flowlog_benchmark/resolve/da9e91b3ff75d94604f57ba2b21ef3aa97e241ec/dataset/csv/pmd.zip" + }, + { + "name": "sunflow", + "tier": "small", + "reference_vpt_rows": 11616018, + "archive_bytes": 67542238, + "archive_sha256": "18641d21a7267abf19616ba12d9a39c667f15c0cd1ea8fd2c611cbe9f01ab717", + "url": "https://huggingface.co/datasets/NemoYuu/flowlog_benchmark/resolve/da9e91b3ff75d94604f57ba2b21ef3aa97e241ec/dataset/csv/sunflow.zip" + }, + { + "name": "h2o", + "tier": "medium", + "reference_vpt_rows": 17911631, + "archive_bytes": 681540893, + "archive_sha256": "65dfd99a751a7de2d6181b6be0eaa7643e2b3303cd2b47c53187c51fa081d816", + "url": "https://huggingface.co/datasets/NemoYuu/flowlog_benchmark/resolve/da9e91b3ff75d94604f57ba2b21ef3aa97e241ec/dataset/csv/h2o.zip" + }, + { + "name": "spring", + "tier": "medium", + "reference_vpt_rows": 25953984, + "archive_bytes": 84803735, + "archive_sha256": "3ee424fb3b748c3b3f28a4841ae99d3506af37eefb6d3bb608c93e8f1519d5a8", + "url": "https://huggingface.co/datasets/NemoYuu/flowlog_benchmark/resolve/da9e91b3ff75d94604f57ba2b21ef3aa97e241ec/dataset/csv/spring.zip" + }, + { + "name": "batik", + "tier": "large", + "reference_vpt_rows": 37237353, + "archive_bytes": 105982324, + "archive_sha256": "0e66c095e6a65e765e0481bb1cc878f1d4d9845bbd11e744dea80be9470adda2", + "url": "https://huggingface.co/datasets/NemoYuu/flowlog_benchmark/resolve/da9e91b3ff75d94604f57ba2b21ef3aa97e241ec/dataset/csv/batik.zip" + }, + { + "name": "eclipse", + "tier": "large", + "reference_vpt_rows": 39139806, + "archive_bytes": 44907723, + "archive_sha256": "bae047f6d5fe1dfaa3c171516922e66ba3d2f8dd7244b43059c2ec7e6677ffc3", + "url": "https://huggingface.co/datasets/NemoYuu/flowlog_benchmark/resolve/da9e91b3ff75d94604f57ba2b21ef3aa97e241ec/dataset/csv/eclipse.zip" + }, + { + "name": "fop", + "tier": "large", + "reference_vpt_rows": 39818826, + "archive_bytes": 108025042, + "archive_sha256": "f88c3d58fa3b5216ad49402a593676e474dc5067e87af498ed3d8f8b64eb343c", + "url": "https://huggingface.co/datasets/NemoYuu/flowlog_benchmark/resolve/da9e91b3ff75d94604f57ba2b21ef3aa97e241ec/dataset/csv/fop.zip" + }, + { + "name": "h2", + "tier": "large", + "reference_vpt_rows": 39871906, + "archive_bytes": 83784448, + "archive_sha256": "f9c4d90ce0868456e73bdb0b05aa6e1d9177f2feff5bd964f3dbb61c8d2c1fdf", + "url": "https://huggingface.co/datasets/NemoYuu/flowlog_benchmark/resolve/da9e91b3ff75d94604f57ba2b21ef3aa97e241ec/dataset/csv/h2.zip" + }, + { + "name": "jython", + "tier": "xlarge", + "reference_vpt_rows": 438304400, + "archive_bytes": 121807572, + "archive_sha256": "67c0f289eda34b05cd8226dc4e41e87d016b8ddd2ec2e0f8a8d888c555596008", + "url": "https://huggingface.co/datasets/NemoYuu/flowlog_benchmark/resolve/da9e91b3ff75d94604f57ba2b21ef3aa97e241ec/dataset/csv/jython.zip" + } + ] +} diff --git a/examples/doop_suite/prepare.py b/examples/doop_suite/prepare.py new file mode 100644 index 0000000..56b94ec --- /dev/null +++ b/examples/doop_suite/prepare.py @@ -0,0 +1,477 @@ +"""Prepare symbolic DOOP facts for the canonical 39-input integer program. + +This adapter deliberately supports the known DOOP raw schema, not arbitrary +Datalog imports. It does not run an engine. Complete projected relations are +materialized with ``LC_ALL=C sort -u``; set DOOP_SORT_TMPDIR to place sort scratch +on a different existing filesystem. Otherwise scratch is local to the output. +""" + +from __future__ import annotations + +import ast +import hashlib +import json +import os +import re +import shutil +import subprocess +import tempfile +from contextlib import ExitStack +from pathlib import Path + +INT32_MIN = -(1 << 31) +INT32_MAX = (1 << 31) - 1 +CONSTANTS = { + "abstract": "abstract", + "public": "public", + "static": "static", + "main": "main", + "clinit": "", + "clinit_descriptor": "void()", + "main_descriptor": "void(java.lang.String[])", + "java_lang_Object": "java.lang.Object", + "java_lang_Cloneable": "java.lang.Cloneable", + "java_io_Serializable": "java.io.Serializable", + "java_lang_String_type": "java.lang.String", + "java_lang_Class_type": "java.lang.Class", + "java_lang_Object_array": "java.lang.Object[]", + # These historical metadata key names denote the three upstream main exclusions. + "class_init_method": "", + "register_natives_method": "", + "desiredAssertionStatus_method": "", +} +ALIASES = { + "Method_Modifier": "Method-Modifier", + "Var_Type": "Var-Type", + "Var_DeclaringMethod": "Var-DeclaringMethod", +} +DIRECT = { + "DirectSuperclass": 2, + "DirectSuperinterface": 2, + "MainClass": 1, + "FormalParam": 3, + "ComponentType": 2, + "AssignReturnValue": 2, + "ActualParam": 3, + "Method_Modifier": 2, + "Var_Type": 2, + "ClassType": 1, + "ArrayType": 1, + "InterfaceType": 1, + "Var_DeclaringMethod": 2, + "ApplicationClass": 1, + "ThisVar": 2, +} +# Raw arity, projected columns, enclosing-method column. +FAT = { + "AssignHeapAllocation": (6, (2, 3, 4), 4), + "AssignLocal": (5, (2, 3, 4), 4), + "AssignCast": (6, (4, 2, 3, 5), 5), + "LoadInstanceField": (6, (3, 4, 2, 5), 5), + "StoreInstanceField": (6, (2, 3, 4, 5), 5), + "LoadStaticField": (5, (3, 2, 4), 4), + "StoreStaticField": (5, (2, 3, 4), 4), + "LoadArrayIndex": (5, (3, 2, 4), 4), + "StoreArrayIndex": (5, (2, 3, 4), 4), + "Return": (4, (2, 3), 3), + "StaticMethodInvocation": (4, (0, 2, 3), 3), +} +SCHEMA = { + **DIRECT, + "HeapAllocation_Type": 2, + "Field_DeclaringType": 2, + "Method_SimpleName": 2, + "Method_DeclaringType": 2, + "Method_Descriptor": 2, + "Instruction_Method": 2, + "MethodInvocation_Method": 2, + "isStaticMethodInvocation_Insn": 1, + "isVirtualMethodInvocation_Insn": 1, + "SpecialMethodInvocation_Base": 2, + "VirtualMethodInvocation_Base": 2, + "VirtualMethodInvocation_SimpleName": 2, + "VirtualMethodInvocation_Descriptor": 2, + **{ + ("ReturnVar" if name == "Return" else name): len(columns) + for name, (_, columns, _) in FAT.items() + }, +} + + +def _dump(path: Path, value: dict) -> None: + with path.open("x", encoding="utf-8", newline="\n") as handle: + json.dump(value, handle, indent=2, sort_keys=True, ensure_ascii=True, allow_nan=False) + handle.write("\n") + + +def _file_info(path: Path, *, count_rows: bool = False) -> dict: + digest = hashlib.sha256() + size = count = 0 + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + size += len(chunk) + if count_rows: + count += chunk.count(b"\n") + info = {"path": path.name, "bytes": size, "sha256": digest.hexdigest()} + if count_rows: + info["rows"] = count + return info + + +def _program_contract(program: Path) -> str: + content = program.read_bytes() + found = {} + for node in ast.walk(ast.parse(content, filename=str(program))): + if not ( + isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "Relation" + ): + continue + keywords = {key.arg: key.value for key in node.keywords} + if "input_file" not in keywords: + continue + name, arity = (ast.literal_eval(arg) for arg in node.args[:2]) + filename = ast.literal_eval(keywords["input_file"]) + if name in found or filename != f"{name}.csv" or type(arity) is not int: + raise ValueError(f"Unsupported input declaration for {name!r} in {program}") + found[name] = arity + if found != SCHEMA: + raise ValueError(f"{program} does not declare the canonical 39-input DOOP schema") + return hashlib.sha256(content).hexdigest() + + +def _int32(value: str, location: str) -> int: + if re.fullmatch(r"[+-]?[0-9]+", value) is None: + raise ValueError(f"{location}: expected signed int32, found {value!r}") + number = int(value) + if not INT32_MIN <= number <= INT32_MAX: + raise ValueError(f"{location}: value {value!r} is outside signed int32") + return number + + +def _integer_rows(directory: Path, name: str): + with (directory / f"{name}.csv").open(encoding="ascii") as handle: + for line in handle: + yield tuple(map(int, line.rstrip("\n").split("\t"))) + + +def _entrypoints(directory: Path, meta: dict, symbols: dict) -> dict: + roots = {row[0] for row in _integer_rows(directory, "MainClass")} + candidates = { + method for method, owner in _integer_rows(directory, "Method_DeclaringType") if owner in roots + } + for relation, value in ( + ("Method_SimpleName", meta["main"]), + ("Method_Descriptor", meta["main_descriptor"]), + ): + candidates &= {method for method, attr in _integer_rows(directory, relation) if attr == value} + public, static = set(), set() + for modifier, method in _integer_rows(directory, "Method_Modifier"): + if method in candidates: + if modifier == meta["public"]: + public.add(method) + if modifier == meta["static"]: + static.add(method) + candidates &= public & static + exclusions = { + meta[key] + for key in ("class_init_method", "register_natives_method", "desiredAssertionStatus_method") + } + wanted = roots | candidates + names = {value: symbol for symbol, value in symbols.items() if value in wanted} + + def describe(values): + return [{"id": value, "symbol": names[value]} for value in sorted(values)] + + return { + "main_classes": describe(roots), + "candidate_main_methods": describe(candidates), + "selected_main_methods": describe(candidates - exclusions), + "excluded_candidates": sorted(candidates & exclusions), + } + + +class _Preparation: + def __init__(self, source: Path, staging: Path): + self.source = source + self.staging = staging + self.paths = {} + self.source_relations = {} + self.relations = {} + self.symbols = {} + self.handles = {} + self.seen_instructions = {} + + def locate(self, name: str, *, required: bool = True) -> Path | None: + if name in self.paths: + return self.paths[name] + stems = dict.fromkeys((ALIASES.get(name, name), name)) + paths = [ + self.source / f"{stem}.facts" for stem in stems if (self.source / f"{stem}.facts").is_file() + ] + if len(paths) > 1: + raise ValueError(f"Ambiguous source relation {name}: {paths}") + if not paths: + if required: + raise FileNotFoundError(f"Missing required source relation {name}.facts in {self.source}") + return None + self.paths[name] = paths[0] + return paths[0] + + def intern(self, symbol: str) -> int: + value = self.symbols.get(symbol) + if value is None: + value = len(self.symbols) + 1 + if value > INT32_MAX: + raise ValueError("Global symbol IDs exceed the signed int32 oracle domain") + self.symbols[symbol] = value + return value + + def source_rows(self, name: str, arity: int, numeric: tuple[int, ...] = ()): + path = self.locate(name) + digest = hashlib.sha256() + size = count = 0 + with path.open("rb") as handle: + before = os.fstat(handle.fileno()) + for count, raw in enumerate(handle, 1): + digest.update(raw) + size += len(raw) + line = raw.decode("utf-8") + if line.endswith("\n"): + line = line[:-1] + if line.endswith("\r"): + line = line[:-1] + fields = line.split("\t") + if len(fields) != arity: + raise ValueError(f"{path}:{count}: expected {arity} columns, found {len(fields)}") + for column in numeric: + fields[column] = _int32(fields[column], f"{path}:{count}:column {column + 1}") + yield tuple(fields) + after = os.fstat(handle.fileno()) + if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns): + raise ValueError(f"Source relation changed during preparation: {path}") + self.source_relations[name] = { + "path": path.name, + "arity": arity, + "rows": count, + "bytes": size, + "sha256": digest.hexdigest(), + } + + def begin(self, stack: ExitStack, name: str, sources: list[str]) -> None: + for source in sources: + self.locate(source) + self.handles[name] = stack.enter_context( + (self.staging / f"{name}.csv").open("x", encoding="ascii", newline="\n") + ) + self.relations[name] = { + "arity": SCHEMA[name], + "rows_before_set_dedup": 0, + "sources": sources, + "operation": "projected_tuple_set", + } + + def emit(self, name: str, values: tuple) -> None: + if len(values) != SCHEMA[name]: + raise ValueError(f"{name}: projection arity mismatch") + encoded = (self.intern(value) if isinstance(value, str) else value for value in values) + self.handles[name].write("\t".join(map(str, encoded)) + "\n") + self.relations[name]["rows_before_set_dedup"] += 1 + + def check_value(self, attribute: str, instruction: str, values: tuple) -> None: + key = (attribute, self.intern(instruction)) + value = tuple(self.intern(item) for item in values) + previous = self.seen_instructions.setdefault(key, value) + if previous != value: + raise ValueError( + f"Nonfunctional {attribute} for {instruction!r}; relational normalization required" + ) + + def check_instruction(self, source: str, row: tuple, method_column: int) -> None: + # Shared attributes must be functional across categories too. The same + # instruction may validly occur in several categories (e.g. multiarray). + self.check_value("Instruction_Method", row[0], (row[method_column],)) + payload = row[2:5] if source == "AssignHeapAllocation" else row[2:] + self.check_value(source, row[0], payload) + if source in ("AssignHeapAllocation", "AssignLocal", "AssignCast"): + self.check_value("AssignInstruction_To", row[0], (row[3],)) + if source in ("LoadInstanceField", "StoreInstanceField", "LoadStaticField", "StoreStaticField"): + self.check_value( + "FieldInstruction_Signature", row[0], (row[4 if "Instance" in source else 3],) + ) + if source.endswith("MethodInvocation"): + self.check_value("MethodInvocation_Method", row[0], (row[2],)) + + def project(self) -> None: + with ExitStack() as stack: + for name, arity in DIRECT.items(): + self.begin(stack, name, [name]) + numeric = (0,) if name in ("FormalParam", "ActualParam") else () + for row in self.source_rows(name, arity, numeric): + self.emit(name, row) + self.begin(stack, "Field_DeclaringType", ["Field"]) + for row in self.source_rows("Field", 4): + self.emit("Field_DeclaringType", row[:2]) + methods = {} + for name in ("Method_SimpleName", "Method_DeclaringType", "Method_Descriptor"): + self.begin(stack, name, ["Method"]) + for method, simple, params, declaring, returns, _, _ in self.source_rows("Method", 7, (6,)): + # Legacy inputs include parentheses; Chopin parameter lists do not. + params = params if params.startswith("(") and params.endswith(")") else f"({params})" + descriptor = returns + params + attributes = (simple, params, declaring, returns) + previous = methods.setdefault(method, attributes) + if previous != attributes: + raise ValueError( + f"Nonfunctional Method attributes for {method!r}; relational normalization required" + ) + self.emit("Method_SimpleName", (method, simple)) + self.emit("Method_DeclaringType", (method, declaring)) + self.emit("Method_Descriptor", (method, descriptor)) + if self.locate("HeapAllocation_Type", required=False) is not None: + self.begin(stack, "HeapAllocation_Type", ["HeapAllocation_Type"]) + for row in self.source_rows("HeapAllocation_Type", 2): + self.emit("HeapAllocation_Type", row) + else: + self.begin(stack, "HeapAllocation_Type", ["NormalHeap", "StringConstant"]) + for row in self.source_rows("NormalHeap", 2): + self.emit("HeapAllocation_Type", row) + for row in self.source_rows("StringConstant", 1): + self.emit("HeapAllocation_Type", (row[0], "java.lang.String")) + invocation_sources = [ + "StaticMethodInvocation", + "SpecialMethodInvocation", + "VirtualMethodInvocation", + ] + self.begin(stack, "Instruction_Method", list(FAT) + invocation_sources[1:]) + self.begin(stack, "MethodInvocation_Method", invocation_sources) + self.begin(stack, "isStaticMethodInvocation_Insn", ["StaticMethodInvocation"]) + for source, (arity, columns, method_column) in FAT.items(): + target = "ReturnVar" if source == "Return" else source + self.begin(stack, target, [source]) + numeric = (1, 5) if source == "AssignHeapAllocation" else (1,) + for row in self.source_rows(source, arity, numeric): + self.check_instruction(source, row, method_column) + self.emit(target, tuple(row[column] for column in columns)) + self.emit("Instruction_Method", (row[0], row[method_column])) + if source == "StaticMethodInvocation": + self.emit("isStaticMethodInvocation_Insn", (row[0],)) + self.emit("MethodInvocation_Method", (row[0], row[2])) + for kind in ("Special", "Virtual"): + source = kind + "MethodInvocation" + self.begin(stack, source + "_Base", [source]) + if kind == "Virtual": + self.begin(stack, "isVirtualMethodInvocation_Insn", [source]) + for name in ("VirtualMethodInvocation_SimpleName", "VirtualMethodInvocation_Descriptor"): + self.begin(stack, name, [source, "Method"]) + for row in self.source_rows(source, 5, (1,)): + self.check_instruction(source, row, 4) + invocation, _, method, base, enclosing = row + self.emit("Instruction_Method", (invocation, enclosing)) + self.emit("MethodInvocation_Method", (invocation, method)) + self.emit(source + "_Base", (invocation, base)) + if kind == "Virtual": + self.emit("isVirtualMethodInvocation_Insn", (invocation,)) + if method in methods: + simple, params, _, returns = methods[method] + self.emit("VirtualMethodInvocation_SimpleName", (invocation, simple)) + self.emit("VirtualMethodInvocation_Descriptor", (invocation, returns + params)) + self.handles.clear() + self.seen_instructions.clear() + if self.relations.keys() != SCHEMA.keys(): + raise ValueError("Preparation did not produce the complete canonical input schema") + + +def prepare( + source: Path, + output: Path, + *, + program: Path | None = None, + provenance: dict | None = None, +) -> dict: + """Prepare a NEW directory; failures leave no published partial dataset. + + Source files are visited in fixed schema order and physical row order, so + identical raw files produce identical IDs and prepared files. All numeric + raw columns use the signed int32 oracle domain; only parameter indices are + retained as numbers in the outputs. Symbol IDs share one global namespace. + """ + source = Path(source).resolve(strict=True) + output = Path(output).absolute() + if not source.is_dir(): + raise NotADirectoryError(source) + if os.path.lexists(output): + raise FileExistsError(f"Refusing to overwrite {output}") + program = ( + Path(program) if program is not None else Path(__file__).resolve().parents[1] / "doop.py" + ) + program_sha256 = _program_contract(program) + if provenance is not None and not isinstance(provenance, dict): + raise TypeError("provenance must be a JSON object") + sorter = shutil.which("sort") + if sorter is None: + raise FileNotFoundError("DOOP preparation requires the external sort command") + scratch_root = os.environ.get("DOOP_SORT_TMPDIR") + if scratch_root is not None and not Path(scratch_root).is_dir(): + raise NotADirectoryError(f"DOOP_SORT_TMPDIR is not a directory: {scratch_root}") + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix=f".{output.name}.prepare-", dir=output.parent + ) as temporary: + work = Path(temporary) + staging = work / "prepared" + staging.mkdir() + adapter = _Preparation(source, staging) + adapter.project() + with tempfile.TemporaryDirectory(prefix="doop-sort-", dir=scratch_root or work) as scratch: + for name, info in adapter.relations.items(): + path = staging / f"{name}.csv" + subprocess.run( + [sorter, "-u", "-T", scratch, "-o", str(path), str(path)], + env=dict(os.environ, LC_ALL="C"), + check=True, + ) + info.update(_file_info(path, count_rows=True)) + meta = {key: adapter.intern(symbol) for key, symbol in CONSTANTS.items()} + _dump(staging / "meta.json", meta) + _dump(staging / "str2num.json", adapter.symbols) + source_identity = json.dumps( + adapter.source_relations, sort_keys=True, separators=(",", ":") + ).encode() + manifest = { + "status": "prepared_not_engine_validated", + "mode": "symbolic", + "source": source.name, + "output": output.name, + "source_sha256": hashlib.sha256(source_identity).hexdigest(), + "program": {"path": program.name, "sha256": program_sha256}, + "adapter_sha256": _file_info(Path(__file__))["sha256"], + "source_relations": adapter.source_relations, + "relations": adapter.relations, + "metadata": _file_info(staging / "meta.json"), + "symbols": _file_info(staging / "str2num.json"), + "meta": meta, + "meta_symbols": CONSTANTS, + "entrypoints": _entrypoints(staging, meta, adapter.symbols), + "provenance": provenance if provenance is not None else {}, + "semantics": { + "roots": "Exact MainClass set; no synthetic roots or heaps", + "normalization": "Known raw DOOP projections; functional shared instruction and method attributes required", + "descriptors": "returnType(params); already parenthesized parameter lists retained", + "heap_types": "Explicit HeapAllocation_Type if supplied, otherwise NormalHeap union StringConstant->java.lang.String", + "set_semantics": "LC_ALL=C sort -u over every complete projected relation; no sampling", + "numeric_domain": "Signed int32 numeric inputs and positive signed-int32 global symbol IDs", + "exclusions": "Exact three upstream symbolic methods named in meta_symbols", + }, + } + _dump(staging / "manifest.json", manifest) + # Reserve without overwrite, then replace our empty reservation with the + # complete directory in one same-filesystem rename. Concurrent preparers + # cannot both reserve the destination. Cleanup owns only our reservation. + output.mkdir() + try: + os.replace(staging, output) + except BaseException: + output.rmdir() + raise + return manifest diff --git a/tests/test_doop_catalog.py b/tests/test_doop_catalog.py new file mode 100644 index 0000000..588a296 --- /dev/null +++ b/tests/test_doop_catalog.py @@ -0,0 +1,48 @@ +"""Public fact acquisition must not trust a cached or unsafe archive.""" + +import hashlib +import stat +import sys +import zipfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'examples')) +from doop_suite.catalog import extract_archive, fetch_archive + + +def archive_spec(path): + return { + 'name': 'sample', + 'archive_bytes': path.stat().st_size, + 'archive_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), + } + + +@pytest.mark.parametrize('kind', ['traversal', 'symlink']) +def test_extraction_rejects_members_outside_fact_contract(tmp_path, kind): + archive = tmp_path / 'sample.zip' + with zipfile.ZipFile(archive, 'w') as stream: + stream.writestr('sample/MainClass.facts', 'Main\n') + if kind == 'traversal': + stream.writestr('sample/../../escaped.facts', 'bad') + else: + entry = zipfile.ZipInfo('sample/Alias.facts') + entry.create_system = 3 + entry.external_attr = (stat.S_IFLNK | 0o777) << 16 + stream.writestr(entry, '../../escaped.facts') + with pytest.raises(ValueError): + extract_archive(archive, archive_spec(archive), tmp_path / 'data') + assert not (tmp_path / 'escaped.facts').exists() + assert not (tmp_path / 'data/raw/sample').exists() + + +def test_existing_archive_with_wrong_bytes_is_not_reused(tmp_path): + cached = tmp_path / 'sample.zip' + cached.write_bytes(b'correct') + specification = archive_spec(cached) + cached.write_bytes(b'changed') + with pytest.raises(ValueError, match='SHA256'): + fetch_archive(specification, tmp_path / 'data', tmp_path) + assert cached.read_bytes() == b'changed' diff --git a/tests/test_doop_prepare.py b/tests/test_doop_prepare.py new file mode 100644 index 0000000..4343493 --- /dev/null +++ b/tests/test_doop_prepare.py @@ -0,0 +1,254 @@ +"""Semantic regressions for symbolic DOOP normalization, without any engine.""" + +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "examples")) +from doop_suite.prepare import prepare + +RAW_RELATIONS = """ +DirectSuperclass DirectSuperinterface MainClass FormalParam ComponentType +AssignReturnValue ActualParam Method-Modifier Var-Type ClassType ArrayType +InterfaceType Var-DeclaringMethod ApplicationClass ThisVar Field Method +NormalHeap StringConstant AssignHeapAllocation AssignLocal AssignCast +LoadInstanceField StoreInstanceField LoadStaticField StoreStaticField +LoadArrayIndex StoreArrayIndex Return StaticMethodInvocation +SpecialMethodInvocation VirtualMethodInvocation +""".split() +MAIN = "" +GET = "" +EXCLUDED = "" +OTHER = "" +FIELD = "" + + +def raw_facts(tmp_path, rows=None): + source = tmp_path / "raw" + source.mkdir() + for name in RAW_RELATIONS: + (source / f"{name}.facts").write_text("", encoding="utf-8") + for name, values in (rows or {}).items(): + (source / f"{name}.facts").write_text( + "".join("\t".join(map(str, row)) + "\n" for row in values), encoding="utf-8" + ) + return source + + +def prepared_rows(output, name): + return { + tuple(map(int, line.split("\t"))) for line in (output / f"{name}.csv").read_text().splitlines() + } + + +def test_complete_projection_preserves_shared_ids_roots_and_set_semantics(tmp_path): + rows = { + "MainClass": [("MainC",), ("MainC",), ("java.util.prefs.Base64",)], + "ClassType": [("MainC",), ("java.lang.Object",)], + "Method": [ + (MAIN, "main", "java.lang.String[]", "MainC", "void", "unused", 1), + (MAIN, "main", "(java.lang.String[])", "MainC", "void", "unused", 1), + (GET, "get", "", "MainC", "java.lang.Object", "unused", 0), + (EXCLUDED, "main", "java.lang.String[]", "java.util.prefs.Base64", "void", "unused", 1), + (OTHER, "main", "java.lang.String[]", "Other", "void", "unused", 1), + ], + "Method-Modifier": [ + (modifier, method) for method in (MAIN, EXCLUDED, OTHER) for modifier in ("public", "static") + ], + "FormalParam": [(0, MAIN, "args")], + "ActualParam": [ + (0, "static-call", "args"), + (-2147483648, "virtual-call", "value"), + (2147483647, "virtual-call", "value"), + ], + "Var-Type": [("value", "java.lang.Object")], + "Var-DeclaringMethod": [("value", MAIN)], + "Field": [(FIELD, "MainC", "field", "java.lang.Object")], + "NormalHeap": [("heap", "MainC"), ("heap", "MainC")], + "StringConstant": [("string-heap",)], + "AssignHeapAllocation": [ + ("alloc", 0, "heap", "value", MAIN, 12), + ("alloc", 0, "heap", "value", MAIN, 13), + ("alloc-again", 1, "heap", "value", MAIN, -1), + ], + "AssignLocal": [("local", 2, "value", "copy", MAIN)], + "AssignCast": [("cast", 3, "copy", "cast-value", "MainC", MAIN)], + "LoadInstanceField": [("load-field", 4, "loaded", "base", FIELD, MAIN)], + "StoreInstanceField": [("store-field", 5, "value", "base", FIELD, MAIN)], + "LoadStaticField": [("load-static", 6, "static-value", FIELD, MAIN)], + "StoreStaticField": [("store-static", 7, "value", FIELD, MAIN)], + "LoadArrayIndex": [("load-array", 8, "element", "array", MAIN)], + # Multiarray instructions legitimately belong to several categories. + "StoreArrayIndex": [("alloc", 0, "value", "array", MAIN)], + "Return": [("return", 9, "value", MAIN)], + "StaticMethodInvocation": [("static-call", 10, GET, MAIN)], + "SpecialMethodInvocation": [("special-call", 11, GET, "base", MAIN)], + "VirtualMethodInvocation": [("virtual-call", 12, GET, "base", MAIN)], + } + source = raw_facts(tmp_path, rows) + output = tmp_path / "prepared" + manifest = prepare(source, output, provenance={"release": "fixture"}) + symbols = json.loads((output / "str2num.json").read_text()) + expected = { + "AssignHeapAllocation": [("heap", "value", MAIN)], + "AssignLocal": [("value", "copy", MAIN)], + "AssignCast": [("MainC", "copy", "cast-value", MAIN)], + "LoadInstanceField": [("base", FIELD, "loaded", MAIN)], + "StoreInstanceField": [("value", "base", FIELD, MAIN)], + "LoadStaticField": [(FIELD, "static-value", MAIN)], + "StoreStaticField": [("value", FIELD, MAIN)], + "LoadArrayIndex": [("array", "element", MAIN)], + "StoreArrayIndex": [("value", "array", MAIN)], + "ReturnVar": [("value", MAIN)], + "StaticMethodInvocation": [("static-call", GET, MAIN)], + "SpecialMethodInvocation_Base": [("special-call", "base")], + "VirtualMethodInvocation_Base": [("virtual-call", "base")], + "VirtualMethodInvocation_SimpleName": [("virtual-call", "get")], + "VirtualMethodInvocation_Descriptor": [("virtual-call", "java.lang.Object()")], + "Field_DeclaringType": [(FIELD, "MainC")], + "HeapAllocation_Type": [("heap", "MainC"), ("string-heap", "java.lang.String")], + "FormalParam": [(0, MAIN, "args")], + "ActualParam": [ + (0, "static-call", "args"), + (-2147483648, "virtual-call", "value"), + (2147483647, "virtual-call", "value"), + ], + "MainClass": [("MainC",), ("java.util.prefs.Base64",)], + "Var_Type": [("value", "java.lang.Object")], + "Var_DeclaringMethod": [("value", MAIN)], + } + for name, tuples in expected.items(): + assert prepared_rows(output, name) == { + tuple(symbols[value] if isinstance(value, str) else value for value in row) for row in tuples + } + assert prepared_rows(output, "Instruction_Method") == { + (symbols[row[0]], symbols[MAIN]) + for name, tuples in rows.items() + if name + in ( + "AssignHeapAllocation", + "AssignLocal", + "AssignCast", + "LoadInstanceField", + "StoreInstanceField", + "LoadStaticField", + "StoreStaticField", + "LoadArrayIndex", + "StoreArrayIndex", + "Return", + "StaticMethodInvocation", + "SpecialMethodInvocation", + "VirtualMethodInvocation", + ) + for row in tuples + } + assert manifest["entrypoints"]["selected_main_methods"] == [{"id": symbols[MAIN], "symbol": MAIN}] + assert manifest["entrypoints"]["excluded_candidates"] == [symbols[EXCLUDED]] + assert {item["symbol"] for item in manifest["entrypoints"]["candidate_main_methods"]} == { + MAIN, + EXCLUDED, + } + meta = json.loads((output / "meta.json").read_text()) + assert meta["class_init_method"] == symbols[EXCLUDED] + assert meta["main_descriptor"] == symbols["void(java.lang.String[])"] + assert manifest["relations"]["AssignHeapAllocation"]["rows_before_set_dedup"] == 3 + assert manifest["relations"]["AssignHeapAllocation"]["rows"] == 1 + assert manifest["source_relations"]["AssignHeapAllocation"]["rows"] == 3 + assert manifest["status"] == "prepared_not_engine_validated" + assert len(manifest["relations"]) == 39 + for name, info in manifest["relations"].items(): + content = (output / info["path"]).read_bytes() + assert info["sha256"] == hashlib.sha256(content).hexdigest() + assert info["rows"] == len(prepared_rows(output, name)) + assert info["bytes"] == len(content) + second = tmp_path / "prepared-again" + prepare(source, second) + for name in ( + *[f"{relation}.csv" for relation in manifest["relations"]], + "meta.json", + "str2num.json", + ): + assert (output / name).read_bytes() == (second / name).read_bytes() + + +@pytest.mark.parametrize( + "relation,values", + [ + ( + "Method", + [ + (GET, "get", "", "MainC", "java.lang.Object", "unused", 0), + (GET, "get", "java.lang.String", "MainC", "void", "unused", 1), + ], + ), + ("StoreArrayIndex", [("shared", 0, "value", "array", OTHER)]), + ("AssignLocal", [("shared", 0, "value", "different-target", MAIN)]), + ], +) +def test_nonfunctional_attributes_fail_without_losing_relational_tuples(tmp_path, relation, values): + source = raw_facts( + tmp_path, + { + "AssignHeapAllocation": [("shared", 0, "heap", "value", MAIN, 1)], + relation: values, + }, + ) + output = tmp_path / "prepared" + with pytest.raises(ValueError, match="Nonfunctional"): + prepare(source, output) + assert not output.exists() + + +@pytest.mark.parametrize( + "relation,values", + [ + ("ActualParam", [(2147483648, "call", "value")]), + # Numeric columns projected away must still satisfy the source oracle schema. + ("Method", [(GET, "get", "", "MainC", "void", "unused", -2147483649)]), + ("AssignHeapAllocation", [("alloc", "not-an-index", "heap", "value", MAIN, 1)]), + ("Field", [(FIELD, "MainC", "field")]), + ], +) +def test_invalid_source_domains_and_arity_are_not_published(tmp_path, relation, values): + source = raw_facts(tmp_path, {relation: values}) + output = tmp_path / "prepared" + with pytest.raises(ValueError): + prepare(source, output) + assert not output.exists() + + +def test_missing_relation_is_not_treated_as_an_empty_relation(tmp_path): + source = raw_facts(tmp_path) + (source / "MainClass.facts").unlink() + output = tmp_path / "prepared" + with pytest.raises(FileNotFoundError, match="MainClass"): + prepare(source, output) + assert not output.exists() + + +def test_external_sort_failure_does_not_publish_partial_preparation(tmp_path, monkeypatch): + source = raw_facts(tmp_path) + output = tmp_path / "prepared" + + def fail_sort(command, **kwargs): + raise subprocess.CalledProcessError(1, command) + + monkeypatch.setattr(subprocess, "run", fail_sort) + with pytest.raises(subprocess.CalledProcessError): + prepare(source, output) + assert not output.exists() + + +def test_refuses_existing_output_and_preserves_its_contents(tmp_path): + source = raw_facts(tmp_path) + output = tmp_path / "prepared" + output.mkdir() + marker = output / "existing" + marker.write_text("keep me") + with pytest.raises(FileExistsError): + prepare(source, output) + assert marker.read_text() == "keep me"