| """ |
| Latent-space lookahead solver ("Baseline A" at depth=1, real branching |
| search at depth>1) -- this is the ORIGINAL search approach this codebase |
| started with, kept here as the comparison baseline `adversarial_search.py` |
| (the real approach actually deployed) is measured against. See the |
| whitepaper's results table for why real board-space search decisively beats |
| this for an adversarial domain. |
| |
| The neurosymbolic decode-gate (`caution` below) exists because an earlier |
| version that let the dynamics model imagine arbitrarily deep with no |
| legality check at all let it extrapolate to state/action combinations it |
| never saw during training, corrupting even the very first move's score. |
| With probability `caution`, each beam entry's latent is decoded back to an |
| estimated real state (via the trained decoder) and the REAL legal-action |
| mask is computed from that -- decoding is used only to filter which moves |
| are allowed, never to make the value judgement itself, which stays fully |
| latent. |
| """ |
| import random |
|
|
| import torch |
|
|
| from .model import WorldModel |
| from .train_utils import StateNormalizer, states_to_tensor |
|
|
| DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
|
|
|
|
| def load_checkpoint(path): |
| ckpt = torch.load(path, map_location=DEVICE, weights_only=False) |
| model = WorldModel( |
| state_dim=ckpt["state_dim"], |
| num_actions=ckpt["num_actions"], |
| latent_dim=ckpt["latent_dim"], |
| hidden_dim=ckpt.get("hidden_dim", 256), |
| ).to(DEVICE) |
| model.load_state_dict(ckpt["model_state"], strict=False) |
| model.eval() |
|
|
| normalizer = StateNormalizer.__new__(StateNormalizer) |
| normalizer.mean = ckpt["norm_mean"].to(DEVICE) |
| normalizer.std = ckpt["norm_std"].to(DEVICE) |
| return model, normalizer |
|
|
|
|
| class VisitedStates: |
| """Plain hash-set cycle detector -- ConnectX is fully discrete, so this |
| is always an O(1) membership check.""" |
|
|
| def __init__(self, initial_state): |
| self._set = {initial_state} |
|
|
| def __contains__(self, state): |
| return state in self._set |
|
|
| def add(self, state): |
| self._set.add(state) |
|
|
|
|
| def _evaluate_with_memory(model, cand_z, memory, memory_weight, memory_k): |
| """model.evaluate(cand_z), optionally blended with an EpisodicMemory's |
| k-NN lookup at the same latents. `memory=None` is the exact original |
| behavior with zero overhead. Blend weight is scaled by the memory's own |
| self-calibrated `trust` (~1 for a genuinely close match, ~0 for nothing |
| similar ever stored), so a distant, irrelevant neighbor doesn't get |
| blended in at the same weight as a close one.""" |
| values = model.evaluate(cand_z) |
| if memory is not None and len(memory) > 0 and memory_weight > 0: |
| blended, trust = memory.query_batch(cand_z, k=memory_k) |
| w = memory_weight * trust |
| values = (1 - w) * values + w * blended |
| return values |
|
|
|
|
| @torch.no_grad() |
| def plan_action(env, model, normalizer, real_state, depth=3, beam_width=8, caution=1.0, rng=None, |
| memory=None, memory_weight=0.25, memory_k=5): |
| """Best first action for real_state, chosen by beam search purely in |
| latent space. depth=1 reduces exactly to Baseline A (no lookahead |
| beyond the immediate predicted next state).""" |
| rng = rng or random |
|
|
| root_legal = [a for a in range(env.num_actions) if env.is_legal(real_state, a)] |
| if not root_legal: |
| return None |
|
|
| z0 = normalizer.normalize(states_to_tensor([env.observe(real_state)]).to(DEVICE)) |
| z0 = model.encode(z0)[0] |
|
|
| |
| beam = [(z0, [], 0.0)] |
|
|
| for step in range(depth): |
| if step == 0: |
| allowed_per_entry = [root_legal] |
| elif rng.random() < caution: |
| zs = torch.stack([z for z, _seq, _r in beam]) |
| decoded = normalizer.denormalize(model.reconstruct(zs)).round() |
| allowed_per_entry = [] |
| for row in decoded.tolist(): |
| decoded_state = tuple(int(x) for x in row) |
| legal = [a for a in range(env.num_actions) if env.is_legal(decoded_state, a)] |
| allowed_per_entry.append(legal if legal else env.always_legal_actions) |
| else: |
| allowed_per_entry = [env.always_legal_actions] * len(beam) |
|
|
| candidates = [] |
| for (z, seq, cum_r), allowed in zip(beam, allowed_per_entry): |
| z_batch = z.unsqueeze(0).repeat(len(allowed), 1) |
| a_batch = torch.tensor(allowed, dtype=torch.long, device=DEVICE) |
| next_z_batch, reward_batch = model.imagine_step(z_batch, a_batch) |
| for i, a in enumerate(allowed): |
| candidates.append((next_z_batch[i], seq + [a], cum_r + reward_batch[i].item())) |
|
|
| if not candidates: |
| break |
|
|
| cand_z = torch.stack([c[0] for c in candidates]) |
| values = _evaluate_with_memory(model, cand_z, memory, memory_weight, memory_k) |
| cum_rewards = torch.tensor([c[2] for c in candidates], dtype=torch.float32, device=DEVICE) |
| scores = -cum_rewards + values |
|
|
| k = min(beam_width, len(candidates)) |
| top_idx = torch.topk(scores, k, largest=False).indices.tolist() |
| beam = [(candidates[i][0], candidates[i][1], candidates[i][2]) for i in top_idx] |
|
|
| final_z = torch.stack([b[0] for b in beam]) |
| final_values = _evaluate_with_memory(model, final_z, memory, memory_weight, memory_k) |
| final_cum_rewards = torch.tensor([b[2] for b in beam], dtype=torch.float32, device=DEVICE) |
| final_scores = -final_cum_rewards + final_values |
| best_idx = torch.argmin(final_scores).item() |
| return beam[best_idx][1][0] |
|
|
|
|
| def solve_with_search_counted(env, model, normalizer, state, depth, beam_width, caution=1.0, max_total_steps=12, |
| memory=None, memory_weight=0.25, memory_k=5): |
| """Runs `plan_action` step by step against the real environment, |
| tracking visited states to fail fast on a cycle. |
| |
| The `is_solved` check on a `done` step is NOT a redundant safety net -- |
| it's a real, previously-fixed bug class: `done=True` fires on a LOSS or |
| a DRAW in this domain, not just a win (unlike every single-agent puzzle |
| domain, where a dead end is simply never marked done, so `done` and |
| `is_solved` always agreed). Trusting `done` alone here silently counted |
| real losses as wins.""" |
| cur = state |
| visited = VisitedStates(state) |
| for i in range(max_total_steps): |
| if env.is_solved(cur): |
| return True, i |
| a = plan_action(env, model, normalizer, cur, depth=depth, beam_width=beam_width, caution=caution, |
| memory=memory, memory_weight=memory_weight, memory_k=memory_k) |
| if a is None: |
| return False, None |
| next_state, _, done = env.step(cur, a) |
| if next_state in visited: |
| return False, None |
| visited.add(next_state) |
| cur = next_state |
| if done: |
| return env.is_solved(cur), (i + 1 if env.is_solved(cur) else None) |
| return env.is_solved(cur), (max_total_steps if env.is_solved(cur) else None) |
|
|
|
|
| def evaluate(env, model, normalizer, problems, depth, beam_width, caution=1.0, max_total_steps=12, label="", |
| memory=None, memory_weight=0.25, memory_k=5): |
| solved = 0 |
| total_steps = 0 |
| for state, _answer in problems: |
| ok, steps = solve_with_search_counted(env, model, normalizer, state, depth, beam_width, caution, |
| max_total_steps, memory=memory, memory_weight=memory_weight, |
| memory_k=memory_k) |
| if ok: |
| solved += 1 |
| total_steps += steps |
| n = len(problems) |
| avg_steps = total_steps / solved if solved else float("nan") |
| print(f"{label:30s} solve_rate={solved/n:.3f} ({solved}/{n}) avg_steps_when_solved={avg_steps:.2f}") |
| return solved / n, avg_steps |
|
|