Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion pyval/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""PyVAL — Pure Python PDDL plan validator."""

__version__ = "0.1.5"
__version__ = "0.1.6"

from pyval.models import (
GoalResult,
Expand Down
13 changes: 9 additions & 4 deletions pyval/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 50 additions & 0 deletions tests/fixtures/satellite/domain.pddl
Original file line number Diff line number Diff line change
@@ -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)))
36 changes: 36 additions & 0 deletions tests/fixtures/satellite/p02.pddl
Original file line number Diff line number Diff line change
@@ -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)
))

)
118 changes: 118 additions & 0 deletions tests/test_case_insensitivity.py
Original file line number Diff line number Diff line change
@@ -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()
Loading