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

Remove add-tokens! and remove-tokens! calls from ProductionNode #386

Closed
WilliamParker opened this issue Mar 1, 2018 · 20 comments
Closed

Comments

@WilliamParker
Copy link
Collaborator

Summary:

We appear to be making calls to the memory in the ProductionNode that we don't actually need and which can just be removed without replacement, but with some complications related to session inspection. The performance benefits of this on rules firing and serialization/deserialization times could be significant.

Longer explanation:

The ProductionNode is a leaf node in a Clara Rete-style rules network that essentially corresponds to the RHS of a rule. This node is responsible for adding RHS activations to a queue to be activated and retracting logically inserted facts when their support is removed.

For understanding the discussion below some brief review of the fields on the TransientLocalMemory is recommended. The TransientLocalMemory is used during rules firing.

Queueing the RHS activations is done by this call to mem/add-activations!. Note that the body of this method mutates the "activation-map" field of the memory. When the engine reaches a point at which it will execute a queued RHS activation, it will call mem/pop-activation!, which also mutates the activation-map. When the support for a RHS activation is removed before it has been executed, the engine will call mem/remove-activations! which also operates on the activation-map. In short, all necessary information on what RHS activations are currently in the queue is contained within the activation map and accessed through it.

When a ProductionNode needs to remove logically inserted facts because their support was retracted, it calls mem/remove-insertions! in order to determine what exactly needs to be retracted. The remove-insertions! implementation accesses the "production-memory" field of the memory and subsequently mutates it. When the engine performs a logical insertion, it [calls mem/add-insertions!] to keep track of what logical insertions have been performed. The implementation of mem/add-insertions! likewise changes the production-memory. In short, the production-memory contains all necessary information to keep track of logical insertions and retract them when their support is removed.

However, the ProductionNode also calls mem/add-tokens! upon left-activation and mem/remove-tokens! upon left-retract. Both mem/add-tokens! and mem/remove-tokens! act on the beta-memory field of the memory. Also note that the ProductionNode does not use the results of either the mem/add-tokens! or mem/remove-tokens! call.

It appears to me that the calls to mem/add-tokens! and mem/remove-tokens! in the ProductionNode are not actually necessary for any function of the engine. The code paths don't seem to lead to any outcome. Furthermore, the activation-map keeps track of the RHS queued activations in full, as does the production-memory for logical insertions. The ProductionNode does lose access to information about previously executed RHS activations that didn't cause any logical insertions. However, the ProductionNode has no actual work to perform if the support for such an activation is removed, so I don't believe this is actually a problem. For example, such a RHS activation might have triggered only unconditional insertions, in which case nothing will be retracted even when the support is removed. Since ProductionNode is a leaf node it doesn't need to keep track of retractions to pass downstream to its (nonexistent) descendants.

The only place that seems to actually use the tokens kept in the beta-memory field for a ProductionNode is session inspection, specifically the generation of :rule-matches data. This implementation takes a simple approach of retrieving all tokens from each ProductionNode and then generating explanations for them. However, I believe that it should be possible for the inspection namespace to rebuild this information from the immediate parent nodes of each ProductionNode. Alternatively, perhaps we don't actually care about returning this information, but no longer doing so would be a nonpassive change to session inspection.

When I simply removed this line in left-activate and this line in left-retract all tests passed in Clojure except tests of session inspection. I didn't run ClojureScript tests locally since my ClojureScript environment is having problems.

@alex-dixon
Copy link
Contributor

Thanks for the thorough and clear explanation.

I’ve built a considerable amount of tooling on top of rule matches. To me they’re magic and one of the great things about Clara because it lets us know basically not just what happened but why.

My implementation uses the listeners namespace so I’m not sure this change would affect that. I’m not sure if there’s test coverage that would show breakage in that namespace directly or if it’d only surface indirectly in the inspect tests.

@mrrodriguez
Copy link
Collaborator

