diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c3a4a..a43da7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S ## [Unreleased] +## [0.1.6] — 2026-11-09 + +### Fixed +- Plan steps whose action or object names differ in case from the parsed + domain/problem are no longer rejected. PDDL names are case-insensitive and + UPF's `PDDLReader` lower-cases domain and problem text, but plan tokens were + looked up exactly as written, so a plan using the declared capitalisation + (e.g. `(turn_to satellite0 Star0 GroundStation1)` against IPC satellite + p02–p05, or `(CRAFT_PLANK ...)` against `pogo_stick`) failed with + `"Action ... is not declared in domain"` / `"object ... is not declared in + problem"`. VAL accepts these plans. `_name_candidates` in + `pyval/validator.py` now also tries the lower-case form (and lower-case plus + `-`→`_`) after the existing candidates, so plans that already resolved pick + the same name as before. Error messages and step display still show the + name as written in the plan. Reported as ISS-027 in pddl-copilot-experiments. + +### Added +- `tests/test_case_insensitivity.py`: upper-case object, upper-case action, + mixed case, `-`/`_` with and without upper case, written-case error + messages and step display, and a regression test on IPC satellite p02 with + its declared capitalisation (`tests/fixtures/satellite/`). + ## [0.1.5] — 2026-05-19 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index a273e96..445f52b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pddl-pyvalidator" -version = "0.1.5" +version = "0.1.6" description = "Pure Python PDDL plan validator" readme = "README.md" license = "MIT" diff --git a/pyval/__init__.py b/pyval/__init__.py index 1fd85d9..f1aadeb 100644 --- a/pyval/__init__.py +++ b/pyval/__init__.py @@ -1,6 +1,6 @@ """PyVAL — Pure Python PDDL plan validator.""" -__version__ = "0.1.5" +__version__ = "0.1.6" from pyval.models import ( GoalResult, diff --git a/pyval/validator.py b/pyval/validator.py index fb8f896..7060561 100644 --- a/pyval/validator.py +++ b/pyval/validator.py @@ -246,9 +246,14 @@ def _lookup_object(problem: Problem, raw_name: str): def _name_candidates(name: str) -> list[str]: - """Return lookup candidates: original, then normalized (if different).""" + """Return lookup candidates, original name first. + + PDDL names are case-insensitive and UPF's PDDLReader lower-cases domain + and problem text, so the lower-case form is also tried. Hyphens may be + stored as underscores, so the hyphen-normalized forms are tried too. + """ candidates = [name] - normalized = name.replace("-", "_") - if normalized != name: - candidates.append(normalized) + for candidate in (name.replace("-", "_"), name.lower(), name.lower().replace("-", "_")): + if candidate not in candidates: + candidates.append(candidate) return candidates diff --git a/tests/fixtures/satellite/domain.pddl b/tests/fixtures/satellite/domain.pddl new file mode 100644 index 0000000..4f6beb1 --- /dev/null +++ b/tests/fixtures/satellite/domain.pddl @@ -0,0 +1,50 @@ +(define (domain satellite) + (:requirements :strips :typing) + (:types satellite direction instrument mode) + (:predicates + (on_board ?i - instrument ?s - satellite) + (supports ?i - instrument ?m - mode) + (pointing ?s - satellite ?d - direction) + (power_avail ?s - satellite) + (power_on ?i - instrument) + (calibrated ?i - instrument) + (have_image ?d - direction ?m - mode) + (calibration_target ?i - instrument ?d - direction)) + + (:action turn_to + :parameters (?s - satellite ?d_new - direction ?d_prev - direction) + :precondition (and (pointing ?s ?d_prev)) + :effect (and (pointing ?s ?d_new) + (not (pointing ?s ?d_prev)))) + + (:action switch_on + :parameters (?i - instrument ?s - satellite) + :precondition (and (on_board ?i ?s) + (power_avail ?s)) + :effect (and (power_on ?i) + (not (calibrated ?i)) + (not (power_avail ?s)))) + + (:action switch_off + :parameters (?i - instrument ?s - satellite) + :precondition (and (on_board ?i ?s) + (power_on ?i)) + :effect (and (not (power_on ?i)) + (power_avail ?s))) + + (:action calibrate + :parameters (?s - satellite ?i - instrument ?d - direction) + :precondition (and (on_board ?i ?s) + (calibration_target ?i ?d) + (pointing ?s ?d) + (power_on ?i)) + :effect (calibrated ?i)) + + (:action take_image + :parameters (?s - satellite ?d - direction ?i - instrument ?m - mode) + :precondition (and (calibrated ?i) + (on_board ?i ?s) + (supports ?i ?m) + (power_on ?i) + (pointing ?s ?d)) + :effect (have_image ?d ?m))) diff --git a/tests/fixtures/satellite/p02.pddl b/tests/fixtures/satellite/p02.pddl new file mode 100644 index 0000000..8241909 --- /dev/null +++ b/tests/fixtures/satellite/p02.pddl @@ -0,0 +1,36 @@ +(define (problem strips-sat-x-1) +(:domain satellite) +(:objects + satellite0 - satellite + instrument0 - instrument + instrument1 - instrument + satellite1 - satellite + instrument2 - instrument + thermograph0 - mode + GroundStation1 - direction + Star0 - direction + Phenomenon2 - direction + Star3 - direction +) +(:init + (supports instrument0 thermograph0) + (calibration_target instrument0 GroundStation1) + (supports instrument1 thermograph0) + (calibration_target instrument1 GroundStation1) + (on_board instrument0 satellite0) + (on_board instrument1 satellite0) + (power_avail satellite0) + (pointing satellite0 Star0) + (supports instrument2 thermograph0) + (calibration_target instrument2 Star0) + (on_board instrument2 satellite1) + (power_avail satellite1) + (pointing satellite1 GroundStation1) +) +(:goal (and + (pointing satellite0 GroundStation1) + (have_image Phenomenon2 thermograph0) + (have_image Star3 thermograph0) +)) + +) diff --git a/tests/test_case_insensitivity.py b/tests/test_case_insensitivity.py new file mode 100644 index 0000000..6e45682 --- /dev/null +++ b/tests/test_case_insensitivity.py @@ -0,0 +1,118 @@ +"""PDDL names are case-insensitive: plan steps must resolve regardless of case. + +UPF's PDDLReader lower-cases domain and problem text on read, but plan tokens +are kept as written. Plan-name lookup must therefore case-fold, while error +messages and step display keep the name exactly as the user wrote it. +""" + +from pathlib import Path + +from tests.conftest import write_pddl_files + +from pyval.validator import PDDLValidator + +FIXTURES = Path(__file__).parent / "fixtures" + +# Upper-case action names and objects, plus one hyphenated action. +CASE_DOMAIN = """\ +(define (domain caseworld) + (:requirements :strips :typing) + (:types loc) + (:predicates (at ?l - loc) (visited ?l - loc)) + (:action MOVE_TO + :parameters (?from - loc ?to - loc) + :precondition (at ?from) + :effect (and (not (at ?from)) (at ?to) (visited ?to))) + (:action go-back + :parameters (?from - loc ?to - loc) + :precondition (at ?from) + :effect (and (not (at ?from)) (at ?to))) +) +""" + +CASE_PROBLEM = """\ +(define (problem case1) + (:domain caseworld) + (:objects Home Store - loc) + (:init (at Home)) + (:goal (and (visited Store) (at Home))) +) +""" + + +def _validate(tmp_path, plan): + paths = write_pddl_files(tmp_path, CASE_DOMAIN, CASE_PROBLEM, plan) + return PDDLValidator().validate(paths["domain"], paths["problem"], paths["plan"]) + + +def test_upper_case_object_in_plan(tmp_path): + result = _validate(tmp_path, "(move_to Home Store)\n(go-back Store Home)\n") + assert result.is_valid, result.report() + + +def test_upper_case_action_name_in_plan(tmp_path): + result = _validate(tmp_path, "(MOVE_TO home store)\n(go-back store home)\n") + assert result.is_valid, result.report() + + +def test_mixed_case_names_in_plan(tmp_path): + result = _validate(tmp_path, "(Move_To hOmE STORE)\n(Go-Back Store home)\n") + assert result.is_valid, result.report() + + +# Lookup retries "-" as "_" (plan `move-to` for domain `move_to`). The reverse +# direction was never supported and is intentionally left unchanged. +def test_hyphen_underscore_still_resolves(tmp_path): + result = _validate(tmp_path, "(move-to home store)\n(go-back store home)\n") + assert result.is_valid, result.report() + + +def test_hyphen_underscore_with_upper_case(tmp_path): + result = _validate(tmp_path, "(MOVE-TO Home Store)\n(GO-BACK Store Home)\n") + assert result.is_valid, result.report() + + +def test_step_display_keeps_written_case(tmp_path): + result = _validate(tmp_path, "(MOVE_TO Home Store)\n(go-back Store Home)\n") + assert result.is_valid + assert result.steps[0].action == "(MOVE_TO Home Store)" + + +def test_unknown_object_error_keeps_written_case(tmp_path): + result = _validate(tmp_path, "(MOVE_TO Home Nowhere)\n") + assert result.status == "STRUCTURE_ERROR" + errors = result.phases["structure"]["errors"] + assert any("'Nowhere'" in e for e in errors), errors + assert any("'MOVE_TO'" in e for e in errors), errors + + +def test_unknown_action_error_keeps_written_case(tmp_path): + result = _validate(tmp_path, "(FLY_TO Home Store)\n") + assert result.status == "STRUCTURE_ERROR" + errors = result.phases["structure"]["errors"] + assert any("'FLY_TO'" in e for e in errors), errors + + +# Real IPC input: satellite p02 declares GroundStation1, Star0, Phenomenon2, +# Star3. A plan using the declared capitalisation must validate (VAL accepts). +SATELLITE_P02_PLAN = """\ +(switch_on instrument2 satellite1) +(turn_to satellite1 Phenomenon2 GroundStation1) +(turn_to satellite1 Star0 Phenomenon2) +(calibrate satellite1 instrument2 Star0) +(turn_to satellite1 Phenomenon2 Star0) +(take_image satellite1 Phenomenon2 instrument2 thermograph0) +(turn_to satellite1 Star3 Phenomenon2) +(take_image satellite1 Star3 instrument2 thermograph0) +(turn_to satellite0 GroundStation1 Star0) +""" + + +def test_satellite_p02_declared_case_plan(tmp_path): + plan = tmp_path / "p02.plan" + plan.write_text(SATELLITE_P02_PLAN) + sat = FIXTURES / "satellite" + result = PDDLValidator().validate( + str(sat / "domain.pddl"), str(sat / "p02.pddl"), str(plan) + ) + assert result.is_valid, result.report()