diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..280fa3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +build/ +__pycache__ +*.Zone.Identifier +.venv/ +token.txt +api_key.txt +.pytest_cache/ +.env + +src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/results/ +src/v2/nlu_pipeline/nlu_benchmark/benchmark_mazes/ +src/v2/nlu_pipeline/nlu_benchmark/terminal_output.txt diff --git a/src/v2/automatic_maze_generation/__init__.py b/src/v2/automatic_maze_generation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/v2/automatic_maze_generation/mazegen/__init__.py b/src/v2/automatic_maze_generation/mazegen/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/v2/automatic_maze_generation/mazegen/generators.py b/src/v2/automatic_maze_generation/mazegen/generators.py new file mode 100644 index 0000000..3057d86 --- /dev/null +++ b/src/v2/automatic_maze_generation/mazegen/generators.py @@ -0,0 +1,485 @@ +from __future__ import annotations + +from typing import Iterable, List, Set, Tuple +from collections import deque + +from .models import ( + Backbone, + Coord, + MazeGenSpec, + MazeLayout, + DenseMazeParams, + MultiRouteParams, + SequentialChainParams, + SideVaultParams, + WindingCorridorParams, +) + + +def in_bounds(c: Coord, width: int, height: int) -> bool: + x, y = c + return 0 <= x < width and 0 <= y < height + + +def neighbors4(c: Coord) -> List[Coord]: + x, y = c + return [(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)] + + +def manhattan(a: Coord, b: Coord) -> int: + return abs(a[0] - b[0]) + abs(a[1] - b[1]) + + +def carve_cells(cells: Iterable[Coord], open_cells: Set[Coord], width: int, height: int, corridor_width: int = 1) -> None: + for x, y in cells: + for dx in range(corridor_width): + for dy in range(corridor_width): + cc = (x + dx, y + dy) + if in_bounds(cc, width, height): + open_cells.add(cc) + + +def build_walls_from_open(width: int, height: int, open_cells: Set[Coord]) -> Set[Coord]: + return {(x, y) for x in range(width) for y in range(height) if (x, y) not in open_cells} + + +def path_from_points(points: List[Coord]) -> List[Coord]: + out: List[Coord] = [] + for i in range(len(points) - 1): + x1, y1 = points[i] + x2, y2 = points[i + 1] + out.append((x1, y1)) + if x1 == x2: + step = 1 if y2 >= y1 else -1 + for y in range(y1 + step, y2 + step, step): + out.append((x1, y)) + elif y1 == y2: + step = 1 if x2 >= x1 else -1 + for x in range(x1 + step, x2 + step, step): + out.append((x, y1)) + else: + raise ValueError("Consecutive points must align horizontally or vertically") + if points: + out.append(points[-1]) + dedup: List[Coord] = [] + seen: Set[Coord] = set() + for p in out: + if not dedup or dedup[-1] != p: + dedup.append(p) + seen.add(p) + return dedup + +def generate_winding_corridor(spec: MazeGenSpec) -> MazeLayout: + assert spec.backbone == Backbone.WINDING_CORRIDOR + rng = spec.rng() + p: WindingCorridorParams = spec.backbone_params + width, height = spec.grid_width, spec.grid_height + + x_min, x_max = 1, max(1, width - 2) + y_min, y_max = 1, max(1, height - 2) + + current = (x_min, y_min) + points = [current] + horizontal = True + + for i in range(p.turn_count + 1): + seg_len = rng.randint(p.segment_min_length, p.segment_max_length) + x, y = current + + if horizontal: + target_x = min(x_max, x + seg_len) if i % 2 == 0 else max(x_min, x - seg_len) + if target_x == x: + target_x = min(x_max, x + seg_len) + current = (target_x, y) + else: + target_y = min(y_max, y + seg_len) if (i // 2) % 2 == 0 else max(y_min, y - seg_len) + if target_y == y: + target_y = min(y_max, y + seg_len) + current = (x, target_y) + + points.append(current) + horizontal = not horizontal + + path = path_from_points(points) + open_cells: Set[Coord] = set() + carve_cells(path, open_cells, width, height, corridor_width=p.corridor_width) + + if p.allow_side_stubs: + candidates = path[1:-1] + rng.shuffle(candidates) + stubs_added = 0 + for cell in candidates: + if stubs_added >= p.side_stub_count: + break + dirs = neighbors4(cell) + rng.shuffle(dirs) + for nb in dirs: + if in_bounds(nb, width, height) and nb not in open_cells: + open_cells.add(nb) + stubs_added += 1 + break + + start = path[0] + goal = path[-1] + walls = build_walls_from_open(width, height, open_cells) + + # --- new: expose mechanism slots on the forced path --- + pickup_idx = max(1, len(path) // 3) + blocker_idx = min(len(path) - 2, (2 * len(path)) // 3) + + # keep them away from start/goal and distinct + if blocker_idx <= pickup_idx: + blocker_idx = min(len(path) - 2, pickup_idx + 2) + + pickup_cell = path[pickup_idx] + blocker_cell = path[blocker_idx] + + return MazeLayout( + width=width, + height=height, + walls=walls, + start=start, + goal=goal, + slots={ + "pickup_1_candidates": [pickup_cell], + "blocker_1_candidates": [blocker_cell], + "distractor_branch_candidates": [], + }, + route_cells=[set(path)], + metadata={ + "backbone": spec.backbone.value, + "logic_chain": spec.logic_chain.value, + "turn_count": p.turn_count, + }, + ) + +def _route_template_cells(width: int, height: int, num_routes: int) -> Tuple[Coord, Coord, List[List[Coord]]]: + start = (1, height // 2) + goal = (width - 2, height // 2) + rows: List[int] = [] + if num_routes == 2: + rows = [1, height - 2] + elif num_routes == 3: + rows = [1, height // 2, height - 2] + else: + rows = [1 + i * max(1, (height - 3) // max(1, num_routes - 1)) for i in range(num_routes)] + rows = [max(1, min(height - 2, r)) for r in rows] + + routes: List[List[Coord]] = [] + for r in rows[:num_routes]: + points = [start, (2, start[1]), (2, r), (width - 3, r), (width - 3, goal[1]), goal] + routes.append(path_from_points(points)) + return start, goal, routes + + +def generate_multi_route(spec: MazeGenSpec) -> MazeLayout: + assert spec.backbone == Backbone.MULTI_ROUTE + p: MultiRouteParams = spec.backbone_params + width, height = spec.grid_width, spec.grid_height + start, goal, routes = _route_template_cells(width, height, p.num_routes) + open_cells: Set[Coord] = set() + route_sets: List[Set[Coord]] = [] + for route in routes: + carve_cells(route, open_cells, width, height, corridor_width=p.main_corridor_width) + route_sets.append(set(route)) + + walls = build_walls_from_open(width, height, open_cells) + return MazeLayout( + width=width, + height=height, + walls=walls, + start=start, + goal=goal, + route_cells=route_sets, + slots={ + "pickup_1_candidates": [c for c in routes[0][2:-2]] if routes else [], + "blocker_1_candidates": [goal], + "distractor_branch_candidates": [], + }, + metadata={ + "backbone": spec.backbone.value, + "logic_chain": spec.logic_chain.value, + "num_routes": len(routes), + }, + ) + + +def generate_side_vault(spec: MazeGenSpec) -> MazeLayout: + assert spec.backbone == Backbone.SIDE_VAULT + p: SideVaultParams = spec.backbone_params + width, height = spec.grid_width, spec.grid_height + + open_cells: Set[Coord] = set() + main_y = height // 2 + start = (1, main_y) + goal = (width - 2, main_y) + main_path = path_from_points([start, (width - 2, main_y)]) + carve_cells(main_path, open_cells, width, height) + + foyer_x = min(width - 4, max(3, width // 3)) + branch_dir = -1 if p.vault_position_mode in {"upper"} else 1 + if p.vault_position_mode == "random": + branch_dir = -1 if spec.rng().random() < 0.5 else 1 + branch_end_y = max(1, min(height - 2, main_y + branch_dir * p.vault_branch_depth)) + vault_path = path_from_points([(foyer_x, main_y), (foyer_x, branch_end_y), (min(width - 3, foyer_x + 2), branch_end_y)]) + carve_cells(vault_path, open_cells, width, height) + + blocker_x = min(width - 3, max(foyer_x + 2, width - 3 - p.blocker_distance_from_goal)) + walls = build_walls_from_open(width, height, open_cells) + return MazeLayout( + width=width, + height=height, + walls=walls, + start=start, + goal=goal, + slots={ + "pickup_1_candidates": [vault_path[-1]], + "blocker_1_candidates": [(blocker_x, main_y)], + "distractor_branch_candidates": [], + }, + route_cells=[set(main_path), set(vault_path)], + metadata={"backbone": spec.backbone.value, "logic_chain": spec.logic_chain.value}, + ) + + +def generate_sequential_chain(spec: MazeGenSpec) -> MazeLayout: + assert spec.backbone == Backbone.SEQUENTIAL_CHAIN + p: SequentialChainParams = spec.backbone_params + width, height = spec.grid_width, spec.grid_height + open_cells: Set[Coord] = set() + + start = (1, height // 3) + choke1 = (max(3, width // 3), height // 3) + zone2_entry = (max(4, width // 3 + 1), 2 * height // 3) + choke2 = (max(6, 2 * width // 3), 2 * height // 3) + goal = (width - 2, 2 * height // 3) + + main_points = [start, choke1, (choke1[0], zone2_entry[1]), zone2_entry, choke2, goal] + main_path = path_from_points(main_points) + carve_cells(main_path, open_cells, width, height) + + pickup1 = (max(1, choke1[0] - 1), max(1, start[1] - p.pickup1_branch_depth)) + pickup1_path = path_from_points([(choke1[0] - 1, start[1]), (choke1[0] - 1, pickup1[1])]) + carve_cells(pickup1_path, open_cells, width, height) + + pickup2 = (min(width - 2, zone2_entry[0] + p.pickup2_branch_depth), max(1, zone2_entry[1] - 1)) + pickup2_path = path_from_points([zone2_entry, (pickup2[0], zone2_entry[1]), pickup2]) + carve_cells(pickup2_path, open_cells, width, height) + + walls = build_walls_from_open(width, height, open_cells) + return MazeLayout( + width=width, + height=height, + walls=walls, + start=start, + goal=goal, + slots={ + "pickup_1_candidates": [pickup1_path[-1]], + "blocker_1_candidates": [choke1], + "pickup_2_candidates": [pickup2_path[-1]], + "blocker_2_candidates": [choke2], + "distractor_branch_candidates": [], + }, + route_cells=[set(main_path), set(pickup1_path), set(pickup2_path)], + metadata={"backbone": spec.backbone.value, "logic_chain": spec.logic_chain.value}, + ) + + + +def _carve_dense_maze_grid(cell_w: int, cell_h: int, rng) -> tuple[set[Coord], int, int]: + """ + Return open cells for a classic carved maze on a tile grid of size: + width = 2*cell_w + 1, height = 2*cell_h + 1 + """ + width = 2 * cell_w + 1 + height = 2 * cell_h + 1 + + open_cells: set[Coord] = set() + + # Mark all logical cells as open + for cx in range(cell_w): + for cy in range(cell_h): + open_cells.add((2 * cx + 1, 2 * cy + 1)) + + visited = set() + stack = [(0, 0)] + visited.add((0, 0)) + + while stack: + cx, cy = stack[-1] + neighbors = [] + for dx, dy in [(1, 0), (-1, 0), (0, 1), (0, -1)]: + nx, ny = cx + dx, cy + dy + if 0 <= nx < cell_w and 0 <= ny < cell_h and (nx, ny) not in visited: + neighbors.append((nx, ny, dx, dy)) + + if not neighbors: + stack.pop() + continue + + nx, ny, dx, dy = rng.choice(neighbors) + # open wall between current cell and next cell + wall_x = 2 * cx + 1 + dx + wall_y = 2 * cy + 1 + dy + open_cells.add((wall_x, wall_y)) + + visited.add((nx, ny)) + stack.append((nx, ny)) + + return open_cells, width, height + + +def _add_dense_maze_loops(open_cells: set[Coord], width: int, height: int, rng, loop_count: int) -> None: + candidates = [] + for x in range(1, width - 1): + for y in range(1, height - 1): + if (x, y) in open_cells: + continue + # candidate interior wall between two open cells + horiz = (x - 1, y) in open_cells and (x + 1, y) in open_cells + vert = (x, y - 1) in open_cells and (x, y + 1) in open_cells + if horiz or vert: + candidates.append((x, y)) + + rng.shuffle(candidates) + for c in candidates[:loop_count]: + open_cells.add(c) + + +def _shortest_path_on_open_cells(start: Coord, goal: Coord, open_cells: set[Coord], width: int, height: int) -> list[Coord]: + q = deque([start]) + parent = {start: None} + + while q: + cur = q.popleft() + if cur == goal: + break + for nb in neighbors4(cur): + if not in_bounds(nb, width, height): + continue + if nb not in open_cells or nb in parent: + continue + parent[nb] = cur + q.append(nb) + + if goal not in parent: + return [] + + path = [] + cur = goal + while cur is not None: + path.append(cur) + cur = parent[cur] + path.reverse() + return path + + + +def _pick_path_cell_by_progress(path: list[Coord], lo: float, hi: float, rng) -> Coord: + if len(path) < 3: + raise ValueError("Path too short to sample progress-based slot") + + start_idx = max(1, int(lo * (len(path) - 1))) + end_idx = min(len(path) - 2, int(hi * (len(path) - 1))) + if end_idx < start_idx: + end_idx = start_idx + idx = rng.randint(start_idx, end_idx) + return path[idx] + + + +def generate_dense_maze(spec: MazeGenSpec) -> MazeLayout: + assert spec.backbone == Backbone.DENSE_MAZE + rng = spec.rng() + p: DenseMazeParams = spec.backbone_params + + open_cells, width, height = _carve_dense_maze_grid( + p.maze_width_cells, + p.maze_height_cells, + rng, + ) + + if p.add_loops and p.loop_count > 0: + _add_dense_maze_loops(open_cells, width, height, rng, p.loop_count) + + # pick start/goal from open odd cells, far apart + candidates = sorted(open_cells) + best_pair = None + best_dist = -1 + for a in candidates: + for b in candidates: + d = manhattan(a, b) + if d > best_dist: + best_dist = d + best_pair = (a, b) + + if best_pair is None: + raise ValueError("Could not find start/goal in dense maze") + + start, goal = best_pair + path = _shortest_path_on_open_cells(start, goal, open_cells, width, height) + if not path: + raise ValueError("Dense maze path generation failed") + + pickup1 = _pick_path_cell_by_progress(path, p.pickup1_progress_min, p.pickup1_progress_max, rng) + blocker1 = _pick_path_cell_by_progress(path, p.blocker1_progress_min, p.blocker1_progress_max, rng) + pickup2 = _pick_path_cell_by_progress(path, p.pickup2_progress_min, p.pickup2_progress_max, rng) + blocker2 = _pick_path_cell_by_progress(path, p.blocker2_progress_min, p.blocker2_progress_max, rng) + + # enforce monotonic order along the path + idx = {cell: i for i, cell in enumerate(path)} + ordered = sorted([pickup1, blocker1, pickup2, blocker2], key=lambda c: idx[c]) + pickup1, blocker1, pickup2, blocker2 = ordered + + # ensure all 4 are distinct and separated + dedup = [] + for cell in [pickup1, blocker1, pickup2, blocker2]: + if cell not in dedup: + dedup.append(cell) + + if len(dedup) < 4: + # simple fallback using spaced path indices + n = len(path) + pickup1 = path[max(1, n // 5)] + blocker1 = path[max(2, (2 * n) // 5)] + pickup2 = path[max(3, (3 * n) // 5)] + blocker2 = path[max(4, (4 * n) // 5)] + + walls = build_walls_from_open(width, height, open_cells) + + return MazeLayout( + width=width, + height=height, + walls=walls, + start=start, + goal=goal, + slots={ + "pickup_1_candidates": [pickup1], + "blocker_1_candidates": [blocker1], + "pickup_2_candidates": [pickup2], + "blocker_2_candidates": [blocker2], + "distractor_branch_candidates": [], + }, + route_cells=[set(path)], + metadata={ + "backbone": spec.backbone.value, + "logic_chain": spec.logic_chain.value, + "dense_maze_cells": [p.maze_width_cells, p.maze_height_cells], + "solution_path_length": len(path) - 1, + }, + ) + + +def generate_from_spec(spec: MazeGenSpec) -> MazeLayout: + if spec.backbone == Backbone.WINDING_CORRIDOR: + return generate_winding_corridor(spec) + if spec.backbone == Backbone.MULTI_ROUTE: + return generate_multi_route(spec) + if spec.backbone == Backbone.SIDE_VAULT: + return generate_side_vault(spec) + if spec.backbone == Backbone.SEQUENTIAL_CHAIN: + return generate_sequential_chain(spec) + if spec.backbone == Backbone.DENSE_MAZE: + return generate_dense_maze(spec) + raise ValueError(f"Unsupported backbone: {spec.backbone}") + diff --git a/src/v2/automatic_maze_generation/mazegen/models.py b/src/v2/automatic_maze_generation/mazegen/models.py new file mode 100644 index 0000000..f708ae2 --- /dev/null +++ b/src/v2/automatic_maze_generation/mazegen/models.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Set, Tuple + +Coord = Tuple[int, int] + + +class Backbone(str, Enum): + WINDING_CORRIDOR = "winding_corridor" + MULTI_ROUTE = "multi_route" + SIDE_VAULT = "side_vault" + SEQUENTIAL_CHAIN = "sequential_chain" + DENSE_MAZE = "dense_maze" + + +class LogicChain(str, Enum): + NONE = "none" + KD = "kd" + SG = "sg" + KS = "ks" + SK = "sk" + KK = "kk" + + +class DistractorMode(str, Enum): + NONE = "none" + WRONG_KEYS = "wrong_keys" + WRONG_SWITCHES = "wrong_switches" + DEAD_END_ROOMS = "dead_end_rooms" + DISTRACTOR_CHAIN = "distractor_chain" + + +@dataclass +class WindingCorridorParams: + corridor_length: int = 20 + turn_count: int = 4 + segment_min_length: int = 2 + segment_max_length: int = 5 + corridor_width: int = 1 + allow_side_stubs: bool = False + side_stub_count: int = 0 + start_goal_at_ends: bool = True + self_proximity_budget: int = 0 + + +@dataclass +class MultiRouteParams: + num_routes: int = 3 + min_route_length: int = 8 + max_route_length: int = 18 + allow_route_rejoin: bool = True + route_overlap_budget: int = 1 + route_asymmetry: float = 0.5 + dead_end_branch_count: int = 0 + main_corridor_width: int = 1 + + +@dataclass +class SideVaultParams: + foyer_size: str = "medium" + vault_branch_depth: int = 4 + vault_branch_turns: int = 1 + main_route_length_before_blocker: int = 8 + blocker_distance_from_goal: int = 2 + vault_position_mode: str = "random" + mainline_shape: str = "linear" + allow_small_dead_ends: bool = False + + +@dataclass +class SequentialChainParams: + zone1_size: str = "medium" + zone2_size: str = "medium" + choke1_orientation: str = "random" + choke2_orientation: str = "random" + pickup1_branch_depth: int = 1 + pickup2_branch_depth: int = 2 + zone2_internal_branches: int = 0 + main_progress_shape: str = "linear" + allow_local_dead_ends: bool = False + + + +@dataclass +class DenseMazeParams: + maze_width_cells: int = 7 + maze_height_cells: int = 7 + add_loops: bool = False + loop_count: int = 0 + pickup1_progress_min: float = 0.20 + pickup1_progress_max: float = 0.40 + blocker1_progress_min: float = 0.45 + blocker1_progress_max: float = 0.65 + pickup2_progress_min: float = 0.60 + pickup2_progress_max: float = 0.80 + blocker2_progress_min: float = 0.80 + blocker2_progress_max: float = 0.92 + + +@dataclass +class ValidationParams: + require_solvable: bool = True + require_no_bypass: bool = True + require_chain_order: bool = True + require_prerequisite_before_blocker: bool = True + require_single_main_path: bool = False + require_unique_shortest_path: bool = False + min_distinct_solution_routes: int = 1 + + +@dataclass +class MazeGenSpec: + backbone: Backbone + logic_chain: LogicChain + difficulty_tier: int + grid_width: int + grid_height: int + seed: int + distractor_mode: DistractorMode = DistractorMode.NONE + max_distractors: int = 0 + backbone_params: object = None + validation_params: ValidationParams = field(default_factory=ValidationParams) + + def rng(self): + import random + return random.Random(self.seed) + + +@dataclass +class Key: + id: str + position: Coord + color: str + + +@dataclass +class Door: + id: str + position: Coord + requires_key: str + initial_state: str = "locked" + + +@dataclass +class Switch: + id: str + position: Coord + controls: List[str] + switch_type: str = "toggle" + initial_state: str = "off" + + +@dataclass +class Gate: + id: str + position: Coord + initial_state: str = "closed" + + +@dataclass +class MazeLayout: + width: int + height: int + walls: Set[Coord] + start: Coord + goal: Coord + slots: Dict[str, List[Coord]] = field(default_factory=dict) + route_cells: List[Set[Coord]] = field(default_factory=list) + metadata: Dict[str, object] = field(default_factory=dict) + + +@dataclass +class MazeInstance: + width: int + height: int + walls: Set[Coord] + start: Coord + goal: Coord + keys: List[Key] = field(default_factory=list) + doors: List[Door] = field(default_factory=list) + switches: List[Switch] = field(default_factory=list) + gates: List[Gate] = field(default_factory=list) + metadata: Dict[str, object] = field(default_factory=dict) + + def to_json_like(self) -> dict: + return { + "maze": { + "dimensions": [self.width, self.height], + "walls": sorted([list(w) for w in self.walls]), + "start": list(self.start), + "goal": list(self.goal), + }, + "mechanisms": { + "keys": [k.__dict__ | {"position": list(k.position)} for k in self.keys], + "doors": [d.__dict__ | {"position": list(d.position)} for d in self.doors], + "switches": [s.__dict__ | {"position": list(s.position)} for s in self.switches], + "gates": [g.__dict__ | {"position": list(g.position)} for g in self.gates], + }, + "metadata": self.metadata, + } \ No newline at end of file diff --git a/src/v2/automatic_maze_generation/mazegen/solver.py b/src/v2/automatic_maze_generation/mazegen/solver.py new file mode 100644 index 0000000..c28a413 --- /dev/null +++ b/src/v2/automatic_maze_generation/mazegen/solver.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +from collections import deque +from heapq import heappop, heappush +from typing import Dict, List, Optional, Tuple + +from .models import Coord, MazeInstance, MazeLayout +from .generators import in_bounds, neighbors4 + + +def solve_navigation_only(layout: MazeLayout) -> dict: + start, goal = layout.start, layout.goal + blocked = layout.walls + pq: List[Tuple[int, Coord]] = [(0, start)] + parent: Dict[Coord, Optional[Coord]] = {start: None} + dist: Dict[Coord, int] = {start: 0} + + while pq: + d, node = heappop(pq) + if node == goal: + break + if d != dist[node]: + continue + for nb in neighbors4(node): + if not in_bounds(nb, layout.width, layout.height) or nb in blocked: + continue + nd = d + 1 + if nb not in dist or nd < dist[nb]: + dist[nb] = nd + parent[nb] = node + heappush(pq, (nd, nb)) + + if goal not in dist: + return {"is_solvable": False, "optimal_cost": None, "path": []} + + path: List[Coord] = [] + cur: Optional[Coord] = goal + while cur is not None: + path.append(cur) + cur = parent[cur] + path.reverse() + return {"is_solvable": True, "optimal_cost": len(path) - 1, "path": path} + + +def count_shortest_paths(layout: MazeLayout, max_count: int = 3) -> int: + start, goal = layout.start, layout.goal + blocked = layout.walls + dist: Dict[Coord, int] = {start: 0} + count: Dict[Coord, int] = {start: 1} + pq: List[Tuple[int, Coord]] = [(0, start)] + + while pq: + d, node = heappop(pq) + if d != dist[node]: + continue + for nb in neighbors4(node): + if not in_bounds(nb, layout.width, layout.height) or nb in blocked: + continue + nd = d + 1 + if nb not in dist: + dist[nb] = nd + count[nb] = count[node] + heappush(pq, (nd, nb)) + elif nd == dist[nb]: + count[nb] = min(max_count, count[nb] + count[node]) + + return count.get(goal, 0) + +def _maze_lookup_tables(maze: MazeInstance) -> dict: + return { + "key_at": {k.position: k for k in maze.keys}, + "door_at": {d.position: d for d in maze.doors}, + "switch_at": {s.position: s for s in maze.switches}, + "gate_at": {g.position: g for g in maze.gates}, + "gate_to_switches": { + g.id: [s.id for s in maze.switches if g.id in s.controls] + for g in maze.gates + }, + } + + + +def _normalize_state( + pos: Coord, + inventory: frozenset[str], + opened_doors: frozenset[str], + switch_states: frozenset[str], +) -> Tuple[Coord, frozenset[str], frozenset[str], frozenset[str]]: + return (pos, inventory, opened_doors, switch_states) + + + +def _apply_cell_effects( + maze: MazeInstance, + pos: Coord, + inventory: frozenset[str], + opened_doors: frozenset[str], + switch_states: frozenset[str], + lookups: dict, +) -> Tuple[frozenset[str], frozenset[str], frozenset[str], List[str]]: + inventory_set = set(inventory) + opened_set = set(opened_doors) + switch_set = set(switch_states) + interactions: List[str] = [] + + key = lookups["key_at"].get(pos) + if key is not None and key.color not in inventory_set: + inventory_set.add(key.color) + interactions.append(f"pickup:{key.id}") + + sw = lookups["switch_at"].get(pos) + if sw is not None and sw.id not in switch_set: + # V1 behavior: activate once and keep on. + switch_set.add(sw.id) + interactions.append(f"toggle:{sw.id}") + + return frozenset(inventory_set), frozenset(opened_set), frozenset(switch_set), interactions + + + +def _can_enter_cell( + maze: MazeInstance, + pos: Coord, + inventory: frozenset[str], + opened_doors: frozenset[str], + switch_states: frozenset[str], + lookups: dict, +) -> Tuple[bool, frozenset[str], frozenset[str], List[str]]: + inventory_set = set(inventory) + opened_set = set(opened_doors) + interactions: List[str] = [] + + door = lookups["door_at"].get(pos) + if door is not None and door.id not in opened_set: + if door.requires_key not in inventory_set: + return False, inventory, opened_doors, [] + inventory_set.remove(door.requires_key) + opened_set.add(door.id) + interactions.append(f"open:{door.id}") + + gate = lookups["gate_at"].get(pos) + if gate is not None: + controllers = lookups["gate_to_switches"].get(gate.id, []) + is_open = any(sw_id in switch_states for sw_id in controllers) + if not is_open: + return False, inventory, opened_doors, [] + interactions.append(f"cross:{gate.id}") + + return True, frozenset(inventory_set), frozenset(opened_set), interactions + + + +def solve_maze(maze: MazeInstance) -> dict: + """ + Solve a maze using shortest-path search over full agent state. + + This solver supports movement plus the current mechanism semantics: + - keys are picked up on entry to their cell + - doors require a matching key color and consume that key on first use + - switches activate on first visit and remain on + - gates are traversable when any controlling switch is on + """ + lookups = _maze_lookup_tables(maze) + + start_inventory, start_opened, start_switches, start_interactions = _apply_cell_effects( + maze, + maze.start, + frozenset(), + frozenset(), + frozenset(), + lookups, + ) + start_state = _normalize_state(maze.start, start_inventory, start_opened, start_switches) + + queue = deque([start_state]) + parent: Dict[Tuple[Coord, frozenset[str], frozenset[str], frozenset[str]], Optional[Tuple[Coord, frozenset[str], frozenset[str], frozenset[str]]]] = { + start_state: None + } + action_taken: Dict[Tuple[Coord, frozenset[str], frozenset[str], frozenset[str]], Tuple[str, List[str]]] = { + start_state: ("START", start_interactions) + } + dist: Dict[Tuple[Coord, frozenset[str], frozenset[str], frozenset[str]], int] = {start_state: 0} + + goal_state: Optional[Tuple[Coord, frozenset[str], frozenset[str], frozenset[str]]] = None + + while queue: + state = queue.popleft() + pos, inventory, opened_doors, switch_states = state + if pos == maze.goal: + goal_state = state + break + + for nb in neighbors4(pos): + if not in_bounds(nb, maze.width, maze.height) or nb in maze.walls: + continue + + allowed, inventory_after_entry, opened_after_entry, entry_interactions = _can_enter_cell( + maze, nb, inventory, opened_doors, switch_states, lookups + ) + if not allowed: + continue + + final_inventory, final_opened, final_switches, cell_interactions = _apply_cell_effects( + maze, + nb, + inventory_after_entry, + opened_after_entry, + switch_states, + lookups, + ) + next_state = _normalize_state(nb, final_inventory, final_opened, final_switches) + if next_state in dist: + continue + + dist[next_state] = dist[state] + 1 + parent[next_state] = state + action_taken[next_state] = ( + f"MOVE_TO:{nb[0]},{nb[1]}", + entry_interactions + cell_interactions, + ) + queue.append(next_state) + + if goal_state is None: + return { + "is_solvable": False, + "optimal_cost": None, + "path": [], + "action_sequence": [], + "interactions": [], + "final_inventory": [], + "final_opened_doors": [], + "active_switches": [], + } + + states_path: List[Tuple[Coord, frozenset[str], frozenset[str], frozenset[str]]] = [] + cur = goal_state + while cur is not None: + states_path.append(cur) + cur = parent[cur] + states_path.reverse() + + path = [s[0] for s in states_path] + action_sequence: List[str] = [] + interactions: List[str] = [] + for st in states_path[1:]: + move_action, side_effects = action_taken[st] + action_sequence.append(move_action) + interactions.extend(side_effects) + + _, final_inventory, final_opened, final_switches = goal_state + return { + "is_solvable": True, + "optimal_cost": len(path) - 1, + "path": path, + "action_sequence": action_sequence, + "interactions": interactions, + "final_inventory": sorted(final_inventory), + "final_opened_doors": sorted(final_opened), + "active_switches": sorted(final_switches), + } + diff --git a/src/v2/automatic_maze_generation/mazegen/validator.py b/src/v2/automatic_maze_generation/mazegen/validator.py new file mode 100644 index 0000000..3442852 --- /dev/null +++ b/src/v2/automatic_maze_generation/mazegen/validator.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from dataclasses import replace +from typing import List, Optional + +from .models import MazeInstance, MazeLayout, ValidationParams +from .solver import count_shortest_paths, solve_maze, solve_navigation_only + + +def validate_navigation_layout(layout: MazeLayout, params: ValidationParams) -> dict: + result = solve_navigation_only(layout) + reasons: List[str] = [] + if params.require_solvable and not result["is_solvable"]: + reasons.append("maze is not solvable") + if params.require_unique_shortest_path and result["is_solvable"]: + nsp = count_shortest_paths(layout) + if nsp != 1: + reasons.append(f"expected unique shortest path, found {nsp}") + return { + "is_valid": len(reasons) == 0, + "reasons": reasons, + "solver_result": result, + } + + + + + + + + + +def _clone_maze(maze: MazeInstance) -> MazeInstance: + return MazeInstance( + width=maze.width, + height=maze.height, + walls=set(maze.walls), + start=maze.start, + goal=maze.goal, + keys=[replace(k) for k in maze.keys], + doors=[replace(d) for d in maze.doors], + switches=[replace(s, controls=list(s.controls)) for s in maze.switches], + gates=[replace(g) for g in maze.gates], + metadata=dict(maze.metadata), + ) + + + +def _remove_mechanism_by_id(maze: MazeInstance, mech_id: str) -> MazeInstance: + new_maze = _clone_maze(maze) + new_maze.keys = [k for k in new_maze.keys if k.id != mech_id] + new_maze.doors = [d for d in new_maze.doors if d.id != mech_id] + new_maze.switches = [s for s in new_maze.switches if s.id != mech_id] + new_maze.gates = [g for g in new_maze.gates if g.id != mech_id] + + for sw in new_maze.switches: + sw.controls = [gid for gid in sw.controls if gid != mech_id] + return new_maze + +def _extract_required_ids(maze: MazeInstance, expected_logic: Optional[str]) -> List[str]: + if expected_logic is None: + return [] + + if expected_logic == "kd": + return [maze.keys[0].id] if maze.keys else [] + + if expected_logic == "sg": + return [maze.switches[0].id] if maze.switches else [] + + if expected_logic == "ks": + ids = [] + if maze.keys: + ids.append(maze.keys[0].id) + if maze.switches: + ids.append(maze.switches[0].id) + return ids + + if expected_logic == "sk": + ids = [] + if maze.switches: + ids.append(maze.switches[0].id) + if maze.keys: + ids.append(maze.keys[0].id) + return ids + + if expected_logic == "kk": + return [k.id for k in maze.keys[:2]] + + return [] + + + +def _run_ablation_checks(maze: MazeInstance, expected_logic: Optional[str]) -> List[str]: + reasons: List[str] = [] + for mech_id in _extract_required_ids(maze, expected_logic): + ablated = _remove_mechanism_by_id(maze, mech_id) + result = solve_maze(ablated) + if result["is_solvable"]: + reasons.append(f"mechanism {mech_id} is not necessary under ablation") + return reasons + + + +def validate_maze(maze: MazeInstance, expected_logic: Optional[str] = None) -> dict: + solver_result = solve_maze(maze) + reasons: List[str] = [] + if not solver_result["is_solvable"]: + reasons.append("maze is not solvable") + + chain_pattern = maze.metadata.get("chain_pattern") + if expected_logic is not None and chain_pattern not in {expected_logic, None}: + reasons.append("chain pattern metadata does not match expected logic") + + interactions = solver_result.get("interactions", []) + if expected_logic == "kd": + if not any(x.startswith("pickup:k") for x in interactions): + reasons.append("expected kd maze to require a key pickup") + if not any(x.startswith("open:D") for x in interactions): + reasons.append("expected kd maze to require opening a door") + elif expected_logic == "sg": + if not any(x.startswith("toggle:s") for x in interactions): + reasons.append("expected sg maze to require activating a switch") + if not any(x.startswith("cross:g") for x in interactions): + reasons.append("expected sg maze to require crossing a gate") + elif expected_logic in {"ks", "sk", "kk"}: + required_prefixes = { + "ks": ["pickup:k", "open:D", "toggle:s", "cross:g"], + "sk": ["toggle:s", "cross:g", "pickup:k", "open:D"], + "kk": ["pickup:k", "open:D", "pickup:k", "open:D"], + }[expected_logic] + idx = 0 + for interaction in interactions: + if interaction.startswith(required_prefixes[idx]): + idx += 1 + if idx == len(required_prefixes): + break + if idx < len(required_prefixes): + reasons.append(f"expected ordered chain {expected_logic} was not observed in solver interactions") + + if solver_result["is_solvable"] and expected_logic is not None: + reasons.extend(_run_ablation_checks(maze, expected_logic)) + + return { + "is_valid": len(reasons) == 0, + "reasons": reasons, + "solver_result": solver_result, + } + diff --git a/src/v2/automatic_maze_generation/render_dataset.py b/src/v2/automatic_maze_generation/render_dataset.py new file mode 100644 index 0000000..7fb4098 --- /dev/null +++ b/src/v2/automatic_maze_generation/render_dataset.py @@ -0,0 +1,398 @@ +# render_dataset.py +from __future__ import annotations + +import json +from copy import deepcopy +from io import BytesIO +from pathlib import Path +from typing import Any, Optional, Tuple + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.patches import Rectangle, Circle + + +CELL = 40 # pixels-ish via figure scale + + +def _extract_payload_fields(payload: dict): + maze = payload["maze"] + mechs = payload.get("mechanisms", {}) + + width, height = maze["dimensions"] + walls = {tuple(w) for w in maze["walls"]} + start = tuple(maze["start"]) + goal = tuple(maze["goal"]) + + keys = mechs.get("keys", []) + doors = mechs.get("doors", []) + switches = mechs.get("switches", []) + gates = mechs.get("gates", []) + + return width, height, walls, start, goal, keys, doors, switches, gates + + +def _row_col_payload_to_xy_payload(payload: dict) -> dict: + """Convert NLU maze JSON (1-based ``[x, y]`` = ``[column, row]``, top-left origin) to renderer indices.""" + out = deepcopy(payload) + maze = out.get("maze", {}) + mechs = out.get("mechanisms", {}) + + dims = maze.get("dimensions") + if dims and len(dims) == 2: + rows, cols = dims[0], dims[1] + else: + rows, cols = 0, 0 + + def cr_to_xy(pos): + col, row = pos[0], pos[1] + return [col - 1, row - 1] + + if dims and len(dims) == 2: + maze["dimensions"] = [cols, rows] + + maze["walls"] = [cr_to_xy(w) for w in maze.get("walls", [])] + if "start" in maze: + maze["start"] = cr_to_xy(maze["start"]) + if "goal" in maze: + maze["goal"] = cr_to_xy(maze["goal"]) + + for k in mechs.get("keys", []): + if "position" in k: + k["position"] = cr_to_xy(k["position"]) + for d in mechs.get("doors", []): + if "position" in d: + d["position"] = cr_to_xy(d["position"]) + for s in mechs.get("switches", []): + if "position" in s: + s["position"] = cr_to_xy(s["position"]) + for g in mechs.get("gates", []): + if "position" in g: + g["position"] = cr_to_xy(g["position"]) + + validation = out.get("validation", {}) + if "optimal_path" in validation: + validation["optimal_path"] = [cr_to_xy(p) for p in validation.get("optimal_path", [])] + return out + + + +def _color_to_facecolor(name: str) -> str: + mapping = { + "red": "#e74c3c", + "blue": "#3498db", + "green": "#2ecc71", + "yellow": "#f1c40f", + "purple": "#9b59b6", + "orange": "#e67e22", + } + return mapping.get(name.lower(), "#95a5a6") + + +def _draw_centered_text(ax, x: int, y: int, height: int, text: str, fontsize: int = 10, color: str = "black"): + ax.text( + x + 0.5, + height - 1 - y + 0.5, + text, + ha="center", + va="center", + fontsize=fontsize, + color=color, + fontweight="bold", + ) + + +def _draw_key(ax, x: int, y: int, height: int, color_name: str): + face = _color_to_facecolor(color_name) + cy = height - 1 - y + 0.5 + + # colored circle badge + ax.add_patch(Circle((x + 0.5, cy), 0.28, facecolor=face, edgecolor="black", linewidth=1.0)) + # key icon / fallback letter + ax.text( + x + 0.5, + cy, + "⚷", # if this glyph looks odd in your env, replace with "K" + ha="center", + va="center", + fontsize=11, + color="white", + fontweight="bold", + ) + + +def _draw_door(ax, x: int, y: int, height: int, color_name: str): + face = _color_to_facecolor(color_name) + by = height - 1 - y + + # colored inner door rectangle + ax.add_patch( + Rectangle( + (x + 0.18, by + 0.12), + 0.64, + 0.76, + facecolor=face, + edgecolor="black", + linewidth=1.0, + ) + ) + # small doorknob + ax.add_patch(Circle((x + 0.68, by + 0.5), 0.04, facecolor="white", edgecolor="white")) + + +def _draw_switch(ax, x: int, y: int, height: int, label: str): + by = height - 1 - y + + ax.add_patch( + Rectangle( + (x + 0.15, by + 0.2), + 0.7, + 0.6, + facecolor="#dfe6e9", + edgecolor="black", + linewidth=1.0, + ) + ) + ax.text( + x + 0.5, + by + 0.5, + label, + ha="center", + va="center", + fontsize=9, + color="black", + fontweight="bold", + ) + + +def _draw_gate(ax, x: int, y: int, height: int, label: str): + by = height - 1 - y + + # gate bars + for dx in [0.22, 0.38, 0.54, 0.70]: + ax.plot([x + dx, x + dx], [by + 0.15, by + 0.85], color="black", linewidth=1.4) + ax.plot([x + 0.18, x + 0.74], [by + 0.18, by + 0.18], color="black", linewidth=1.4) + ax.plot([x + 0.18, x + 0.74], [by + 0.82, by + 0.82], color="black", linewidth=1.4) + + ax.text( + x + 0.5, + by + 0.5, + label, + ha="center", + va="center", + fontsize=8, + color="black", + fontweight="bold", + bbox=dict(boxstyle="round,pad=0.08", facecolor="white", edgecolor="none", alpha=0.8), + ) + + +_AGENT_FACING_DELTA = { + "NORTH": (-1, 0), + "EAST": (0, 1), + "SOUTH": (1, 0), + "WEST": (0, -1), +} + +_AGENT_ARROW_CELL_FRAC = 0.5 + + +def _draw_agent(ax, col: int, row: int, height: int, facing: str) -> None: + """Agent overlay; NLU 1-based ``column``, ``row`` (origin top-left, row increases downward).""" + sx, sy = col - 1, row - 1 + cx = sx + 0.5 + cy = height - 1 - sy + 0.5 + ax.plot( + cx, + cy, + "o", + color="black", + markersize=6, + zorder=6, + markeredgecolor="black", + ) + dr, dc = _AGENT_FACING_DELTA.get(facing, (0, 0)) + if dr == 0 and dc == 0: + return + tip_sx = sx + dc + tip_sy = sy + dr + tip_x = tip_sx + 0.5 + tip_y = height - 1 - tip_sy + 0.5 + end_x = cx + _AGENT_ARROW_CELL_FRAC * (tip_x - cx) + end_y = cy + _AGENT_ARROW_CELL_FRAC * (tip_y - cy) + ax.annotate( + "", + xy=(end_x, end_y), + xytext=(cx, cy), + arrowprops=dict(arrowstyle="->", color="black", lw=1.5), + zorder=7, + ) + + +def _extract_optimal_path(payload: dict): + validation = payload.get("validation", {}) + return [tuple(p) for p in validation.get("optimal_path", [])] + + + + + + +def _draw_optimal_path(ax, path, height: int): + if not path: + return + + xs = [x + 0.5 for x, y in path] + ys = [height - 1 - y + 0.5 for x, y in path] + + ax.plot( + xs, + ys, + linewidth=3.0, + alpha=0.45, + zorder=2, + ) + + # mark start of path a little more clearly + ax.scatter( + [xs[0]], + [ys[0]], + s=35, + alpha=0.7, + zorder=3, + ) + + + +def _figure_from_maze_payload(payload: dict, title: str) -> Tuple[Any, Any, int]: + """Build figure/axes for a maze JSON payload; caller savesfig and closes.""" + payload = _row_col_payload_to_xy_payload(payload) + width, height, walls, start, goal, keys, doors, switches, gates = _extract_payload_fields(payload) + optimal_path = _extract_optimal_path(payload) + + fig_w = max(6, width * 0.55) + fig_h = max(4, height * 0.55) + fig, ax = plt.subplots(figsize=(fig_w, fig_h)) + + # base grid + for x in range(width): + for y in range(height): + is_wall = (x, y) in walls + facecolor = "black" if is_wall else "white" + ax.add_patch( + Rectangle( + (x, height - 1 - y), + 1, + 1, + facecolor=facecolor, + edgecolor="lightgray", + linewidth=0.8, + zorder=0, + ) + ) + + # path overlay first, so icons remain visible above it + _draw_optimal_path(ax, optimal_path, height) + + # start / goal + sx, sy = start + gx, gy = goal + ax.add_patch(Rectangle((sx, height - 1 - sy), 1, 1, facecolor="#c8f7c5", edgecolor="black", linewidth=1.2, zorder=4)) + ax.add_patch(Rectangle((gx, height - 1 - gy), 1, 1, facecolor="#f7d6c5", edgecolor="black", linewidth=1.2, zorder=4)) + _draw_centered_text(ax, sx, sy, height, "S", fontsize=11) + _draw_centered_text(ax, gx, gy, height, "G", fontsize=11) + + # keys + for key in keys: + x, y = key["position"] + color_name = key.get("color", "gray") + _draw_key(ax, x, y, height, color_name) + + # doors + for door in doors: + x, y = door["position"] + color_name = door.get("requires_key", "gray") + _draw_door(ax, x, y, height, color_name) + + # switches + for sw in switches: + x, y = sw["position"] + _draw_switch(ax, x, y, height, "S") + + # gates + for gate in gates: + x, y = gate["position"] + _draw_gate(ax, x, y, height, "G") + + if title: + ax.set_title(title) + ax.set_xlim(0, width) + ax.set_ylim(0, height) + # Use default adjustable ("datalim"): ``adjustable="box"`` rescales the axes + # rectangle inside the subplot; with ``tight_layout`` + ``bbox_inches="tight"`` + # that often yields asymmetric white bands (e.g. extra empty rows/cols on one side). + ax.set_aspect("equal") + ax.axis("off") + + return fig, ax, height + + +def render_maze_payload(payload: dict, output_path: Path) -> None: + title = payload.get("task_id", output_path.stem) + fig, _ax, _height = _figure_from_maze_payload(payload, title) + plt.tight_layout() + fig.savefig(output_path, dpi=150, bbox_inches="tight", pad_inches=0.08) + plt.close(fig) + + +def render_maze_payload_bytes( + payload: dict, + *, + dpi: int = 150, + agent_pos: Optional[Tuple[int, int]] = None, + facing: str = "NORTH", +) -> bytes: + """Same layout as ``render_maze_payload``, PNG bytes (e.g. NLU live observations). + + ``agent_pos`` is NLU 1-based ``(column_index, row_index)`` for matplotlib overlay (same as JSON ``x``, ``y``). + """ + title = str(payload.get("task_id", "") or "") + fig, ax, height = _figure_from_maze_payload(payload, title) + if agent_pos is not None: + col1, row1 = int(agent_pos[0]), int(agent_pos[1]) + _draw_agent(ax, col1, row1, height, facing) + plt.tight_layout() + buf = BytesIO() + fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", pad_inches=0.08) + plt.close(fig) + return buf.getvalue() + + + + +def main() -> None: + input_dir = Path("generated_mazes") + # input_dir = Path("../nlu_pipeline/nlu_benchmark/sample mazes") + output_dir = input_dir / "pngs" + output_dir.mkdir(parents=True, exist_ok=True) + + json_files = sorted(p for p in input_dir.glob("*.json") if p.name != "manifest.json") + if not json_files: + print("No maze JSON files found in generated_mazes/") + return + + for jf in json_files: + with open(jf, "r", encoding="utf-8") as f: + payload = json.load(f) + + out_path = output_dir / f"{jf.stem}.png" + render_maze_payload(payload, out_path) + print(f"[OK] rendered {out_path.name}") + + print(f"\nRendered {len(json_files)} PNGs to: {output_dir.resolve()}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/v2/nlu_pipeline/__init__.py b/src/v2/nlu_pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/v2/nlu_pipeline/nlu_benchmark/__init__.py b/src/v2/nlu_pipeline/nlu_benchmark/__init__.py new file mode 100644 index 0000000..0b845b9 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/__init__.py @@ -0,0 +1 @@ +"""NLU maze benchmark package.""" diff --git a/src/v2/nlu_pipeline/nlu_benchmark/agents.py b/src/v2/nlu_pipeline/nlu_benchmark/agents.py new file mode 100644 index 0000000..48646e2 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/agents.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import json +import logging +import os +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +from transformers import AutoModelForCausalLM, AutoTokenizer + +# Stable defaults for HF Hub downloads on Windows (local Transformers path). +os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "0") +os.environ.setdefault("HF_HUB_DISABLE_XET", "1") + +logger = logging.getLogger(__name__) + +DEFAULT_LOCAL_MODEL = "HuggingFaceTB/SmolLM2-360M-Instruct" + +DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6" + + +def _parse_data_image_url(url: str) -> tuple[str, str]: + """Split ``data:;base64,`` into media type and raw base64 payload.""" + if not isinstance(url, str) or not url.startswith("data:"): + raise ValueError("Expected a data: URL with base64 image payload.") + rest = url[5:] + if ";base64," not in rest: + raise ValueError("Expected ';base64,' in image data URL.") + meta, _, b64 = rest.partition(";base64,") + media_type = (meta.strip() or "image/png").split(";")[0].strip() + return media_type, b64.strip() + + +def _openai_blocks_to_anthropic(blocks: List[dict]) -> List[dict]: + """Convert runner/OpenAI-style content blocks to Anthropic Messages ``content`` blocks.""" + out: List[dict] = [] + for b in blocks: + if not isinstance(b, dict): + continue + t = b.get("type") + if t == "text": + out.append({"type": "text", "text": str(b.get("text", ""))}) + elif t == "image_url": + url_holder = b.get("image_url") + url = url_holder.get("url") if isinstance(url_holder, dict) else url_holder + if isinstance(url, str) and url.startswith("data:"): + mt, raw_b64 = _parse_data_image_url(url) + out.append({"type": "image", "source": {"type": "base64", "media_type": mt, "data": raw_b64}}) + return out + + +def _anthropic_turn_content(content: object, role: str) -> object: + if isinstance(content, str): + return content if role != "assistant" else content.strip() + if isinstance(content, list): + anthropic_blocks = _openai_blocks_to_anthropic(content) + if not anthropic_blocks: + return "" + if len(anthropic_blocks) == 1 and anthropic_blocks[0].get("type") == "text": + return str(anthropic_blocks[0].get("text", "")) + return anthropic_blocks + return str(content) + + +def _anthropic_chat_turns(messages: List[dict]) -> Tuple[Optional[str], List[Dict[str, object]]]: + """Split OpenAI-style chat messages into Anthropic `system` + user/assistant turns.""" + system_parts: List[str] = [] + turns: List[Dict[str, object]] = [] + for m in messages: + role = m.get("role") + content = m.get("content", "") + if role == "system": + system_parts.append(str(content)) + elif role in ("user", "assistant"): + turns.append({"role": role, "content": _anthropic_turn_content(content, role)}) + else: + raise ValueError(f"Unsupported message role for Claude agent: {role!r}") + system = "\n\n".join(system_parts) if system_parts else None + return system, turns + + +def _anthropic_messages_http( + api_key: str, + *, + model: str, + max_tokens: int, + temperature: float, + system: Optional[str], + messages: List[Dict[str, object]], + timeout: Optional[float], +) -> str: + """POST /v1/messages (Anthropic Messages API); uses stdlib only.""" + body: Dict[str, object] = { + "model": model, + "max_tokens": max_tokens, + "messages": messages, + "temperature": temperature, + } + if system: + body["system"] = system + + raw = json.dumps(body).encode("utf-8") + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Anthropic request: model=%s json_bytes=%d", model, len(raw)) + + req = urllib.request.Request( + "https://api.anthropic.com/v1/messages", + data=raw, + headers={ + "Content-Type": "application/json", + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + }, + method="POST", + ) + t0 = time.perf_counter() + try: + with urllib.request.urlopen(req, timeout=timeout or 60.0) as resp: + payload = json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace") + raise RuntimeError(f"Anthropic API HTTP {e.code}: {detail}") from e + elapsed = time.perf_counter() - t0 + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Anthropic Messages API: model=%s elapsed=%.2fs", model, elapsed) + + parts: List[str] = [] + for block in payload.get("content", []) or []: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(str(block.get("text", ""))) + return "".join(parts).strip() + + +@dataclass +class ClaudeAnthropicConfig: + model: str = DEFAULT_CLAUDE_MODEL + temperature: float = 0.0 + max_tokens: int = 1024 + timeout: Optional[float] = 60.0 + + +@dataclass +class ClaudeAnthropicAgent: + """Claude via Anthropic Messages API (`ANTHROPIC_API_KEY`). Supports vision user turns.""" + + config: ClaudeAnthropicConfig = field(default_factory=ClaudeAnthropicConfig) + api_key: Optional[str] = None + + def __post_init__(self) -> None: + key = (self.api_key or os.environ.get("ANTHROPIC_API_KEY") or "").strip() + if not key: + raise ValueError( + "No Anthropic API key found. Set ANTHROPIC_API_KEY or pass api_key=... to ClaudeAnthropicAgent." + ) + self.api_key = key + + def __call__(self, messages: List[dict]) -> str: + system, turns = _anthropic_chat_turns(messages) + return _anthropic_messages_http( + self.api_key, + model=self.config.model, + max_tokens=self.config.max_tokens, + temperature=self.config.temperature, + system=system, + messages=turns, + timeout=self.config.timeout, + ) + + +@dataclass +class LocalLLMConfig: + # Open-source/open-weight local models (examples): + # - Qwen/Qwen2.5-0.5B-Instruct + # - google/gemma-2-2b-it + model: str = DEFAULT_LOCAL_MODEL + temperature: float = 0.0 + max_new_tokens: int = 64 + device_map: str = "auto" + + +@dataclass +class LocalTransformersAgent: + """Local agent using Hugging Face Transformers (no inference credits).""" + + config: LocalLLMConfig = field(default_factory=LocalLLMConfig) + tokenizer: Optional[AutoTokenizer] = None + model: Optional[AutoModelForCausalLM] = None + + def __post_init__(self) -> None: + if self.tokenizer is None: + self.tokenizer = AutoTokenizer.from_pretrained(self.config.model) + if self.model is None: + self.model = AutoModelForCausalLM.from_pretrained( + self.config.model, + device_map=self.config.device_map, + ) + + def __call__(self, messages: List[Dict[str, str]]) -> str: + prompt = self.tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True, + ) + + inputs = self.tokenizer(prompt, return_tensors="pt") + inputs = {k: v.to(self.model.device) for k, v in inputs.items()} + + t0 = time.perf_counter() + generated = self.model.generate( + **inputs, + max_new_tokens=self.config.max_new_tokens, + temperature=self.config.temperature, + do_sample=self.config.temperature > 0, + ) + gen_s = time.perf_counter() - t0 + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "Local generate: model=%s elapsed=%.2fs prompt_tokens=%d", + self.config.model, + gen_s, + inputs["input_ids"].shape[1], + ) + + prompt_len = inputs["input_ids"].shape[1] + new_tokens = generated[0][prompt_len:] + return self.tokenizer.decode(new_tokens, skip_special_tokens=True).strip() + + +if __name__ == "__main__": + agent = LocalTransformersAgent(config=LocalLLMConfig()) + out = agent( + [ + {"role": "system", "content": "Reply with one short sentence."}, + {"role": "user", "content": "What is 2+2?"}, + ] + ) + print(out) diff --git a/src/v2/nlu_pipeline/nlu_benchmark/config.py b/src/v2/nlu_pipeline/nlu_benchmark/config.py new file mode 100644 index 0000000..c7c6b23 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/config.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Literal + + +@dataclass +class ExperimentConfig: + """Selects one implementation along each experimental axis. + + Task JSON uses ``maze.dimensions = [rows, cols]`` and 1-based ``[x, y]`` cells (east, south from **top-left** ``(1,1)``), + i.e. ``[column, row]``. Env tuples are ``(row, column)``. + + prompting + minimal – goal + action list only (system prompt) + standard – adds ``MECHANISM_LIST`` to the system prompt + verbose – standard + ``MECHANISM_RULES`` + extra user fields (neighbours, hints). + Maze **layout** text is in the system / user split from ``observation``, not from prompting. + + observation + text_only – initial NL maze in system; current situation text per user turn; last3 history + image_text – same as text_only + live PNG each turn; last3 = full feedback + image_only – live PNG only (no NL map); last3 = prior decision-frame PNGs + ``Action: …`` only (multimodal) + + context_window + current – only the current observation (no prior steps in the prompt) + last3 – last 3 steps as structured lines prepended to the prompt (default) + + querying + step_by_step – one LLM call per env step (only the first action in FINAL_OUTPUT is used) + subgoal – SUB_GOAL + ACTIONS list; re-queries when queue empty, stuck, or mid-budget + full_trajectory – same format as subgoal, but exactly one LLM call per episode (no re-query) + + chat_history + stateless – each API call is only ``[system, current_user]`` (cheapest; no prior assistant text). + rolling – append user/assistant each query, but keep at most ``chat_turns_max`` prior + user+assistant **pairs** after ``system`` (default: good balance for vision + reasoning). + full – append without trimming (original behavior; high token use with images). + + chat_turns_max + For ``chat_history=\"rolling\"``: max number of full ``(user, assistant)`` rounds kept in the API + payload after ``system``. Ignored for ``stateless`` / ``full``. + """ + + prompting: Literal["minimal", "standard", "verbose"] = "standard" + observation: Literal["text_only", "image_text", "image_only"] = "image_only" + context_window: Literal["current", "last3"] = "current" + querying: Literal["step_by_step", "subgoal", "full_trajectory"] = "step_by_step" + chat_history: Literal["stateless", "rolling", "full"] = "rolling" + chat_turns_max: int = 3 + + def to_dict(self) -> dict: + return asdict(self) diff --git a/src/v2/nlu_pipeline/nlu_benchmark/env.py b/src/v2/nlu_pipeline/nlu_benchmark/env.py new file mode 100644 index 0000000..247effc --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/env.py @@ -0,0 +1,218 @@ +from dataclasses import dataclass, field +from typing import Any, Dict, List, Set, Tuple, Optional + +# Grid positions are 1-based ``(row, column)``: origin **top-left** ``(1, 1)``; row increases **southward**, +# column increases eastward. Movement deltas are ``(Δrow, Δcolumn)``. +Pos = Tuple[int, int] + +FACING_ORDER = ["NORTH", "EAST", "SOUTH", "WEST"] + +FACING_TO_DELTA: Dict[str, Tuple[int, int]] = { + "NORTH": (-1, 0), + "EAST": ( 0, +1), + "SOUTH": (+1, 0), + "WEST": ( 0, -1), +} + + +@dataclass +class GridState: + rows: int + cols: int + walls: Set[Pos] + start: Pos # (row, column) 1-based + goal: Pos + agent_pos: Pos + facing: str = "NORTH" + step_count: int = 0 + max_steps: int = 50 + inventory: List[str] = field(default_factory=list) # collected key colors + keys: List[Dict[str, Any]] = field(default_factory=list) + doors: List[Dict[str, Any]] = field(default_factory=list) + switches: List[Dict[str, Any]] = field(default_factory=list) + gates: List[Dict[str, Any]] = field(default_factory=list) + + +@dataclass +class StepEvent: + type: str # TURNED, MOVED, BLOCKED, DONE, PICKUP, TOGGLED, NOTHING, WRONG_DONE, INVALID + message: str + + +class GridWorldEnv: + + def __init__( + self, + rows: int, + cols: int, + walls: Set[Pos], + start: Pos, + goal: Pos, + max_steps: int = 50, + mechanisms: Optional[Dict[str, Any]] = None, + ): + mechs = mechanisms or {} + self.initial = GridState( + rows=rows, + cols=cols, + walls=walls, + start=start, + goal=goal, + agent_pos=start, + max_steps=max_steps, + keys=mechs.get("keys", []), + doors=mechs.get("doors", []), + switches=mechs.get("switches", []), + gates=mechs.get("gates", []), + ) + self.state: Optional[GridState] = None + + def reset(self) -> GridState: + s = self.initial + self.state = GridState( + rows=s.rows, + cols=s.cols, + walls=set(s.walls), + start=s.start, + goal=s.goal, + agent_pos=s.start, + facing="NORTH", + step_count=0, + max_steps=s.max_steps, + inventory=[], + keys=[dict(k) for k in s.keys], + doors=[dict(d) for d in s.doors], + switches=[{**dict(sw), "on": bool(sw.get("on", False))} for sw in s.switches], + gates=[GridWorldEnv._gate_state_from_switches(dict(g), s.switches) for g in s.gates], + ) + return self.state + + @staticmethod + def _gate_state_from_switches(gate: Dict, switches: List[Dict]) -> Dict: + """Gates are open if any linked switch is on, else use initial/embedded state.""" + g = dict(gate) + gid = g.get("id") + if gid: + if any( + bool(sw.get("on")) and gid in sw.get("controls", []) + for sw in switches + ): + g["state"] = "open" + else: + g["state"] = g.get("state", g.get("initial_state", "closed")) + return g + + def step(self, action: str) -> tuple[GridState, StepEvent]: + assert self.state is not None, "Call reset() first." + + verb = action.strip().upper() + + # --- Turns --- + if verb in ("TURN_LEFT", "TURN_RIGHT"): + idx = FACING_ORDER.index(self.state.facing) + self.state.facing = FACING_ORDER[(idx + (-1 if verb == "TURN_LEFT" else 1)) % 4] + self.state.step_count += 1 + return self.state, StepEvent("TURNED", f"Now facing {self.state.facing}.") + + # --- Move one step forward --- + if verb == "MOVE_FORWARD": + dr, dc = FACING_TO_DELTA[self.state.facing] + r, c = self.state.agent_pos + nr, nc = r + dr, c + dc + reason = self._blocked(nr, nc) + if reason: + return self.state, StepEvent("BLOCKED", f"MOVE_FORWARD blocked by {reason}.") + self.state.agent_pos = (nr, nc) + # With matching key in inventory, moving onto a door tile opens it (no TOGGLE on doors) + door = self._door_at((nr, nc)) + if door and door["requires_key"] in self.state.inventory: + self.state.doors = [ + d for d in self.state.doors if tuple(d["position"]) != (nr, nc) + ] + self.state.step_count += 1 + if self.state.agent_pos == self.state.goal: + return self.state, StepEvent("DONE", f"Reached goal at {self.state.goal}.") + return self.state, StepEvent("MOVED", f"Moved to {self.state.agent_pos}.") + + # --- Pick up object at current position --- + if verb == "PICKUP": + pos = self.state.agent_pos + key = self._key_at(pos) + if key: + self.state.inventory.append(key["color"]) + self.state.keys = [k for k in self.state.keys if tuple(k["position"]) != pos] + self.state.step_count += 1 + return self.state, StepEvent("PICKUP", f"Picked up {key['color']} key.") + self.state.step_count += 1 + return self.state, StepEvent("NOTHING", f"Nothing to pick up at {pos}.") + + # --- Toggle facing switch only (opens/closes linked gates; doors and gates are not toggled directly) --- + if verb == "TOGGLE": + dr, dc = FACING_TO_DELTA[self.state.facing] + r, c = self.state.agent_pos + target = (r + dr, c + dc) + sw = self._switch_at(target) + if sw: + self._toggle_switch(sw) + self.state.step_count += 1 + st = "on" if sw.get("on") else "off" + return self.state, StepEvent("TOGGLED", f"Switch at {target} is {st}.") + self.state.step_count += 1 + if self._door_at(target) or self._gate_at(target): + return self.state, StepEvent("NOTHING", "Use PICKUP to collect keys. Doors open when you have the right key. Only switches can be TOGGLED (gates follow switch on/off).") + return self.state, StepEvent("NOTHING", f"No switch to toggle at {target}.") + + # --- Agent signals task complete --- + if verb == "DONE": + if self.state.agent_pos == self.state.goal: + return self.state, StepEvent("DONE", f"Task complete at {self.state.goal}.") + self.state.step_count += 1 + return self.state, StepEvent("WRONG_DONE", f"DONE called but not at goal {self.state.goal}.") + + return self.state, StepEvent("INVALID", f"Unknown action: {action}") + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _blocked(self, nrow: int, ncol: int) -> Optional[str]: + """Return a reason string if ``(nrow, ncol)`` is impassable, else ``None``. Indices are ``(row, column)``.""" + if nrow < 1 or nrow > self.state.rows or ncol < 1 or ncol > self.state.cols: + return "out of bounds" + if (nrow, ncol) in self.state.walls: + return "wall" + door = self._door_at((nrow, ncol)) + if door and door["requires_key"] not in self.state.inventory: + return f"locked {door['requires_key']} door" + gate = self._gate_at((nrow, ncol)) + if gate and gate.get("state", gate.get("initial_state", "closed")) == "closed": + return "closed gate" + return None + + def _key_at(self, pos: Pos): + return next((k for k in self.state.keys if tuple(k["position"]) == pos), None) + + def _door_at(self, pos: Pos): + return next((d for d in self.state.doors if tuple(d["position"]) == pos), None) + + def _switch_at(self, pos: Pos): + return next((s for s in self.state.switches if tuple(s["position"]) == pos), None) + + def _gate_at(self, pos: Pos): + return next((g for g in self.state.gates if tuple(g["position"]) == pos), None) + + def _recompute_gates_from_switches(self) -> None: + """A gate is open if any of its linked switches is on.""" + for gate in self.state.gates: + gid = gate.get("id") + if not gid: + continue + on = any( + bool(s.get("on")) and gid in s.get("controls", []) + for s in self.state.switches + ) + gate["state"] = "open" if on else "closed" + + def _toggle_switch(self, sw: Dict) -> None: + sw["on"] = not sw.get("on", False) + self._recompute_gates_from_switches() diff --git a/src/v2/nlu_pipeline/nlu_benchmark/examples/run_llm.py b/src/v2/nlu_pipeline/nlu_benchmark/examples/run_llm.py new file mode 100644 index 0000000..ab6e386 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/examples/run_llm.py @@ -0,0 +1,21 @@ +import os +from pathlib import Path + +# Load Anthropic API key from repo-root api_key.txt if ANTHROPIC_API_KEY is unset. +if not os.environ.get("ANTHROPIC_API_KEY"): + for directory in Path(__file__).resolve().parents: + key_file = directory / "api_key.txt" + if key_file.is_file(): + os.environ["ANTHROPIC_API_KEY"] = key_file.read_text().strip() + break + +from nlu_benchmark.runner import ExperimentRunner +from nlu_benchmark.agents import ClaudeAnthropicAgent, ClaudeAnthropicConfig + +runner = ExperimentRunner.from_json("nlu_benchmark/sample mazes/V01_empty_room.json") + +# Override model=... on ClaudeAnthropicConfig if needed (see Anthropic model IDs). +agent = ClaudeAnthropicAgent(config=ClaudeAnthropicConfig()) + +result = runner.run(agent) +print(result["success"]) diff --git a/src/v2/nlu_pipeline/nlu_benchmark/examples/run_local_llm.py b/src/v2/nlu_pipeline/nlu_benchmark/examples/run_local_llm.py new file mode 100644 index 0000000..1b7511d --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/examples/run_local_llm.py @@ -0,0 +1,20 @@ +from nlu_benchmark.config import ExperimentConfig +from nlu_benchmark.runner import ExperimentRunner +from nlu_benchmark.agents import LocalTransformersAgent, LocalLLMConfig + +runner = ExperimentRunner.from_json( + "nlu_benchmark/sample mazes/V01_empty_room.json", + config=ExperimentConfig(observation="text_only"), +) + +# Small local model (no HF inference credits required). +agent = LocalTransformersAgent( + config=LocalLLMConfig( + model="HuggingFaceTB/SmolLM2-360M-Instruct", + max_new_tokens=16, + ) +) + +result = runner.run(agent) +print(result["success"]) + diff --git a/src/v2/nlu_pipeline/nlu_benchmark/feedback.py b/src/v2/nlu_pipeline/nlu_benchmark/feedback.py new file mode 100644 index 0000000..afaa8f2 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/feedback.py @@ -0,0 +1,42 @@ +"""Step feedback strings for the episode loop — independent of prompt strategy.""" + +from __future__ import annotations + +from typing import Any, Literal + +ObservationKind = Literal["text_only", "image_text", "image_only"] + + +def action_feedback_for_prompt(_observation: ObservationKind, text: str) -> str: + """Step outcomes for ``Last result:`` and for observation history. + + All observation modes receive the same ``text`` (from :func:`format_step_feedback`). + ``image_only`` includes this so stateless API turns still see BLOCKED/MOVED/etc. without + relying on prior assistant messages. ``_observation`` is kept for call-site compatibility. + """ + return text + + +def format_step_feedback( + action: str, event_type: str, event_message: str, prev_pos: Any +) -> str: + """Format env step for ``Last result:`` (branches match ``StepEvent.type`` in ``env``).""" + if event_type == "BLOCKED": + return f"BLOCKED — {action}: {event_message} You remain at {prev_pos}." + if event_type == "TURNED": + return f"TURNED — {action}: {event_message}" + if event_type == "MOVED": + return f"MOVED — {action}: {event_message}" + if event_type == "DONE": + return f"SUCCESS — {action}: {event_message}" + if event_type == "PICKUP": + return f"PICKUP — {action}: {event_message}" + if event_type == "NOTHING": + return f"NOTHING — {action}: {event_message} You remain at {prev_pos}." + if event_type == "TOGGLED": + return f"TOGGLED — {action}: {event_message}" + if event_type == "WRONG_DONE": + return f"WRONG DONE — {action}: {event_message} You remain at {prev_pos}." + if event_type == "INVALID": + return f"INVALID — {action}: {event_message} You remain at {prev_pos}." + return f"{event_type} — {action}: {event_message}" diff --git a/src/v2/nlu_pipeline/nlu_benchmark/loader.py b/src/v2/nlu_pipeline/nlu_benchmark/loader.py new file mode 100644 index 0000000..1211553 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/loader.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import copy +import json +from dataclasses import replace +from pathlib import Path +from typing import Any + +from automatic_maze_generation.mazegen.models import Door, Gate, Key, MazeInstance, Switch + +from nlu_benchmark.env import GridState, GridWorldEnv + + +def _swap_validation_v04_dimensions_if_raw(maze: dict[str, Any], task_id: str) -> None: + """``validation_10_v04_single_key.json`` lists ``dimensions`` as ``[cols, rows]`` = ``[14, 12]``; normalize once.""" + if str(task_id) != "validation_10_v04_single_key": + return + dims = maze.get("dimensions") + if isinstance(dims, list) and len(dims) == 2 and dims[0] == 14 and dims[1] == 12: + maze["dimensions"] = [12, 14] + + +def _json_cell_to_pos(pair: list | tuple) -> tuple[int, int]: + """JSON cell ``[x, y]`` with origin at **top-left** ``(1, 1)``: ``x`` east (column), ``y`` south (row). + + Same as ``[column, row]``. Internal env tuple is ``(row, column)``. + """ + col, row = int(pair[0]), int(pair[1]) + return (row, col) + + +def _normalize_mechanisms_from_json(mechs: dict[str, Any] | None) -> dict[str, Any]: + """Deep-copy mechanisms; JSON ``position`` is ``[x, y]`` / ``[column, row]``, stored as ``[row, column]`` internally.""" + m = copy.deepcopy(mechs or {}) + for name in ("keys", "doors", "switches", "gates"): + for item in m.get(name, []): + pos = item.get("position") + if isinstance(pos, (list, tuple)) and len(pos) == 2: + r, c = _json_cell_to_pos(pos) + item["position"] = [r, c] + return m + + +def _task_dict_to_env(data: dict[str, Any]) -> GridWorldEnv: + maze = data["maze"] + _swap_validation_v04_dimensions_if_raw(maze, str(data.get("task_id", ""))) + rows, cols = maze["dimensions"] + walls = {_json_cell_to_pos(w) for w in maze["walls"]} + start = _json_cell_to_pos(maze["start"]) + goal = _json_cell_to_pos(maze["goal"]) + max_steps = data.get("max_steps", 100) + mechanisms = _normalize_mechanisms_from_json(data.get("mechanisms", {})) + return GridWorldEnv( + rows=rows, + cols=cols, + walls=walls, + start=start, + goal=goal, + max_steps=max_steps, + mechanisms=mechanisms, + ) + + +def load_maze(path) -> GridWorldEnv: + data = json.loads(Path(path).read_text(encoding="utf-8")) + return _task_dict_to_env(data) + + +def load_maze_from_dict(data: dict[str, Any]) -> GridWorldEnv: + """Build env from a parsed task dict (same schema as ``load_maze`` JSON files).""" + return _task_dict_to_env(data) + + +def grid_state_to_maze_instance(st: GridState) -> MazeInstance: + def rc_to_xy(pos): + row, col = pos + # Mazegen ``y`` increases south from the north edge; NLU row 1 is north (top) → ``y = row - 1``. + return (col - 1, row - 1) + + return MazeInstance( + width=st.cols, + height=st.rows, + walls={rc_to_xy(w) for w in st.walls}, + start=rc_to_xy(st.start), + goal=rc_to_xy(st.goal), + keys=[ + Key(id=k.get("id", f"key_{i}"), position=rc_to_xy(tuple(k["position"])), color=k["color"]) + for i, k in enumerate(st.keys) + ], + doors=[ + Door( + id=d.get("id", f"door_{i}"), + position=rc_to_xy(tuple(d["position"])), + requires_key=d["requires_key"], + initial_state=d.get("initial_state", "locked"), + ) + for i, d in enumerate(st.doors) + ], + switches=[ + Switch( + id=s.get("id", f"switch_{i}"), + position=rc_to_xy(tuple(s["position"])), + controls=list(s.get("controls", [])), + switch_type=s.get("switch_type", "toggle"), + initial_state=s.get("initial_state", "off"), + ) + for i, s in enumerate(st.switches) + ], + gates=[ + Gate( + id=g.get("id", f"gate_{i}"), + position=rc_to_xy(tuple(g["position"])), + initial_state=g.get("initial_state", "closed"), + ) + for i, g in enumerate(st.gates) + ], + ) + + +def load_maze_instance(path) -> MazeInstance: + """Parse task JSON like :func:`load_maze`, reset env once, and build a :class:`MazeInstance` for mazegen.""" + p = Path(path) + data = json.loads(p.read_text(encoding="utf-8")) + return maze_instance_from_task_dict(data) + + +def maze_instance_from_task_dict(data: dict[str, Any]) -> MazeInstance: + """Same as :func:`load_maze_instance` but from an already-parsed task dict (avoids a second disk read).""" + inst = grid_state_to_maze_instance(_task_dict_to_env(data).reset()) + return replace(inst, metadata=dict(data.get("metadata", {}))) + + +def task_dict_shrink_dimensions_minus_two(data: dict[str, Any]) -> dict[str, Any]: + """ + Return a deep copy whose ``maze.dimensions`` are each reduced by 2 (e.g. ``[10, 10] -> [8, 8]``). + + JSON coordinates are 1-based ``[x, y]`` with origin at the **top-left** cell ``(1, 1)``: ``x`` east (column), + ``y`` south (row). Same as ``[column, row]``. They are not rewritten—only ``dimensions`` shrink. + + Raises ``ValueError`` if the new size would be <2 or any coordinate lies outside the shrunk grid. + """ + out = copy.deepcopy(data) + maze = out["maze"] + _swap_validation_v04_dimensions_if_raw(maze, str(out.get("task_id", ""))) + rows, cols = maze["dimensions"] + if rows < 2 or cols < 2: + raise ValueError("maze dimensions must be at least 2 to shrink by 2") + nr, nc = rows - 2, cols - 2 + + def bad_cell(col: int, row: int) -> bool: + return not (1 <= row <= nr and 1 <= col <= nc) + + scol, srow = int(maze["start"][0]), int(maze["start"][1]) + gcol, grow = int(maze["goal"][0]), int(maze["goal"][1]) + if bad_cell(scol, srow) or bad_cell(gcol, grow): + raise ValueError(f"start/goal outside shrunk grid x 1..{nc}, y 1..{nr}: start={maze['start']} goal={maze['goal']}") + + for w in maze["walls"]: + wc, wr = int(w[0]), int(w[1]) + if bad_cell(wc, wr): + raise ValueError(f"wall {w} outside shrunk grid ({nr}x{nc})") + + mech = out.get("mechanisms", {}) + for name in ("keys", "doors", "switches", "gates"): + for item in mech.get(name, []): + pos = item.get("position") + if pos is None: + continue + wc, wr = int(pos[0]), int(pos[1]) + if bad_cell(wc, wr): + raise ValueError(f"{name} position {pos} outside shrunk grid ({nr}x{nc})") + + g = out.get("goal") + if isinstance(g, dict) and g.get("type") == "reach_position": + t = g.get("target") + if isinstance(t, (list, tuple)) and len(t) == 2: + tc, tr = int(t[0]), int(t[1]) + if bad_cell(tc, tr): + raise ValueError(f"goal.target {t} outside shrunk grid ({nr}x{nc})") + + maze["dimensions"] = [nr, nc] + return out diff --git a/src/v2/nlu_pipeline/nlu_benchmark/observation.py b/src/v2/nlu_pipeline/nlu_benchmark/observation.py new file mode 100644 index 0000000..9cbe4c5 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/observation.py @@ -0,0 +1,132 @@ +"""Observation builder for the NLU benchmark. + +* **text_only** / **image_text** – The runner appends initial NL layout to the + system message once per episode. Each user turn: ``render_user_observation_text``, + last3 history, and live PNG when image is enabled. + +* **image_only** – No initial NL map in system; live PNG each query; ``last3`` + history is multimodal: up to three prior **decision-frame** PNGs (view before each + executed action) plus ``Action: …`` lines only — pose/outcome are left to the image. + +``build_image_blocks`` adds PNGs whenever observation is not ``text_only`` (see ``runner._build_message``). +""" + +from __future__ import annotations + +import base64 +from pathlib import Path +from typing import List, Literal, Optional + +from nlu_benchmark.renderer import render_maze_image_png_bytes, render_user_observation_text + + +class _StepRecord: + __slots__ = ("position", "facing", "action", "feedback", "png_raw") + + def __init__(self, position, facing, action, feedback, png_raw: Optional[bytes] = None): + self.position = position + self.facing = facing + self.action = action + self.feedback = feedback + self.png_raw = png_raw + + +class ObservationBuilder: + """Builds what the model sees each step from config.observation + context_window.""" + + def __init__( + self, + observation: Literal["text_only", "image_text", "image_only"], + context_window: Literal["current", "last3"], + ) -> None: + self._observation = observation + self._context_window = context_window + self._history: List[_StepRecord] = [] + + def reset(self) -> None: + self._history.clear() + + def render_decision_frame_png(self, state) -> Optional[bytes]: + """PNG of the maze **before** ``env.step`` mutates ``state`` (``image_only`` only).""" + if self._observation != "image_only": + return None + try: + return render_maze_image_png_bytes(state) + except Exception: + return None + + def record( + self, + position, + facing: str, + action: str, + feedback: str, + *, + decision_frame_png: Optional[bytes] = None, + ) -> None: + png_raw = decision_frame_png if self._observation == "image_only" else None + self._history.append(_StepRecord(position, facing, action, feedback, png_raw)) + + def history_content_blocks(self) -> List[dict]: + """Multimedia tail for ``image_only`` + ``last3``: prior frames + action labels only.""" + if self._observation != "image_only" or self._context_window == "current" or not self._history: + return [] + recs = self._history[-3:] + blocks: List[dict] = [] + for rec in recs: + if not rec.png_raw: + continue + b64 = base64.b64encode(rec.png_raw).decode("utf-8") + blocks.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}) + blocks.append({"type": "text", "text": f"Action: {rec.action}\n\n"}) + if not blocks: + return [] + intro = ( + "Recent steps (oldest first). Each image is the maze view from which the " + "following action was chosen; infer pose and environment state from the image.\n\n" + ) + return [{"type": "text", "text": intro}] + blocks + + def history_text(self) -> str: + if ( + self._context_window == "current" + or not self._history + or self._observation == "image_only" + ): + return "" + recs = self._history[-3:] + lines = ["Recent history (last 3 steps, oldest first):"] + for rec in recs: + lines.append( + f" {rec.position} facing {rec.facing} -> {rec.action} -> {rec.feedback}" + ) + return "\n".join(lines) + + def build_text(self, state) -> str: + if self._observation == "image_only": + return "" + return render_user_observation_text(state) + + def build_image_blocks(self, state, maze_json_path: Optional[str]) -> List[dict]: + if self._observation == "text_only": + return [] + try: + raw = render_maze_image_png_bytes(state) + except Exception: + raw = b"" + if raw: + b64 = base64.b64encode(raw).decode("utf-8") + return [{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}] + b = _load_maze_png_block(maze_json_path) + return [b] if b else [] + + +def _load_maze_png_block(maze_json_path: Optional[str]) -> Optional[dict]: + if not maze_json_path: + return None + p = Path(maze_json_path) + img_path = p.parent / "pngs" / (p.stem + ".png") + if not img_path.exists(): + return None + b64 = base64.b64encode(img_path.read_bytes()).decode("utf-8") + return {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}} diff --git a/src/v2/nlu_pipeline/nlu_benchmark/parser.py b/src/v2/nlu_pipeline/nlu_benchmark/parser.py new file mode 100644 index 0000000..d321cd9 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/parser.py @@ -0,0 +1,88 @@ +import re +from typing import List, Optional + +# Canonical order for prompts / error messages (single source of truth). +ACTION_ORDER = ( + "TURN_LEFT", + "TURN_RIGHT", + "MOVE_FORWARD", + "PICKUP", + "TOGGLE", + "DONE", +) +VALID_ACTIONS = set(ACTION_ORDER) +ACTIONS_HINT = ", ".join(ACTION_ORDER) + +_SYNONYMS = { + "turn left": "TURN_LEFT", + "rotate left": "TURN_LEFT", + "turn right": "TURN_RIGHT", + "rotate right": "TURN_RIGHT", + "move forward": "MOVE_FORWARD", + "go forward": "MOVE_FORWARD", + "forward": "MOVE_FORWARD", + "pick up": "PICKUP", + "pickup": "PICKUP", + "toggle": "TOGGLE", + "done": "DONE", + "finished": "DONE", +} + +_FINAL_OUTPUT_RE = re.compile(r"(?i)^FINAL_OUTPUT\s*:\s*(.*)\s*$") + + +def parse_final_output( + text: str, allow_regex_fallback: bool = True +) -> Optional[List[str]]: + """Parse model output into one or more validated action tokens, or None. + + Checks the last 5 non-empty lines for: + FINAL_OUTPUT: or FINAL_OUTPUT: a, b, c + (comma-separated, each token must be a valid action). + + If that fails, optionally falls back to a single action from the last + matching synonym in the full text. + """ + lines = [ln.strip() for ln in text.splitlines() if ln.strip()] + trailing = lines[-5:] if len(lines) >= 5 else lines + + for line in reversed(trailing): + m = _FINAL_OUTPUT_RE.match(line) + if m: + rest = m.group(1).strip() + if not rest: + return None + out: List[str] = [] + for part in rest.split(","): + p = part.strip() + if not p: + continue + a = normalize_action(p) + if not a: + return None + out.append(a) + return out if out else None + if re.match(r"(?i)^FINAL_OUTPUT\s*:", line): + return None + + if allow_regex_fallback: + norm = text.lower() + matches = [] + for phrase, canonical in _SYNONYMS.items(): + pattern = re.escape(phrase).replace(r"\ ", r"\s+") + for m in re.finditer(pattern, norm): + matches.append((m.start(), canonical)) + if matches: + matches.sort(key=lambda x: x[0]) + return [matches[-1][1]] + + return None + + +def normalize_action(raw: str) -> str: + """Normalize a raw token from a comma-separated list into a canonical action string. + + Returns "" for unrecognized tokens. + """ + verb = raw.strip().upper().replace(" ", "_") + return verb if verb in VALID_ACTIONS else "" diff --git a/src/v2/nlu_pipeline/nlu_benchmark/prompt_strategies.py b/src/v2/nlu_pipeline/nlu_benchmark/prompt_strategies.py new file mode 100644 index 0000000..90f8e2e --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/prompt_strategies.py @@ -0,0 +1,196 @@ +"""Prompt strategies for the NLU benchmark. + + minimal – goal + action list. Per-turn user text is ``render_user_observation_text``; + initial layout is in the system message when observation is text or image+text. + standard – system prompt adds the static ``MECHANISM_LIST``; user layout same as + minimal for text observation content. + verbose – system: mechanism list + domain rules; user: neighbour view, + inventory, per-step mechanism hints. + +Initial maze NL is ``render_initial_maze_text`` in the system prompt; each user +turn includes ``render_user_observation_text`` (when text or image+text), not +here. +""" + +from __future__ import annotations + +from nlu_benchmark.env import FACING_ORDER, FACING_TO_DELTA + +# Standard system prompt: high-level object types. Verbose reuses this and adds +# MECHANISM_RULES + per-step hints in the user turn. +MECHANISM_LIST = ( + "The environment may contain:\n" + "- Keys: pick them up to open doors of the matching color\n" + "- Doors: blocked passages that require a matching key\n" + "- Switches: toggle these to open or close linked gates\n" + "- Gates: blocked passages controlled by switches\n" +) + +# Verbose system prompt: operational rules (action semantics). Not in Standard. +MECHANISM_RULES = ( + "RULES (domain logic):\n" + " - PICKUP: take a key on your current cell and store it in your inventory.\n" + " - Doors: keys and doors are color-matched. With the matching key in your inventory, move onto\n" + " the door to open it\n" + " - Switches: face a switch and TOGGLE to flip it on or off. Only switches are toggled. Linked\n" + " gates are open if at least one linked switch is on, and closed if all are off.\n" + " - Gates: you cannot TOGGLE a gate. CLOSED gates block movement; OPEN gates do not.\n" + " - Closed gates and doors you lack a key for block movement like walls until resolved.\n" + " - Use DONE only when you are standing on the goal cell." +) + +# How models must terminate the reply (Minimal + Standard + Verbose base). +FINAL_OUTPUT_INSTRUCTION = ( + "On the last line, output exactly:\n" + "FINAL_OUTPUT: or FINAL_OUTPUT: , , ... " + "(comma-separated; one or more valid actions)" +) + + +class PromptStrategy: + """Base: shared action hint injection.""" + + def __init__(self, actions_hint: str) -> None: + self._actions_hint = actions_hint + + def build_system_prompt(self, querying_suffix: str = "") -> str: + raise NotImplementedError + + def build_user_prompt( + self, + obs_text: str, + history_text: str, + state, + last_feedback: str, + ) -> str: + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# Minimal — goal + action list only +# --------------------------------------------------------------------------- + +class MinimalPromptStrategy(PromptStrategy): + def build_system_prompt(self, querying_suffix: str = "") -> str: + return ( + "Task: move to the goal cell in the grid.\n" + f"Valid actions: {self._actions_hint}.\n" + f"{FINAL_OUTPUT_INSTRUCTION}" + + (f"\n\n{querying_suffix}" if querying_suffix else "") + ) + + def build_user_prompt( + self, + obs_text: str, + history_text: str, + state, + last_feedback: str, + ) -> str: + history_block = f"{history_text}\n\n" if history_text else "" + obs_block = f"Observation:\n{obs_text}\n\n" if obs_text else "" + return ( + f"{history_block}" + f"{obs_block}" + f"Position: {state.agent_pos} | Facing: {state.facing} | Goal: {state.goal} | " + f"Step {state.step_count + 1}/{state.max_steps}\n" + f"Last result: {last_feedback}\n" + "What is your next action?" + ) + + +# --------------------------------------------------------------------------- +# Standard — mechanism list only (user prompt same as Minimal) +# --------------------------------------------------------------------------- + +class StandardPromptStrategy(MinimalPromptStrategy): + def build_system_prompt(self, querying_suffix: str = "") -> str: + return ( + "Task: move to the goal cell in the grid.\n" + f"{MECHANISM_LIST}\n" + f"Valid actions: {self._actions_hint}.\n" + f"{FINAL_OUTPUT_INSTRUCTION}" + + (f"\n\n{querying_suffix}" if querying_suffix else "") + ) + + +# --------------------------------------------------------------------------- +# Verbose — mechanism list + rules (system); optional hint lines (user) +# --------------------------------------------------------------------------- + +class VerbosePromptStrategy(StandardPromptStrategy): + def build_system_prompt(self, querying_suffix: str = "") -> str: + std = StandardPromptStrategy.build_system_prompt(self, "").rstrip() + chunks = [std, MECHANISM_RULES] + if querying_suffix: + chunks.append(querying_suffix) + return "\n\n".join(chunks) + + def build_user_prompt( + self, + obs_text: str, + history_text: str, + state, + last_feedback: str, + ) -> str: + steps_left = state.max_steps - state.step_count + budget_warn = ( + f" WARNING: Only {steps_left} steps remaining!\n" + if steps_left <= max(5, state.max_steps // 5) + else "" + ) + row, col = state.agent_pos + grow, gcol = state.goal + manhattan = abs(row - grow) + abs(col - gcol) + + facing_idx = FACING_ORDER.index(state.facing) + rel_dirs = [ + ("AHEAD", FACING_ORDER[facing_idx % 4]), + ("RIGHT", FACING_ORDER[(facing_idx + 1) % 4]), + ("BEHIND", FACING_ORDER[(facing_idx + 2) % 4]), + ("LEFT", FACING_ORDER[(facing_idx + 3) % 4]), + ] + neighbour_lines = [] + for rel, cardinal in rel_dirs: + dr, dc = FACING_TO_DELTA[cardinal] + nr, nc = row + dr, col + dc + if nr < 1 or nr > state.rows or nc < 1 or nc > state.cols: + desc = "out of bounds" + elif (nr, nc) in state.walls: + desc = "wall" + elif (nr, nc) == state.goal: + desc = f"GOAL ({nr},{nc})" + else: + desc = f"open ({nr},{nc})" + neighbour_lines.append(f" {rel}: {desc}") + neighbour_block = "From your perspective:\n" + "\n".join(neighbour_lines) + "\n" + + mechanism_block = _mechanism_hints_text(state) + + history_block = f"{history_text}\n\n" if history_text else "" + obs_block = f"Observation:\n{obs_text}\n\n" if obs_text else "" + inventory_str = ", ".join(state.inventory) if state.inventory else "none" + + return ( + f"{history_block}" + f"{obs_block}" + f"Position: {state.agent_pos} | Facing: {state.facing} | Goal: {state.goal} | " + f"Manhattan: {manhattan} | Step {state.step_count + 1}/{state.max_steps} ({steps_left} left)\n" + f"Inventory: {inventory_str}\n" + f"{budget_warn}" + f"{neighbour_block}" + f"{mechanism_block}" + f"Last result: {last_feedback}\n" + "What is your next action?" + ) + + +def _mechanism_hints_text(state) -> str: + """Short reminders when the map has interactive objects; observation still has details.""" + lines = [] + if state.keys or state.doors: + lines.append(" - PICKUP keys; with the right key, MOVE_FORWARD into a door to open it.") + if state.switches or state.gates: + lines.append(" - Face a switch and TOGGLE; gates follow linked switches (do not TOGGLE gates).") + if not lines: + return "" + return "Hints:\n" + "\n".join(lines) + "\n" diff --git a/src/v2/nlu_pipeline/nlu_benchmark/querying.py b/src/v2/nlu_pipeline/nlu_benchmark/querying.py new file mode 100644 index 0000000..fcf352c --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/querying.py @@ -0,0 +1,92 @@ +"""Querying modes for the NLU benchmark. + +A single `QueryingMode` class covers all three behaviours; only `should_query()` +and a few small details differ: + + step_by_step — one LLM call per env step: queue holds at most one action + (only the first action from FINAL_OUTPUT is used; then re-query). + subgoal — same output format as full trajectory, but re-query when the + queue runs out, after failures, or mid-episode. + full_trajectory — one query per episode; same SUB_GOAL / ACTIONS format + (or FINAL_OUTPUT: … as fallback, like step_by_step). + +The episode loop lives in ExperimentRunner.run() (runner.py), not here. +""" + +from __future__ import annotations + +import re +from typing import List, Literal + +from nlu_benchmark.parser import normalize_action, parse_final_output + +QueryingKind = Literal["step_by_step", "subgoal", "full_trajectory"] + +_SUBGOAL_RE = re.compile(r"(?i)SUB_GOAL\s*:\s*(.+)") +_ACTIONS_RE = re.compile(r"(?i)ACTIONS\s*:\s*(.+)") + + +class QueryingMode: + """When to call the model and how to parse its reply.""" + + def __init__(self, kind: QueryingKind) -> None: + self.kind = kind + self.current_subgoal = "" + self._trajectory_loaded = False + + def reset(self) -> None: + self.current_subgoal = "" + self._trajectory_loaded = False + + def should_query(self, queue, failures) -> bool: + if self.kind == "step_by_step": + # With at most one queued action (see parse_actions), this is true after each step. + return not queue + if self.kind == "subgoal": + return not queue or failures >= 3 + # full_trajectory + return not self._trajectory_loaded and not queue + + def parse_actions(self, model_text: str) -> List[str]: + if self.kind == "step_by_step": + out = parse_final_output(model_text) + return [out[0]] if out else [] + + m = _SUBGOAL_RE.search(model_text) + self.current_subgoal = m.group(1).strip() if m else "" + + m2 = _ACTIONS_RE.search(model_text) + if m2: + actions = [a for a in (normalize_action(t) for t in m2.group(1).split(",")) if a] + else: + out = parse_final_output(model_text) + actions = out if out else [] + + if self.kind == "full_trajectory" and actions: + self._trajectory_loaded = True + return actions + + def system_prompt_suffix(self) -> str: + if self.kind == "step_by_step": + return "" + if self.kind == "subgoal": + return ( + "For each turn output:\n" + " SUB_GOAL: \n" + " ACTIONS: " + ) + return ( + "Output your complete trajectory once as:\n" + " SUB_GOAL: \n" + " ACTIONS: \n" + "The last action in ACTIONS should be DONE (when you expect to be at the goal).\n" + "You will not be queried again — this is your only planning turn." + ) + + def step_metadata(self) -> dict: + if self.kind == "step_by_step": + return {} + meta = {"subgoal": self.current_subgoal} + if self.kind == "full_trajectory": + meta["full_trajectory"] = True + return meta diff --git a/src/v2/nlu_pipeline/nlu_benchmark/renderer.py b/src/v2/nlu_pipeline/nlu_benchmark/renderer.py new file mode 100644 index 0000000..e869acc --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/renderer.py @@ -0,0 +1,174 @@ +"""Maze text split: **initial** layout (system) vs **current** situation (user turn).""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import Any + +from nlu_benchmark.env import GridState + +_RENDER_DATASET_MOD = None + + +def _render_dataset_module(): + """Load ``automatic_maze_generation/render_dataset.py`` without requiring ``v2`` on ``PYTHONPATH``.""" + global _RENDER_DATASET_MOD + if _RENDER_DATASET_MOD is None: + path = Path(__file__).resolve().parents[2] / "automatic_maze_generation" / "render_dataset.py" + name = "_multinet_automatic_maze_generation_render_dataset" + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load maze renderer from {path}") + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + _RENDER_DATASET_MOD = mod + return _RENDER_DATASET_MOD + + +def _internal_pos_to_json_list(pos: tuple[int, int]) -> list[int]: + """Env ``(row, column)`` → JSON ``[x, y]`` = ``[column, row]`` (standard Cartesian order).""" + row, col = pos + return [col, row] + + +def _mechanism_dict_for_payload(item: dict[str, Any]) -> dict[str, Any]: + out = dict(item) + if "position" in out: + out["position"] = _internal_pos_to_json_list(tuple(out["position"])) + return out + + +def _grid_state_to_maze_payload(state: GridState, *, task_id: str = "") -> dict: + """JSON-shaped maze dict for ``render_maze_payload`` / ``render_maze_payload_bytes``.""" + out: dict[str, Any] = { + "maze": { + # Task JSON ``[x, y]`` = ``[column, row]`` (``dimensions`` are ``[rows, cols]``). + "dimensions": [state.rows, state.cols], + "walls": [_internal_pos_to_json_list(w) for w in sorted(state.walls)], + "start": _internal_pos_to_json_list(state.start), + "goal": _internal_pos_to_json_list(state.goal), + }, + "mechanisms": { + "keys": [_mechanism_dict_for_payload(k) for k in state.keys], + "doors": [_mechanism_dict_for_payload(d) for d in state.doors], + "switches": [_mechanism_dict_for_payload(s) for s in state.switches], + "gates": [_mechanism_dict_for_payload(g) for g in state.gates], + }, + } + if task_id: + out["task_id"] = task_id + return out + + +def _static_layout_lines(state: GridState) -> list[str]: + wall_str = ", ".join(f"({row},{col})" for row, col in sorted(state.walls)) or "none" + return [ + f"The world is a {state.rows} by {state.cols} grid.", + "Coordinates: JSON lists use ``[x, y]`` (east, south) from the **top-left** corner ``(1, 1)``;" + " tuples in this text use ``(row, column)`` matching env state (row southward, column east)." + " So ``x`` = column index, ``y`` = row index (e.g. goal ``[2, 12]`` is the cell ``(12, 2)``).", + f"The start is at {state.start}.", + f"The goal is at {state.goal}.", + f"The following cells are walls: {wall_str}.", + ] + + +def _mechanism_lines(state: GridState) -> list[str]: + parts: list[str] = [] + for key in state.keys: + row, col = key["position"] + parts.append(f"There is a {key['color']} key at ({row},{col}).") + + for door in state.doors: + row, col = door["position"] + parts.append( + f"There is a locked {door['requires_key']} door at ({row},{col})." + f" It requires the {door['requires_key']} key to open." + ) + + for switch in state.switches: + row, col = switch["position"] + controls = ", ".join(switch.get("controls", [])) + on_off = "on" if switch.get("on") else "off" + parts.append( + f"There is a {switch.get('switch_type', 'toggle')} switch at ({row},{col}) (currently {on_off})." + f" It controls: {controls}." + ) + + for gate in state.gates: + row, col = gate["position"] + cur = gate.get("state", gate.get("initial_state", "closed")) + parts.append( + f"There is a gate ({gate['id']}) at ({row},{col})." + f" It is currently {cur} (initially {gate.get('initial_state', 'closed')})." + ) + return parts + + +def render_initial_maze_text(state: GridState) -> str: + """Episode layout for the **system** prompt. Pass ``state`` from ``env.reset()``.""" + return "\n".join(_static_layout_lines(state) + _mechanism_lines(state)) + + +def render_user_observation_text(state: GridState) -> str: + """**Current** state for the **user** turn (text or image+text modes).""" + inv = ", ".join(state.inventory) if state.inventory else "empty" + head = [ + "Current situation (this step):", + f"The goal is at {state.goal}.", + f"You are at {state.agent_pos} facing {state.facing}.", + "Environment steps used so far: " + f"{state.step_count} (max {state.max_steps} before timeout).", + f"Your inventory: {inv}.", + "", + "Map contents as of this step (keys on the ground, doors, switches, gates):", + ] + mech = _mechanism_lines(state) + if mech: + head.extend(mech) + else: + head.append("(No keys on the ground, doors, switches, or gates in the current state description.)") + return "\n".join(head) + + +def render_maze_image_png_bytes(state: GridState, *, task_id: str = "") -> bytes: + """Render the current ``GridState`` to a PNG (same style as ``render_dataset.render_maze_payload``). + + ``task_id`` is only for the optional figure title (smoke replay uses the JSON id; LLM observations + default to empty so the title does not change ``tight_layout`` / margins). + """ + mod = _render_dataset_module() + payload = _grid_state_to_maze_payload(state, task_id=task_id) + row, col = state.agent_pos + return mod.render_maze_payload_bytes( + payload, + dpi=150, + agent_pos=(col, row), + facing=state.facing, + ) + + +def render_task_json_with_solver_path_png( + task_data: dict[str, Any], + solver_path_xy: list[tuple[int, int]], + output_path: Path, +) -> None: + """ + One static figure like ``automatic_maze_generation/render_dataset.py`` / ``main()``: + maze + mechanisms + semi-transparent optimal route. + + ``solver_path_xy`` is ``solve_maze(...)["path"]`` (mazegen 0-based ``(x, y)``; ``x`` = column index, ``y`` = row index). + """ + optimal_path_cells = [[x + 1, y + 1] for (x, y) in solver_path_xy] + payload = { + **task_data, + "validation": { + **task_data.get("validation", {}), + "optimal_path": optimal_path_cells, + }, + } + mod = _render_dataset_module() + mod.render_maze_payload(payload, output_path) diff --git a/src/v2/nlu_pipeline/nlu_benchmark/runner.py b/src/v2/nlu_pipeline/nlu_benchmark/runner.py new file mode 100644 index 0000000..16be21a --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/runner.py @@ -0,0 +1,307 @@ +"""ExperimentRunner — the single episode loop for all experiment configurations. + +Usage +----- + from nlu_benchmark.config import ExperimentConfig + from nlu_benchmark.runner import build_runner + + cfg = ExperimentConfig(prompting="verbose", querying="full_trajectory") + runner = build_runner(cfg, env, maze_json_path="path/to/maze.json") + +Or from a JSON file directly: + + runner = ExperimentRunner.from_json("path/to/maze.json", config=cfg) + result = runner.run(agent) +""" + +from __future__ import annotations + +import logging +import time +from typing import Callable, List, Optional + +logger = logging.getLogger(__name__) + + +def _user_message_has_image(message: dict) -> bool: + content = message.get("content") + if not isinstance(content, list): + return False + return any(isinstance(b, dict) and b.get("type") == "image_url" for b in content) + + +def _trim_rolling_chat(messages: List[dict], max_pairs: int) -> None: + """Keep ``messages[0]`` (system) and at most ``max_pairs`` following (user, assistant) pairs.""" + if max_pairs < 1 or len(messages) <= 1: + return + tail_len = len(messages) - 1 + cap = 2 * max_pairs + if tail_len > cap: + del messages[1 : 1 + (tail_len - cap)] + + +from nlu_benchmark.config import ExperimentConfig +from nlu_benchmark.feedback import action_feedback_for_prompt, format_step_feedback +from nlu_benchmark.observation import ObservationBuilder +from nlu_benchmark.prompt_strategies import ( + PromptStrategy, + MinimalPromptStrategy, + StandardPromptStrategy, + VerbosePromptStrategy, +) +from nlu_benchmark.parser import ACTIONS_HINT +from nlu_benchmark.querying import QueryingMode +from nlu_benchmark.renderer import render_initial_maze_text + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + +def build_runner( + config: ExperimentConfig, + env, + maze_json_path: Optional[str] = None, +) -> ExperimentRunner: + """Assemble an ExperimentRunner from a config. + + This is the one place that maps config values to concrete implementations. + """ + obs = ObservationBuilder(config.observation, config.context_window) + + prompt: PromptStrategy = { + "minimal": MinimalPromptStrategy, + "standard": StandardPromptStrategy, + "verbose": VerbosePromptStrategy, + }[config.prompting](ACTIONS_HINT) + + querying = QueryingMode(config.querying) + + return ExperimentRunner( + env=env, + config=config, + obs_builder=obs, + prompt_strategy=prompt, + querying_mode=querying, + maze_json_path=maze_json_path, + ) + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + +class ExperimentRunner: + """Runs a maze episode. API chat style is set by ``ExperimentConfig.chat_history``.""" + + def __init__( + self, + env, + config: ExperimentConfig, + obs_builder: ObservationBuilder, + prompt_strategy: PromptStrategy, + querying_mode, + maze_json_path: Optional[str] = None, + ) -> None: + self.env = env + self.config = config + self.obs = obs_builder + self.prompt = prompt_strategy + self.querying = querying_mode + self.maze_json_path = maze_json_path + + @classmethod + def from_json( + cls, + path: str, + config: Optional[ExperimentConfig] = None, + ) -> ExperimentRunner: + from nlu_benchmark.loader import load_maze + return build_runner(config or ExperimentConfig(), load_maze(path), path) + + # ------------------------------------------------------------------ + # Episode loop + # ------------------------------------------------------------------ + + def run(self, agent: Callable[[List[dict]], str], *, verbose: bool = True) -> dict: + """Run one full episode. + + Parameters + ---------- + verbose: + If True, print per-step progress to stdout. Use False for batch evaluation. + + Returns + ------- + dict: + success bool + steps_used int + final_state GridState + transcript list[dict] with one record per executed action + config dict, serialised ExperimentConfig for this run + """ + state = self.env.reset() + self.obs.reset() + self.querying.reset() + + system_prompt = self.prompt.build_system_prompt(self.querying.system_prompt_suffix()) + if self.config.observation in ("text_only", "image_text"): + system_prompt = ( + f"{system_prompt}\n\nInitial maze (fixed for this episode):\n" + f"{render_initial_maze_text(state)}" + ) + system_message = {"role": "system", "content": system_prompt} + chat_history = self.config.chat_history + messages: List[dict] = [system_message] if chat_history in ("rolling", "full") else [] + + action_queue: List[str] = [] + last_feedback = "Episode start." + consecutive_failures = 0 + transcript: List[dict] = [] + max_steps = self.env.initial.max_steps + query_count = 0 + + if logger.isEnabledFor(logging.INFO): + logger.info( + "Episode start: max_steps=%s prompting=%s querying=%s observation=%s context_window=%s " + "chat_history=%s chat_turns_max=%s", + max_steps, + self.config.prompting, + self.config.querying, + self.config.observation, + self.config.context_window, + chat_history, + self.config.chat_turns_max if chat_history == "rolling" else "-", + ) + + while state.step_count < max_steps: + + # --- Query model if needed --- + if self.querying.should_query(action_queue, consecutive_failures): + consecutive_failures = 0 + query_count += 1 + user_message = self._build_message(state, last_feedback) + has_image = _user_message_has_image(user_message) + if chat_history == "stateless": + agent_messages: List[dict] = [system_message, user_message] + else: + messages.append(user_message) + agent_messages = messages + if logger.isEnabledFor(logging.INFO): + logger.info( + "LLM query #%d: chat_history=%s messages_in_context=%d current_turn_has_image=%s", + query_count, + chat_history, + len(agent_messages), + has_image, + ) + t_llm = time.perf_counter() + model_text = agent(agent_messages) + llm_s = time.perf_counter() - t_llm + if chat_history != "stateless": + messages.append({"role": "assistant", "content": model_text}) + if chat_history == "rolling": + _trim_rolling_chat(messages, max(1, self.config.chat_turns_max)) + action_queue = self.querying.parse_actions(model_text) + if logger.isEnabledFor(logging.INFO): + logger.info( + "LLM query #%d finished in %.2fs: reply_chars=%d actions_parsed=%d", + query_count, + llm_s, + len(model_text), + len(action_queue), + ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("LLM query #%d reply preview: %s", query_count, model_text[:4000]) + + if not action_queue: + logger.warning( + "LLM query #%d: no valid actions parsed; empty queue so another query follows, " + "user turn will include parse-hint feedback", + query_count, + ) + last_feedback = ( + f"Could not parse FINAL_OUTPUT (one or more valid actions). " + f"Use only: {ACTIONS_HINT}." + ) + continue + + if not action_queue: + # e.g. full trajectory finished executing (no re-query) + break + + # --- Execute next queued action --- + action = action_queue.pop(0) + position_before = state.agent_pos + + decision_png = self.obs.render_decision_frame_png(state) + state, event = self.env.step(action) + step_detail = format_step_feedback(action, event.type, event.message, position_before) + last_feedback = action_feedback_for_prompt(self.config.observation, step_detail) + event_type = event.type + + if event_type in {"BLOCKED", "WRONG_DONE", "INVALID"}: + consecutive_failures += 1 + action_queue.clear() # abandon the rest of the planned sequence + else: + consecutive_failures = 0 + + transcript.append({ + "step": state.step_count, + "position_before": position_before, + "position_after": state.agent_pos, + "action": action, + "event_type": event_type, + "feedback": step_detail, + **self.querying.step_metadata(), + }) + + self.obs.record( + state.agent_pos, + state.facing, + action, + last_feedback, + decision_frame_png=decision_png, + ) + + if event_type == "DONE": + if logger.isEnabledFor(logging.INFO): + logger.info("Episode success at env step %s (LLM queries=%d)", state.step_count, query_count) + if verbose: + print(f" Success at step {state.step_count}") + return self._result(True, state, transcript) + + if verbose: + print(f" Step {state.step_count}/{max_steps}: {action} -> {event_type}") + + if logger.isEnabledFor(logging.INFO): + logger.info( + "Episode ended without DONE: env_steps=%s success=false LLM_queries=%d", + state.step_count, + query_count, + ) + return self._result(False, state, transcript) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _build_message(self, state, last_feedback: str) -> dict: + obs_text = self.obs.build_text(state) + history_text = self.obs.history_text() + prompt_text = self.prompt.build_user_prompt(obs_text, history_text, state, last_feedback) + hist_blocks = self.obs.history_content_blocks() + images = self.obs.build_image_blocks(state, self.maze_json_path) + text_block = {"type": "text", "text": prompt_text} + if hist_blocks or images: + return {"role": "user", "content": hist_blocks + images + [text_block]} + return {"role": "user", "content": prompt_text} + + def _result(self, success: bool, state, transcript: List[dict]) -> dict: + return { + "success": success, + "steps_used": state.step_count, + "final_state": state, + "transcript": transcript, + "config": self.config.to_dict(), + } diff --git a/src/v2/nlu_pipeline/nlu_benchmark/sample mazes/V01_empty_room.json b/src/v2/nlu_pipeline/nlu_benchmark/sample mazes/V01_empty_room.json new file mode 100644 index 0000000..fcb8890 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/sample mazes/V01_empty_room.json @@ -0,0 +1,52 @@ +{ + "task_id": "validation_10_v01_empty_room", + "version": "2.0", + "seed": 101, + "difficulty_tier": 1, + "description": "Baseline open room with no mechanisms.", + "maze": { + "dimensions": [ + 8, + 8 + ], + "walls": [], + "start": [ + 1, + 1 + ], + "goal": [ + 6, + 6 + ] + }, + "mechanisms": { + "keys": [], + "doors": [], + "switches": [], + "gates": [], + "blocks": [], + "teleporters": [], + "hazards": [] + }, + "rules": { + "key_consumption": true, + "switch_type": "toggle", + "hidden_mechanisms": [], + "observability": "full", + "view_size": 7 + }, + "goal": { + "type": "reach_position", + "target": [ + 6, + 6 + ], + "auxiliary_conditions": [] + }, + "metadata": { + "chain_pattern": "none", + "tiling": "square", + "wall_topology": "open" + }, + "max_steps": 100 +} \ No newline at end of file diff --git a/src/v2/nlu_pipeline/nlu_benchmark/sample mazes/V04_single_key.json b/src/v2/nlu_pipeline/nlu_benchmark/sample mazes/V04_single_key.json new file mode 100644 index 0000000..9744382 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/sample mazes/V04_single_key.json @@ -0,0 +1,96 @@ +{ + "task_id": "validation_10_v04_single_key", + "version": "2.0", + "seed": 104, + "difficulty_tier": 2, + "description": "Retrieve the red key from the lower vault, return through the foyer, and open the red door guarding the goal room.", + "maze": { + "dimensions": [ + 14, + 12 + ], + "walls": [ + [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], + [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], + [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], + [4, 1], [4, 3], [4, 4], [4, 5], [4, 10], + [5, 4], [5, 5], [5, 10], + [6, 10], + [7, 4], [7, 5], [7, 10], + [8, 1], [8, 3], [8, 4], [8, 5], [8, 10], + [9, 4], [9, 5], [9, 6], [9, 7], [9, 8], [9, 9], [9, 10], + [10, 4], [10, 5], [10, 6], [10, 7], [10, 8], [10, 9], [10, 10], + [11, 4], [11, 5], [11, 6], [11, 7], [11, 8], [11, 9], [11, 10], + [12, 1], [12, 3], [12, 4], [12, 5], [12, 6], [12, 7], [12, 8], [12, 9], [12, 10] + ], + "start": [ + 1, + 2 + ], + "goal": [ + 12, + 2 + ] + }, + "mechanisms": { + "keys": [ + { + "id": "kR", + "position": [ + 5, + 8 + ], + "color": "red" + } + ], + "doors": [ + { + "id": "DR", + "position": [ + 9, + 2 + ], + "requires_key": "red", + "initial_state": "locked" + } + ], + "switches": [], + "gates": [], + "blocks": [], + "teleporters": [], + "hazards": [] + }, + "rules": { + "key_consumption": true, + "switch_type": "toggle", + "hidden_mechanisms": [], + "observability": "full", + "view_size": 7 + }, + "goal": { + "type": "reach_position", + "target": [ + 12, + 2 + ], + "auxiliary_conditions": [] + }, + "dependency_chain": { + "depth": 1, + "sequence": [ + { + "step": 1, + "type": "key-door", + "element": "kR", + "unlocks": "DR" + } + ], + "notation": "kR -> DR -> G" + }, + "metadata": { + "chain_pattern": "key_door", + "tiling": "square", + "wall_topology": "room_chain_with_key_branch" + }, + "max_steps": 140 +} \ No newline at end of file diff --git a/src/v2/nlu_pipeline/nlu_benchmark/sample mazes/pngs/V01_empty_room.png b/src/v2/nlu_pipeline/nlu_benchmark/sample mazes/pngs/V01_empty_room.png new file mode 100644 index 0000000..c4325e8 Binary files /dev/null and b/src/v2/nlu_pipeline/nlu_benchmark/sample mazes/pngs/V01_empty_room.png differ diff --git a/src/v2/nlu_pipeline/nlu_benchmark/sample mazes/pngs/V04_single_key.png b/src/v2/nlu_pipeline/nlu_benchmark/sample mazes/pngs/V04_single_key.png new file mode 100644 index 0000000..88c5df4 Binary files /dev/null and b/src/v2/nlu_pipeline/nlu_benchmark/sample mazes/pngs/V04_single_key.png differ diff --git a/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/__init__.py b/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/analyze_smoke_runner_logs.py b/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/analyze_smoke_runner_logs.py new file mode 100644 index 0000000..b9d88a6 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/analyze_smoke_runner_logs.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description="Analyze smoke workflow logs.") + parser.add_argument("--maze", default="V01_empty_room.json", help="Maze JSON filename used by smoke run.") + parser.add_argument("--tag", default="", help="Optional output tag suffix used at smoke run time.") + args = parser.parse_args() + + maze_stem = Path(args.maze).stem + suffix = f"_{args.tag}" if args.tag else "" + p = Path(__file__).resolve().parent / "results" / f"smoke_all_{maze_stem}{suffix}" / "detailed_logs.json" + d = json.loads(p.read_text(encoding="utf-8")) + runs = d["runs"] + print("runs", len(runs)) + + issues: list[tuple] = [] + for r in runs: + label = r["label"] + cfg = r["config"] + queries = r["queries"] + transcript = r["transcript"] + system_prompt = r["system_prompt"] + + if r["summary"]["query_count"] != len(queries): + issues.append((label, "query_count_mismatch", r["summary"]["query_count"], len(queries))) + + if cfg["observation"] == "text_only": + if any(q["has_image"] for q in queries): + issues.append((label, "text_only_has_image")) + if any(q["user_content_type"] != "str" for q in queries): + issues.append((label, "text_only_content_type")) + else: + if any(not q["has_image"] for q in queries): + issues.append((label, "image_mode_missing_image")) + if any(q["user_content_type"] != "list" for q in queries): + issues.append((label, "image_mode_not_list")) + + has_initial = "Initial maze (fixed for this episode):" in system_prompt + obs = cfg["observation"] + if obs == "image_only" and has_initial: + issues.append((label, "image_only_has_initial_maze")) + if obs != "image_only" and not has_initial: + issues.append((label, "missing_initial_maze")) + + has_mechanism_list = "The environment may contain:" in system_prompt + has_rules = "RULES (domain logic):" in system_prompt + if cfg["prompting"] == "minimal" and has_mechanism_list: + issues.append((label, "minimal_has_mech_list")) + if cfg["prompting"] == "standard" and (not has_mechanism_list or has_rules): + issues.append((label, "standard_prompt_wrong")) + if cfg["prompting"] == "verbose" and not has_rules: + issues.append((label, "verbose_missing_rules")) + + if cfg["querying"] == "full_trajectory" and len(queries) != 1: + issues.append((label, "full_trajectory_query_count", len(queries))) + if cfg["querying"] == "step_by_step" and len(queries) < 2: + issues.append((label, "step_by_step_too_few_queries", len(queries))) + if cfg["querying"] == "subgoal": + if len(queries) < 2: + issues.append((label, "subgoal_too_few_queries", len(queries))) + if not any("subgoal" in t for t in transcript): + issues.append((label, "subgoal_metadata_missing")) + + if len(queries) >= 2: + second_text = queries[1]["user_text"] + has_recent = "Recent history (last 3 steps, oldest first):" in second_text + has_image_step_history = "infer pose and environment state from the image" in second_text + if cfg["context_window"] == "current" and (has_recent or has_image_step_history): + issues.append((label, "current_has_history")) + if cfg["context_window"] == "last3": + if obs == "image_only" and not has_image_step_history: + issues.append((label, "last3_image_only_missing_visual_history")) + if obs != "image_only" and not has_recent: + issues.append((label, "last3_missing_history")) + + steps = [t["step"] for t in transcript] + if steps != sorted(steps): + issues.append((label, "transcript_steps_unsorted")) + + print("issues", len(issues)) + for issue in issues: + print("ISSUE", issue) + + for r in runs: + label = r["label"] + cfg = r["config"] + print( + f"{label:24} q={r['summary']['query_count']:2} " + f"steps={r['summary']['steps_used']:2} success={r['summary']['success']} " + f"obs={cfg['observation']} ctx={cfg['context_window']} qry={cfg['querying']}" + ) + + +if __name__ == "__main__": + main() diff --git a/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/smoke_benchmark_mazes.py b/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/smoke_benchmark_mazes.py new file mode 100644 index 0000000..3ed3d67 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/smoke_benchmark_mazes.py @@ -0,0 +1,147 @@ +""" +For each ``nlu_benchmark/benchmark_mazes/**/*.json``: + +0. Apply :func:`~nlu_benchmark.loader.task_dict_shrink_dimensions_minus_two` (labeled 10×10 → 8×8 grid; coordinates unchanged). +1. Run mazegen :func:`~automatic_maze_generation.mazegen.solver.solve_maze` (no ``validate_maze``). +2. Write **one** PNG (same style as ``automatic_maze_generation/render_dataset.py``) under + ``smoke_tests/results/benchmark_solver//.png``: + if solvable, maze + mechanisms + overlaid optimal path; if not solvable, maze + mechanisms only (no path). +3. Write ``smoke_tests/results/benchmark_solver/benchmark_mazes_metadata.csv`` with columns: + ``rel_path``, ``chain_pattern``, ``is_solvable``, ``optimal_cost``, ``optimal_path``, ``n_interactions``, ``error``. + ``optimal_path`` is a JSON list of ``[x, y]`` cells in mazegen 0-based coordinates (column, row), + only filled when ``is_solvable``; otherwise empty. + +Run from repo root:: + + PYTHONPATH=src/v2 python3 src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/smoke_benchmark_mazes.py +""" + +from __future__ import annotations + +import csv +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +V2_ROOT = Path(__file__).resolve().parents[3] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +if str(V2_ROOT) not in sys.path: + sys.path.insert(0, str(V2_ROOT)) + +from automatic_maze_generation.mazegen.solver import solve_maze # noqa: E402 +from nlu_benchmark.loader import maze_instance_from_task_dict, task_dict_shrink_dimensions_minus_two # noqa: E402 +from nlu_benchmark.renderer import render_task_json_with_solver_path_png # noqa: E402 + +_SCRIPT_DIR = Path(__file__).resolve().parent +_RESULTS_ROOT = _SCRIPT_DIR / "results" +_BENCHMARK_SOLVER_DIR = _RESULTS_ROOT / "benchmark_solver" +_CSV_PATH = _BENCHMARK_SOLVER_DIR / "benchmark_mazes_metadata.csv" + +_CSV_FIELDNAMES = [ + "rel_path", + "chain_pattern", + "is_solvable", + "optimal_cost", + "optimal_path", + "n_interactions", + "error", +] + + +def _fill_solver_columns(row: dict[str, object], result: dict) -> None: + solvable = bool(result.get("is_solvable")) + inter = result.get("interactions") or [] + cost = result.get("optimal_cost") + row["is_solvable"] = solvable + row["optimal_cost"] = "" if cost is None else cost + row["n_interactions"] = len(inter) + if solvable: + pts = result.get("path") or [] + as_lists = [[int(x), int(y)] for x, y in pts] + row["optimal_path"] = json.dumps(as_lists, ensure_ascii=False) + else: + row["optimal_path"] = "" + + +def main() -> None: + base = ROOT / "nlu_benchmark" / "benchmark_mazes" + if not base.is_dir(): + print(f"error: {base} not found (add benchmark JSONs there)", file=sys.stderr) + sys.exit(2) + + paths = sorted(base.glob("**/*.json")) + if not paths: + print(f"warning: no JSON files under {base}", file=sys.stderr) + sys.exit(0) + + _RESULTS_ROOT.mkdir(parents=True, exist_ok=True) + _BENCHMARK_SOLVER_DIR.mkdir(parents=True, exist_ok=True) + csv_rows: list[dict[str, object]] = [] + + failed = 0 + failures: list[tuple[Path, str]] = [] + for path in paths: + rel = path.relative_to(base) + row: dict[str, object] = { + "rel_path": str(rel), + "chain_pattern": "", + "is_solvable": "", + "optimal_cost": "", + "optimal_path": "", + "n_interactions": "", + "error": "", + } + + try: + text = path.read_text(encoding="utf-8") + raw = json.loads(text) + data = task_dict_shrink_dimensions_minus_two(raw) + row["chain_pattern"] = (raw.get("metadata") or {}).get("chain_pattern", "") + except Exception as e: + failed += 1 + msg = str(e) + row["error"] = msg + failures.append((rel, msg)) + print(f"FAIL {rel}: {msg}", file=sys.stderr, flush=True) + csv_rows.append(row) + continue + + try: + result = solve_maze(maze_instance_from_task_dict(data)) + _fill_solver_columns(row, result) + solvable = bool(result.get("is_solvable")) + path_pts = (result.get("path") or []) if solvable else [] + out_png = _BENCHMARK_SOLVER_DIR / rel.parent / f"{path.stem}.png" + out_png.parent.mkdir(parents=True, exist_ok=True) + render_task_json_with_solver_path_png(data, path_pts, out_png) + if solvable: + print(f"ok {rel} cost={result.get('optimal_cost')} png={out_png}") + else: + print(f"ok {rel} not solvable png={out_png} (no path)") + except Exception as e: + failed += 1 + msg = str(e) + row["error"] = msg + failures.append((rel, msg)) + print(f"FAIL {rel}: {msg}", file=sys.stderr, flush=True) + csv_rows.append(row) + + with _CSV_PATH.open("w", encoding="utf-8", newline="") as f: + w = csv.DictWriter(f, fieldnames=_CSV_FIELDNAMES, extrasaction="ignore") + w.writeheader() + w.writerows(csv_rows) + + print(f"\n{len(paths)} files, {failed} failed") + if failures: + print("Failed files:", file=sys.stderr) + for rel, msg in failures: + print(f" - {rel}: {msg}", file=sys.stderr) + print(f"Outputs under {_BENCHMARK_SOLVER_DIR}") + print(f"CSV {_CSV_PATH}") + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() diff --git a/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/smoke_bfs.py b/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/smoke_bfs.py new file mode 100644 index 0000000..2cbdad6 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/smoke_bfs.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +V2_ROOT = Path(__file__).resolve().parents[3] +if str(V2_ROOT) not in sys.path: + sys.path.insert(0, str(V2_ROOT)) + +from nlu_benchmark.smoke_tests.solver_plan_trace import write_png_trace_for_maze_json # noqa: E402 + + +def main() -> None: + parser = argparse.ArgumentParser(description="Smoke test: mazegen solver plan replayed in NLU env (PNG trace under results/smoke_*_bfs/).") + parser.add_argument("--maze", default="V04_single_key.json", help="Maze JSON filename under sample mazes/") + parser.add_argument("--tag", default="", help="Optional output tag suffix.") + args = parser.parse_args() + + maze_path = ROOT / "nlu_benchmark" / "sample mazes" / args.maze + maze_stem = Path(args.maze).stem + suffix = f"_{args.tag}" if args.tag else "" + out_dir = Path(__file__).resolve().parent / "results" / f"smoke_{maze_stem}_bfs{suffix}" + + r = write_png_trace_for_maze_json(maze_path, out_dir) + if not r["ok"]: + print("Solver reported unsolvable maze.") + return + print(f"\nsuccess={r['success']}") + print(f"steps_used={r['steps_used']}") + print(f"out={r['out_dir']}") + + +if __name__ == "__main__": + main() diff --git a/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/smoke_llm.py b/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/smoke_llm.py new file mode 100644 index 0000000..19349da --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/smoke_llm.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import argparse +import json +import logging +import os +import sys +from pathlib import Path +from typing import Callable, Dict, List + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +V2_ROOT = Path(__file__).resolve().parents[3] +if str(V2_ROOT) not in sys.path: + sys.path.insert(0, str(V2_ROOT)) + +from nlu_benchmark.agents import ( + ClaudeAnthropicAgent, + ClaudeAnthropicConfig, + DEFAULT_LOCAL_MODEL, + LocalLLMConfig, + LocalTransformersAgent, +) +from nlu_benchmark.config import ExperimentConfig +from nlu_benchmark.loader import load_maze_from_dict, task_dict_shrink_dimensions_minus_two +from nlu_benchmark.runner import ExperimentRunner, build_runner +from nlu_benchmark.smoke_trace import trace_prepare, trace_reset, trace_step, trace_write_text_artifacts + + +def _configure_benchmark_logging(level_name: str) -> None: + level = getattr(logging, level_name.upper(), logging.WARNING) + if level == logging.WARNING: + return + logging.basicConfig( + level=level, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + datefmt="%H:%M:%S", + ) + + +def _ensure_anthropic_api_key() -> None: + if os.environ.get("ANTHROPIC_API_KEY"): + return + for directory in Path(__file__).resolve().parents: + key_file = directory / "api_key.txt" + if key_file.is_file(): + os.environ["ANTHROPIC_API_KEY"] = key_file.read_text().strip() + return + + +def _persist_llm_queries_jsonl(out_dir: Path, records: List[dict]) -> None: + if not records: + return + path = out_dir / "llm_queries.jsonl" + with path.open("w", encoding="utf-8") as f: + for row in records: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + +def _write_episode_json( + out_dir: Path, + result: Dict[str, object], + *, + backend: str, + model: str, +) -> None: + payload = { + "success": result["success"], + "steps_used": result["steps_used"], + "config": result["config"], + "transcript": result["transcript"], + "smoke": {"backend": backend, "model": model}, + } + (out_dir / "episode.json").write_text( + json.dumps(payload, indent=2, ensure_ascii=False, default=str), + encoding="utf-8", + ) + + +class _AgentRecorder: + """Delegates to a real agent and records each assistant reply for ``llm_queries.jsonl``.""" + + __slots__ = ("_inner", "_records", "_query_seq") + + def __init__(self, inner: Callable[[List[dict]], str], records: List[dict]) -> None: + self._inner = inner + self._records = records + self._query_seq = 0 + + def __call__(self, messages: List[dict]) -> str: + self._query_seq += 1 + text = self._inner(messages) + self._records.append( + { + "query": self._query_seq, + "messages_in_context": len(messages), + "reply": text, + } + ) + return text + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Smoke test: LLM-guided maze episode (PNG trace under results/). " + "Writes llm_queries.jsonl (model replies) and episode.json (transcript + config). " + "Anthropic runs in the cloud; --backend local uses Hugging Face. " + "--log-level INFO for query timing; -v for per-step prints." + ), + ) + parser.add_argument("--maze", default="V04_single_key.json", help="Maze JSON filename under sample mazes/") + parser.add_argument("--tag", default="", help="Optional output tag suffix.") + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Print per-step progress from ExperimentRunner.", + ) + parser.add_argument( + "--log-level", + default="WARNING", + choices=["DEBUG", "INFO", "WARNING"], + help="Structured logs from nlu_benchmark (timestamps, LLM query stats; default: no extra logs).", + ) + parser.add_argument( + "--backend", + choices=("anthropic", "local"), + default="anthropic", + help="anthropic: Claude API (remote). local: Hugging Face Transformers on this machine.", + ) + parser.add_argument( + "--hf-model", + default=DEFAULT_LOCAL_MODEL, + help="With --backend local: Hugging Face model id (default: %(default)s).", + ) + parser.add_argument( + "--device-map", + default="auto", + help='With --backend local: passed to from_pretrained device_map (e.g. "auto", "cuda:0").', + ) + parser.add_argument( + "--chat-history", + choices=("stateless", "rolling", "full"), + default=None, + help="ExperimentConfig.chat_history (default: rolling = last N turns in API).", + ) + parser.add_argument( + "--chat-turns-max", + type=int, + default=None, + metavar="N", + help="ExperimentConfig.chat_turns_max for rolling mode (default: 3).", + ) + args = parser.parse_args() + _configure_benchmark_logging(args.log_level) + + maze_path = ROOT / "nlu_benchmark" / "sample mazes" / args.maze + maze_stem = Path(args.maze).stem + suffix = f"_{args.tag}" if args.tag else "" + out_slug = "claude" if args.backend == "anthropic" else "local" + out_dir = Path(__file__).resolve().parent / "results" / f"smoke_{maze_stem}_{out_slug}{suffix}" + + if not maze_path.is_file(): + print(f"Missing maze file: {maze_path}") + return + + trace_prepare(out_dir) + + task_data = task_dict_shrink_dimensions_minus_two( + json.loads(maze_path.read_text(encoding="utf-8")) + ) + maze_env = load_maze_from_dict(task_data) + + exp_cfg = ExperimentConfig() + if args.chat_history is not None: + exp_cfg.chat_history = args.chat_history + if args.chat_turns_max is not None: + exp_cfg.chat_turns_max = args.chat_turns_max + + runner = build_runner(exp_cfg, maze_env, maze_json_path=str(maze_path.resolve())) + + query_log: List[dict] = [] + if args.backend == "anthropic": + _ensure_anthropic_api_key() + claude_cfg = ClaudeAnthropicConfig() + agent_inner = ClaudeAnthropicAgent(config=claude_cfg) + model_id = claude_cfg.model + else: + llm_cfg = LocalLLMConfig(model=args.hf_model, device_map=args.device_map) + agent_inner = LocalTransformersAgent(config=llm_cfg) + model_id = llm_cfg.model + + agent = _AgentRecorder(agent_inner, query_log) + + try: + result = runner.run(agent, verbose=args.verbose) + except Exception as e: + _persist_llm_queries_jsonl(out_dir, query_log) + print(f"runner.run raised: {e}") + return + + _persist_llm_queries_jsonl(out_dir, query_log) + _write_episode_json(out_dir, result, backend=args.backend, model=model_id) + + transcript = result["transcript"] + planned_actions = [rec["action"] for rec in transcript] + + env = load_maze_from_dict(task_data) + state = env.reset() + + lines = trace_reset(out_dir, state) + + for step, action in enumerate(planned_actions, start=1): + before = state.agent_pos + state, event = trace_step(out_dir, lines, step, action, env, position_before=before) + if event.type == "DONE": + break + + trace_write_text_artifacts(out_dir, lines, planned_actions) + + print(f"\nsuccess={state.agent_pos == state.goal}") + print(f"steps_used={state.step_count}") + print(f"out={out_dir}") + + +if __name__ == "__main__": + main() diff --git a/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/smoke_prompting_observation_querying.py b/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/smoke_prompting_observation_querying.py new file mode 100644 index 0000000..4266fe3 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/smoke_prompting_observation_querying.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import json +import argparse +import base64 +import re +import sys +from dataclasses import replace +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +V2_ROOT = Path(__file__).resolve().parents[3] +if str(V2_ROOT) not in sys.path: + sys.path.insert(0, str(V2_ROOT)) + +from nlu_benchmark.config import ExperimentConfig +from nlu_benchmark.env import FACING_ORDER, FACING_TO_DELTA +from nlu_benchmark.loader import load_maze, grid_state_to_maze_instance +from nlu_benchmark.runner import ExperimentRunner +import nlu_benchmark.observation as observation_module +from automatic_maze_generation.mazegen.solver import solve_maze + + +_POS_RE = re.compile(r"Position:\s*\((\d+),\s*(\d+)\)") +_FACING_RE = re.compile(r"Facing:\s*([A-Z]+)") +_GOAL_RE = re.compile(r"Goal:\s*\((\d+),\s*(\d+)\)") + + +_ONE_BY_ONE_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5nVxUAAAAASUVORK5CYII=" +) + + +def _extract_user_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + texts = [blk.get("text", "") for blk in content if isinstance(blk, dict) and blk.get("type") == "text"] + return "\n".join(texts) + return "" + + +def _parse_prompt_state(user_text: str): + pm = _POS_RE.search(user_text) + fm = _FACING_RE.search(user_text) + gm = _GOAL_RE.search(user_text) + if not (pm and fm and gm): + return None + row = int(pm.group(1)) + col = int(pm.group(2)) + facing = fm.group(1) + grow = int(gm.group(1)) + gcol = int(gm.group(2)) + return (row, col), facing, (grow, gcol) + + +def _turn_to_face(cur: str, target: str) -> list[str]: + ci = FACING_ORDER.index(cur) + ti = FACING_ORDER.index(target) + diff = (ti - ci) % 4 + if diff == 0: + return [] + if diff == 1: + return ["TURN_RIGHT"] + if diff == 2: + return ["TURN_RIGHT", "TURN_RIGHT"] + return ["TURN_LEFT"] + + +def _plan_to_goal_from_prompt(user_text: str, budget: int = 6) -> list[str]: + parsed = _parse_prompt_state(user_text) + if parsed is None: + return ["TURN_RIGHT"] + (row, col), facing, (grow, gcol) = parsed + actions: list[str] = [] + if col != gcol: + target = "EAST" if gcol > col else "WEST" + actions.extend(_turn_to_face(facing, target)) + actions.extend(["MOVE_FORWARD"] * min(abs(gcol - col), max(1, budget - len(actions)))) + elif row != grow: + target = "SOUTH" if grow > row else "NORTH" + actions.extend(_turn_to_face(facing, target)) + actions.extend(["MOVE_FORWARD"] * min(abs(grow - row), max(1, budget - len(actions)))) + else: + actions.append("DONE") + return actions[:budget] if actions else ["DONE"] + + +def _xy_path_to_rc(path_xy) -> list[tuple[int, int]]: + """mazegen 0-based ``(x, y)`` (y south) → NLU 1-based ``(row, column)`` (row southward).""" + return [(y + 1, x + 1) for (x, y) in path_xy] + + +def _path_to_actions(path, start_facing: str = "NORTH") -> list[str]: + if not path or len(path) < 2: + return ["DONE"] + facing = start_facing + actions: list[str] = [] + for (r, c), (nr, nc) in zip(path, path[1:]): + dr, dc = nr - r, nc - c + target = next((f for f, d in FACING_TO_DELTA.items() if d == (dr, dc)), None) + if target is None: + continue + cur_idx = FACING_ORDER.index(facing) + tgt_idx = FACING_ORDER.index(target) + diff = (tgt_idx - cur_idx) % 4 + if diff == 1: + actions.append("TURN_RIGHT") + elif diff == 2: + actions.extend(["TURN_RIGHT", "TURN_RIGHT"]) + elif diff == 3: + actions.append("TURN_LEFT") + actions.append("MOVE_FORWARD") + facing = target + actions.append("DONE") + return actions + + +def _inject_pickups(actions: list[str], env, state) -> list[str]: + out: list[str] = [] + sim_state = state + for a in actions: + has_key_here = any(tuple(k["position"]) == sim_state.agent_pos for k in sim_state.keys) + if has_key_here and a != "PICKUP": + out.append("PICKUP") + sim_state, _ = env.step("PICKUP") + out.append(a) + sim_state, _ = env.step(a) + return out + + +def _full_trajectory_actions_for_maze(maze_path: Path) -> list[str]: + env = load_maze(maze_path) + state = env.reset() + maze_inst = grid_state_to_maze_instance(state) + solver_result = solve_maze(maze_inst) + if not solver_result.get("is_solvable"): + return ["DONE"] + path_rc = _xy_path_to_rc(solver_result.get("path", [])) + planned = _path_to_actions(path_rc, start_facing="NORTH") + return _inject_pickups(planned, env, state) + + +class ProbeAgent: + """Deterministic test agent that records message structure on each query.""" + + def __init__(self, full_trajectory_actions: list[str]) -> None: + self.calls: list[dict[str, Any]] = [] + self._full_trajectory_actions = full_trajectory_actions + + def __call__(self, messages: list[dict]) -> str: + system_text = str(messages[0]["content"]) + user_msg = messages[-1] + user_content = user_msg.get("content") + user_text = _extract_user_text(user_content) + has_image = isinstance(user_content, list) and any( + isinstance(blk, dict) and blk.get("type") == "image_url" for blk in user_content + ) + + full_mode = "You will not be queried again" in system_text + subgoal_mode = "SUB_GOAL:" in system_text and "ACTIONS:" in system_text + + reply: str + if full_mode: + reply = ( + "SUB_GOAL: Execute maze-aware end-to-end plan.\n" + f"ACTIONS: {', '.join(self._full_trajectory_actions)}" + ) + elif subgoal_mode: + chunk = _plan_to_goal_from_prompt(user_text, budget=4) + reply = f"SUB_GOAL: Advance toward goal.\nACTIONS: {', '.join(chunk)}" + else: + step = _plan_to_goal_from_prompt(user_text, budget=1)[0] + reply = f"FINAL_OUTPUT: {step}" + + self.calls.append( + { + "system": system_text, + "user_content_type": type(user_content).__name__, + "has_image": has_image, + "user_text": user_text, + "assistant_reply": reply, + } + ) + return reply + + +def _assert(cond: bool, msg: str, errors: list[str]) -> None: + if not cond: + errors.append(msg) + + +def _run_case(base: ExperimentConfig, maze_path: Path, label: str, full_trajectory_actions: list[str], max_steps: int): + runner = ExperimentRunner.from_json(str(maze_path), config=base) + runner.env.initial.max_steps = min(runner.env.initial.max_steps, max_steps) + agent = ProbeAgent(full_trajectory_actions) + result = runner.run(agent, verbose=False) + return label, base, result, agent + + +def _suite_cases(base: ExperimentConfig, suite: str): + all_cases = [ + (replace(base, prompting="minimal"), "prompting=minimal"), + (replace(base, prompting="standard"), "prompting=standard"), + (replace(base, prompting="verbose"), "prompting=verbose"), + (replace(base, context_window="current"), "context=current"), + (replace(base, context_window="last3"), "context=last3"), + (replace(base, observation="text_only", context_window="last3"), "obs=text_only"), + (replace(base, observation="image_text", context_window="last3"), "obs=image_text"), + (replace(base, observation="image_only", context_window="last3"), "obs=image_only"), + (replace(base, querying="step_by_step"), "query=step_by_step"), + (replace(base, querying="subgoal"), "query=subgoal"), + (replace(base, querying="full_trajectory"), "query=full_trajectory"), + ] + if suite == "all": + return all_cases + if suite == "prompting": + return [c for c in all_cases if c[1].startswith("prompting=")] + if suite == "observation": + return [c for c in all_cases if c[1].startswith("obs=") or c[1].startswith("context=")] + if suite == "querying": + return [c for c in all_cases if c[1].startswith("query=")] + raise ValueError(f"Unknown suite: {suite}") + + +def run_smoke_suite(maze_name: str, tag: str, max_steps: int, suite: str = "all") -> tuple[Path, Path]: + maze_path = ROOT / "nlu_benchmark" / "sample mazes" / maze_name + maze_stem = Path(maze_name).stem + suffix = f"_{tag}" if tag else "" + full_trajectory_actions = _full_trajectory_actions_for_maze(maze_path) + # Smoke test already validated rendering elsewhere; use tiny static bytes for speed. + observation_module.render_maze_image_png_bytes = lambda _state: _ONE_BY_ONE_PNG + base = ExperimentConfig(prompting="standard", observation="image_only", context_window="last3", querying="step_by_step") + selected = _suite_cases(base, suite) + outputs = [ + _run_case(cfg, maze_path, label, full_trajectory_actions, max_steps) + for cfg, label in selected + ] + errors: list[str] = [] + summary_lines: list[str] = [] + detailed_runs: list[dict[str, Any]] = [] + + for label, cfg, result, agent in outputs: + calls = len(agent.calls) + first = agent.calls[0] + summary_lines.append( + f"{label:<24} success={result['success']!s:<5} steps={result['steps_used']:<3} queries={calls:<3}" + ) + + if cfg.prompting == "minimal": + _assert("The environment may contain:" not in first["system"], f"{label}: minimal has mechanism list", errors) + if cfg.prompting == "standard": + _assert("The environment may contain:" in first["system"], f"{label}: standard missing mechanism list", errors) + _assert("RULES (domain logic):" not in first["system"], f"{label}: standard unexpectedly has verbose rules", errors) + if cfg.prompting == "verbose": + _assert("RULES (domain logic):" in first["system"], f"{label}: verbose missing rules", errors) + + if cfg.observation == "text_only": + _assert(first["user_content_type"] == "str", f"{label}: text_only should send string content", errors) + _assert(not first["has_image"], f"{label}: text_only should not include image", errors) + else: + _assert(first["user_content_type"] == "list", f"{label}: image mode should send list content", errors) + _assert(first["has_image"], f"{label}: image mode should include image block", errors) + + if cfg.observation == "image_only": + _assert("Initial maze (fixed for this episode):" not in first["system"], f"{label}: image_only should omit initial NL map", errors) + else: + _assert("Initial maze (fixed for this episode):" in first["system"], f"{label}: expected initial NL map in system prompt", errors) + + if cfg.context_window == "current" and len(agent.calls) > 1: + second_text = agent.calls[1]["user_text"] + _assert("Recent history (last 3 steps" not in second_text, f"{label}: current unexpectedly includes history", errors) + _assert( + "infer pose and environment state from the image" not in second_text, + f"{label}: current unexpectedly includes visual step history", + errors, + ) + if cfg.context_window == "last3" and len(agent.calls) > 1: + second_text = agent.calls[1]["user_text"] + if cfg.observation == "image_only": + _assert( + "infer pose and environment state from the image" in second_text, + f"{label}: last3 image_only should include PNG+action history intro", + errors, + ) + else: + _assert("Recent history (last 3 steps, oldest first):" in second_text, f"{label}: last3 should include full history", errors) + + if cfg.querying == "full_trajectory": + _assert(calls == 1, f"{label}: full_trajectory should query once, got {calls}", errors) + if cfg.querying == "step_by_step": + _assert(calls >= 3, f"{label}: step_by_step should query repeatedly, got {calls}", errors) + if cfg.querying == "subgoal": + _assert(calls >= 2, f"{label}: subgoal should query at least twice, got {calls}", errors) + has_subgoal_meta = any("subgoal" in t for t in result["transcript"]) + _assert(has_subgoal_meta, f"{label}: transcript missing subgoal metadata", errors) + + detailed_runs.append( + { + "label": label, + "config": cfg.to_dict(), + "summary": { + "success": result["success"], + "steps_used": result["steps_used"], + "query_count": calls, + }, + "system_prompt": first["system"], + "queries": [ + { + "call_idx": i + 1, + "user_content_type": call["user_content_type"], + "has_image": call["has_image"], + "user_text": call["user_text"], + "assistant_reply": call["assistant_reply"], + } + for i, call in enumerate(agent.calls) + ], + "transcript": result["transcript"], + } + ) + + out_dir = Path(__file__).resolve().parent / "results" / f"smoke_{suite}_{maze_stem}{suffix}" + out_dir.mkdir(parents=True, exist_ok=True) + report = out_dir / "report.txt" + details_json = out_dir / "detailed_logs.json" + body = ["Runner/Prompt/Observation/Querying smoke report", ""] + summary_lines + [""] + if errors: + body.append("FAILURES:") + body.extend(f"- {e}" for e in errors) + else: + body.append("All checks passed.") + report.write_text("\n".join(body), encoding="utf-8") + details_json.write_text(json.dumps({"maze": str(maze_path), "runs": detailed_runs}, indent=2), encoding="utf-8") + + print("\n".join(summary_lines)) + print("") + if errors: + print(f"FAILED checks: {len(errors)}") + for e in errors: + print(f"- {e}") + else: + print("All checks passed.") + print(f"report={report}") + print(f"details={details_json}") + return report, details_json + + +def main() -> None: + parser = argparse.ArgumentParser(description="Smoke test prompting/context/querying/observation workflow.") + parser.add_argument("--maze", default="V01_empty_room.json", help="Maze JSON filename under sample mazes/") + parser.add_argument("--tag", default="", help="Optional output tag suffix.") + parser.add_argument("--max-steps", type=int, default=40, help="Cap per-episode steps for faster smoke runs.") + parser.add_argument("--suite", choices=["all", "prompting", "observation", "querying"], default="all") + args = parser.parse_args() + run_smoke_suite(args.maze, args.tag, args.max_steps, args.suite) + + +if __name__ == "__main__": + main() diff --git a/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/solver_plan_trace.py b/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/solver_plan_trace.py new file mode 100644 index 0000000..b344da1 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/smoke_tests/solver_plan_trace.py @@ -0,0 +1,111 @@ +"""Build mazegen solver plan for a task JSON maze and replay it in :class:`~nlu_benchmark.env.GridWorldEnv` with PNG traces (same outputs as ``smoke_bfs``).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from automatic_maze_generation.mazegen.solver import solve_maze +from nlu_benchmark.env import FACING_ORDER, FACING_TO_DELTA +from nlu_benchmark.loader import ( + grid_state_to_maze_instance, + load_maze_from_dict, + task_dict_shrink_dimensions_minus_two, +) +from nlu_benchmark.smoke_trace import trace_prepare, trace_reset, trace_step, trace_write_text_artifacts + + +def path_to_actions(path: list[tuple[int, int]], start_facing: str = "NORTH") -> list[str]: + if not path or len(path) < 2: + return ["DONE"] + facing = start_facing + actions: list[str] = [] + for (r, c), (nr, nc) in zip(path, path[1:]): + dr, dc = nr - r, nc - c + target = next((f for f, d in FACING_TO_DELTA.items() if d == (dr, dc)), None) + if target is None: + continue + cur_idx = FACING_ORDER.index(facing) + tgt_idx = FACING_ORDER.index(target) + diff = (tgt_idx - cur_idx) % 4 + if diff == 1: + actions.append("TURN_RIGHT") + elif diff == 2: + actions.extend(["TURN_RIGHT", "TURN_RIGHT"]) + elif diff == 3: + actions.append("TURN_LEFT") + actions.append("MOVE_FORWARD") + facing = target + actions.append("DONE") + return actions + + +def xy_path_to_rc(path_xy: list[tuple[int, int]]) -> list[tuple[int, int]]: + """mazegen 0-based ``(x, y)`` (y south from north edge) → NLU ``(row, column)`` with row southward.""" + return [(y + 1, x + 1) for (x, y) in path_xy] + + +def inject_pickups(actions: list[str], env: Any, state: Any) -> list[str]: + """NLU env needs explicit PICKUP; solver assumes pickup-on-entry.""" + out: list[str] = [] + sim_state = state + for a in actions: + has_key_here = any(tuple(k["position"]) == sim_state.agent_pos for k in sim_state.keys) + if has_key_here and a != "PICKUP": + out.append("PICKUP") + sim_state, _ = env.step("PICKUP") + out.append(a) + sim_state, _ = env.step(a) + return out + + +def write_png_trace_for_maze_json(maze_path: Path, out_dir: Path) -> dict[str, Any]: + """ + Solve ``maze_path``, replay the plan in the NLU env, write ``step_*.png``, ``run_log.txt``, ``plan.txt`` under ``out_dir``. + + Applies :func:`~nlu_benchmark.loader.task_dict_shrink_dimensions_minus_two` to the task JSON first + (same convention as benchmark maze smoke). + + Returns a dict with keys ``ok`` (bool), ``optimal_cost``, ``success``, ``steps_used``, ``out_dir``, + and on failure ``reason`` (str) or ``solver_result``. + """ + trace_prepare(out_dir) + raw: dict[str, Any] = task_dict_shrink_dimensions_minus_two( + json.loads(maze_path.read_text(encoding="utf-8")) + ) + env_plan = load_maze_from_dict(raw) + plan_state = env_plan.reset() + maze_inst = grid_state_to_maze_instance(plan_state) + solver_result = solve_maze(maze_inst) + if not solver_result.get("is_solvable"): + return { + "ok": False, + "out_dir": out_dir, + "solver_result": solver_result, + "reason": "solver reported unsolvable", + } + + path_rc = xy_path_to_rc(solver_result.get("path", [])) + planned_actions = path_to_actions(path_rc, start_facing="NORTH") + executable_actions = inject_pickups(planned_actions, env_plan, plan_state) + + env = load_maze_from_dict(raw) + state = env.reset() + lines = trace_reset(out_dir, state) + + for step, action in enumerate(executable_actions, start=1): + before = state.agent_pos + state, event = trace_step(out_dir, lines, step, action, env, position_before=before) + if event.type == "DONE": + break + + trace_write_text_artifacts(out_dir, lines, executable_actions) + success = state.agent_pos == state.goal + return { + "ok": True, + "out_dir": out_dir, + "optimal_cost": solver_result.get("optimal_cost"), + "success": success, + "steps_used": state.step_count, + } diff --git a/src/v2/nlu_pipeline/nlu_benchmark/smoke_trace.py b/src/v2/nlu_pipeline/nlu_benchmark/smoke_trace.py new file mode 100644 index 0000000..bcad8b6 --- /dev/null +++ b/src/v2/nlu_pipeline/nlu_benchmark/smoke_trace.py @@ -0,0 +1,62 @@ +"""PNG + text artifacts for smoke scripts (``smoke_bfs``, ``smoke_claude``, …). + +Writes ``step_000_reset.png``, ``step_NNN_.png``, ``run_log.txt``, ``plan.txt`` +under a caller-chosen ``results/…`` directory. +``trace_prepare`` also removes ``*.json`` and ``*.jsonl`` there (e.g. LLM smoke sidecars). + +PNG frames omit a figure title by default (see ``task_id=""`` on ``trace_reset`` / ``trace_step``) +so ``tight_layout`` + ``bbox_inches="tight"`` framing stays balanced; pass a non-empty +``task_id`` only if you want a title. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Tuple + +from nlu_benchmark.renderer import render_maze_image_png_bytes + + +def trace_prepare(out_dir: Path) -> None: + out_dir.mkdir(parents=True, exist_ok=True) + for p in out_dir.glob("*.png"): + p.unlink() + for p in out_dir.glob("*.txt"): + p.unlink() + for p in out_dir.glob("*.json"): + p.unlink() + for p in out_dir.glob("*.jsonl"): + p.unlink() + + +def trace_reset(out_dir: Path, state: Any, *, task_id: str = "") -> list[str]: + (out_dir / "step_000_reset.png").write_bytes(render_maze_image_png_bytes(state, task_id=task_id)) + return [f"000 RESET pos={state.agent_pos} facing={state.facing} inv={state.inventory}"] + + +def trace_step( + out_dir: Path, + lines: list[str], + step: int, + action: str, + env: Any, + *, + position_before: tuple[Any, ...], + task_id: str = "", +) -> Tuple[Any, Any]: + state, event = env.step(action) + (out_dir / f"step_{step:03d}_{action}.png").write_bytes( + render_maze_image_png_bytes(state, task_id=task_id) + ) + line = ( + f"{step:03d} {action:<12} {event.type:<10} from={position_before} " + f"to={state.agent_pos} facing={state.facing} inv={state.inventory}" + ) + print(line) + lines.append(line) + return state, event + + +def trace_write_text_artifacts(out_dir: Path, lines: list[str], plan_actions: list[str]) -> None: + (out_dir / "run_log.txt").write_text("\n".join(lines), encoding="utf-8") + (out_dir / "plan.txt").write_text("\n".join(plan_actions), encoding="utf-8")