mrrodriguez commented Mar 2, 2018

Alternatively, perhaps we don't actually care about returning this information, but no longer doing so would be a nonpassive change to session inspection.

@WilliamParker @alex-dixon

If I'm understanding Will's proposed removal of memory interaction in the ProductionNode correctly, this change wouldn't affect the information that is accessible via Clara's listeners. The listener calls are left intact.

The change seems to only affect anyone who was directly accessing the memory of the ProductNode via the memory API. This is a lower-level of access and wouldn't be something that we'd expect to be stable to rely on as an external API anyways.

However, Clara did use this memory access in its own implementation of session inspection that Will linked to above. So to not change inspection output, that'd have to be changed to figure out the :rule-matches a different way.

So Alex, I'm not sure if you are using the :rule-matches output from the clara.tools.inspect functionality or you are just building that sort of information in a custom way based on your listener implementation. It sounds to me like the latter. If so, that shouldn't be affected by this based on my understanding of Will's proposal.

@alex-dixon
Copy link
Contributor

@mrrodriguez Thanks. Wasn't sure but wanted to be sure :) Looks like I'm getting :matches and :bindings off the :token key passed to the listener methods:
https://github.com/CoNarrative/precept/blob/cda985afcc6c91505dd9152731c2328803dc5ffc/src/cljc/precept/serialize/dto.cljc#L131

@WilliamParker
Copy link
Collaborator Author

WilliamParker commented Mar 2, 2018

@alex-dixon In general my approach to the changes to inspection would be that it is "beta enough" that some changes to the way the data is accessed are probably reasonable depending on the case in question, but that I don't want to actually change what data is made available. Basically perhaps a minor change in consumer codebases when taking a new version of Clara, but not anything that would undercut assumptions about what data is available and thus user's architectures. I don't want to go around completely breaking people's code. :) Obviously opinions may vary on what degree of change is acceptable.

I'll post some more detailed thoughts later, but one approach could be that if certain operations require additional information the user could be required to attach a new "inspection listener" provided by Clara to the session. Do note that we have the information on rule matches for cases where logical insertions resulted elsewhere too, and other fields in the inspection data map are essentially different projections of this information.

@mrrodriguez
Copy link
Collaborator

@alex-dixon
I looked at your code link. Yeah, the changes here aren't about changing the structure of tokens themselves. The still will have :matches. The key that was discussed here was :rule-matches which was in a datastructure related to clara.tools.inspect functions.

However, from your code above, I don't know how you are obtaining these tokens. Will's changes here would result in ProductionNode to not be directly adding/removing tokens of their own since these tokens aren't useful at leaf nodes in the rete graph.

If you had something relying on getting the tokens for a given ProductionNode, then you may be affected.

The listener calls though shouldn't be affected. I'd think there would be no reason to remove the listener activate/retract (eg. l/left-retract! ) calls in the ProductionNode since they are not a bottleneck unless you have a listener that is causing a perf degradation. The Clara default doesn't do anything on these listener calls.
Therefore, if you are using listeners to gather your token information, it seems that that may not be something that would need to be affected here.

If you are getting tokens more directly via hitting the Clara memory API for tokens by node-ids etc, then this sort of change could have an affect on you.

@WilliamParker
Copy link
Collaborator Author

Regarding :rule-matches in clara.tools.inspect, its behavior seems somewhat odd to me at present. Take the following case

(defrule cold-rule
    [ColdAndWindy]
   =>
   (insert-unconditional! (->Cold)))

I'd expect it to return a :rule-match in this case:

(-> (mk-session [cold-rule])
       (insert (->ColdAndWindy))
       fire-rules)

but not in this case:

(-> (mk-session [cold-rule])
       (insert (->ColdAndWindy))
       fire-rules
      (retract (->ColdAndWindy))
       fire-rules)

