Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue1030 #155

Merged
merged 7 commits into from Jan 18, 2024
Merged

Issue1030 #155

merged 7 commits into from Jan 18, 2024

Conversation

PatrickFerber
Copy link
Member

No description provided.


def check_named_block(alist, names):
return isinstance(alist, list) and alist and alist[0] in names

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather not have two functions that do almost the same (check_named_block vs assert_named_block). I suggest only having assert_named_block. The check version is used in a few places where we also check for some other things (e.g. is the second element of alist a string), but we could just call first assert_named_block and then check the rest in a separate if block. This also allows us to give a more detailed error message.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me.

"or '(either WORD*)")
for item in items:
if only_variables and not item.startswith("?"):
context.error("Expected item to be a variable", item)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it clear to the user what "item" is here? And it may also be helpful to mention that variables are word/strings that start with ?
The output the user gets is:

Expected item to be a variable
Got: x

I'd rather suggest something like

Expected a variable, but got 'x'. Did you forget the leading question mark?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good.

error_msg += f"\nGot: {item}"
raise ParseError(error_msg)

def expected_word_error(self, name, *args, **kwargs):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling this function is always preceded by checking if name is of type string. I would instead suggest a function like assert_string or similar (like assert_named_block on line 86) that does the type check and creates the error message.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that it would be nicer to have a function do the check and yielding the error (if necessary). This applies to all three cases expected_word_error, expected_list_error, and expected_name_block_error. I also checked the calls to these functions and they all have similar if-clauses preceding them, so it shouldn't be a problem to unify this in a function.

If I understood Malte's comment in the daily sprint meeting correctly, they should preferrably be named check... rather than assert... because assert should be reserved for code errors rather than user input errors.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing I noticed is that if the PDDL has numbers where it expects a string the number will just be interpreted as a string. I don't know if this is the behaviour we want, but if not then we could maybe add a check for numbers in the new check_string function (or however we end up calling it).

if not check_named_block(alist, names):
context.expected_named_block_error(alist, names)

def construct_typed_object(context, name, _type):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not seem intuitive to only run checks on name but not on _type. The function is only used on line 136 in a loop where name changes but _type doesn't, so I understand that checking _type in this function would be wasteful. Maybe just do both checks outside the function? (Although I'm not happy with this idea either...)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with your point that _type should also be tested. After thinking about it a little bit, I don't see an issue with just doing the checks outside the function. Sure, one could argue that it makes sense to test this whenever trying to construct an object. However, within the constructing function, it would make more sense to assert this in my opinion.

(_type and _type[0] == "either" and
all(isinstance(_sub_type, str) for _sub_type in _type[1:]))):
context.error("Type value is expected to be a single word "
"or '(either WORD*)")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This message was at first unclear to me. Maybe expand it to the following?

Type value is expected to be a single word, or a choice of words denoted by '(either word1 word2 ...)'

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now from my digging in the issue tracker yesterday it sounded like we don't support either. Here I see the opposite and from the code it seems it's supported. We might need to clean that up in the issue tracker then.

That being said, I prefer your formulation.

def expected_word_error(self, name, *args, **kwargs):
self.error(f"{name} is expected to be a word.", *args, **kwargs)

def expected_list_error(self, name, *args, **kwargs):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine the same comment as for expected_word_error applies (I did not check all function calls here though).

