oakes / odoyle-rules

A rules engine for Clojure(Script)
The Unlicense
530 stars 20 forks source link

Nondeterministic behavior? #6

Closed telekid closed 3 years ago

telekid commented 3 years ago

I spent a bit of time fiddling about with odoyle this morning, and I was surprised to find that rule execution order seems to be nondeterministic. Is this known or intentional?

For an example of what I mean, see this nextjournal notebook. Notice that if you run it multiple times, the order of "purchase failed" / "purchase succeeded" changes. Sometimes "purchase failed" doesn't execute at all. Why is that?

oakes commented 3 years ago

Not sure how nextjournal works but i copied it locally and i always get this result:

card1633
Purchase failed
card1634
Purchase succeeded
Cards: [{:id card1633, :purchased false, :color :blue} {:id card1634, :purchased true, :color :red}]
Tokens in wallet: [{:token-type-id token-type1630, :color :red, :count 2}]

I think you changed it a bit after i copied it but the output doesn't seem to change on the website either. How can i make it rerun on there?

telekid commented 3 years ago

Sorry, I could have made that easier to reproduce. Here's some quick and dirty code to make it easier to test locally. If I have time later today, I'll see if I can make a more minimal reproduction.

Try throwing this in a buffer and calling (reproduce) repeatedly:

(ns bug?)

(require '[odoyle.rules :as o])

(def rules
  (o/ruleset
    {::purchase-success
     [:what
      [::action ::attempt-purchase id]
      [id :card/purchased? false]
      [card-cost-id :card-cost/for-card id]
      [card-cost-id :card-cost/color color]
      [card-cost-id :card-cost/count needs-count]
      [token-type-id :token/color color]
      [token-type-id :token/count has-count]

      :when
      (<= needs-count (or has-count 0))

      :then
      (println "Purchase succeeded")
      (-> o/*session*
        (o/insert id :card/purchased? true)
        (o/insert token-type-id :token/count (- has-count needs-count))
        o/reset!)]

     ::purchase-fail
     [:what
      [::action ::attempt-purchase id]
      [id :card/purchased? false]

      :when
      true

      :then
      (println "Purchase failed")]

     ::card
     [:what
      [id :card/purchased? purchased]
      [id :card/color color]]
     ::token
     [:what
      [token-type-id :token/color color]
      [token-type-id :token/count count]]}))

(defn initialize-wallet [session tokens]
  (swap! session
    (fn [session]
      (let [tokens (mapcat (fn [[color count]]
                             (let [token-type-id (gensym "token-type")]
                               [[token-type-id :token/color color]
                                [token-type-id :token/count count]]))
                     tokens)
            session (reduce o/insert session tokens)]
        (o/fire-rules session)))))

(defn create-card [session card-id color costs]
  (swap! session
    (fn [session]
      (let [card-cost-id (gensym "card-cost")
            costs (mapcat (fn [[color count]]
                            [[card-cost-id :card-cost/for-card card-id]
                             [card-cost-id :card-cost/color color]
                             [card-cost-id :card-cost/count count]])
                    costs)
            card [[card-id :card/color color]
                  [card-id :card/purchased? false]]
            facts (concat costs card)
            session (reduce o/insert session facts)]
        (o/fire-rules session)))))

(defn attempt-purchase [session card-id]
  (swap! session
    (fn [session]
      (-> session
        (o/insert ::action ::attempt-purchase card-id)
        (o/fire-rules)))))

(def i (atom 0))

(defn reproduce []
  (def *session
    (atom (reduce o/add-rule (o/->session) rules)))

  (println "Run " (swap! i inc))
  ;; Put three red tokens in wallet
  (initialize-wallet *session {:red 3})

  ;; Create a card and attempt to purchase it
  (let [card-1-id (gensym "card")
        card-2-id (gensym "card")]
    (create-card *session card-1-id :blue {:red 4})
    (create-card *session card-2-id :red {:red 1})
    (attempt-purchase *session card-1-id)
    (attempt-purchase *session card-2-id))

  (println
   "Cards:"
   (o/query-all @*session ::card))

  (println
   "Tokens in wallet:"
   (o/query-all @*session ::token))

  (println))

(reproduce)

This is the result that I get from calling (reproduce) three times in a row:

(reproduce)
Run  1
Purchase failed
Purchase succeeded
Cards: [{:id card30234, :purchased false, :color :blue} {:id card30235, :purchased true, :color :red}]
Tokens in wallet: [{:token-type-id token-type30233, :color :red, :count 2}]

nil
bug?> (reproduce)
Run  2
Purchase failed
Purchase succeeded
Cards: [{:id card30241, :purchased false, :color :blue} {:id card30242, :purchased true, :color :red}]
Tokens in wallet: [{:token-type-id token-type30240, :color :red, :count 2}]

nil
bug?> (reproduce)
Run  3
Purchase failed
Purchase failed
Purchase succeeded
Cards: [{:id card30248, :purchased false, :color :blue} {:id card30249, :purchased true, :color :red}]
Tokens in wallet: [{:token-type-id token-type30247, :color :red, :count 2}]

nil

Note that the third time, Purchase failed is printed twice. That is surprising to me, but maybe it's normal? I'm new to this whole rules engine thing.

BTW, awesome work on this, and great talk. I'm excited to explore this further.

oakes commented 3 years ago

You found a bug :D I have pushed a fix: https://github.com/oakes/odoyle-rules/commit/28c0b3621b035eec8ebc686f39e8735c353c3e0e

The way your code should work is, it should always print "Purchase failed" twice. This is because your two facts always start out with :card/purchased? false. The ::purchase-success rule might change that, but by the time that :then block runs, the ::purchase-fail rule should already have been queued up to run. The bug was that :then blocks could sometimes prevent rules from running even when they were already queued up.

oakes commented 3 years ago

I released 0.8.0 with the fix. Keep in mind that the order rules execute in is not guaranteed if they are not dependent on each other, so your example will print "Purchase succeeded" in different places each time you run it. But with this version it should always print "Purchase failed" twice. I'll close this but let me know anything still seems wrong.

telekid commented 3 years ago

Cool! Makes sense – thanks for looking into it!