The reason I expect this is that the tokens are removed from the beta-memory upon retraction regardless of what happens in the RHS and session inspection uses the beta memory tokens to generate :rule-matches data.

However, if my rule is performing unconditional insertions, RHS retractions, or otherwise executing side effects what I really would care about is that the rule was in fact fired, not that its support was removed, since the removal of that support would not remove the impact of the activation on the state of the system.

This makes me think that a more functionally correct/useful approach might be to:

  • Have :rule-matches provide a map of rules to explanations when the activation caused a logical insertion that was not retracted.
  • Provide a listener that can give the user all activations of all rules that ultimately executed, regardless of whether their support was retracted later. Access to this listener could be through the clara.tools.inspect namespace.

Thoughts?

@mrrodriguez
Copy link
Collaborator

@WilliamParker

Have :rule-matches provide a map of rules to explanations when the activation caused a logical insertion that was not retracted.

So you are proposing to leave out any unconditional fact details from this functionality now?

Provide a listener that can give the user all activations of all rules that ultimately executed, regardless of whether their support was retracted later.

There is already a listener on the left-activate in the ProductionNode. This can already be used to monitor all activity of the rule regardless of the truth maintenance.
What it doesn't do is provide details about what the activation of the rule produced (if anything). Perhaps that is what you mean? So a rule RHS activation may affect working memory, and if it does, you are proposing to have some sort of way to listen for that for inspection purposes?

@WilliamParker
Copy link
Collaborator Author

WilliamParker commented Mar 6, 2018

So you are proposing to leave out any unconditional fact details from this functionality now?

Correct. I don't think the current behavior makes sense in that it excludes unconditional fact details if the support is retracted, which isn't meaningful in the case of unconditional insertions. I'd consider the current behavior to be broken due to this.

There is already a listener on the left-activate in the ProductionNode. This can already be used to monitor all activity of the rule regardless of the truth maintenance.
What it doesn't do is provide details about what the activation of the rule produced (if anything). Perhaps that is what you mean? So a rule RHS activation may affect working memory, and if it does, you are proposing to have some sort of way to listen for that for inspection purposes?

I'm envisioning an API like the following:

(-> session
      inspect/add-activations-listener
      insert-facts
      fire-rules
      inspect
      :all-rule-matches)

where :all-rule-matches would have the same information as the current :rule-matches key, but for all rule matches that led to a RHS firing regardless of whether the match was retracted. Along the same lines you could create an :unconditional-fact->explanations key, which I believe has been requested on either the Slack channel or mailing list before.

Basically I'd like to make to make all the current information available from session inspection still available and actually increase it (by showing rule activations whose support was retracted). The user would just need to make a call to add an additional listener implementation supplied by Clara. The doc on such a listener would make clear that it holds onto references that would otherwise be GC'ed. At the same time, if the user doesn't need the additional information they could choose to take the performance benefits of not keeping track of it. Note that this is distinct from saying that the user has the ability to create their own listener, which is a more difficult thing to do. I think this might require adding some new listener call(s) to new listener method(s) around activation firing.

@mrrodriguez
Copy link
Collaborator

@WilliamParker why are activation listeners special compared to the existing listener protocol? I find it odd that it comes from the inspect namespace, which is a higher-level than the listeners APIs.

I just am worried the API is getting clunky with this. Listeners weren't generally specific to inspection. Also, inspection was fine without any supplied user-level listeners before.

The ideal scenario is probably:


(1) If we could remove the support for :fact-matches to supply details about unconditional inserts if those details were currently misleading as implemented.

However, I think this is likely problematic for people. I've seen several clara-in-cljs impl's using unconditional inserts and RHS retracts, mostly due to Clara not providing a clear way to do a fact-update right now. Losing that inspection may be fairly impactful.


(2) The current listener API could have support for activation listening.

One problematic thing as far as the existing listener protocolis concerned though, is that it isn't very extensible. There isn't a good way to add new functions to it and not break existing impl's.


