from __future__ import annotations import argparse import json from dataclasses import replace from typing import Any import torch from config import V2Config from env import FastFloatingBeaconEnv from mapgen import configure_motif_mapgen, install_motif_mapgen, mapgen_summary from settings import ENV_DEFAULTS, MAPGEN_KWARGS, PHYSICS_DEFAULTS STAGES: dict[str, tuple[int, int]] = { "rookie": (10, 6), "standard": (14, 10), "cursed": (18, 14), "nightmare": (22, 18), } def cfg_for(stage: str, route_jumps: int, distractors: int, *, envs: int, seed: int, device: str) -> V2Config: fields = V2Config.__dataclass_fields__ cfg = V2Config( seed=int(seed), device=str(device), envs=int(envs), eval_envs=int(envs), route_jumps=int(route_jumps), distractors=int(distractors), pillar_platforms=False, sensor_mode="topk", sensor_topk=16, sensor_token_range=9.5, sensor_fov_degrees=120.0, sensor_token_sort="distance", observe_progress=False, **{key: value for key, value in PHYSICS_DEFAULTS.items() if key in fields}, **{key: value for key, value in ENV_DEFAULTS.items() if key in fields}, **{key: value for key, value in MAPGEN_KWARGS.items() if key in fields}, ) return replace(cfg, run_dir=f"data/audit/{stage}") def audit_stage(stage: str, route_jumps: int, distractors: int, *, envs: int, seed: int, device: str) -> dict[str, Any]: cfg = cfg_for(stage, route_jumps, distractors, envs=envs, seed=seed, device=device) env = FastFloatingBeaconEnv(cfg, envs=envs, seed=seed) env.reset() summary = mapgen_summary(env) route = max(1, int(route_jumps)) shortcut_ratio = float(summary["all_path_shortcut_ratio_mean"]) return { "stage": stage, "seed": int(seed), "envs": int(envs), "route_jumps": int(route_jumps), "distractors": int(distractors), "platforms": int(summary["platforms"]), "goal_xy_min": float(summary["goal_xy_min"]), "goal_xy_mean": float(summary["goal_xy_mean"]), "height_span_mean": float(summary["height_span_mean"]), "route_path_xy_mean": float(summary["route_path_xy_mean"]), "goal_xy_to_route_path_ratio_mean": float(summary["goal_xy_to_route_path_ratio_mean"]), "extra_after_goal_fraction": float(summary["extra_after_goal_fraction"]), "extra_before_start_fraction": float(summary["extra_before_start_fraction"]), "extra_between_start_goal_fraction": float(summary["extra_between_start_goal_fraction"]), "route_edge_gap_mean": float(summary["route_edge_gap_mean"]), "route_edge_gap_min": float(summary["route_edge_gap_min"]), "shortest_hops_mean": float(summary["all_path_shortest_hops_mean"]), "shortest_hops_p50": float(summary["all_path_shortest_hops_p50"]), "shortcut_ratio_mean": shortcut_ratio, "shortcut_le_half_fraction": float(summary["all_path_shortcut_le_half_fraction"]), "shortcut_le_five_fraction": float(summary["all_path_shortcut_le_five_fraction"]), "nearest_edge_gap_mean": float(summary["all_platform_nearest_edge_gap_mean"]), "nearest_edge_gap_min": float(summary["all_platform_nearest_edge_gap_min"]), "tiny_platform_fraction": float(summary["tiny_platform_fraction"]), "route_away_goal_fraction": float(summary["route_away_goal_fraction"]), "route_side_goal_fraction": float(summary["route_side_goal_fraction"]), "route_down_step_fraction": float(summary["route_down_step_fraction"]), "pass_basic_hardness": bool( float(summary["goal_xy_min"]) >= max(16.0, route * 1.45) and float(summary["goal_xy_mean"]) >= max(18.0, route * 1.75) and float(summary["extra_after_goal_fraction"]) <= 0.02 and shortcut_ratio >= 0.58 and float(summary["all_path_shortcut_le_five_fraction"]) <= 0.05 and float(summary["all_path_shortcut_le_half_fraction"]) <= 0.25 ), } def parse_stage(value: str) -> tuple[str, int, int]: if ":" in value: name, route, distractors = value.split(":", 2) return name.strip(), int(route), int(distractors) route, distractors = STAGES[value] return value, route, distractors def main() -> None: parser = argparse.ArgumentParser(description="Audit procedural parkour maps for shortcut/easiness inflation.") parser.add_argument("--device", default="cpu") parser.add_argument("--envs", type=int, default=512) parser.add_argument("--seed", type=int, default=7) parser.add_argument( "--stage", action="append", default=[], help="Stage name or name:route_jumps:distractors. Defaults to all app stages.", ) parser.add_argument("--json", action="store_true") args = parser.parse_args() configure_motif_mapgen(replace=True, **MAPGEN_KWARGS) install_motif_mapgen() stages = [parse_stage(item) for item in args.stage] if args.stage else [ (name, route, distractors) for name, (route, distractors) in STAGES.items() ] rows = [ audit_stage(name, route, distractors, envs=int(args.envs), seed=int(args.seed) + i * 1009, device=str(args.device)) for i, (name, route, distractors) in enumerate(stages) ] if args.json: print(json.dumps({"rows": rows}, indent=2, sort_keys=True)) return print( "stage route distr goal_min goal_mean path_xy straight extra_after shortest_p50 ratio le_half le_five edge_gap tiny away side pass", flush=True, ) for row in rows: print( f"{row['stage']} {row['route_jumps']} {row['distractors']} " f"{row['goal_xy_min']:.2f} {row['goal_xy_mean']:.2f} " f"{row['route_path_xy_mean']:.2f} {row['goal_xy_to_route_path_ratio_mean']:.2f} " f"{row['extra_after_goal_fraction']:.3f} " f"{row['shortest_hops_p50']:.2f} {row['shortcut_ratio_mean']:.2f} " f"{row['shortcut_le_half_fraction']:.3f} {row['shortcut_le_five_fraction']:.3f} " f"{row['nearest_edge_gap_mean']:.2f} {row['tiny_platform_fraction']:.2f} " f"{row['route_away_goal_fraction']:.2f} {row['route_side_goal_fraction']:.2f} " f"{'yes' if row['pass_basic_hardness'] else 'NO'}", flush=True, ) if __name__ == "__main__": torch.set_float32_matmul_precision("high") main()