#!/usr/bin/env python3 """Evaluate static patch suggestions against catalog-only ground truth (no catalog-assisted suggestions). eval_heuristics.py [--verbose] eval_heuristics.py --snapshot analyses.json --split all --json baseline.json eval_heuristics.py --snapshot analyses.json --fit-report --out fit-report.md Snapshots can be in progress: each read copies to a temporary file and retries incomplete JSON. The split is stratified by analysis engine and stable for a fixed set of packages. Converted inputs are included and labelled. Patch scores are micro-averaged across games, excluding enabled automatic defaults. Expected defaults are detected per analysis before applying explicit catalog removals; inapplicable defaults create no removal decisions. A wrong adapter value is both an FP and an FN. Removal decisions and alternate-build patches have their own identities ("remove:", "alt:"). Unsupported catalog entries and runtime-only choices are reported separately from the headline. """ from __future__ import annotations import argparse import copy import hashlib import json import shutil import subprocess import sys import tempfile import time from collections import Counter, defaultdict from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from frameport.analysis.detect import analyze # noqa: E402 from frameport.core.library import analysis_from_dict # noqa: E402 from frameport.core.models import Recipe # noqa: E402 from frameport.patches import base # noqa: E402 from frameport.recommend import catalog, engine # noqa: E402 from frameport.sources import quest_dump # noqa: E402 RUNTIME_ONLY = [ "use_alt", "adapter.vk_shader_fix", "adapter.focus_hold", "adapter.focus_hold_ms", "adapter.sync_guard", "adapter.scale", "adapter.layer_debug", "adapter.refresh_rate", "adapter.pose_consistency", "lepton_env", "config_sync", ] REPO = Path(__file__).resolve().parents[1] DEVICE_FIELDS = {"device.files", "device.lepton_env", "device.config_sync", "device.foveation"} def expected_defaults(analysis) -> set[str]: """Defaults recommended by their own detection, before recipe normalization/removals. As in suggest's initial pass, default_on patches bypass the opt-in applies gate; their own detect() decides. This masks e.g. LTW defaults in games without LTW, without importing any heuristic opt-in/adapter choices. """ return {p.id for p in base.all_patches() if p.default_on and base.for_game(p, analysis) and (s := p.detect(analysis)) and s.recommended} def signature(recipe: Recipe, defaults: set[str] | None = None) -> dict: """Recipe decisions, excluding enabled defaults but retaining explicit removal of defaults.""" pats = recipe.patches rift = recipe.package.startswith("rift.") patches = [p for p in base.all_patches() if (p.category == "pcvr") == rift] out = {} for category in ("overport", "frame", "pcvr"): out[category] = sorted(p.id for p in patches if p.category == category and not p.default_on and p.id in pats) out[category + "_removed"] = sorted(p.id for p in patches if p.category == category and p.default_on and p.id not in pats and (defaults is None or p.id in defaults)) out.update({ "adapter": {p.split(".", 1)[1]: v.get("value") for p, v in sorted(pats.items()) if p.startswith("adapter.")}, "device": sorted(p for p in pats if p.startswith("device.") and p not in DEVICE_FIELDS and not base.get(p).default_on), "device_files": sorted((pats.get("device.files") or {}).get("files", {})), "foveation": dict(pats.get("device.foveation") or {}), "lepton_env": dict((pats.get("device.lepton_env") or {}).get("env", {})), "config_sync": dict((pats.get("device.config_sync") or {}).get("files", {})), "alt": sorted(recipe.alt_patches), "use_alt": recipe.use_alt, "status_unsupported": recipe.status == "unsupported", }) return out def catalog_recipe(entry: catalog.CatalogEntry, defaults: set[str] | None = None, analysis=None) -> Recipe: """Materialize catalog choices plus expected defaults; never import heuristic opt-in choices. With the game's analysis, the engine's structural transforms apply as in suggest(): a 32-bit game goes through the bridge (its entry relies on that instead of listing what the bridge replaces), a 2D app keeps only its non-VR patches.""" r = Recipe(entry.package, status=entry.status, as_is=entry.as_is, alt_patches=list(entry.alt_overport), use_alt=entry.use_alt) rift = entry.kind == "rift" or entry.package.startswith("rift.") r.patches = {p.id: {} for p in base.all_patches() if p.default_on and (p.category == "pcvr") == rift and (defaults is None or p.id in defaults)} for pid in (*entry.overport_extra, *entry.frame, *entry.device, *entry.pcvr): r.patches[pid] = {} for pid in (*entry.overport_remove, *entry.frame_remove, *entry.pcvr_remove): r.patches.pop(pid, None) for key, value in entry.adapter.items(): r.patches[f"adapter.{key}"] = {"value": value} for pid, param, values in (("device.files", "files", entry.device_files), ("device.lepton_env", "env", entry.lepton_env), ("device.config_sync", "files", entry.config_sync)): if values: r.patches[pid] = {param: copy.deepcopy(values)} if entry.foveation: r.patches["device.foveation"] = {"mode": entry.foveation} if entry.foveation_radius: r.patches["device.foveation"]["radius"] = entry.foveation_radius if entry.foveation_test: r.patches["device.foveation"]["test"] = True if analysis is not None: if analysis.only_32bit and "frame.a32_bridge" not in entry.frame_remove: r.patches.setdefault("frame.a32_bridge", {}) engine.a32_recipe(analysis, r, newly=False) elif analysis.vr_kind == "none": needed = {pid for pid in r.patches if not base.get(pid).needs_vr and pid != "frame.launcher"} needed |= {"frame.launcher"} if analysis.has_info_category else set() r.patches = {pid: v for pid, v in r.patches.items() if pid in needed} r.alt_patches = [] return r def catalog_signature(entry: catalog.CatalogEntry, defaults: set[str] | None = None, analysis=None) -> dict: return signature(catalog_recipe(entry, defaults, analysis), defaults) def engine_group(name: str) -> str: return name if name in ("Unity", "Unreal") else "Other" def split_packages(engines: dict[str, str]) -> dict[str, str]: """package -> dev/holdout; nearest 20% per engine (half rounded up), lowest sha256 first. Only pass the intersection of catalog and analyses. This is stable under input reordering; adding packages can change membership at a stratum's cutoff. Tiny strata may have no holdout. Unsupported entries participate too. """ groups = defaultdict(list) for pkg, name in engines.items(): groups[engine_group(name)].append(pkg) result = {} for packages in groups.values(): ordered = sorted(packages, key=lambda p: (hashlib.sha256(p.encode()).digest(), p)) n = (len(ordered) + 2) // 5 result.update((p, "holdout" if i < n else "dev") for i, p in enumerate(ordered)) return dict(sorted(result.items())) def _parts(sig: dict) -> tuple[dict, dict]: headline, runtime = {}, {} for key, value in sig.items(): if key == "adapter": headline[key] = {k: v for k, v in value.items() if f"adapter.{k}" not in RUNTIME_ONLY} runtime[key] = {k: v for k, v in value.items() if f"adapter.{k}" in RUNTIME_ONLY} else: (runtime if key in RUNTIME_ONLY else headline)[key] = value return headline, runtime def _decisions(sig: dict) -> dict: """Patch/action identities -> values. Status is a field metric, not a patch.""" out = {} for field, value in sig.items(): if field == "status_unsupported": continue if field in ("adapter", "lepton_env", "config_sync"): prefix = "adapter" if field == "adapter" else f"device.{field}" out.update((f"{prefix}.{k}", v) for k, v in value.items()) elif field == "foveation": if value: out["device.foveation"] = value elif field == "use_alt": if value: out["use_alt"] = value else: prefix = ("remove:" if field.endswith("_removed") else "alt:" if field == "alt" else "device.files:" if field == "device_files" else "") out.update((prefix + p, True) for p in value) return out def _rates(tp: int, fp: int, fn: int) -> dict: # No positive decisions yields 0, rather than inflating scores for empty recipes/strata. return {"tp": tp, "fp": fp, "fn": fn, "precision": tp / (tp + fp) if tp + fp else 0.0, "recall": tp / (tp + fn) if tp + fn else 0.0, "f1": 2 * tp / (2 * tp + fp + fn) if 2 * tp + fp + fn else 0.0} def score_signatures(want: dict, got: dict, gaps: frozenset | set = frozenset()) -> dict: """Compare one pair of signatures; value mismatches count as both missing and extra. `gaps` = decisions the catalog entry couldn't have made (the patch didn't exist when it was verified): left out of both sides.""" result = {} for scope, w, g in zip(("headline", "runtime"), _parts(want), _parts(got), strict=True): wanted, actual = _decisions(w), _decisions(g) gap = {k: actual.pop(k) for k in list(actual) if k in gaps and k not in wanted} missing = {k: v for k, v in wanted.items() if k not in actual or actual[k] != v} extra = {k: v for k, v in actual.items() if k not in wanted or wanted[k] != v} per_patch = {k: _rates(int(k in wanted and k in actual and wanted[k] == actual[k]), int(k in extra), int(k in missing)) for k in sorted(wanted.keys() | actual.keys())} fields = {k: w.get(k) == g.get(k) for k in sorted(w.keys() | g.keys())} exact = all(fields.values()) if not gap else not missing and not extra result[scope] = {"exact": exact, "fields": fields, "missing": missing, "extra": extra, "per_patch": per_patch, "gap": gap} return result def _aggregate(rows: list[dict], scope: str, score_key: str = "score") -> dict: fields = defaultdict(lambda: {"matched": 0, "total": 0}) counts = defaultdict(Counter) gaps = Counter() for row in rows: s = row[score_key][scope] gaps.update(s.get("gap") or {}) for key, match in s["fields"].items(): fields[key]["matched"] += int(match) fields[key]["total"] += 1 for key, stats in s["per_patch"].items(): counts[key].update({k: stats[k] for k in ("tp", "fp", "fn")}) per_patch = {k: _rates(**v) for k, v in sorted(counts.items())} total = Counter() for stats in counts.values(): total.update(stats) exact = sum(row[score_key][scope]["exact"] for row in rows) return {"games": len(rows), "exact": exact, "exact_ratio": exact / len(rows) if rows else 0.0, "fields": dict(sorted(fields.items())), "per_patch": per_patch, "gaps": dict(sorted(gaps.items())), "patches": _rates(*(total[k] for k in ("tp", "fp", "fn")))} def score_records(records: list[dict]) -> dict: """Score {package, engine, status, converted, want, got, ...} records; no filesystem needed.""" rows = [{**r, "score": score_signatures(r["want"], r["got"]), "score_dated": score_signatures(r["want"], r["got"], set(r.get("gaps") or ()))} for r in sorted(records, key=lambda r: r["package"])] def metrics(group): supported = [r for r in group if r["status"] != "unsupported"] unsupported = [r for r in group if r["status"] == "unsupported"] return {"total_games": len(group), "converted_games": sum(bool(r.get("converted")) for r in group), "excluded_unsupported": len(unsupported), "headline": _aggregate(supported, "headline"), "runtime": _aggregate(supported, "runtime"), "headline_dated": _aggregate(supported, "headline", "score_dated"), "unsupported": {scope: _aggregate(unsupported, scope) for scope in ("headline", "runtime")}} return {**metrics(rows), "by_engine": {name: metrics([r for r in rows if engine_group(r["engine"]) == name]) for name in ("Unity", "Unreal", "Other")}, "games": rows} def read_snapshot(path: Path, attempts: int = 8) -> dict: """Copy before parsing, even on retries; never write to the source or read APK/data paths in it.""" with tempfile.TemporaryDirectory(prefix="frameport-eval-") as tmp: dest = Path(tmp) / "snapshot.json" for attempt in range(attempts): shutil.copyfile(path, dest) try: return json.loads(dest.read_text(encoding="utf-8")) except json.JSONDecodeError: if attempt == attempts - 1: raise time.sleep(0.25 * (attempt + 1)) raise AssertionError("unreachable") def read_dumps(path: Path) -> dict: records = {} for g in quest_dump.scan(path): a = analyze(g.apk, data_bytes=g.data_bytes()) row = {"analysis": a.to_dict(), "converted": a.is_overport_output, "name": g.name} if a.package not in records or records[a.package]["converted"] and not row["converted"]: records[a.package] = row return records def gap_decisions(entry, intro_dates: dict | None) -> set[str]: """Decision keys for patches introduced after the entry was verified: the catalog can't be wrong about them.""" verified = str((entry.verified or {}).get("date") or "") if not intro_dates or not verified: return set() return {(f"alt:{pid}" if pid.startswith("patch_remove_unreal") else pid) for pid, date in intro_dates.items() if date and date > verified} def inherited_decisions() -> set[str]: """Heuristic picks a catalog recipe keeps even when the entry doesn't name them (engine.suggest: adapter settings and device patches other than catalog.TOGGLED_DEVICE): the verified build had them, so an "extra" one isn't a detection error. A catalog value that differs still counts (the key is in both sides then).""" return {p.id for p in base.all_patches() if p.category == "adapter" or (p.category == "device" and p.id not in catalog.TOGGLED_DEVICE)} def evaluate(snapshot: dict, entries: dict, split: str = "dev", intro_dates: dict | None = None) -> dict: analyses = {pkg: analysis_from_dict(v["analysis"]) for pkg, v in snapshot.items() if pkg in entries} assignments = split_packages({pkg: a.engine for pkg, a in analyses.items()}) records = [] for pkg, a in sorted(analyses.items()): if split != "all" and assignments[pkg] != split: continue entry = entries[pkg] defaults = expected_defaults(a) records.append({"package": pkg, "title": entry.title, "engine": a.engine, "status": entry.status, "converted": bool(snapshot[pkg].get("converted", a.is_overport_output)), "analysis_version": snapshot[pkg].get("analysis_version"), "split": assignments[pkg], "want": catalog_signature(entry, defaults, a), "got": signature(engine.suggest(a, use_catalog=False), defaults), "gaps": sorted(gap_decisions(entry, intro_dates) | inherited_decisions())}) return {"schema_version": 1, "split": split, "runtime_only": RUNTIME_ONLY, "snapshot_packages": len(snapshot), "default_policy": "per-analysis default detection, then explicit catalog removals", "catalog_packages": len(entries), "matched_packages": len(analyses), "split_assignments": assignments, **score_records(records)} def fit_candidates(analysis, entry: catalog.CatalogEntry) -> list[dict]: """Use suggest's final heuristic pass, including platform filtering, non-VR and a32 normalization. Requiring a recommended detect() as well excludes dependencies and synthetic recipe additions. No engine refactor is needed: suggest() remains untouched, and catalog data is used only for the candidate exclusion and the hypothetical warning recipe. """ suggested = engine.suggest(analysis, use_catalog=False) chosen = catalog_recipe(entry, expected_defaults(analysis), analysis) listed = set(chosen.patches) | set(entry.alt_overport) removed = set(entry.overport_remove) | set(entry.frame_remove) | set(entry.pcvr_remove) result = [] for pid, params in sorted(suggested.patches.items()): p = base.get(pid) if (p.default_on or pid in listed or pid in removed or not base.for_game(p, analysis) or not p.applies(analysis)): continue s = p.detect(analysis) if not s or not s.recommended: continue trial = copy.deepcopy(chosen) trial.patches[pid] = dict(params) result.append({"patch": pid, "category": p.category, "experimental": p.experimental, "strict": bool(getattr(p, "strict", False)), "reason": s.reason, "warnings": engine.warnings(trial, analysis)}) return result def patch_intro_dates(cache_path: Path) -> dict: """One local git pickaxe per uncached patch (including missing-history results); never use the network.""" try: cached = json.loads(cache_path.read_text(encoding="utf-8")) except (OSError, ValueError): cached = {} changed = False for p in base.all_patches(): if p.id in cached: continue needle = p.id.split(".", 1)[1] if p.category == "adapter" else p.id paths = ["src/frameport/patches/settings.py"] if p.category == "adapter" else ["src/frameport"] log = subprocess.run(["git", "log", f'-S"{needle}"', "--reverse", "--format=%ad", "--date=short", "--", *paths], cwd=REPO, text=True, capture_output=True, check=True) dates = log.stdout.splitlines() cached[p.id] = dates[0] if dates else None changed = True if changed: cache_path.write_text(json.dumps(cached, indent=2, sort_keys=True) + "\n", encoding="utf-8") return cached def entry_date(entry) -> str: """When the recipe was verified; `updated` only when there is no verified date (it also moves for unrelated edits, e.g. a note, so it says nothing about which patches the verifier could pick).""" return str(entry.verified.get("date") or "") or str(entry.updated or "") def fit_report(snapshot: dict, entries: dict, intro_dates: dict) -> dict: games, metadata = [], [] totals = Counter() for pkg, entry in sorted(entries.items()): common = {"package": pkg, "title": entry.title, "engine": entry.engine, "xr": entry.xr, "status": entry.status, "verified_date": str(entry.verified.get("date") or ""), "updated": str(entry.updated or ""), "entry_date": entry_date(entry)} if pkg not in snapshot: metadata.append(common) continue candidates = fit_candidates(analysis_from_dict(snapshot[pkg]["analysis"]), entry) for c in candidates: intro = intro_dates.get(c["patch"]) c.update(intro_date=intro, newer_patch=bool(intro and common["entry_date"] and intro > common["entry_date"])) totals[c["patch"]] += 1 games.append({**common, "converted": bool(snapshot[pkg].get("converted")), "candidates": candidates}) return {"games": games, "metadata_only": metadata, "totals_by_patch": dict(sorted(totals.items())), "candidate_count": sum(totals.values()), "games_with_candidates": sum(bool(g["candidates"]) for g in games)} def _md(value) -> str: return str(value if value is not None else "unknown").replace("|", "\\|").replace("\n", " ") def render_fit_report(report: dict) -> str: lines = ["# New patch fit", "", f"{len(report['games'])} catalog games with analyses; " f"{report['games_with_candidates']} with candidates; {report['candidate_count']} candidate additions. " f"{len(report['metadata_only'])} metadata-only entries.", "", "Candidates are opt-in recommended patches absent from the entry, after the heuristic engine's platform " "and a32 rules. A listed adapter key is excluded even if its value differs. Warnings are for the full " "catalog recipe plus that one patch; they can include pre-existing conflicts. Unknown history/date " "does not imply a newer patch. Default-on patches are not candidates. All splits and statuses included.", "", "## Totals by patch", "", "| Patch | Games |", "| --- | ---: |"] lines.extend(f"| {pid} | {n} |" for pid, n in sorted(report["totals_by_patch"].items(), key=lambda kv: (-kv[1], kv[0]))) for game in report["games"]: lines += ["", f"## {_md(game['title'])} (`{game['package']}`)", "", f"{game['engine']} / {game['xr']}; status: {game['status']}; " f"entry date: {game['entry_date'] or 'unknown'}; converted: {game['converted']}.", ""] if not game["candidates"]: lines += ["No candidates."] continue lines += ["| Patch | Category | Experimental | Strict | Reason | Introduced | Newer patch | Warnings |", "| --- | --- | --- | --- | --- | --- | --- | --- |"] for c in game["candidates"]: cells = [c["patch"], c["category"], c["experimental"], c["strict"], c["reason"], c["intro_date"], c["newer_patch"], "; ".join(c["warnings"]) or "none"] lines.append("| " + " | ".join(_md(v) for v in cells) + " |") lines += ["", "## Catalog entries without analyses (metadata only)", "", "| Package | Title | Engine | XR | Status | Verified | Updated | Entry date |", "| --- | --- | --- | --- | --- | --- | --- | --- |"] for g in report["metadata_only"]: lines.append("| " + " | ".join(_md(g[k]) for k in ("package", "title", "engine", "xr", "status", "verified_date", "updated", "entry_date")) + " |") return "\n".join(lines) + "\n" def print_metrics(report: dict, verbose: bool = False) -> None: print(f"Split: {report['split']}; catalog/analysis intersection: {report['matched_packages']}; " f"converted: {report['converted_games']}; unsupported excluded: {report['excluded_unsupported']}") print(f"{'Engine':10} {'Games':>5} {'Exact':>9} {'Fields':>11} {'Prec':>7} {'Recall':>7} {'F1':>7} {'Runtime':>9}") for name, metrics in [("All", report), *report["by_engine"].items()]: h, r = metrics["headline"], metrics["runtime"] fields = h["fields"].values() f = f"{sum(x['matched'] for x in fields)}/{sum(x['total'] for x in h['fields'].values())}" p = h["patches"] print(f"{name:10} {h['games']:5} {h['exact']:4}/{h['games']:<4} {f:>11} " f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%} {r['exact']:4}/{r['games']:<4}") print("\nDated (extras the entry couldn't have listed - patch newer than its verified date - or that catalog " "recipes inherit anyway - adapter/device picks - are not errors):") for name, metrics in [("All", report), *report["by_engine"].items()]: d = metrics["headline_dated"] p = d["patches"] print(f"{name:10} {d['games']:5} {d['exact']:4}/{d['games']:<4} {'':>11} " f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%}") gaps = report["headline_dated"]["gaps"] if gaps: print("Catalog gaps by patch: " + ", ".join(f"{k}={v}" for k, v in sorted(gaps.items(), key=lambda kv: -kv[1]))) for name, metrics in [("All", report), *report["by_engine"].items()]: print(f"\n{name}: field matches (headline / runtime)") for scope in ("headline", "runtime"): matches = metrics[scope]["fields"].items() print(scope + ": " + ", ".join(f"{k}={v['matched']}/{v['total']}" for k, v in matches)) print(f"{'Scope / patch':55} {'TP':>5} {'FP':>5} {'FN':>5} {'Prec':>7} {'Recall':>7} {'F1':>7}") for scope in ("headline", "runtime"): for pid, p in metrics[scope]["per_patch"].items(): print(f"{scope + ' / ' + pid:55} {p['tp']:5} {p['fp']:5} {p['fn']:5} " f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%}") u = report["unsupported"]["headline"] print(f"\nUnsupported (excluded): {u['exact']}/{u['games']} exact; patch F1={u['patches']['f1']:.1%}") for game in report["games"]: s = game["score"] if verbose or not s["headline"]["exact"] or not s["runtime"]["exact"] or game["status"] == "unsupported": print(f"\n{game['package']} [{game['engine']}, {game['status']}, {game['split']}" + (", converted" if game["converted"] else "") + "]") for scope in ("headline", "runtime"): for diff in ("missing", "extra"): if s[scope][diff]: print(f" {scope} {diff}: {json.dumps(s[scope][diff], sort_keys=True)}") if not s[scope]["fields"].get("status_unsupported", True): print(f" {scope} status_unsupported: catalog={game['want']['status_unsupported']} " f"heuristics={game['got']['status_unsupported']}") def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("dumps", type=Path, nargs="?") ap.add_argument("--snapshot", type=Path) ap.add_argument("--split", choices=("dev", "holdout", "all"), default="dev") ap.add_argument("--json", type=Path, dest="json_out") ap.add_argument("--verbose", action="store_true") ap.add_argument("--fit-report", action="store_true") ap.add_argument("--out", type=Path, help="Markdown destination for --fit-report (otherwise stdout)") args = ap.parse_args() if not args.snapshot and not args.dumps: ap.error("provide a dumps folder or --snapshot") if args.out and not args.fit_report: ap.error("--out requires --fit-report") snapshot = read_snapshot(args.snapshot) if args.snapshot else read_dumps(args.dumps) # Explicitly bundled entries: remote caches and personal recipes must not affect this benchmark. entries = catalog._load_dir(REPO / "catalog/games", "bundled") if args.fit_report: cache = (args.snapshot.parent if args.snapshot else args.dumps) / "patch-intro-dates.json" report = fit_report(snapshot, entries, patch_intro_dates(cache)) markdown = render_fit_report(report) if args.out: args.out.write_text(markdown, encoding="utf-8") print(f"{report['candidate_count']} candidates in {report['games_with_candidates']} games -> {args.out}") else: print(markdown, end="") else: cache = (args.snapshot.parent if args.snapshot else args.dumps) / "patch-intro-dates.json" # dated scoring reads the cache --fit-report writes; a plain run never shells out for dates dates = json.loads(cache.read_text(encoding="utf-8")) if cache.is_file() else None report = evaluate(snapshot, entries, args.split, dates) print_metrics(report, args.verbose) if args.json_out: args.json_out.write_text(json.dumps(report, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8") # Retain the legacy evaluator's nonzero exit for recipe differences; JSON is still written. return 0 if args.fit_report or report["headline"]["games"] == report["headline"]["exact"] else 1 if __name__ == "__main__": sys.exit(main())