(3) If a listener is in use that implements this new activation listening, it would be used by inspection.

This would be different than existing inspection features though, since the rest are ok without custom-listener impl's.

@WilliamParker
Copy link
Collaborator Author

why are activation listeners special compared to the existing listener protocol?

I'm just not sure that the current listener methods provide the granularity of "was this RHS actually fired" versus "was this ProductionNode left-activated".

Regarding the concerns about breaking those using :rule-matches on unconditional insertions now, regardless of the performance improvement discussed here, it seems to me that they

  • Are relying on functionality that is not currently correct.
  • That this functionality fundamentally can't be fixed without holding onto references, namely to retracted facts, that can currently be GC'ed. Holding onto those references in all cases isn't an option because of cases (including at Cerner) that rely on GC'ing them to not run out of memory.

I agree that needing to add a listener to access certain subsets of session inspection functionality is a bit clunky, but it is the best option I've found so far that allows us to:

  • Implement session inspection looking at all rule matches correctly.
  • Still allow users who need to GC retracted facts to do so.

The reason to add it to session inspection would be to reduce the clunkiness as much as possible - the user wouldn't even need to understand the idea of listeners. It could simply be documented as "enable more inspection on this session at the cost of holding onto more memory". Obviously we'd want to very clearly communicate the change when releasing a version of Clara with the change.

Regarding the addition of new listener methods, to be honest my POV on that is that if one is going to create a custom implementation of a low-level Clara API one should reasonably expect that it may be necessary to update it from time to time to take new versions of Clara. I'd be more hesitant to remove listener methods that are currently present, but when adding new method(s) such a consumer can just add an empty implementation of them. It is a very easy uplift to perform.

@mrrodriguez
Copy link
Collaborator

@WilliamParker

Are relying on functionality that is not currently correct.

What does it currently return? Unconditional facts with no support? I may need to run your above examples since you discussed your expectations, but not the current behavior (perhaps we should add them to this post).

That this functionality fundamentally can't be fixed without holding onto references, namely to retracted facts, that can currently be GC'ed.

Yeah, I definitely wasn't suggesting to hold references to facts that aren't currently held. Not a viable option.

The reason to add it to session inspection would be to reduce the clunkiness as much as possible - the user wouldn't even need to understand the idea of listeners.