effects = []
for eff in alist[1:]:
if not isinstance(eff, list):
context.error("All sub-effects of a conjunction have to be blocks.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use expected_list_error?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, seems a bit inconsistent. Though, this gives clearer instructions where to look for the faulty input. would the context-layer provide enough info about this as well? Then I don't see an issue with turning this into expected_list_error.

context.error("'when' effect expects exactly two arguments.",
syntax=SYNTAX_EFFECT_WHEN)
if not isinstance(alist[1], list):
context.error(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use expected_list_error?

elif exp.replace(".", "").isdigit() and exp.count(".") <= 1:
return pddl.NumericConstant(float(exp))
elif exp[0] == "-":
context.error("Expression cannot be a negative number",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I liked the old phrasing "Negative numbers are not supported" better, mainly because it does not use the word "expression" and thus is still understandable if you're not too familiar with PDDL terminology.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds reasonable, I support changing it back.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what it's worth, I think we don't support fractional numbers either. I think it's a mistake that we accept ".".

yield use_metric

for _ in iterator:
assert False, "This line should be unreachable"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can happen when we have malformed PDDL, i.e. when someone defines more things after metric, correct? Then we should give an error message.

As an aside, if someone does not specify a metric but other things, do they get swallowed and the PDDL accepted? I will test this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can neither confirm that this can happen nor do I understand the context to meaningfully comment on this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran this and found three issues:

(
   ...
    (:goal ...  )
    (foo)
    (:metric minimize (total-cost))
    (bar)
)

Anything after (:goal ...) other than (:metric ...) is silently ignored. The assert actually never triggers because the loop above consumes all elements. But there shouldn't be a loop to ignore (foo) to begin with, right?

The other issue is this with this code:

(
   ...
    (:goal ...  )
    (:metric minimize (total-cost))
    ()
)

where the check entry[0] == ":metric" fails.

@@ -753,3 +754,6 @@ def handle_sigxcpu(signum, stackframe):
traceback.print_exc(file=sys.stdout)
print("=" * 79)
sys.exit(TRANSLATE_OUT_OF_MEMORY)
except pddl_parser.ParseError as e:
print(e)
sys.exit(TRANSLATE_INPUT_ERROR)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the meaning of TRANSLATE_INPUT_ERROR compared to what we say on the wiki ("Usage error: wrong command line options"). If nobody things we need two different error codes for command-line errors vs. input file errors, the code can stay is is, but then we should update the wiki documentation to cover both cases.


def assert_named_block(context, alist, names):
if not check_named_block(alist, names):
context.expected_named_block_error(alist, names)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to keep the word "assert" for actual assertions (which test program errors, not user errors). can we rename this? From line 88, a possible verb could be "expect". Others could be "verify", "validate", "check".

@@ -68,15 +68,22 @@ def dump(self):
for axiom in self.axioms:
axiom.dump()


REQUIREMENT_LABELS = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Python 3.8, there's a notion of Final: https://peps.python.org/pep-0591/


# Basic functions for parsing PDDL (Lisp) files.
def parse_nested_list(input_file):
tokens = tokenize(input_file)
next_token = next(tokens)
if next_token != "(":
raise ParseError("Expected '(', got %s." % next_token)
raise ParseError("Expected '(', got '%s'." % next_token)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

switch to f-string?

except lisp_parser.ParseError as e:
raise SystemExit("Error: Could not parse %s file: %s\nReason: %s." %
except parse_error.ParseError as e:
raise parse_error.ParseError("Error: Could not parse %s file: %s\nReason: %s" %
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or, in a more idiomatic way:

except parse_error.ParseError as e:
        raise parse_error.ParseError(f"Error: Could not parse {type} file: {filename}") from e

Copy link
Contributor

@salome-eriksson salome-eriksson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These comments are from notes I made when testing different ways to specify wrong PDDLs. Unfortunately it was a while ago and I don't always remember the details.
I think basically all of my comments are not handled better by the current code, meaning we can also merge issue1030 as is and tackle these comments at a later time.

if syntax:
error_msg += f"\nSyntax: {syntax}"
if item:
error_msg += f"\nGot: {item}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we explicitly pass something like an empty list as item, the if statement will not trigger. For example, if we expect a named block and got an empty one, it will just say that it expected a non-empty block with starting with a word from some given set (see function expected_named_block_error on line 71).

return pddl.Requirements(alist)
except ValueError as e:
context.error(f"Error in requirements.\n"
f"Reason: {e}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows for listing the same requirement multiple times, e.g. :adl :adl

error_msg += f"\nGot: {item}"
raise ParseError(error_msg)

def expected_word_error(self, name, *args, **kwargs):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing I noticed is that if the PDDL has numbers where it expects a string the number will just be interpreted as a string. I don't know if this is the behaviour we want, but if not then we could maybe add a check for numbers in the new check_string function (or however we end up calling it).

if not isinstance(name, str):
context.expected_word_error("Predicate name", name)
with context.layer(f"Parsing arguments of predicate '{name}'"):
arguments = parse_typed_list(context, alist[1:], only_variables=True)
return pddl.Predicate(name, arguments)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like (available) (?x - object) (instead of (available ?x - object)) will get accepted. The first part because the first while-loop of parsed_type_list (line112) does not trigger since alist[1:] is empty, and the second because the items defined on line 125 are empty and thus the loop on line 133 does not trigger.

def parse_predicates(context, alist):
with context.layer("Parsing predicates"):
the_predicates = []
for no, entry in enumerate(alist):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe start counting at 1? "Parsing 0. predicate" might be confusing.

f"Reason: two '{field}' specifications.")
if (seen_fields and
correct_order.index(seen_fields[-1]) > correct_order.index(field)):
msg = f"\nWarning: {field} specification not allowed here (cf. PDDL BNF)"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this error triggers the output looks somewhat weird:
b'\nWarning: :requirements specification not allowed here (cf. PDDL BNF)\n'
(This happens in other places as well, mostly when errors from the PDDL classes trigger.)

@@ -42,49 +153,111 @@ def set_supertypes(type_list):
type_name_to_type[desc_name].supertype_names.append(anc_name)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing types can cause a KeyError crash. Unfortunately I don't remember anymore how I triggered it and I don't understand the function enough to pinpoint why it might happen (maybe it happens in graph.transitive_closure?)

len(goal) != 2 or not isinstance(goal[1], list) or
not goal[1]):
context.error("Expected non-empty goal.", syntax=SYNTAX_GOAL)
yield parse_condition(context, goal[1], type_dict, predicate_dict)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too many parenthesis can cause a crash, e.g. (:goal ((blocked philosopher-1)))
(Not sure if this can happen with conditions in general or just the goal.)

assert entry[0] == ":action"
with context.layer(f"Parsing {len(the_actions) + 1}. action"):
action = parse_action(context, entry, type_dict, predicate_dict)
if action is not None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is a none action allowed and gets added?

the_actions.append(action)
return the_axioms, the_actions

def parse_init(context, alist):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems we do not check if predicates specified in init exist (in the goal we do).

@maltehelmert
Copy link
Contributor

[issue1030] translator: Improve PDDL error reporting.

We have substantially revised the PDDL syntax checks in the translator. Many PDDL errors are now diagnosed much better and lead to a meaningful error message and exit code where previously they would trigger failed assertions or be silently accepted with unclear semantics.

@maltehelmert maltehelmert merged commit 768091d into aibasel:main Jan 18, 2024
6 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
6 participants