jruizgit / rules

Durable Rules Engine
MIT License
1.14k stars 209 forks source link

Adjusting flowchart idea to fit stateless use case #366

Open awilde27 opened 3 years ago

awilde27 commented 3 years ago

Hi @jruizgit, brilliant package you've created here.

I have a use case in which I'm using durable_rules to a construct decision rule tree, ideally where each node is its own ruleset and the further down the tree, the more sophisticated the consequents (in my case, insights or recommendations). A flowchart seems like a great candidate, but the only catch is that my use case is stateless, i.e. when I post an event I need it to flow through entirely rather than be queued to the next stage.

I'm still learning and thinking through the abilities of durable_rules, but my intuition tells me I need something between a set of rulesets and a flowchart, and something in between a fact assertion and an event posting.

What I'd appreciate insight on:

  1. Does the example below seem like an antipattern?
  2. Is there a way other than a callback to an API or global variable, as examples, to create an in-memory stack within the state or somewhere else to capture/record all triggered rules? I've read through #116 and also tried to create a state variable, c.s.result_stack which seemed to work until I posted an event to a ruleset which posted that same event to a third.
  3. Based on the docs, is there a way to force an event to pass through a flowchart without posting multiple events?

For this dummy example, you can assume I'm posting data that contains the same k attributes each time, uniqueness exists for item_name.

Thank you in advance.

import pandas as pd
from durable.lang import *

def post_in_rule(ruleset_name, c):
    post_d = dict(c.m.items())
    try:
        # redirect event to ruleset `ruleset_name`
        result = post(ruleset_name, post_d)
        # grab rule name and result from current state after passing through ruleset `ruleset_name`
        rule_name, consequent = result.get('rule_name'), result.get('result')
        stack_payload = {'item': c.m.item_name, 'action_dt': c.m.action_dt,
                         'ruleset': ruleset_name, 'rule_name': rule_name, 'result': consequent}
        if c.s.result_stack is None:
            c.s.result_stack = []
        c.s.result_stack.append(stack_payload)
    except Exception as e:
        if isinstance(e, MessageNotHandledException):
            print('Item was posted to ruleset "{}" and did not trigger any rules'.format(ruleset_name))
            pass
        else:
            raise e

def add_event_capture(c, rule_name, consequent):
    c.s.result = consequent
    c.s.rule_name = rule_name
    ruleset_name = c._ruleset._name
    stack_payload = {'item': c.m.item_name, 'action_dt': c.m.action_dt,
                     'ruleset': ruleset_name, 'rule_name': rule_name, 'result': consequent}
    if c.s.result_stack is None:
        c.s.result_stack = []
    c.s.result_stack.append(stack_payload)

with ruleset('report'):
    @when_all(m.days_old > 100)
    def report_out_of_date(c):
        res = '{} out of date report ({} days old)'.format(c.m.item_name, int(c.m.days_old))
        print(res)
        # record the rule was triggered, originally recording metadata to c.s.result_stack
        add_event_capture(c, 'report_out_of_date', res)

    @when_all(m.num_references > 10)
    def large_num_references(c):
        res = '{} has large number of references, prioritize (references:{})'.format(c.m.item_name, int(c.m.num_references))
        print(res)
        add_event_capture(c, 'large_num_references', res)

with ruleset('classify_due_item'):
    @when_all(m.item_type == 'report')
    def is_report(c):
        res = 'Item {} is a report'.format(c.m.item_name)
        print(res)
        add_event_capture(c, 'diff_exp', res)
        # post to other ruleset, i.e. move down condition tree
        post_to_ruleset('report', c)

    @when_all(m.item_type != 'report')
    def not_report(c):
        res = '{} is not report, but alert team'.format(c.m.item_name)
        print(res)
        add_event_capture(c, 'not_report', res)

with ruleset('action_item'):
    # all items action_dt >= `today`
    @when_all(m.action_dt <= 1610746698 + 604881)  # within a week from today
    def due_soon(c):
        res = 'Item {} needs attention by {}'.format(c.m.item_name, pd.datetime.from_timestamp(c.m.action_dt).strftime('%Y-%m-%d'))
        print(res)
        add_event_capture(c, 'due_soon', res)
        post_to_ruleset('classify_due_item', c)

    @when_all(m.action_dt > 1610746698 + 604881)
    def due_later(c):
        res = '{} due later, put on backlog (due date: {})'.format(
            c.m.item_name,
            pd.datetime.from_timestamp(c.m.action_dt).strftime('%Y-%m-%d')
        )
        print(res)
        add_event_capture(c, 'due_later', res)

d = {'item_name': 'Report 123', 'item_type': 'report', 'action_dt': 1611146698, 'num_references': 18, 'days_old': 23}
post('action_item', d)
jruizgit commented 3 years ago

Hi, thanks for posting the question. There is no good or wrong answer. But, I think your example would be more efficient if you write all the rules in a single ruleset and use forward chaining by making assertions.

Below is a very simple example: asserting "Kermit eats flies" and "Kermit lives in water" will trigger the assertion "Kermit is a frog", which will trigger the assertion "Kermit is green".

Internally the rules engine will remember the facts you have asserted by building a decision tree (Rete), the decision to assert "Kermit is a frog" right after asserting "Kermit lives in water" is optimal as all the facts don't need to be re-evaluated again.

from durable.lang import *

with ruleset('animal'):
    @when_all(c.first << (m.predicate == 'eats') & (m.object == 'flies'),
              (m.predicate == 'lives') & (m.object == 'water') & (m.subject == c.first.subject))
    def frog(c):
        c.assert_fact({ 'subject': c.first.subject, 'predicate': 'is', 'object': 'frog' })

    @when_all((m.predicate == 'is') & (m.object == 'frog'))
    def green(c):
        c.assert_fact({ 'subject': c.m.subject, 'predicate': 'is', 'object': 'green' })

   @when_all(+m.subject)
    def output(c):
        print('Fact: {0} {1} {2}'.format(c.m.subject, c.m.predicate, c.m.object))

assert_fact('animal', { 'subject': 'Kermit', 'predicate': 'eats', 'object': 'flies' })
assert_fact('animal', { 'subject': 'Kermit', 'predicate': 'lives', 'object': 'water' })

Hope this helps.

awilde27 commented 3 years ago

Thank you for the quick response. I understand the idea here that I can forward-chain assertions. Given your example, I think the additional element I have to incorporate here is to forward chain specific attributes of c.m so that when a rule asserts a new fact I only include relevant data so that a rule I wish to trigger lower in the set gets triggered as opposed to getting caught in an infinite loop. What I find tricky is organizing a ruleset for scale and readability. Nevertheless, it might be a bit more overhead on my part, but agreed it is a more efficient implementation.

I think it makes sense, within a rule, to assert a fact out to a separate ruleset dedicated to simply for storing consequents triggered within the main ruleset. This seems necessary, as the engine will only store the data that a fact passed to the ruleset as opposed to the consequent. Will try this approach and follow up with any further questions - appreciate the insight.