import argparse from concurrent.futures import ThreadPoolExecutor import json import os import pickle import time import sys from pathlib import Path from code.common.fsf_wrapper import FSFEncoder from code.common.utils import set_seed, validate_plan from code.tokenization.factory import create_tokenizer from code.tokenization.multidomain import MultiDomainUnionTokenizer, load_tokenizer_from_manifest import numpy as np import xgboost as xgb from pyperplan.grounding import ground from pyperplan.pddl.parser import Parser from tqdm import tqdm DEFAULT_SPLITS = ["validation", "test-interpolation", "test-extrapolation"] def progress_enabled() -> bool: return bool(sys.stdout.isatty()) def xgb_cuda_supported() -> bool: """Best-effort check for CUDA support in installed XGBoost build.""" try: info = xgb.build_info() except Exception: return False flag = info.get("USE_CUDA") if isinstance(flag, bool): return flag if isinstance(flag, str): return flag.lower() in {"1", "true", "on", "yes"} return False def resolve_xgb_device(device_arg: str) -> str: """Resolve XGBoost device target from CLI preference.""" if device_arg in {"cpu", "cuda"}: return device_arg return "cuda" if xgb_cuda_supported() else "cpu" def score_distances( pred_next_emb: np.ndarray, cand_matrix: np.ndarray, *, score_metric: str, delta: bool, ) -> np.ndarray: """ Compute successor distances under the requested metric. `native` preserves the original behavior from the main study: - cosine distance for state prediction - L2 distance for delta prediction """ metric = score_metric if metric == "native": metric = "l2" if delta else "cosine" u = pred_next_emb.reshape(-1) if metric == "cosine": u_norm = np.linalg.norm(u) v_norms = np.linalg.norm(cand_matrix, axis=1) denom = u_norm * v_norms cos_sim = np.divide( cand_matrix @ u, denom, out=np.zeros_like(v_norms, dtype=np.float32), where=(denom != 0), ) return np.where((u_norm == 0) | (v_norms == 0), 1.0, 1.0 - cos_sim) if metric == "l2": return np.linalg.norm(cand_matrix - u, axis=1) raise ValueError(f"Unsupported score metric: {score_metric}") def normalize_encoding_name(name: str) -> str: """Normalize encoding aliases to canonical tokenizer names.""" return "wl" if name == "graphs" else name def resolve_model_artifact_path(data_dir: str, filename: str) -> str: """ Resolve files from data/encodings/models robustly. Supports callers passing either `data` or an encoding subdir. """ p = Path(data_dir).resolve() search_dirs = [ p / "encodings" / "models", p / "models", p.parent / "models", p.parent.parent / "models", Path("data") / "encodings" / "models", ] seen = set() for d in search_dirs: d_str = str(d) if d_str in seen: continue seen.add(d_str) candidate = d / filename if candidate.exists(): return str(candidate) return str(search_dirs[0] / filename) def resolve_vocab_path(data_dir: str, domain: str, raw_encoding: str) -> str | None: """Find the most likely vocabulary file for a tokenizer.""" normalized = normalize_encoding_name(raw_encoding) names = [f"{domain}_{normalized}.json"] if normalized == "wl": names.extend( [ f"{domain}_wl_tok.json", f"{domain}_wl.json", f"{domain}_graphs.json", ] ) if raw_encoding != normalized: names.append(f"{domain}_{raw_encoding}.json") for name in names: path = resolve_model_artifact_path(data_dir, name) if os.path.exists(path): return path return None def embed_state_generic(atoms_set, tokenizer, goal_atoms, objects, problem_path): """ Helper to convert a set of atoms (strings) into a Numpy Array [1, D] using generic tokenizer. """ # 1. Convert set to list state_atoms = list(atoms_set) # 2. Transform # Passing problem_pddl_path as kwarg for WLTokenizer which needs it try: vec = tokenizer.transform_state( state_atoms, goal_atoms, objects, problem_pddl_path=problem_path, ) except TypeError: vec = tokenizer.transform_state(state_atoms, goal_atoms, objects) # Return [1, D] return vec.reshape(1, -1) def embed_state_generic_cached( atoms_set, tokenizer, goal_atoms, objects, problem_path, *, wl_prob=None, ): """Embed a state while reusing a pre-parsed WL problem when available.""" try: vec = tokenizer.transform_state( list(atoms_set), goal_atoms, objects, problem_pddl_path=problem_path, _wl_prob=wl_prob, ) except TypeError: vec = embed_state_generic(atoms_set, tokenizer, goal_atoms, objects, problem_path) return vec.reshape(1, -1) def embed_state_fsf(atoms_set, encoder, objects, obj_map): """ Helper to convert a set of atoms (strings) into a Numpy Array [1, D] using FSF. """ # Convert set of strings to list of tuples: "(on a b)" -> ("on", "a", "b") atom_tuples = [] for a in atoms_set: content = a.replace("(", "").replace(")", "").lower() parts = content.split() if parts: atom_tuples.append(tuple(parts)) # Use the encoder's internal logic vec = encoder._state_to_vector(atom_tuples, objects, obj_map) # Return [1, D] return vec.reshape(1, -1) def extract_objects_from_problem(prob, dom) -> list[str]: """Extract sorted object names from a parsed pyperplan problem/domain pair.""" objs = set() prob_objects = getattr(prob, "objects", {}) if isinstance(prob_objects, dict): objs.update(str(name) for name in prob_objects.keys()) else: for obj in prob_objects: objs.add(obj.name if hasattr(obj, "name") else str(obj)) dom_constants = getattr(dom, "constants", {}) if isinstance(dom_constants, dict): objs.update(str(name) for name in dom_constants.keys()) else: for obj in dom_constants: objs.add(obj.name if hasattr(obj, "name") else str(obj)) return sorted(objs) def solve_problem( prob_file, domain_path, prob_path, model, max_steps, steps_per_object, delta, encoding_type, feature_encoder=None, # Tokenizer or FSFEncoder objects=None, # List of strings (objects) obj_map=None, # For FSF beam_width=3, score_metric="native", collect_search_stats: bool = False, ): """ Runs Latent Space Search using Beam Search (XGBoost version). Supports both Generic Tokenizers and FSF encodings. """ # print( # f"Inference using {'Delta Prediction' if delta else 'State Prediction'} for {prob_file}" # ) search_start_time = None if collect_search_stats: search_start_time = time.perf_counter() search_stats = { "beam_expansions": 0, "model_calls": 0, "successor_evals": 0, "outer_steps": 0, "terminated_reason": "max_steps", "score_metric": score_metric, "beam_width": beam_width, } # 1. Pyperplan Parsing (Ground Truth Physics) try: parser = Parser(domain_path, prob_path) dom = parser.parse_domain() prob = parser.parse_problem(dom) task = ground(prob) except Exception as e: print(f"Pyperplan Parsing Error on {prob_file}: {e}") raise e # Match upstream inference behavior: allow longer searches on larger problems. num_objects = len(prob.objects) + len(dom.constants) effective_max_steps = max(max_steps, steps_per_object * num_objects) if objects is None and encoding_type != "fsf": objects = extract_objects_from_problem(prob, dom) initial_atoms = task.initial_state goal_set = set(task.goals) state_cache = {} successor_cache = {} sorted_operators = sorted(task.operators, key=lambda op: op.name) # Pre-compute goal atoms list for generic tokenizer goal_atoms_list = list(goal_set) wl_prob = None if encoding_type == "wl": from wlplan.planning import parse_problem as wl_parse_problem wl_prob = wl_parse_problem(domain_path, prob_path) # 2. Embedding Setup (Goal & Init) if encoding_type == "fsf": # FSF Setup encoder = feature_encoder # FSF requires problem-specific object mapping # objects and obj_map passed in are ignored/recomputed for FSF usually? # The original code recomputed them per problem for FSF. fsf_objects = encoder._get_sorted_objects(prob_path) fsf_obj_map = encoder._get_object_indices(fsf_objects) # Embed Goal goal_vec_1d = encoder.embed_goal(prob_path) goal_vec = goal_vec_1d.reshape(1, -1) # [1, D] # Embed Init init_vec = embed_state_fsf(initial_atoms, encoder, fsf_objects, fsf_obj_map) # Update these for loop usage objects = fsf_objects obj_map = fsf_obj_map def get_cached_vec(atoms): key = frozenset(atoms) cached = state_cache.get(key) if cached is None: cached = embed_state_fsf(atoms, encoder, objects, obj_map) state_cache[key] = cached return cached else: # Generic Tokenizer Setup tokenizer = feature_encoder def get_cached_vec(atoms): key = frozenset(atoms) cached = state_cache.get(key) if cached is None: cached = embed_state_generic_cached( atoms, tokenizer, goal_atoms_list, objects, prob_path, wl_prob=wl_prob, ) state_cache[key] = cached return cached # Embed Goal try: goal_vec_1d = tokenizer.transform_goal( goal_atoms_list, objects, problem_pddl_path=prob_path, _wl_prob=wl_prob, ) except TypeError: goal_vec_1d = tokenizer.transform_goal(goal_atoms_list, objects) goal_vec = goal_vec_1d.reshape(1, -1) # Embed Init init_vec = get_cached_vec(initial_atoms) # 3. Initialize Beam # Beam Element: (score, current_vec, atoms, plan, visited_hashes) # Note: XGBoost is stateless (no hidden state), unlike LSTM. initial_hash = frozenset(initial_atoms) # Explicitly initialize set to avoid dict confusion visited_set = set() visited_set.add(initial_hash) beam = [(0.0, init_vec, initial_atoms, [], visited_set)] def get_successors(atoms): state_hash = frozenset(atoms) cached = successor_cache.get(state_hash) if cached is None: cached = [] for op in sorted_operators: if op.applicable(atoms): next_atoms = op.apply(atoms) cached.append((op.name, next_atoms, frozenset(next_atoms))) successor_cache[state_hash] = cached return cached for _ in range(effective_max_steps): if collect_search_stats: search_stats["outer_steps"] += 1 search_stats["effective_max_steps"] = effective_max_steps candidates = [] for score, current_vec, current_atoms, plan, visited in beam: if collect_search_stats: search_stats["beam_expansions"] += 1 # Check Goal if goal_set.issubset(current_atoms): result = { "problem": prob_file, "search_solved": True, "plan_len": len(plan), "plan": plan, "effective_max_steps": effective_max_steps, } if collect_search_stats: search_stats["terminated_reason"] = "goal_reached" search_stats["search_elapsed_sec"] = time.perf_counter() - search_start_time result.update(search_stats) return result # A. Predict Next Latent State/Delta # Input: Concat [State, Goal] -> [1, 2D] model_input = np.hstack([current_vec, goal_vec]) # Predict if collect_search_stats: search_stats["model_calls"] += 1 pred = model.predict(model_input) # [1, D] # Reshape is crucial: XGBoost might return (D,) or (1, D) pred = pred.reshape(1, -1) if delta: pred_next_emb = current_vec + pred else: pred_next_emb = pred # B. Generate and score successors successor_items = [] successor_vecs = [] for op_name, next_atoms, next_hash in get_successors(current_atoms): if collect_search_stats: search_stats["successor_evals"] += 1 if next_hash in visited: continue cand_vec = get_cached_vec(next_atoms) successor_items.append((op_name, next_atoms, next_hash, cand_vec)) successor_vecs.append(cand_vec.reshape(-1)) if not successor_items: continue cand_matrix = np.vstack(successor_vecs) dists = score_distances( pred_next_emb, cand_matrix, score_metric=score_metric, delta=delta, ) for (op_name, next_atoms, next_hash, cand_vec), dist in zip(successor_items, dists.tolist()): new_score = score + dist new_visited = visited.copy() new_visited.add(next_hash) candidates.append( (new_score, cand_vec, next_atoms, plan + [op_name], new_visited) ) # D. Prune Beam if not candidates: if collect_search_stats: search_stats["terminated_reason"] = "dead_end" break # Stable Sort: # Primary Key: Score (float) # Secondary Key: String representation of the plan (deterministic tie-breaker) candidates.sort(key=lambda x: (x[0], str(x[3]))) beam = candidates[:beam_width] best_attempt = beam[0] if beam else (0, None, None, [], set()) result = { "problem": prob_file, "search_solved": False, "plan_len": len(best_attempt[3]), "plan": best_attempt[3], "effective_max_steps": effective_max_steps, } if collect_search_stats: search_stats["search_elapsed_sec"] = time.perf_counter() - search_start_time result.update(search_stats) return result def run_inference(args): set_seed(args.seed) device = resolve_xgb_device(args.device) print(f"Using device: {device}") # 0. Load Metadata to determine encoding model_name = args.model_name or args.domain meta_path = os.path.join(args.checkpoint_dir, f"{model_name}_xgb_meta.pkl") if not os.path.exists(meta_path): print(f"Error: Metadata not found at {meta_path}. Cannot determine encoding.") return with open(meta_path, "rb") as f: meta = pickle.load(f) # Preserve historical file naming while using canonical tokenizer ids internally. encoding_type = meta.get("encoding_alias", meta.get("encoding", "graphs")) tokenizer_name = meta.get("encoding", encoding_type) tokenizer_name = normalize_encoding_name(tokenizer_name) # Override delta with what the model was actually trained on trained_delta = meta.get("delta", args.delta) if trained_delta != args.delta: print(f"Warning: Argument --delta={args.delta} but model was trained with delta={trained_delta}. Using model setting.") print( f"Detected Encoding: {encoding_type} (tokenizer: {tokenizer_name}) | Delta Mode: {trained_delta}" ) # 1. Load Encoders feature_encoder = None # Common variables domain_pddl = os.path.join(args.pddl_dir, args.domain, "domain.pddl") if tokenizer_name == "fsf": # Load FSF Config config_path = resolve_model_artifact_path( args.data_dir, f"{args.domain}_fsf_config.json" ) if not os.path.exists(config_path): print(f"Error: FSF Config not found at {config_path}") return with open(config_path, "r") as f: config = json.load(f) max_objects = config["max_objects"] feature_encoder = FSFEncoder(args.domain, domain_pddl, max_objects) print(f"Initialized FSF Encoder with Max Objects: {max_objects}") else: # Generic Tokenizer Support (WL, SimHash, etc.) # The metadata keeps 'encoding' as the tokenizer name (e.g. 'simhash') # We need to recreate it. try: if args.tokenizer_manifest: tokenizer = load_tokenizer_from_manifest(args.tokenizer_manifest) print(f"Loaded tokenizer manifest from {args.tokenizer_manifest}") else: vocab_path = resolve_vocab_path(args.data_dir, args.domain, tokenizer_name) tokenizer = create_tokenizer(tokenizer_name) if vocab_path and os.path.exists(vocab_path): tokenizer.load_vocabulary(vocab_path) print(f"Loaded {tokenizer_name} vocabulary from {vocab_path}") else: print( f"Warning: Vocabulary file not found for '{tokenizer_name}'. Using default params." ) if isinstance(tokenizer, MultiDomainUnionTokenizer): tokenizer.set_active_domain(args.domain, domain_pddl) elif hasattr(tokenizer, "set_domain"): tokenizer.set_domain(domain_pddl) feature_encoder = tokenizer except Exception as e: print(f"Failed to initialize tokenizer '{tokenizer_name}': {e}") return # 2. Load XGBoost xgb_path = os.path.join(args.checkpoint_dir, f"{model_name}_xgb.json") print(f"Loading XGBoost from {xgb_path}...") model = xgb.XGBRegressor(device=device, n_jobs=args.n_jobs) model.load_model(xgb_path) all_split_solved = [] all_split_exec = [] # 4. Run on Splits splits = args.splits try: validation_workers = max(1, args.validation_workers) for split in splits: print(f"\n*** Testing on {split} ***") split_dir = os.path.join(args.pddl_dir, args.domain, split) if not os.path.exists(split_dir): print(f"Skipping {split} (not found)") continue results = [] solved_count = 0 executable_count = 0 prob_files = sorted([f for f in os.listdir(split_dir) if f.endswith(".pddl")]) if args.problems: requested = set(args.problems) prob_files = [f for f in prob_files if f in requested] if args.max_problems is not None: prob_files = prob_files[: args.max_problems] print(f" Found {len(prob_files)} problems for {split}") pending_validations = [] with ThreadPoolExecutor(max_workers=validation_workers) as validation_pool: for prob_file in tqdm( prob_files, desc=f"Solving {split}", disable=(not progress_enabled()), ): prob_path = os.path.join(split_dir, prob_file) try: res = solve_problem( prob_file=prob_file, domain_path=domain_pddl, prob_path=prob_path, model=model, max_steps=args.max_steps, steps_per_object=args.steps_per_object, delta=trained_delta, encoding_type=tokenizer_name, feature_encoder=feature_encoder, objects=None, beam_width=args.beam_width, score_metric=args.score_metric, collect_search_stats=args.collect_search_stats, ) if args.skip_validation: is_solved = bool(res.get("search_solved", False)) is_executable = is_solved res["val_skipped"] = True res["val_solved"] = is_solved res["val_executable"] = is_executable res["solved"] = is_solved results.append(res) if is_solved: solved_count += 1 if is_executable: executable_count += 1 else: future = validation_pool.submit( validate_plan, domain_pddl, prob_path, res["plan"], args.val_path, ) pending_validations.append((prob_file, res, future)) except Exception as e: import traceback traceback.print_exc() results.append({"problem": prob_file, "solved": False, "error": str(e)}) if not args.skip_validation: for prob_file, res, future in pending_validations: try: is_solved, is_executable = future.result() except Exception as e: print(f"Validation failed for {prob_file}: {e}") is_solved, is_executable = False, False res["val_solved"] = is_solved res["val_executable"] = is_executable res["solved"] = is_solved results.append(res) if is_solved: solved_count += 1 if is_executable: executable_count += 1 total = len(prob_files) accuracy = solved_count / total if total else 0 exec_rate = executable_count / total if total else 0 avg_plan_len = ( sum(r.get("plan_len", 0) for r in results if "plan_len" in r) / total if total else 0.0 ) print( f"Result {split}: Solved {solved_count}/{total} ({accuracy:.2%}) | Executable {executable_count}/{total} ({exec_rate:.2%})" ) # Save os.makedirs(args.results_dir, exist_ok=True) tag_suffix = f"_{args.tag}" if getattr(args, "tag", "") else "" out_file = os.path.join( args.results_dir, f"{args.domain}_{encoding_type}_{split}{tag_suffix}_results.json", ) with open(out_file, "w") as f: json.dump(results, f, indent=2) print(f"Saved results to {out_file}") all_split_solved.append(accuracy) all_split_exec.append(exec_rate) finally: pass if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--domain", required=True) parser.add_argument("--checkpoint_dir", required=True) parser.add_argument( "--model_name", default=None, help="Optional filename prefix for pooled/shared models", ) parser.add_argument("--results_dir", required=True) parser.add_argument("--pddl_dir", default="data/pddl") parser.add_argument("--data_dir", default="data") parser.add_argument( "--tokenizer_manifest", default=None, help="Optional explicit tokenizer manifest for pooled/all-domain runs", ) parser.add_argument( "--device", choices=["auto", "cuda", "cpu"], default="auto", help="XGBoost device target", ) parser.add_argument("--n_jobs", type=int, default=8) parser.add_argument("--max_steps", type=int, default=100) parser.add_argument( "--steps_per_object", type=int, default=10, help="Minimum search budget scales to max(max_steps, steps_per_object * num_objects).", ) parser.add_argument("--beam_width", type=int, default=3, help="Search beam width") parser.add_argument( "--score_metric", choices=["native", "cosine", "l2"], default="native", help="Successor scoring metric. 'native' reproduces the original study setting.", ) parser.add_argument( "--splits", nargs="+", default=DEFAULT_SPLITS, help="Splits to run (default: validation/test-interpolation/test-extrapolation)", ) parser.add_argument( "--problems", nargs="+", default=None, help="Optional explicit problem file names to run (e.g., probBLOCKS-8-0.pddl)", ) parser.add_argument( "--max_problems", type=int, default=None, help="Optional cap on number of problems per split after filtering", ) parser.add_argument( "--skip_validation", action="store_true", help="Skip VAL-based validation and use internal search goal check for solved status", ) parser.add_argument( "--validation_workers", type=int, default=8, help="Number of background VAL validation workers to overlap with search.", ) parser.add_argument( "--collect_search_stats", action="store_true", help="Record search-effort and termination statistics in the output JSON.", ) parser.add_argument("--delta", action="store_true") parser.add_argument("--tag", default="state") parser.add_argument("--seed", type=int, default=13) HOME = os.path.expanduser("~") ROOT_DIR = f"{HOME}/planning/" parser.add_argument( "--val_path", default=os.environ.get("VAL_PATH", f"{ROOT_DIR}VAL/bin/Validate"), help="Path to VAL binary", ) args = parser.parse_args() run_inference(args)