zeroSteiner / rule-engine

A lightweight, optionally typed expression language with a custom grammar for matching arbitrary Python objects.
https://zerosteiner.github.io/rule-engine/
BSD 3-Clause "New" or "Revised" License
445 stars 54 forks source link

request for help for weird problem: incomplete things may result in rule.matches #21

Closed cons0l3 closed 3 years ago

cons0l3 commented 3 years ago

Hi @zeroSteiner,

love your work in this open source contribution. Thanks!

I currently work on evaluating purely "boolean" expressions. a simplified example is: (a in [1,2] and c=='abc') or b==1. My "things" are not always fully defined. e.g. rule.match({'b':1}) the "thing" is not completly defined and will throw the SymbolResolutionError rightly so.

If I define default_value=None in the context as work around, I get passed the SymbolResolutionError and in the above example will receive a "true" as the OR will never evaluate to anything else but true. Actually it would not require to evaluate the left-hand-side, as the right-hand-side of the OR already fully defines the result.

def test_rule_partial_evaluate():
    context = rule_engine.Context(default_value=None)
    rule = rule_engine.Rule("""(a in [1,2] and c=='abc') or b==1""", context=context)
    assert rule.matches({'b':1})==True    # correct result, because a=None, c=None... and will not change anything anway.
    assert rule.matches({'a':3})==False   # false and will only get true with providing a value for b. value for c is not needed
    assert rule.matches({'a':1})==False   # false can become true if values for c or b are provided. 
    assert rule.matches({'a':1, 'c':'abc'})==True # correct result, because b does not matter

My problem with the Default_Value=None work around is, that I cannot determine easily if the "False" is really false or false because it could not completely evaluate.

After a match I would love to have list of symbols that tell me, if I need more attributes defined in the "thing".

Can I convice the rule-engine to "partially" evaluate a rule with a incomplete thing (maybe with the reduce function) and see if either the rule is reduced to a literal or some expression is left over? That would of course require that if b is "1" in thing, the reduced rule will be "true" literally.

Hope you understand what I am getting at?

Cheers Carsten

zeroSteiner commented 3 years ago

Actually it would not require to evaluate the left-hand-side, as the right-hand-side of the OR already fully defines the result.

That's true for this case. The reduction takes place at parsing time though (when you initialize the Rule instance), so it doesn't account for default values because it doesn't know which will be missing and any should be called to be missing or specified later on at evaluation time. So that's why it's not fully reduced.

You can get all of the symbols a rule references using rule.context.symbols, for example:

In [3]: rule = Rule("""(a in [1,2] and c=='abc') or b==1""")

In [4]: rule.context
Out[4]: <rule_engine.engine.Context at 0x7faaf43c0e80>

In [5]: rule.context.symbols
Out[5]: {'a', 'b', 'c'}

You could use a sort of resolver-wrapper though to identify this. For example:

In [13]: class CustomResolver(object):
    ...:     def __init__(self, resolver):
    ...:         self.resolver = resolver
    ...:         self.missing_symbols = set()
    ...:     def __call__(self, thing, name):
    ...:         try:
    ...:             self.resolver(thing, name)
    ...:         except SymbolResolutionError as error:
    ...:             self.missing_symbols.add(name)
    ...:             raise error
    ...: custom_resolver = CustomResolver(resolve_item)

In [14]: context = Context(resolver=custom_resolver, default_value=None)

In [15]: rule = Rule("""(a in [1,2] and c=='abc') or b==1""", context=context)

In [16]: rule.evaluate({'b': 1})
Out[16]: False

In [17]: rule.context.symbols # all symbols
Out[17]: {'a', 'b', 'c'}

In [18]: custom_resolver.missing_symbols # symbols where the default was used
Out[18]: {'a'}

Keep in mind though that this would not be a thread-safe solution. If you are evaluating the rules in different threads, then you'd need to use thread local data to organize the values (this is what the rule engine does for a couple of things).

cons0l3 commented 3 years ago

Thanks Spencer,

I had to adjust jour example a little to make it work:

...
                try:
                                 **return** self.resolver(thing, name)
                except SymbolResolutionError as error:
...

as a rule.evaluate({'b':1}) should result in true. And the CustomerResolver did not find the undefined "c". Mostlikely because the raised exception, that aborted the AST-tree-walking during evaluation of the left hand side of the OR-node.

I was already playing around with the concept of "find what is undefined". It is much more straight forward:

input_data = {'b':1}
undefined_symbols = rule.context.symbols.difference(set(input_data.keys()))
print(undefined_symbols)
=> {'c', 'a'}

I come to the conclusion, that I need to evalute the AST from the parser with the following features myself: 1) variables/symbols may be defined or undefined. variables may be None in which case they are defined, but have a value of None 2) None is False 3) None AND False evaluates as false, None AND True evaluates as false, None OR true evaluates as true, NOT None is true ... 4) False AND undefined == undefined AND False evaluates as false, undefined OR True evaluates as true, ... 5) A Rule (AST-node) may evaluate as "undefined" and return a list of "missing" symbols in such a case. 6) A Rule (AST-node) evaluates to the value if it is defined enough for the node-type by the values of its children. ...

I threw together a small peg parser (thanks Guido for putting the urge into my head to play around with peg parsers) and am currently working on a visitor pattern to walk and evaluate the tree on the above stated rules.

Thanks for your support Spencer,

Cheers Carsten