Perhaps this functionality should instead go in clara.tools.tracing and not directly refer to listeners at all then. Perhaps it should just be a with-activation-tracing. I'm going for the same idea as with-tracing.
The impl could actually add functionality to listeners if it is potentially useful listener information (that will break old listener impl's things if ProductionNodes call the new functions unchecked).

Regarding the addition of new listener methods, to be honest my POV on that is that if one is going to create a custom implementation of a low-level Clara API one should reasonably expect that it may be necessary to update it from time to time to take new versions of Clara

I don't know that I agree here. The listener API has a lot of utility for external purposes. I'm wondering if Clara could provide an extension mechanism that wouldn't need to be broken after each new feature is added to it, i.e. as new listener functionality is added.

Removing things is a different topic than adding. That is often harder to be compatible with. That should be really rare though in this case.

I don't think it is too frequent to expect some fundamental shift in fact/token propagation in the engine that must break the existing listener interface.

@WilliamParker
Copy link
Collaborator Author

I wrote out an example of a case I think is broken now below. This was done in a REPL against the master branch.

;; First setup the rule and query.
(defrule first-rule
                    [First]
                    =>
                    (insert-unconditional! (->Second)))
(defquery second-query [] [Second])

;; Define sessions where First is inserted and rules fired, but in one of them it is retracted later.

(def inserted-session (-> (mk-session [first-rule second-query] :cache false)
                                            (insert (->First))
                                            fire-rules))

(def retracted-session (-> (mk-session [first-rule second-query] :cache false)
                                            (insert (->First))
                                            fire-rules
                                            (retract (->First))
                                            fire-rules))
;;;  The session with no retraction has nonempty :rule-matches and a query result for Second.

clara.test-rules> (-> inserted-session inspect :rule-matches)
{{:ns-name clara.test-rules,
  :lhs [{:type clara.rules.testfacts.First, :constraints []}],
  :rhs (do (insert-unconditional! (->Second))),
  :name "clara.test-rules/first-rule"}
 ({:matches
   ({:fact {},
     :condition
     {:type clara.rules.testfacts.First, :constraints []}}),
   :bindings {}})}
clara.test-rules> (query inserted-session second-query)
({})

;;; The session in which First was retracted afterwards has empty :rule-matches, but still has a Second query result.

clara.test-rules> (-> retracted-session inspect :rule-matches)
{{:ns-name clara.test-rules,
  :lhs [{:type clara.rules.testfacts.First, :constraints []}],
  :rhs (do (insert-unconditional! (->Second))),
  :name "clara.test-rules/first-rule"}
 ()}
clara.test-rules> (query retracted-session second-query)
({})

Basically, if you're using session inspection to understand how the Second fact came to exist in the session, these cases should both return the same result, which they don't currently do. I suppose you could argue that the :rule-matches "correctly" returns rule matches that weren't removed, but if you care about unconditional operations I don't see how that is useful, and suspect that users just aren't taking that caveat into account.

@mrrodriguez
Copy link
Collaborator

@WilliamParker thanks for the details. I agree it is confusing to have those 2 different behaviors depending on if retract happened or not. After a retract the fact no longer has an explanation. Doesn't sound intuitive to me.

@WilliamParker
Copy link
Collaborator Author

After a retract the fact no longer has an explanation.

More precisely, the :rule-match map is as if the rule never fired at all; the empty entry you're seeing would be present in any case i.e. it is a map of the rule to an empty list of matches.

clara.test-rules> (-> (mk-session [first-rule second-query] :cache false) inspect :rule-matches)
{{:ns-name clara.test-rules,
  :lhs [{:type clara.rules.testfacts.First, :constraints []}],
  :rhs (do (insert-unconditional! (->Second))),
  :name "clara.test-rules/first-rule"}
 ()}

Also notably, the :fact->explanations keys doesn't have unconditionally inserted facts since its implementation uses mem/get-insertions-all and mem/get-insertions which both use the production-memory. The production-memory is the field in the memory that is used for truth maintenance; see here in the engine for example. This was a deliberate design decision since we don't want the overhead of keeping track of facts in this way for some performance-intensive cases; thus unconditional insertions can be an "escape hatch" to more performant procedural code. The :insertions keys won't have unconditionally inserted facts for the same reason.

Demonstration:

;; No fact->explanations entries.
clara.test-rules> (-> inserted-session inspect :fact->explanations)
                      
nil
;; No :insertions for the rule
clara.test-rules> (-> inserted-session inspect :insertions)
{{:ns-name clara.test-rules,
  :lhs [{:type clara.rules.testfacts.First, :constraints []}],
  :rhs (do (insert-unconditional! (->Second))),
  :name "clara.test-rules/first-rule"}
 ()}
;; But we do have a query result indicating the presence of a Second fact.
clara.test-rules> (query inserted-session second-query)
({})

I believe there have been requests for inspection functionality on unconditional insertions before, but it has never been implemented. Inspection functionality backed by a listener would allow us to do this. Something like

(-> session inspect/enable-unconditionals-inspection inspect)

The reason I'd be inclined against putting it in tracing is that the fact that it would use a listener would be an implementation detail - the user would only really need to be concerned with the tradeoff of the additional functionality versus holding onto references. I'd also like to clearly communicate that tracing is a less stable API than session inspection, and mixing things of varying stability in the same namespace seems more confusing to me.

@mrrodriguez
Copy link
Collaborator

mrrodriguez commented Mar 8, 2018

More precisely, the :rule-match map is as if the rule never fired at all; the empty entry you're seeing would be present in any case i.e. it is a map of the rule to an empty list of matches.

Thanks for the clarification.

The reason I'd be inclined against putting it in tracing is that the fact that it would use a listener would be an implementation detail

That sounds reasonable. I see inspection features as more flexible and easier to toggle different behaviors on via functions like you propose.
It seems fine to be flexible here and disable certain things for performance etc.

I think that listener API may be used more for consumers to perform logic that is important for their own logic's correctness. So my biggest concern I'd have at this point would be doing something to the listeners where they no longer had enough information to find facts that were unconditionally inserted/retracted.
I don't think that this issues performance optimization is going to to do anything that'd affect this aspects of listeners, so that is good.

@WilliamParker
Copy link
Collaborator Author

WilliamParker commented Mar 8, 2018

So my biggest concern I'd have at this point would be doing something to the listeners where they no longer had enough information to find facts that were unconditionally inserted/retracted.
I don't think that this issues performance optimization is going to to do anything that'd affect this aspects of listeners, so that is good.

I don't see any reason to remove the l/left-activate! or l/left-retract! calls from ProductionNode. Since the default listener will just have a null operation the performance cost should be very minimal. We can just add a comment on why the listener calls exists without a corresponding memory call i.e. for passivity.

EDIT: Link to the listener calls, not the memory calls as referenced by @mrrodriguez below.

@mrrodriguez
Copy link
Collaborator

I don't see any reason to remove the mem/add-tokens! or mem/remove-tokens! calls from ProductionNode.

I think you meant the listener calls and not the memory calls you linked right?

e.g. l/left-activate!

Removing the memory token modifications was the optimization proposed by this issue I think.

@WilliamParker
Copy link
Collaborator Author

I think you meant the listener calls and not the memory calls you linked right?

Oops. You are correct @mrrodriguez . I corrected the comment.

@WilliamParker
Copy link
Collaborator Author

OK, so to summarize the proposed changes:

Inspection API Changes

  • The :rule-matches in clara.tools.inspect will no longer include matches for rules that don't cause logical insertions.
  • A new key :all-activations (or other name TBD) will be added that includes all rule RHS activations that are actually executed regardless of downstream retractions and has the same data form as :rule-matches. This would not be present for all sessions; in order to use it one would use a new API of the form
(-> session inspect/full-activation-logging (insert ...) fires-rules  inspect :all-activations)

The inspect/full-activation-logging function would return a new session rather than modifying the session passed to it.

  • Optionally, we can add a key :unconditional-insertions->explanations that would have the same form as the existing :fact->explanations key and be activated
(-> session inspect/full-activation-logging (insert ...) fire-rules inspect :unconditional-insertions->explanations)

Something similar could be done for a counterpart to the :insertions key as well.

  • A function inspect/disable-full-activation-logging will also be added that returns a new session with this logging disabled.

Listener changes

  • All existing listener calls will remain.
  • New listener calls with new method(s) may be added, probably around the actual activation firing.

Performance benefits

Breakage of consumers

  • Consumers using the :rule-matches key might have to switch to the new :all-activations key. They would also get a superset of the data that they used to, but I think this is a good thing since I don't see a good reason to not want the data that is currently being excluded.
  • Consumers implementing listeners would need to add one or new methods to their listeners; these methods could just have an empty implementation.

Individual notes:

  • @mrrodriguez : From the conversation above it sounds like we're on the same page, but obviously please post if that is not the case.
  • @alex-dixon : Since you're using listeners rather than session inspection, I don't think this will impact you.
  • @rbrush : Haven't heard from you on this issue yet - do you have thoughts/comments/objections on these changes?

@WilliamParker
Copy link
Collaborator Author

I've merged a PR to address this issue: #387
I also logged oracle-samples/clara-site#30 to update the docs to reflect these changes.

Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants