From 297f717d8f177822c91d78d9fcb78da3831209f8 Mon Sep 17 00:00:00 2001 From: Tyler Bessire <134957105+tylerbessire@users.noreply.github.com> Date: Fri, 12 Sep 2025 22:33:06 -0700 Subject: [PATCH] chore: record progress marker [S:ALG v1] translate/recolor int normalisation pass --- AGENTS.md | 16 ++++++++++ arc_solver/dsl.py | 42 ++++++++++++++++++++++---- arc_solver/dsl_complete.py | 28 +++++++++++------ arc_solver/enhanced_search_complete.py | 2 +- arc_solver/heuristics_complete.py | 14 ++++----- arc_solver/neural/episodic.py | 20 +++++++++--- tests/test_beam_search.py | 2 +- tests/test_recolor_fix.py | 40 ++++++++++++++++++++++++ tests/test_translate_fix.py | 42 ++++++++++++++++++++++++++ 9 files changed, 178 insertions(+), 28 deletions(-) create mode 100644 tests/test_recolor_fix.py create mode 100644 tests/test_translate_fix.py diff --git a/AGENTS.md b/AGENTS.md index 052028c..0e129e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -479,6 +479,22 @@ class MetaCognition: Notes: Resource limits and diversity enforced ``` +``` +[X] Step 4.3 UPDATE - Recolor parameter mismatch fixed preventing training failures + Date: 2025-09-12 + Test Result: pytest tests/test_recolor_fix.py passed + Notes: Standardised 'mapping' parameter across heuristics; episodic loader normalises keys + +[X] Step 4.3 UPDATE2 - Translate parameter mismatch fixed preventing training warnings + Date: 2025-09-13 + Test Result: pytest tests/test_translate_fix.py passed; python tools/train_guidance_on_arc.py --epochs 1 + Notes: Canonicalised 'fill' parameter for translate; legacy 'fill_value' still accepted +[X] Step 4.3 UPDATE3 - Translate/recolor params normalised to integers preventing training failures + Date: 2025-09-13 + Test Result: pytest tests/test_translate_fix.py tests/test_recolor_fix.py -q + Notes: Episode loader and DSL cast dy/dx/fill and mapping entries to int +``` + --- ## Step 4.4: Final Validation diff --git a/arc_solver/dsl.py b/arc_solver/dsl.py index 57ca245..5240ff1 100644 --- a/arc_solver/dsl.py +++ b/arc_solver/dsl.py @@ -60,7 +60,7 @@ def op_transpose(a: Array) -> Array: return transpose_grid(a) -def op_translate(a: Array, dy: int, dx: int, fill: Optional[int] = None) -> Array: +def op_translate(a: Array, dy: int, dx: int, fill: Optional[int] = None, *, fill_value: Optional[int] = None) -> Array: """Translate the grid by ``(dy, dx)`` filling uncovered cells. Parameters @@ -69,11 +69,13 @@ def op_translate(a: Array, dy: int, dx: int, fill: Optional[int] = None) -> Arra Input grid. dy, dx: Translation offsets. Positive values move content down/right. - fill: - Optional fill value for uncovered cells. If ``None`` the background - colour of ``a`` is used. + fill, fill_value: + Optional fill value for uncovered cells. ``fill_value`` is an alias for + backward compatibility. When both are ``None`` the background colour of + ``a`` is used. """ - fill_val = 0 if fill is None else fill + chosen = fill if fill is not None else fill_value + fill_val = 0 if chosen is None else chosen return translate_grid(a, dy, dx, fill=fill_val) @@ -114,9 +116,37 @@ def op_pad(a: Array, out_h: int, out_w: int) -> Array: _sem_cache: Dict[Tuple[bytes, str, Tuple[Tuple[str, Any], ...]], Array] = {} +def _canonical_params(name: str, params: Dict[str, Any]) -> Dict[str, Any]: + """Return a copy of ``params`` with legacy aliases normalised and typed.""" + new_params = dict(params) + if name == "recolor": + mapping = new_params.get("mapping") or new_params.pop("color_map", {}) + if mapping: + new_params["mapping"] = {int(k): int(v) for k, v in mapping.items()} + elif name == "translate": + if "fill" not in new_params and "fill_value" in new_params: + new_params["fill"] = new_params.pop("fill_value") + for key in ("dy", "dx", "fill"): + if key in new_params and new_params[key] is not None: + new_params[key] = int(new_params[key]) + return new_params + + +def _norm_params(params: Dict[str, Any]) -> Tuple[Tuple[str, Any], ...]: + """Normalise parameters to a hashable tuple.""" + items: List[Tuple[str, Any]] = [] + for k, v in sorted(params.items()): + if isinstance(v, dict): + items.append((k, tuple(sorted(v.items())))) + else: + items.append((k, v)) + return tuple(items) + + def apply_op(a: Array, name: str, params: Dict[str, Any]) -> Array: """Apply a primitive operation with semantic caching.""" - key = (a.tobytes(), name, tuple(sorted(params.items()))) + params = _canonical_params(name, params) + key = (a.tobytes(), name, _norm_params(params)) cached = _sem_cache.get(key) if cached is not None: return cached diff --git a/arc_solver/dsl_complete.py b/arc_solver/dsl_complete.py index 5f0f2bb..ecd869d 100644 --- a/arc_solver/dsl_complete.py +++ b/arc_solver/dsl_complete.py @@ -39,15 +39,20 @@ def transpose(grid: Array) -> Array: return grid.T -def translate(grid: Array, dx: int = 0, dy: int = 0, fill_value: int = 0) -> Array: - """Translate grid by (dx, dy) with wraparound or filling.""" +def translate(grid: Array, dy: int = 0, dx: int = 0, fill: int = 0, *, fill_value: int | None = None) -> Array: + """Translate grid by (dy, dx) with wraparound or filling. + + ``fill_value`` is accepted for backward compatibility. + """ + if fill_value is not None and fill == 0: + fill = fill_value H, W = grid.shape - result = np.full_like(grid, fill_value) + result = np.full_like(grid, fill) # Source bounds src_y_start = max(0, -dy) src_y_end = min(H, H - dy) - src_x_start = max(0, -dx) + src_x_start = max(0, -dx) src_x_end = min(W, W - dx) # Destination bounds @@ -98,10 +103,15 @@ def resize(grid: Array, new_height: int, new_width: int, method: str = 'nearest' # ================== COLOR OPERATIONS ================== -def recolor(grid: Array, color_map: Dict[int, int]) -> Array: - """Recolor grid according to color mapping.""" +def recolor( + grid: Array, + color_map: Dict[int, int] | None = None, + mapping: Dict[int, int] | None = None, +) -> Array: + """Recolor grid according to a color mapping.""" + mapping = mapping if mapping is not None else (color_map or {}) result = grid.copy() - for old_color, new_color in color_map.items(): + for old_color, new_color in mapping.items(): result[grid == old_color] = new_color return result @@ -688,11 +698,11 @@ def get_operation_signatures() -> Dict[str, List[str]]: 'rotate': ['k'], 'flip': ['axis'], 'transpose': [], - 'translate': ['dx', 'dy', 'fill_value'], + 'translate': ['dy', 'dx', 'fill'], 'crop': ['top', 'bottom', 'left', 'right'], 'pad': ['top', 'bottom', 'left', 'right', 'fill_value'], 'resize': ['new_height', 'new_width', 'method'], - 'recolor': ['color_map'], + 'recolor': ['mapping'], 'recolor_by_position': ['position_map'], 'swap_colors': ['color1', 'color2'], 'dominant_color_recolor': ['target_color'], diff --git a/arc_solver/enhanced_search_complete.py b/arc_solver/enhanced_search_complete.py index 6f9d00c..c682611 100644 --- a/arc_solver/enhanced_search_complete.py +++ b/arc_solver/enhanced_search_complete.py @@ -368,7 +368,7 @@ def _generate_comprehensive_parameters(self, op_name: str, for old_color in colors_in_input: for new_color in range(1, 10): if new_color != old_color: - param_combinations.append({'color_map': {old_color: new_color}}) + param_combinations.append({"mapping": {old_color: new_color}}) elif op_name == 'crop': for top in range(min(H, 3)): diff --git a/arc_solver/heuristics_complete.py b/arc_solver/heuristics_complete.py index 595b11a..d8964fe 100644 --- a/arc_solver/heuristics_complete.py +++ b/arc_solver/heuristics_complete.py @@ -104,7 +104,7 @@ def detect_basic_transformations(inp: Array, out: Array) -> List[List[Tuple[str, translated = translate_safe(inp, dx, dy) if np.array_equal(translated, out): - programs.append([('translate', {'dx': dx, 'dy': dy, 'fill_value': 0})]) + programs.append([('translate', {'dy': dy, 'dx': dx, 'fill': 0})]) return programs @@ -202,9 +202,9 @@ def detect_color_patterns(inp: Array, out: Array) -> List[List[Tuple[str, Dict[s # Direct color mapping color_map = infer_color_mapping(inp, out) if color_map and len(color_map) > 0: - recolored = apply_program(inp, [('recolor', {'color_map': color_map})]) + recolored = apply_program(inp, [("recolor", {"mapping": color_map})]) if np.array_equal(recolored, out): - programs.append([('recolor', {'color_map': color_map})]) + programs.append([("recolor", {"mapping": color_map})]) # Color swapping unique_colors = np.unique(inp) @@ -324,9 +324,9 @@ def detect_multi_step_operations(inp: Array, out: Array) -> List[List[Tuple[str, intermediate = apply_program(inp, [op]) color_map = infer_color_mapping(intermediate, out) if color_map: - final = apply_program(intermediate, [('recolor', {'color_map': color_map})]) + final = apply_program(intermediate, [("recolor", {"mapping": color_map})]) if np.array_equal(final, out): - programs.append([op, ('recolor', {'color_map': color_map})]) + programs.append([op, ("recolor", {"mapping": color_map})]) return programs @@ -497,8 +497,8 @@ def infer_color_mapping(inp: Array, out: Array) -> Optional[Dict[int, int]]: for i in range(inp.shape[0]): for j in range(inp.shape[1]): - inp_color = inp[i, j] - out_color = out[i, j] + inp_color = int(inp[i, j]) + out_color = int(out[i, j]) if inp_color in color_map: if color_map[inp_color] != out_color: diff --git a/arc_solver/neural/episodic.py b/arc_solver/neural/episodic.py index 62c4057..56c2865 100644 --- a/arc_solver/neural/episodic.py +++ b/arc_solver/neural/episodic.py @@ -88,12 +88,24 @@ def from_dict(cls, data: Dict[str, Any]) -> "Episode": (np.array(inp, dtype=int), np.array(out, dtype=int)) for inp, out in data.get("train_pairs", []) ] + programs: List[Program] = [] + for program in data.get("programs", []): + prog_ops: Program = [] + for op, params in program: + if op == "recolor": + mapping = params.get("mapping") or params.get("color_map") or {} + params = {"mapping": {int(k): int(v) for k, v in mapping.items()}} + elif op == "translate": + clean = {k: int(v) for k, v in params.items() if v is not None} + if "fill_value" in clean and "fill" not in clean: + clean["fill"] = clean.pop("fill_value") + params = clean + prog_ops.append((op, params)) + programs.append(prog_ops) + episode = cls( task_signature=data["task_signature"], - programs=[ - [(op, params) for op, params in program] - for program in data.get("programs", []) - ], + programs=programs, task_id=data.get("task_id", ""), train_pairs=train_pairs, success_count=data.get("success_count", 1), diff --git a/tests/test_beam_search.py b/tests/test_beam_search.py index ea6bd6f..3e6d08b 100644 --- a/tests/test_beam_search.py +++ b/tests/test_beam_search.py @@ -29,7 +29,7 @@ def test_beam_search_rotation_property(grid, k): def test_beam_search_no_solution(): a = to_array([[0]]) - b = to_array([[1]]) + b = to_array([[1, 1]]) progs, _ = beam_search([(a, b)], beam_width=3, depth=1) assert progs == [] diff --git a/tests/test_recolor_fix.py b/tests/test_recolor_fix.py new file mode 100644 index 0000000..b06973b --- /dev/null +++ b/tests/test_recolor_fix.py @@ -0,0 +1,40 @@ +import json +from typing import Dict +import sys +from pathlib import Path + +import numpy as np +from hypothesis import given, strategies as st + +sys.path.append(str(Path(__file__).parent.parent)) + +from arc_solver.grid import to_array +from arc_solver.dsl import apply_program +from arc_solver.heuristics_complete import detect_color_patterns +from arc_solver.neural.episodic import Episode + + +def test_detect_color_patterns_recolor_program() -> None: + """Heuristic recolor programs use mapping parameter.""" + inp = to_array([[1, 0], [0, 0]]) + out = to_array([[2, 0], [0, 0]]) + programs = detect_color_patterns(inp, out) + assert [("recolor", {"mapping": {1: 2}})] in programs + assert np.array_equal(apply_program(inp, programs[0]), out) + + +@given(st.dictionaries(st.integers(min_value=1, max_value=9), + st.integers(min_value=0, max_value=9), + min_size=1, max_size=3).filter(lambda m: all(k != v for k, v in m.items()))) +def test_episode_recolor_roundtrip(mapping: Dict[int, int]) -> None: + """Episode serialization preserves integer recolor mappings.""" + src, dst = next(iter(mapping.items())) + inp = to_array([[src]]) + out = to_array([[dst]]) + episode = Episode(task_signature="sig", programs=[[('recolor', {'mapping': mapping})]], + train_pairs=[(inp, out)]) + data = json.loads(json.dumps(episode.to_dict())) + loaded = Episode.from_dict(data) + prog = loaded.programs[0] + assert prog[0][1]['mapping'] == {int(k): int(v) for k, v in mapping.items()} + assert np.array_equal(apply_program(inp, prog), out) diff --git a/tests/test_translate_fix.py b/tests/test_translate_fix.py new file mode 100644 index 0000000..9bf46c9 --- /dev/null +++ b/tests/test_translate_fix.py @@ -0,0 +1,42 @@ +import json +from pathlib import Path +import sys + +import numpy as np +from hypothesis import given, strategies as st + +sys.path.append(str(Path(__file__).resolve().parents[1])) +from arc_solver.dsl import apply_program +from arc_solver.heuristics_complete import detect_basic_transformations +from arc_solver.neural.episodic import Episode + + +def test_translate_fill_value_alias_and_detection(): + inp = np.array([[1, 0], [0, 0]]) + expected = np.array([[0, 0], [0, 1]]) + + # legacy alias still works + legacy_prog = [("translate", {"dy": 1, "dx": 1, "fill_value": 0})] + assert np.array_equal(apply_program(inp, legacy_prog), expected) + + # heuristics now emit canonical parameter name + detected = detect_basic_transformations(inp, expected) + assert [("translate", {"dy": 1, "dx": 1, "fill": 0})] in detected + + +@given(st.integers(-3, 3), st.integers(-3, 3), st.integers(0, 9)) +def test_episode_translate_roundtrip(dy: int, dx: int, fill: int) -> None: + """Episode serialisation normalises translate parameters to ints.""" + inp = np.array([[1]]) + out = apply_program(inp, [("translate", {"dy": dy, "dx": dx, "fill": fill})]) + episode = Episode( + task_signature="sig", + programs=[[('translate', {'dy': str(dy), 'dx': str(dx), 'fill_value': str(fill)})]], + train_pairs=[(inp, out)], + ) + data = json.loads(json.dumps(episode.to_dict())) + loaded = Episode.from_dict(data) + op, params = loaded.programs[0][0] + assert op == 'translate' + assert all(isinstance(params[k], int) for k in ('dy', 'dx', 'fill')) + assert np.array_equal(apply_program(inp, [(op, params)]), out)