vishalp's picture
Deploy State-Centric Learning live demo
dbc6675 verified
Raw
History Blame Contribute Delete
9.46 kB
import glob
import hashlib
import os
import re
import numpy as np
import pddl
import pddl.logic.base
import pddl.logic.predicates
from tqdm import tqdm
# Regex to parse "(on a b)" -> "on a b"
PREDICATE_REGEX = re.compile(r"\(([\w-]+(?: [\w-]+)*)\)")
class WLEncoder:
def __init__(self, domain_pddl_path, iterations=2):
self.domain_pddl_path = domain_pddl_path
self.iterations = iterations
# Vocabulary: Map specific WL hash strings to integer indices
self.vocab = {}
self.is_collected = False
# Parse domain using the 'pddl' library (robust)
print(f" [GC-WL] Parsing Domain: {domain_pddl_path}")
self.pddl_domain = pddl.parse_domain(domain_pddl_path)
# Cache domain predicates to know arity (unary vs binary)
# p.name is string, p.arity is int
self.domain_info = {
p.name.lower(): p.arity for p in self.pddl_domain.predicates
}
def _get_initial_graph(self, objects, state_atoms, goal_atoms):
"""
Builds a graph where nodes = objects.
Features include current state AND goal state info.
"""
# 1. Initialize Nodes
# Graph structure: {obj_name: {'attributes': [], 'neighbors': []}}
graph = {obj: {"attributes": [], "neighbors": []} for obj in objects}
# 2. Process State Atoms
for atom in state_atoms:
parts = atom.replace("(", "").replace(")", "").lower().split()
if not parts:
continue
pred = parts[0]
args = parts[1:]
if pred not in self.domain_info:
continue # Skip unknown predicates (e.g. equality)
arity = self.domain_info[pred]
if arity == 1 and len(args) == 1:
# Unary State Feature: clear(a) -> a has attr "state-clear"
if args[0] in graph:
graph[args[0]]["attributes"].append(f"state-{pred}")
elif arity == 2 and len(args) == 2:
# Binary State Edge: on(a, b) -> a -[state-on]-> b
u, v = args
if u in graph and v in graph:
graph[u]["neighbors"].append((f"state-{pred}", v))
# We treat edges as directed. For WL, we can add inverse if needed,
# but standard directed WL is usually fine for planning.
# 3. Process Goal Atoms (The "Goal-Aware" part)
for atom in goal_atoms:
parts = atom.replace("(", "").replace(")", "").lower().split()
if not parts:
continue
pred = parts[0]
args = parts[1:]
if pred not in self.domain_info:
continue
arity = self.domain_info[pred]
if arity == 1 and len(args) == 1:
# Unary Goal Feature: goal-clear(a)
if args[0] in graph:
graph[args[0]]["attributes"].append(f"goal-{pred}")
elif arity == 2 and len(args) == 2:
# Binary Goal Edge: goal-on(a, b)
u, v = args
if u in graph and v in graph:
graph[u]["neighbors"].append((f"goal-{pred}", v))
# 4. Sort attributes for determinism
for obj in graph:
graph[obj]["attributes"].sort()
graph[obj]["neighbors"].sort()
return graph
def _compute_wl_hashes(self, graph):
"""
Runs k-iterations of Weisfeiler-Leman.
Returns a list of all colors found in the final graph.
"""
# Initial Coloring: Hash of attributes
# current_colors: {obj_name: hash_string}
current_colors = {}
for obj, data in graph.items():
# Hash the sorted list of attributes
attr_str = "|".join(data["attributes"])
current_colors[obj] = hashlib.md5(attr_str.encode()).hexdigest()
# Iterations
for _ in range(self.iterations):
new_colors = {}
for obj in graph:
# Collect neighbor colors
# neighbor_desc = list of (edge_label, neighbor_color)
neighbors = graph[obj]["neighbors"]
neighbor_descriptors = []
for label, neighbor in neighbors:
neighbor_descriptors.append(f"{label}:{current_colors[neighbor]}")
# Sort to ensure invariance to neighbor order
neighbor_descriptors.sort()
# Aggregate: (SelfColor, Neighbors)
aggregate_str = (
current_colors[obj] + "||" + ",".join(neighbor_descriptors)
)
new_hash = hashlib.md5(aggregate_str.encode()).hexdigest()
new_colors[obj] = new_hash
current_colors = new_colors
# Return all node colors (multiset)
return list(current_colors.values())
def parse_state_string_to_atoms(self, state_str_or_list):
if isinstance(state_str_or_list, str):
return PREDICATE_REGEX.findall(state_str_or_list)
return state_str_or_list
def parse_pddl_goal(self, problem_path):
"""Extracts goal atoms and objects from PDDL using the 'pddl' library."""
# Parse problem
problem = pddl.parse_problem(problem_path)
# Collect objects (problem objects + domain constants)
objects = set()
for o in problem.objects:
objects.add(o.name)
for o in self.pddl_domain.constants:
objects.add(o.name)
# Extract Goal Atoms recursively
goals = []
def visit(node):
if isinstance(node, pddl.logic.predicates.Predicate):
# node.name is predicate name, node.terms are arguments
# Handle terms that might be objects or just strings
args = [t.name if hasattr(t, "name") else str(t) for t in node.terms]
s = f"({node.name} {' '.join(args)})"
goals.append(s)
elif hasattr(node, "operands"): # Handle And, Or, etc.
for op in node.operands:
visit(op)
elif hasattr(node, "_operands"): # Fallback for older pddl versions
for op in node._operands:
visit(op)
# Note: We ignore 'Not' for graph edges usually, or can be handled it if needed.
visit(problem.goal)
return sorted(list(objects)), goals
def collect_vocabulary(self, train_states_dir):
print(f" [GC-WL] Collecting vocabulary from {train_states_dir}...")
self.vocab = {}
unique_hashes = set()
train_files = sorted(glob.glob(os.path.join(train_states_dir, "*.traj")))
pddl_train_dir = train_states_dir.replace("states", "pddl")
for traj_file in tqdm(train_files, desc=" Parsing Traces"):
prob_name = os.path.splitext(os.path.basename(traj_file))[0]
prob_pddl = os.path.join(pddl_train_dir, f"{prob_name}.pddl")
if not os.path.exists(prob_pddl):
continue
try:
# 1. Get Objects and Goal
objects, goal_atoms = self.parse_pddl_goal(prob_pddl)
# 2. Read Trajectory
with open(traj_file, "r") as f:
lines = f.read().strip().split("\n")
# 3. Process states
for line in lines:
state_atoms = self.parse_state_string_to_atoms(line)
graph = self._get_initial_graph(objects, state_atoms, goal_atoms)
colors = self._compute_wl_hashes(graph)
unique_hashes.update(colors)
except Exception:
# print(f"Error reading {prob_name}: {e}")
pass
# Build Vocab Map
sorted_hashes = sorted(list(unique_hashes))
self.vocab = {h: i for i, h in enumerate(sorted_hashes)}
self.is_collected = True
print(f" [GC-WL] Vocabulary collected. Size: {len(self.vocab)}")
def embed_state(self, state_atoms_or_obj, problem_pddl_path):
if not self.is_collected:
raise RuntimeError("Vocab not collected")
# Handle input types (if legacy code passes State objects)
if hasattr(state_atoms_or_obj, "atoms"):
# Extract string representation from wlplan State object if passed
state_atoms = []
for atom in state_atoms_or_obj.atoms:
args = " ".join(atom.objects)
state_atoms.append(f"({atom.predicate.name} {args})")
else:
state_atoms = self.parse_state_string_to_atoms(state_atoms_or_obj)
# We need objects and goals again
objects, goal_atoms = self.parse_pddl_goal(problem_pddl_path)
# Build Graph
graph = self._get_initial_graph(objects, state_atoms, goal_atoms)
# Run WL
colors = self._compute_wl_hashes(graph)
# Vectorize (Histogram)
vec = np.zeros(len(self.vocab), dtype=np.float32)
for c in colors:
if c in self.vocab:
vec[self.vocab[c]] += 1.0
return vec
# Adapter methods to match existing interface
def parse_state_string_to_wl_state(self, s):
return s
def parse_pddl_goal_to_wl_state(self, p):
_, goals = self.parse_pddl_goal(p)
return goals