From 42f1cf9ff3b56915fc0fae7f75ee8f1bbd278d86 Mon Sep 17 00:00:00 2001 From: Jose Gomez Date: Fri, 9 Jan 2026 23:55:00 -0600 Subject: [PATCH 1/9] feat: save root facts to memory --- src/main/clojure/clara/rules/compiler.clj | 3 +- src/main/clojure/clara/rules/engine.clj | 49 ++++++++++++--------- src/main/clojure/clara/rules/memory.clj | 21 +++++++++ src/main/clojure/clara/tools/fact_graph.clj | 2 +- src/main/clojure/clara/tools/inspect.clj | 17 ++++++- 5 files changed, 68 insertions(+), 24 deletions(-) diff --git a/src/main/clojure/clara/rules/compiler.clj b/src/main/clojure/clara/rules/compiler.clj index 6aa73497..75001caf 100644 --- a/src/main/clojure/clara/rules/compiler.clj +++ b/src/main/clojure/clara/rules/compiler.clj @@ -1941,7 +1941,8 @@ (.add fact-list fact) fact-list))))] - (fn [facts] + (fn do-get-alphas + [facts] (let [roots->facts (java.util.LinkedHashMap.)] (doseq [fact facts roots-group (fact-type->roots (wrapped-fact-type-fn fact))] diff --git a/src/main/clojure/clara/rules/engine.clj b/src/main/clojure/clara/rules/engine.clj index 251749fd..5b271ccc 100644 --- a/src/main/clojure/clara/rules/engine.clj +++ b/src/main/clojure/clara/rules/engine.clj @@ -34,6 +34,11 @@ ;; An activation for the given production and token. (defrecord Activation [node token]) +(defn ->RootElement + "Creates a root element with a root fact and empty bindings." + [fact] + (->Element fact {})) + ;; Token with no bindings, used as the root of beta nodes. (def empty-token (->Token [] {})) @@ -1969,10 +1974,10 @@ (case op-type - :insertion + :insert (do (l/insert-facts! listener nil nil facts) - + (mem/add-root-elements! memory (map ->RootElement facts)) (binding [*pending-external-retractions* (atom [])] ;; Bind the external retractions cache so that any logical retractions as a result ;; of these insertions can be cached and executed as a batch instead of eagerly realizing @@ -1983,10 +1988,10 @@ (alpha-activate root fact-group memory transport listener)) (external-retract-loop get-alphas-fn memory transport listener))) - :retraction + :retract (do (l/retract-facts! listener nil nil facts) - + (mem/remove-root-elements! memory (map ->RootElement facts)) (binding [*pending-external-retractions* (atom facts)] (external-retract-loop get-alphas-fn memory transport listener))))) @@ -1995,24 +2000,26 @@ (let [insertions (sequence (comp (filter (fn [pending-op] (= (:type pending-op) - :insertion))) + :insert))) (mapcat :facts)) pending-operations) retractions (sequence (comp (filter (fn [pending-op] (= (:type pending-op) - :retraction))) + :retract))) (mapcat :facts)) pending-operations)] ;; Insertions should come before retractions so that if we insert and then retract the same ;; fact that is not already in the session the end result will be that the session won't have that fact. ;; If retractions came first then we'd first retract a fact that isn't in the session, which doesn't do anything, ;; and then later we would insert the fact. + (mem/add-root-elements! memory (map ->RootElement insertions)) (doseq [[alpha-roots fact-group] (get-alphas-fn insertions) root alpha-roots] (alpha-activate root fact-group memory transport listener)) + (mem/remove-root-elements! memory (map ->RootElement retractions)) (doseq [[alpha-roots fact-group] (get-alphas-fn retractions) root alpha-roots] (alpha-retract root fact-group memory transport listener)) @@ -2046,15 +2053,16 @@ ISession (insert [session facts] - (let [new-pending-operations (conj pending-operations (uc/->PendingUpdate :insertion - ;; Preserve the behavior prior to https://github.com/cerner/clara-rules/issues/268 - ;; , particularly for the Java API, where the caller could freely mutate a - ;; collection of facts after passing it to Clara for the constituent - ;; facts to be inserted or retracted. If the caller passes a persistent - ;; Clojure collection don't do any additional work. - (if (coll? facts) - facts - (into [] facts))))] + (let [new-pending-operations (conj pending-operations + (uc/->PendingUpdate :insert + ;; Preserve the behavior prior to https://github.com/cerner/clara-rules/issues/268 + ;; , particularly for the Java API, where the caller could freely mutate a + ;; collection of facts after passing it to Clara for the constituent + ;; facts to be inserted or retracted. If the caller passes a persistent + ;; Clojure collection don't do any additional work. + (if (coll? facts) + facts + (into [] facts))))] (->LocalSession rulebase memory @@ -2065,11 +2073,12 @@ (retract [session facts] - (let [new-pending-operations (conj pending-operations (uc/->PendingUpdate :retraction - ;; As in insert above defend against facts being a mutable collection. - (if (coll? facts) - facts - (into [] facts))))] + (let [new-pending-operations (conj pending-operations + (uc/->PendingUpdate :retract + ;; As in insert above defend against facts being a mutable collection. + (if (coll? facts) + facts + (into [] facts))))] (->LocalSession rulebase memory diff --git a/src/main/clojure/clara/rules/memory.clj b/src/main/clojure/clara/rules/memory.clj index 9857a947..3bdf430f 100644 --- a/src/main/clojure/clara/rules/memory.clj +++ b/src/main/clojure/clara/rules/memory.clj @@ -20,6 +20,9 @@ ;; Returns the rulebase associated with the given memory. (get-rulebase [memory]) + ;; Returns the root elements in the working memory. + (get-root-elements [memory]) + ;; Returns the elements assoicated with the given node. (get-elements [memory node bindings]) @@ -62,6 +65,12 @@ (defprotocol ITransientMemory + ;; Adds working memory elements to the given working memory at the root node. + (add-root-elements! [memory elements]) + + ;; Removes working memory elements from the given working memory at the root node. + (remove-root-elements! [memory elements]) + ;; Adds working memory elements to the given working memory at the given node. (add-elements! [memory node join-bindings elements]) @@ -460,6 +469,9 @@ IMemoryReader (get-rulebase [memory] rulebase) + (get-root-elements [memory] + (get-elements-all memory {:id 0})) + (get-elements [memory node bindings] (get (get alpha-memory (:id node) {}) bindings @@ -517,6 +529,12 @@ (vals activation-map))) ITransientMemory + (add-root-elements! [memory elements] + (add-elements! memory {:id 0} {} elements)) + + (remove-root-elements! [memory elements] + (remove-elements! memory {:id 0} {} elements)) + (add-elements! [memory node join-bindings elements] (hm/compute! alpha-memory (:id node) (fn do-add-bem @@ -814,6 +832,9 @@ IMemoryReader (get-rulebase [memory] rulebase) + (get-root-elements [memory] + (get-elements-all memory {:id 0})) + (get-elements [memory node bindings] (get (get alpha-memory (:id node) {}) bindings diff --git a/src/main/clojure/clara/tools/fact_graph.clj b/src/main/clojure/clara/tools/fact_graph.clj index dacc7455..65d58ec5 100644 --- a/src/main/clojure/clara/tools/fact_graph.clj +++ b/src/main/clojure/clara/tools/fact_graph.clj @@ -77,7 +77,7 @@ ;; to be accessible without generating the entire session inspection map. However, we want to reuse the functionality ;; here without the performance penalty of generating all of the inspection data for the session. Therefore, for now ;; we break the privacy of the function here. Once issue 286 is completed we should remove this private Var access. - fact->explanations (@#'i/gen-fact->explanations session) + fact->explanations (i/gen-fact->explanations session) ;; Produce tuples of the form [inserted-fact {:rule rule :explanation clara.tools.inspect.Explanation}] insertion-tuples (into [] diff --git a/src/main/clojure/clara/tools/inspect.clj b/src/main/clojure/clara/tools/inspect.clj index b2831863..43958fec 100644 --- a/src/main/clojure/clara/tools/inspect.clj +++ b/src/main/clojure/clara/tools/inspect.clj @@ -131,7 +131,7 @@ (.startsWith (name k) "?__gen__")) bindings)))))) -(defn ^:private gen-all-rule-matches +(defn gen-all-rule-matches [session] (when-let [activation-info (i/get-activation-info session)] (let [grouped-info (group-by #(-> % :activation :node) activation-info)] @@ -141,7 +141,7 @@ (to-explanations session (map #(-> % :activation :token) v))])) grouped-info)))) -(defn ^:private gen-fact->explanations +(defn gen-fact->explanations [session] (let [{:keys [memory rulebase]} (eng/components session) @@ -352,3 +352,16 @@ (throw (ex-info "Unable to determine node from function" {:name node-fn :simple-name simple-fn-name})))))) + +(defn get-all-facts + [session] + (let [{:keys [memory rulebase]} (eng/components session) + {:keys [production-nodes]} rulebase + root-facts (->> (mem/get-root-elements memory) + (map :fact))] + (->> (for [rule-node production-nodes + token (keys (mem/get-insertions-all memory rule-node)) + insertion-group (mem/get-insertions memory rule-node token) + insertion insertion-group] + insertion) + (concat root-facts)))) From 21718986f382f67cf8236daac164b9c0d5ced843 Mon Sep 17 00:00:00 2001 From: Jose Gomez Date: Sat, 10 Jan 2026 17:53:19 -0600 Subject: [PATCH 2/9] feat: add inspect-facts fn --- src/main/clojure/clara/rules/compiler.clj | 14 ++-- src/main/clojure/clara/tools/inspect.clj | 79 ++++++++++++++----- src/test/clojure/clara/tools/test_inspect.clj | 60 ++++++++++++-- 3 files changed, 120 insertions(+), 33 deletions(-) diff --git a/src/main/clojure/clara/rules/compiler.clj b/src/main/clojure/clara/rules/compiler.clj index 75001caf..22728383 100644 --- a/src/main/clojure/clara/rules/compiler.clj +++ b/src/main/clojure/clara/rules/compiler.clj @@ -1941,6 +1941,8 @@ (.add fact-list fact) fact-list))))] + ^{:fact-type-fn wrapped-fact-type-fn + :ancestors-fn wrapped-ancestors-fn} (fn do-get-alphas [facts] (let [roots->facts (java.util.LinkedHashMap.)] @@ -1951,12 +1953,12 @@ (let [return-list (hf/mut-list) entries (.entrySet roots->facts) entries-it (.iterator entries)] - ;; We iterate over the LinkedHashMap manually to avoid potential issues described at http://dev.clojure.org/jira/browse/CLJ-1738 - ;; where a Java iterator can return the same entry object repeatedly and mutate it after each next() call. We use mutable lists - ;; for performance but wrap them in unmodifiableList to make it clear that the caller is not expected to mutate these lists. - ;; Since after this function returns the only reference to the fact lists will be through the unmodifiedList we can depend elsewhere - ;; on these lists not changing. Since the only expected workflow with these lists is to loop through them, not add or remove elements, - ;; we don't gain much from using a transient (which can be efficiently converted to a persistent data structure) rather than a mutable type. + ;; We iterate over the LinkedHashMap manually to avoid potential issues described at http://dev.clojure.org/jira/browse/CLJ-1738 + ;; where a Java iterator can return the same entry object repeatedly and mutate it after each next() call. We use mutable lists + ;; for performance but wrap them in unmodifiableList to make it clear that the caller is not expected to mutate these lists. + ;; Since after this function returns the only reference to the fact lists will be through the unmodifiedList we can depend elsewhere + ;; on these lists not changing. Since the only expected workflow with these lists is to loop through them, not add or remove elements, + ;; we don't gain much from using a transient (which can be efficiently converted to a persistent data structure) rather than a mutable type. (loop [] (when (.hasNext entries-it) (let [^java.util.Map$Entry e (.next entries-it)] diff --git a/src/main/clojure/clara/tools/inspect.clj b/src/main/clojure/clara/tools/inspect.clj index 43958fec..4058fc14 100644 --- a/src/main/clojure/clara/tools/inspect.clj +++ b/src/main/clojure/clara/tools/inspect.clj @@ -55,13 +55,24 @@ bindings :- {s/Keyword s/Any}]) ; Bound variables ;; Schema of an inspected rule session. -(def InspectionSchema +(def RulesInspectionSchema {:rule-matches {schema/Rule [Explanation]} :query-matches {schema/Query [Explanation]} :condition-matches {schema/Condition [s/Any]} - :insertions {schema/Rule [{:explanation Explanation :fact s/Any}]}}) - -(defn- get-condition-matches + :root-facts [s/Any] + :insertions {schema/Rule [{:explanation Explanation :fact s/Any}]} + :fact->explanations {s/Any [{:rule schema/Rule + :explanation Explanation}]} + (s/optional-key :unfiltered-rule-matches) {schema/Rule [Explanation]}}) + +(def FactsInspectionSchema + {:rules {s/Int schema/Rule} + :facts [{:fact s/Any + (s/optional-key :rule-id) s/Int + (s/optional-key :bindings) {s/Keyword s/Any} + :fact-types [s/Any]}]}) + +(defn get-condition-matches "Returns facts matching each condition" [nodes memory] (let [node-class->node-type (fn [node] @@ -98,7 +109,7 @@ {} join-node-ids))) -(defn- to-explanations +(defn to-explanations "Helper function to convert tokens to explanation records." [session tokens] (let [memory (-> session eng/components :memory) @@ -145,7 +156,7 @@ [session] (let [{:keys [memory rulebase]} (eng/components session) - {:keys [productions production-nodes query-nodes]} rulebase + {:keys [production-nodes]} rulebase rule-to-rule-node (into {} (for [rule-node production-nodes] [(:production rule-node) rule-node]))] (apply merge-with into @@ -165,7 +176,7 @@ This new session will not retain references to any such information previously gathered."} without-full-logging i/without-activation-listening) -(s/defn inspect +(s/defn inspect :- RulesInspectionSchema " Returns a representation of the given rule session useful to understand the state of the underlying rules. @@ -205,9 +216,9 @@ ... The above segment will return matches for the rule in question." - [session] :- InspectionSchema + [session] (let [{:keys [memory rulebase]} (eng/components session) - {:keys [productions production-nodes query-nodes id-to-node]} rulebase + {:keys [production-nodes query-nodes id-to-node]} rulebase ;; Map of queries to their nodes in the network. query-to-nodes (into {} (for [[query-name query-node] query-nodes] @@ -229,6 +240,9 @@ :condition-matches (get-condition-matches (vals id-to-node) memory) + :root-facts (for [{:keys [fact]} (mem/get-root-elements memory)] + fact) + :insertions (into {} (for [[rule rule-node] rule-to-nodes] [rule @@ -237,13 +251,13 @@ insertion insertion-group] {:explanation (first (to-explanations session [token])) :fact insertion})])) - :fact->explanations (gen-fact->explanations session)}] + :fact->explanations (into {} (gen-fact->explanations session))}] (if-let [unfiltered-rule-matches (gen-all-rule-matches session)] (assoc base-info :unfiltered-rule-matches unfiltered-rule-matches) base-info))) -(defn- explain-activation +(defn explain-activation "Prints a human-readable explanation of the facts and conditions that created the Rete token." ([explanation] (explain-activation explanation "")) ([explanation prefix] @@ -353,15 +367,38 @@ {:name node-fn :simple-name simple-fn-name})))))) -(defn get-all-facts +(s/defn inspect-facts :- FactsInspectionSchema + "Returns a map with all rules and their associated facts in the session. + - :rules - a map of rule ids to their production nodes + - :facts - a sequence of maps with the following keys: + - :fact - the fact inserted + - :rule-id (optional) - the id of the rule that inserted the fact, + absent for root facts + - :bindings (optional) - the bindings used to insert the fact, + absent for root facts + - :fact-types - a sequence of fact types associated with the fact, + including ancestors" [session] - (let [{:keys [memory rulebase]} (eng/components session) + (let [{:keys [memory rulebase get-alphas-fn]} (eng/components session) + {:keys [fact-type-fn + ancestors-fn]} (meta get-alphas-fn) {:keys [production-nodes]} rulebase - root-facts (->> (mem/get-root-elements memory) - (map :fact))] - (->> (for [rule-node production-nodes - token (keys (mem/get-insertions-all memory rule-node)) - insertion-group (mem/get-insertions memory rule-node token) - insertion insertion-group] - insertion) - (concat root-facts)))) + root-facts (for [{:keys [fact]} (mem/get-root-elements memory) + :let [fact-type (fact-type-fn fact) + ancestors (ancestors-fn fact-type)]] + {:fact fact + :fact-types (cons fact-type ancestors)}) + rule-nodes (for [{:keys [id production]} production-nodes] + [id production]) + rule-facts (for [{:keys [id] :as rule-node} production-nodes + {:keys [bindings] :as token} (keys (mem/get-insertions-all memory rule-node)) + insertion-group (mem/get-insertions memory rule-node token) + fact insertion-group + :let [fact-type (fact-type-fn fact) + ancestors (ancestors-fn fact-type)]] + {:fact fact + :rule-id id + :bindings bindings + :fact-types (cons fact-type ancestors)})] + {:rules (into {} rule-nodes) + :facts (concat root-facts rule-facts)})) diff --git a/src/test/clojure/clara/tools/test_inspect.clj b/src/test/clojure/clara/tools/test_inspect.clj index e245c7df..4db752e8 100644 --- a/src/test/clojure/clara/tools/test_inspect.clj +++ b/src/test/clojure/clara/tools/test_inspect.clj @@ -1,6 +1,7 @@ (ns clara.tools.test-inspect (:require [clara.tools.testing-utils :as tu] - [clara.tools.inspect :refer [inspect map->Explanation + [clara.tools.inspect :refer [inspect inspect-facts + map->Explanation explain-activations with-full-logging without-full-logging @@ -48,7 +49,8 @@ (insert (->Temperature 90 "MCI")) (fire-rules)) - rule-dump (inspect session)] + rule-dump (inspect session) + rule-facts (inspect-facts session)] ;; Retrieve the tokens matching the cold query. This test validates ;; the tokens contain the expected matching conditions by retrieving @@ -65,6 +67,12 @@ (get-in rule-dump [:rule-matches hot-rule])) "Rule matches test") + (is (= [(->Temperature 15 "MCI") + (->Temperature 10 "MCI") + (->Temperature 90 "MCI")] + (get rule-dump :root-facts)) + "Root Facts test") + (is (= [{:explanation hot-rule-90-explanation :fact (map->Hot {:temperature 90})}] (get-in rule-dump [:insertions hot-rule])) @@ -75,6 +83,47 @@ (frequencies (get-in rule-dump [:condition-matches (first (:lhs cold-rule))]))) "Condition matches test") + (is (= [{:fact (->Temperature 15 "MCI") + :rule-id nil + :bindings nil + :fact-type Temperature + :ancestors true} + {:fact (->Temperature 10 "MCI") + :rule-id nil + :bindings nil + :fact-type Temperature + :ancestors true} + {:fact (->Temperature 90 "MCI") + :rule-id nil + :bindings nil + :fact-type Temperature + :ancestors true} + {:fact (->Cold :too-cold) + :rule-id 3 + :bindings {:?t 10} + :fact-type Cold + :ancestors true} + {:fact (->Cold :too-cold) + :rule-id 3 + :bindings {:?t 15} + :fact-type Cold + :ancestors true} + {:fact (->Hot 90) + :rule-id 5 + :bindings {:?t 90} + :fact-type Hot + :ancestors true}] + (->> (for [{:keys [fact + rule-id + bindings + fact-types]} (:facts rule-facts)] + {:fact fact + :rule-id rule-id + :bindings bindings + :fact-type (first fact-types) + :ancestors (boolean (seq (rest fact-types)))}))) + "Rule facts test") + ;; Test the :fact->explanations key in the inspected session data. (is (= {(map->Cold {:temperature :too-cold}) [{:explanation cold-rule-10-explanation :rule cold-rule} @@ -422,10 +471,9 @@ (defn original-constraints->constraints [condition-matches] (let [rename-fn (fn [form] - (if - (and - (map? form) - (contains? form :original-constraints)) + (if (and + (map? form) + (contains? form :original-constraints)) (-> form (dissoc :original-constraints) (assoc :constraints (:original-constraints form))) From 9576eafd2a348c70f265256f9c8625466e50eca8 Mon Sep 17 00:00:00 2001 From: Jose Gomez Date: Sat, 10 Jan 2026 22:45:28 -0600 Subject: [PATCH 3/9] feat: implement get-root-facts with fallback for old sessions --- src/main/clojure/clara/rules/memory.clj | 47 ++++++++++++-- src/main/clojure/clara/rules/platform.clj | 18 +++++- src/main/clojure/clara/tools/inspect.clj | 64 +++++++++---------- src/test/clojure/clara/tools/test_inspect.clj | 29 +++++++-- 4 files changed, 113 insertions(+), 45 deletions(-) diff --git a/src/main/clojure/clara/rules/memory.clj b/src/main/clojure/clara/rules/memory.clj index 3bdf430f..0d17d1be 100644 --- a/src/main/clojure/clara/rules/memory.clj +++ b/src/main/clojure/clara/rules/memory.clj @@ -1,7 +1,9 @@ (ns clara.rules.memory "This namespace is for internal use and may move in the future. Specification and default implementation of working memory" - (:require [ham-fisted.api :as hf] + (:require [clara.rules.platform :as platform] + [clojure.set :as set] + [ham-fisted.api :as hf] [ham-fisted.mut-map :as hm]) (:import [java.util Map @@ -11,6 +13,7 @@ NavigableMap PriorityQueue TreeMap] + [clara.rules.platform FactIdentityWrapper] [ham_fisted MutableMap])) (defprotocol IPersistentMemory @@ -21,7 +24,7 @@ (get-rulebase [memory]) ;; Returns the root elements in the working memory. - (get-root-elements [memory]) + (get-root-facts [memory]) ;; Returns the elements assoicated with the given node. (get-elements [memory node bindings]) @@ -457,6 +460,38 @@ ;;; Transient local memory implementation. Typically only persistent memory will be visible externally. +(defn- get-root-facts-impl + [{:keys [rulebase alpha-memory] :as memory}] + ;;; If there are any root elements at all then attempt to find them in the memory. + ;;; Old sessions may not have any root elements stored in memory when serialized. + (if (contains? alpha-memory 0) + (for [{:keys [fact]} (get-elements-all memory {:id 0})] + fact) + (let [{:keys [production-nodes query-nodes]} rulebase + ;;; Gather facts that were inserted by rules + rule-facts (for [rule-node production-nodes + match-token (keys (get-insertions-all memory rule-node)) + insertion-group (get-insertions memory rule-node match-token) + fact insertion-group] + (platform/fact-id-wrap fact)) + ;;; Gather facts that were matched by rules + rule-matches (for [rule-node production-nodes + {:keys [matches]} (keys (get-insertions-all memory rule-node)) + [fact] matches] + (platform/fact-id-wrap fact)) + ;;; Gather facts that were matched by queries + query-matches (for [rule-node (vals query-nodes) + {:keys [matches]} (get-tokens-all memory rule-node) + [fact] matches] + (platform/fact-id-wrap fact)) + ;;; Combine all gathered facts and remove duplicates, using their identity wrappers + unique-facts (set (concat rule-facts rule-matches query-matches)) + ;;; Root facts are those that are not derived from rules + root-facts (set/difference unique-facts (set rule-facts))] + ;;; Return the unwrapped root facts + (for [^FactIdentityWrapper wrapper root-facts] + (.wrapped wrapper))))) + (deftype TransientLocalMemory [rulebase activation-group-sort-fn activation-group-fn @@ -469,8 +504,8 @@ IMemoryReader (get-rulebase [memory] rulebase) - (get-root-elements [memory] - (get-elements-all memory {:id 0})) + (get-root-facts [memory] + (get-root-facts-impl memory)) (get-elements [memory node bindings] (get (get alpha-memory (:id node) {}) @@ -832,8 +867,8 @@ IMemoryReader (get-rulebase [memory] rulebase) - (get-root-elements [memory] - (get-elements-all memory {:id 0})) + (get-root-facts [memory] + (get-root-facts-impl memory)) (get-elements [memory node bindings] (get (get alpha-memory (:id node) {}) diff --git a/src/main/clojure/clara/rules/platform.clj b/src/main/clojure/clara/rules/platform.clj index eb4b11bd..ca96bdd8 100644 --- a/src/main/clojure/clara/rules/platform.clj +++ b/src/main/clojure/clara/rules/platform.clj @@ -47,10 +47,26 @@ (hashCode [this] hash-code)) (defn jeq-wrap - "wraps the value with a JavaEqualityWrapper" + "Wraps the value with a JavaEqualityWrapper" ^JavaEqualityWrapper [value] (JavaEqualityWrapper. value (hash value))) +;;; This class wraps objects to ensure identity semantics are used for equality +;;; and hash code. This is used in places where we want to distinguish between +;;; different instances of equal objects, such as when comparing facts. +;;; This class also accepts and stores the hash code, since it almost always will be used +;;; once and generally more than once. +(deftype FactIdentityWrapper [wrapped ^int hash-code] + Object + (equals [this other] + (identical? wrapped (.wrapped ^FactIdentityWrapper other))) + (hashCode [this] hash-code)) + +(defn fact-id-wrap + "Wraps the value with a FactIdentityWrapper" + ^FactIdentityWrapper [value] + (FactIdentityWrapper. value (hash value))) + (defn group-by-seq "Groups the items of the given coll by f to each item. Returns a seq of tuples of the form [f-val xs] where xs are items from the coll and f-val is the result of applying f to any of diff --git a/src/main/clojure/clara/tools/inspect.clj b/src/main/clojure/clara/tools/inspect.clj index 4058fc14..b532c86e 100644 --- a/src/main/clojure/clara/tools/inspect.clj +++ b/src/main/clojure/clara/tools/inspect.clj @@ -219,40 +219,38 @@ [session] (let [{:keys [memory rulebase]} (eng/components session) {:keys [production-nodes query-nodes id-to-node]} rulebase - ;; Map of queries to their nodes in the network. - query-to-nodes (into {} (for [[query-name query-node] query-nodes] - [(:query query-node) query-node])) - + query-to-nodes (->> (for [[query-name query-node] query-nodes] + [(:query query-node) query-node]) + (into {})) + query-matches (->> (for [[query query-node] query-to-nodes] + [query (to-explanations session + (mem/get-tokens-all memory query-node))]) + (into {})) ;; Map of rules to their nodes in the network. - rule-to-nodes (into {} (for [rule-node production-nodes] - [(:production rule-node) rule-node])) - - base-info {:rule-matches (into {} - (for [[rule rule-node] rule-to-nodes] - [rule (to-explanations session - (keys (mem/get-insertions-all memory rule-node)))])) - - :query-matches (into {} - (for [[query query-node] query-to-nodes] - [query (to-explanations session - (mem/get-tokens-all memory query-node))])) - - :condition-matches (get-condition-matches (vals id-to-node) memory) - - :root-facts (for [{:keys [fact]} (mem/get-root-elements memory)] - fact) - - :insertions (into {} - (for [[rule rule-node] rule-to-nodes] - [rule - (for [token (keys (mem/get-insertions-all memory rule-node)) - insertion-group (get (mem/get-insertions-all memory rule-node) token) - insertion insertion-group] - {:explanation (first (to-explanations session [token])) :fact insertion})])) - - :fact->explanations (into {} (gen-fact->explanations session))}] - + rule-to-nodes (->> (for [rule-node production-nodes] + [(:production rule-node) rule-node]) + (into {})) + rule-matches (->> (for [[rule rule-node] rule-to-nodes] + [rule (to-explanations session + (keys (mem/get-insertions-all memory rule-node)))]) + (into {})) + condition-matches (get-condition-matches (vals id-to-node) memory) + root-facts (mem/get-root-facts memory) + insertions (->> (for [[rule rule-node] rule-to-nodes] + [rule + (for [token (keys (mem/get-insertions-all memory rule-node)) + insertion-group (get (mem/get-insertions-all memory rule-node) token) + insertion insertion-group] + {:explanation (first (to-explanations session [token])) :fact insertion})]) + (into {})) + fact-explanations (into {} (gen-fact->explanations session)) + base-info {:rule-matches rule-matches + :query-matches query-matches + :condition-matches condition-matches + :root-facts root-facts + :insertions insertions + :fact->explanations fact-explanations}] (if-let [unfiltered-rule-matches (gen-all-rule-matches session)] (assoc base-info :unfiltered-rule-matches unfiltered-rule-matches) base-info))) @@ -383,7 +381,7 @@ {:keys [fact-type-fn ancestors-fn]} (meta get-alphas-fn) {:keys [production-nodes]} rulebase - root-facts (for [{:keys [fact]} (mem/get-root-elements memory) + root-facts (for [fact (mem/get-root-facts memory) :let [fact-type (fact-type-fn fact) ancestors (ancestors-fn fact-type)]] {:fact fact diff --git a/src/test/clojure/clara/tools/test_inspect.clj b/src/test/clojure/clara/tools/test_inspect.clj index 4db752e8..2ef5a3b4 100644 --- a/src/test/clojure/clara/tools/test_inspect.clj +++ b/src/test/clojure/clara/tools/test_inspect.clj @@ -7,6 +7,7 @@ without-full-logging node-fn-name->production-name]] [clara.rules :refer [insert fire-rules insert! insert-unconditional! retract query]] + [clara.rules.engine :as eng] [clara.rules.accumulators :as acc] [schema.test :as st] [clojure.walk :as w] @@ -48,8 +49,18 @@ (insert (->Temperature 10 "MCI")) (insert (->Temperature 90 "MCI")) (fire-rules)) - + session' (-> (eng/components session) + (update-in [:memory :alpha-memory] dissoc 0) + (eng/assemble)) + session-ro (-> (eng/components session) + (eng/assemble-read-only)) + session-ro' (-> (eng/components session) + (update-in [:memory :alpha-memory] dissoc 0) + (eng/assemble-read-only)) rule-dump (inspect session) + rule-dump' (inspect session') + rule-dump-ro (inspect session-ro) + rule-dump-ro' (inspect session-ro') rule-facts (inspect-facts session)] ;; Retrieve the tokens matching the cold query. This test validates @@ -67,10 +78,18 @@ (get-in rule-dump [:rule-matches hot-rule])) "Rule matches test") - (is (= [(->Temperature 15 "MCI") - (->Temperature 10 "MCI") - (->Temperature 90 "MCI")] - (get rule-dump :root-facts)) + (is (= (sort-by :temperature + [(->Temperature 15 "MCI") + (->Temperature 10 "MCI") + (->Temperature 90 "MCI")]) + (sort-by :temperature + (get rule-dump :root-facts)) + (sort-by :temperature + (get rule-dump' :root-facts)) + (sort-by :temperature + (get rule-dump-ro :root-facts)) + (sort-by :temperature + (get rule-dump-ro' :root-facts))) "Root Facts test") (is (= [{:explanation hot-rule-90-explanation From 890647d7daa19398a6c5ee19ccfa7fcdca4ea61b Mon Sep 17 00:00:00 2001 From: Jose Gomez Date: Sat, 10 Jan 2026 22:54:06 -0600 Subject: [PATCH 4/9] chore: replace some magical ids with constant for ROOT_NODE_ID --- src/main/clojure/clara/rules/memory.clj | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/clojure/clara/rules/memory.clj b/src/main/clojure/clara/rules/memory.clj index 0d17d1be..74ad05fb 100644 --- a/src/main/clojure/clara/rules/memory.clj +++ b/src/main/clojure/clara/rules/memory.clj @@ -126,6 +126,8 @@ ;; Converts the transient memory to persistent form. (to-persistent! [memory])) +(def ^:private ROOT_NODE_ID 0) + (defn- coll-empty? "Returns true if the collection is empty. Does not call seq due to avoid overhead that may cause for non-persistent collection types, e.g. @@ -464,8 +466,11 @@ [{:keys [rulebase alpha-memory] :as memory}] ;;; If there are any root elements at all then attempt to find them in the memory. ;;; Old sessions may not have any root elements stored in memory when serialized. - (if (contains? alpha-memory 0) - (for [{:keys [fact]} (get-elements-all memory {:id 0})] + (if (contains? alpha-memory ROOT_NODE_ID) + (for [{:keys [fact]} (sequence + cat + (vals + (get alpha-memory ROOT_NODE_ID {})))] fact) (let [{:keys [production-nodes query-nodes]} rulebase ;;; Gather facts that were inserted by rules @@ -565,10 +570,10 @@ ITransientMemory (add-root-elements! [memory elements] - (add-elements! memory {:id 0} {} elements)) + (add-elements! memory {:id ROOT_NODE_ID} {} elements)) (remove-root-elements! [memory elements] - (remove-elements! memory {:id 0} {} elements)) + (remove-elements! memory {:id ROOT_NODE_ID} {} elements)) (add-elements! [memory node join-bindings elements] (hm/compute! alpha-memory (:id node) From f832a6afafbfbbbded74c34bc7399ba248342e76 Mon Sep 17 00:00:00 2001 From: Jose Gomez Date: Sat, 10 Jan 2026 23:42:24 -0600 Subject: [PATCH 5/9] feat: implement get root facts again using memory but only unmatched --- dev/user.clj | 88 ++++++++++++++++--- src/main/clojure/clara/rules/compiler.clj | 42 +++++---- src/main/clojure/clara/rules/engine.clj | 69 ++++++++------- src/main/clojure/clara/rules/memory.clj | 71 +++------------ src/main/clojure/clara/tools/inspect.clj | 53 ++++++++++- src/test/clojure/clara/tools/test_inspect.clj | 7 +- 6 files changed, 204 insertions(+), 126 deletions(-) diff --git a/dev/user.clj b/dev/user.clj index e2133680..38113d27 100644 --- a/dev/user.clj +++ b/dev/user.clj @@ -9,6 +9,9 @@ query mk-session clear-ns-vars!]] + [clara.rules.accumulators :as acc] + [clara.rules.engine :as eng] + [clara.tools.inspect :as inspect] [clojure.core.cache.wrapped :as cache])) (comment @@ -26,24 +29,85 @@ (insert! {:type :thing/result :value ?value})) +(defrule return-a-thang + [?thang <- :thang/that [{:keys [value]}] (= value ?value)] + [?thing <- :thing/that [{:keys [value]}] (= value ?value)] + [?zulu <- (acc/all) :from [:zulu/that]] + [:test (and (some? ?thing) + (some? ?thang) + (some? ?zulu))] + => + (insert! {:type :thang/result + :value ?value})) + (defquery query-a-thing [] [?output <- :thing/result]) -(defrule default-data - (insert-all! - [{:type :thing/foo - :value 1} - {:type :thing/bar - :value 2} - {:type :thing/bar - :value 3}])) +(defquery query-a-thang + [] + [?output <- :thang/result]) (comment - (time - (-> (mk-session 'user :fact-type-fn :type) - (fire-rules) - (query query-a-thing)))) + (do + (def facts1 + [{:type :thing/foo + :value 1} + {:type :thing/bar + :value 2} + {:type :thang/that + :value 3} + {:type :zulu/that + :value 4} + {:type :zulu/that + :value 5} + {:type :unmatched-it + :value 100}]) + + (def facts2 + [{:type :thing/that + :value 3}]) + + (def session + (-> (mk-session 'user :fact-type-fn :type) + (insert-all facts1) + (fire-rules) + (insert-all facts2) + (fire-rules))) + + (def components + (eng/components session)) + + (def get-alphas-fn + (-> components :rulebase :get-alphas-fn))) + + (-> components :memory keys) + + (->> components :memory :alpha-memory + vals + (mapcat vals) + (mapcat identity) + (map :fact)) + (->> components :memory :beta-memory + vals + (mapcat vals) + (mapcat identity) + (mapcat :matches) + (map first)) + (->> components :memory :accum-memory + vals + (mapcat vals) + (mapcat vals) + (mapcat first)) + (-> components :memory :production-memory) + + (get-alphas-fn facts1) + (get-alphas-fn facts2) + + (query session query-a-thing) + (query session query-a-thang) + + (inspect/inspect-facts session)) (def session-cache (cache/lru-cache-factory {})) diff --git a/src/main/clojure/clara/rules/compiler.clj b/src/main/clojure/clara/rules/compiler.clj index 22728383..7bda65d3 100644 --- a/src/main/clojure/clara/rules/compiler.clj +++ b/src/main/clojure/clara/rules/compiler.clj @@ -1889,9 +1889,10 @@ (defn create-get-alphas-fn "Returns a function that given a sequence of facts, - returns a map associating alpha nodes with the facts they accept." + returns a tuple of:: + - a list of tuples with each containing alpha nodes and the facts they accept. + - a list of facts that did not match any alpha nodes." [fact-type-fn ancestors-fn alpha-roots] - (let [;; If a customized fact-type-fn is provided, ;; we must use a specialized grouping function ;; that handles internal control types that may not @@ -1945,27 +1946,34 @@ :ancestors-fn wrapped-ancestors-fn} (fn do-get-alphas [facts] - (let [roots->facts (java.util.LinkedHashMap.)] - (doseq [fact facts - roots-group (fact-type->roots (wrapped-fact-type-fn fact))] - (update-roots->facts! roots->facts roots-group fact)) - - (let [return-list (hf/mut-list) + (let [roots->facts (java.util.LinkedHashMap.) + unmatched-facts (hf/mut-list)] + (doseq [fact facts] + ;;; For each fact, find the matching alpha roots based on its type and ancestors. + (if-let [match-roots (seq (fact-type->roots (wrapped-fact-type-fn fact)))] + (doseq [roots-group match-roots] + ;;; Update the map of roots to facts + (update-roots->facts! roots->facts roots-group fact)) + ;;; No matching roots, add to orphans + (.add unmatched-facts fact))) + + (let [matched-alphas (hf/mut-list) entries (.entrySet roots->facts) entries-it (.iterator entries)] - ;; We iterate over the LinkedHashMap manually to avoid potential issues described at http://dev.clojure.org/jira/browse/CLJ-1738 - ;; where a Java iterator can return the same entry object repeatedly and mutate it after each next() call. We use mutable lists - ;; for performance but wrap them in unmodifiableList to make it clear that the caller is not expected to mutate these lists. - ;; Since after this function returns the only reference to the fact lists will be through the unmodifiedList we can depend elsewhere - ;; on these lists not changing. Since the only expected workflow with these lists is to loop through them, not add or remove elements, - ;; we don't gain much from using a transient (which can be efficiently converted to a persistent data structure) rather than a mutable type. + ;; We iterate over the LinkedHashMap manually to avoid potential issues described at http://dev.clojure.org/jira/browse/CLJ-1738 + ;; where a Java iterator can return the same entry object repeatedly and mutate it after each next() call. We use mutable lists + ;; for performance but wrap them in unmodifiableList to make it clear that the caller is not expected to mutate these lists. + ;; Since after this function returns the only reference to the fact lists will be through the unmodifiedList we can depend elsewhere + ;; on these lists not changing. Since the only expected workflow with these lists is to loop through them, not add or remove elements, + ;; we don't gain much from using a transient (which can be efficiently converted to a persistent data structure) + ;; rather than a mutable type. (loop [] (when (.hasNext entries-it) (let [^java.util.Map$Entry e (.next entries-it)] - (.add return-list [(-> e ^AlphaRootsWrapper (.getKey) (.wrapped)) - (hf/persistent! (.getValue e))]) + (.add matched-alphas [(-> e ^AlphaRootsWrapper (.getKey) (.wrapped)) + (hf/persistent! (.getValue e))]) (recur)))) - (hf/persistent! return-list)))))) + [(hf/persistent! matched-alphas) (hf/persistent! unmatched-facts)]))))) (defn create-ancestors-fn [{:keys [ancestors-fn diff --git a/src/main/clojure/clara/rules/engine.clj b/src/main/clojure/clara/rules/engine.clj index 5b271ccc..a597fd84 100644 --- a/src/main/clojure/clara/rules/engine.clj +++ b/src/main/clojure/clara/rules/engine.clj @@ -7,6 +7,7 @@ [clara.rules.platform :as platform] [clara.rules.update-cache.core :as uc] [clara.rules.update-cache.cancelling :as ca] + [ham-fisted.api :as hf] [futurama.core :refer [async async? async-cancelled? @@ -236,13 +237,13 @@ This is similar to the function of the pending-updates in the fire-rules* loop." [get-alphas-fn memory transport listener] (loop [] - (let [retractions (deref *pending-external-retractions*) - ;; We have already obtained a direct reference to the facts to be - ;; retracted in this iteration of the loop outside the cache. Now reset - ;; the cache. The retractions we execute may cause new retractions to be queued - ;; up, in which case the loop will execute again. - _ (reset! *pending-external-retractions* [])] - (doseq [[alpha-roots fact-group] (get-alphas-fn retractions) + (let [retractions (deref *pending-external-retractions*)] + ;; We have already obtained a direct reference to the facts to be + ;; retracted in this iteration of the loop outside the cache. Now reset + ;; the cache. The retractions we execute may cause new retractions to be queued + ;; up, in which case the loop will execute again. + (reset! *pending-external-retractions* []) + (doseq [[alpha-roots fact-group] (first (get-alphas-fn retractions)) root alpha-roots] (alpha-retract root fact-group memory transport listener)) (when (-> *pending-external-retractions* deref not-empty) @@ -256,7 +257,7 @@ (throw (ex-info "session pending updates missing:" {:session current-session :label label}))) (letfn [(flush-all [current-session flushed-items?] - (let [{:keys [rulebase transient-memory transport insertions get-alphas-fn listener]} current-session + (let [{:keys [transient-memory transport get-alphas-fn listener]} current-session pending-updates (-> current-session :pending-updates uc/get-updates-and-reset!)] (if (empty? pending-updates) @@ -264,7 +265,7 @@ (do (doseq [partition pending-updates :let [facts (mapcat :facts partition)] - [alpha-roots fact-group] (get-alphas-fn facts) + [alpha-roots fact-group] (first (get-alphas-fn facts)) root alpha-roots] (if (= :insert (:type (first partition))) @@ -299,7 +300,7 @@ This should only be used for facts explicitly retracted in a RHS. It should not be used for retractions that occur as part of automatic truth maintenance." [facts] - (let [{:keys [rulebase transient-memory transport insertions get-alphas-fn listener]} *current-session* + (let [{:keys [transient-memory transport insertions get-alphas-fn listener]} *current-session* {:keys [node token]} *rule-context*] ;; Update the count so the rule engine will know when we have normalized. (swap! insertions + (count facts)) @@ -307,7 +308,7 @@ (when listener (l/retract-facts! listener node token facts)) - (doseq [[alpha-roots fact-group] (get-alphas-fn facts) + (doseq [[alpha-roots fact-group] (first (get-alphas-fn facts)) root alpha-roots] (alpha-retract root fact-group transient-memory transport listener)))) @@ -316,7 +317,7 @@ "Perform the actual fact insertion, optionally making them unconditional. This should only be called once per rule activation for logical insertions." [facts unconditional] - (let [{:keys [rulebase transient-memory transport insertions get-alphas-fn listener]} *current-session* + (let [{:keys [transient-memory insertions listener]} *current-session* {:keys [node token]} *rule-context*] ;; Update the insertion count. @@ -1977,21 +1978,23 @@ :insert (do (l/insert-facts! listener nil nil facts) - (mem/add-root-elements! memory (map ->RootElement facts)) (binding [*pending-external-retractions* (atom [])] ;; Bind the external retractions cache so that any logical retractions as a result ;; of these insertions can be cached and executed as a batch instead of eagerly realizing ;; them. An external insertion of a fact that matches ;; a negation or accumulator condition can cause logical retractions. - (doseq [[alpha-roots fact-group] (get-alphas-fn facts) - root alpha-roots] - (alpha-activate root fact-group memory transport listener)) + (let [[matched-alphas unmatched-facts] (get-alphas-fn facts)] + (doseq [[alpha-roots fact-group] matched-alphas + root alpha-roots] + (alpha-activate root fact-group memory transport listener)) + (when (seq unmatched-facts) + (mem/add-elements! memory mem/ROOT_NODE {} + (map ->RootElement unmatched-facts)))) (external-retract-loop get-alphas-fn memory transport listener))) :retract (do (l/retract-facts! listener nil nil facts) - (mem/remove-root-elements! memory (map ->RootElement facts)) (binding [*pending-external-retractions* (atom facts)] (external-retract-loop get-alphas-fn memory transport listener))))) @@ -2009,20 +2012,24 @@ (= (:type pending-op) :retract))) (mapcat :facts)) - pending-operations)] + pending-operations) + [matched-alphas unmatched-facts] (get-alphas-fn insertions)] ;; Insertions should come before retractions so that if we insert and then retract the same ;; fact that is not already in the session the end result will be that the session won't have that fact. ;; If retractions came first then we'd first retract a fact that isn't in the session, which doesn't do anything, ;; and then later we would insert the fact. - (mem/add-root-elements! memory (map ->RootElement insertions)) - (doseq [[alpha-roots fact-group] (get-alphas-fn insertions) + (doseq [[alpha-roots fact-group] matched-alphas root alpha-roots] (alpha-activate root fact-group memory transport listener)) - (mem/remove-root-elements! memory (map ->RootElement retractions)) - (doseq [[alpha-roots fact-group] (get-alphas-fn retractions) + (when (seq unmatched-facts) + (mem/add-elements! memory mem/ROOT_NODE {} + (map ->RootElement unmatched-facts))) + + (doseq [[alpha-roots fact-group] (first (get-alphas-fn retractions)) root alpha-roots] (alpha-retract root fact-group memory transport listener)) + (fire-rules-handler session opts)))))) (defn- query* @@ -2094,11 +2101,10 @@ (fire-rules [session opts] (let [transient-memory (mem/to-transient memory) transient-listener (l/to-transient listener)] - (fire-rules* - rulebase transient-memory transport - transient-listener get-alphas-fn - pending-operations opts - fire-rules!) + (fire-rules* rulebase transient-memory transport + transient-listener get-alphas-fn + pending-operations opts + fire-rules!) (->LocalSession rulebase (mem/to-persistent! transient-memory) transport @@ -2112,11 +2118,10 @@ (async (let [transient-memory (mem/to-transient memory) transient-listener (l/to-transient listener)] - (LocalSession rulebase (mem/to-persistent! transient-memory) transport diff --git a/src/main/clojure/clara/rules/memory.clj b/src/main/clojure/clara/rules/memory.clj index 74ad05fb..bb7d00e7 100644 --- a/src/main/clojure/clara/rules/memory.clj +++ b/src/main/clojure/clara/rules/memory.clj @@ -1,9 +1,7 @@ (ns clara.rules.memory "This namespace is for internal use and may move in the future. Specification and default implementation of working memory" - (:require [clara.rules.platform :as platform] - [clojure.set :as set] - [ham-fisted.api :as hf] + (:require [ham-fisted.api :as hf] [ham-fisted.mut-map :as hm]) (:import [java.util Map @@ -13,7 +11,6 @@ NavigableMap PriorityQueue TreeMap] - [clara.rules.platform FactIdentityWrapper] [ham_fisted MutableMap])) (defprotocol IPersistentMemory @@ -23,8 +20,9 @@ ;; Returns the rulebase associated with the given memory. (get-rulebase [memory]) - ;; Returns the root elements in the working memory. - (get-root-facts [memory]) + ;; Returns the unmatched root element facts in the working memory, + ;; useful to get facts that were inserted but did not match any alpha roots. + (get-unmatched-root-facts [memory]) ;; Returns the elements assoicated with the given node. (get-elements [memory node bindings]) @@ -68,12 +66,6 @@ (defprotocol ITransientMemory - ;; Adds working memory elements to the given working memory at the root node. - (add-root-elements! [memory elements]) - - ;; Removes working memory elements from the given working memory at the root node. - (remove-root-elements! [memory elements]) - ;; Adds working memory elements to the given working memory at the given node. (add-elements! [memory node join-bindings elements]) @@ -126,7 +118,8 @@ ;; Converts the transient memory to persistent form. (to-persistent! [memory])) -(def ^:private ROOT_NODE_ID 0) +(def ROOT_NODE_ID 0) +(def ROOT_NODE {:id ROOT_NODE_ID}) (defn- coll-empty? "Returns true if the collection is empty. Does not call seq due to avoid @@ -461,42 +454,6 @@ (declare ->PersistentLocalMemory) ;;; Transient local memory implementation. Typically only persistent memory will be visible externally. - -(defn- get-root-facts-impl - [{:keys [rulebase alpha-memory] :as memory}] - ;;; If there are any root elements at all then attempt to find them in the memory. - ;;; Old sessions may not have any root elements stored in memory when serialized. - (if (contains? alpha-memory ROOT_NODE_ID) - (for [{:keys [fact]} (sequence - cat - (vals - (get alpha-memory ROOT_NODE_ID {})))] - fact) - (let [{:keys [production-nodes query-nodes]} rulebase - ;;; Gather facts that were inserted by rules - rule-facts (for [rule-node production-nodes - match-token (keys (get-insertions-all memory rule-node)) - insertion-group (get-insertions memory rule-node match-token) - fact insertion-group] - (platform/fact-id-wrap fact)) - ;;; Gather facts that were matched by rules - rule-matches (for [rule-node production-nodes - {:keys [matches]} (keys (get-insertions-all memory rule-node)) - [fact] matches] - (platform/fact-id-wrap fact)) - ;;; Gather facts that were matched by queries - query-matches (for [rule-node (vals query-nodes) - {:keys [matches]} (get-tokens-all memory rule-node) - [fact] matches] - (platform/fact-id-wrap fact)) - ;;; Combine all gathered facts and remove duplicates, using their identity wrappers - unique-facts (set (concat rule-facts rule-matches query-matches)) - ;;; Root facts are those that are not derived from rules - root-facts (set/difference unique-facts (set rule-facts))] - ;;; Return the unwrapped root facts - (for [^FactIdentityWrapper wrapper root-facts] - (.wrapped wrapper))))) - (deftype TransientLocalMemory [rulebase activation-group-sort-fn activation-group-fn @@ -509,8 +466,9 @@ IMemoryReader (get-rulebase [memory] rulebase) - (get-root-facts [memory] - (get-root-facts-impl memory)) + (get-unmatched-root-facts [memory] + (->> (get-elements-all memory ROOT_NODE) + (map :fact))) (get-elements [memory node bindings] (get (get alpha-memory (:id node) {}) @@ -569,12 +527,6 @@ (vals activation-map))) ITransientMemory - (add-root-elements! [memory elements] - (add-elements! memory {:id ROOT_NODE_ID} {} elements)) - - (remove-root-elements! [memory elements] - (remove-elements! memory {:id ROOT_NODE_ID} {} elements)) - (add-elements! [memory node join-bindings elements] (hm/compute! alpha-memory (:id node) (fn do-add-bem @@ -872,8 +824,9 @@ IMemoryReader (get-rulebase [memory] rulebase) - (get-root-facts [memory] - (get-root-facts-impl memory)) + (get-unmatched-root-facts [memory] + (->> (get-elements-all memory ROOT_NODE) + (map :fact))) (get-elements [memory node bindings] (get (get alpha-memory (:id node) {}) diff --git a/src/main/clojure/clara/tools/inspect.clj b/src/main/clojure/clara/tools/inspect.clj index b532c86e..8c63e3aa 100644 --- a/src/main/clojure/clara/tools/inspect.clj +++ b/src/main/clojure/clara/tools/inspect.clj @@ -7,6 +7,8 @@ (:require [clara.rules.engine :as eng] [clara.rules.schema :as schema] [clara.rules.memory :as mem] + [clara.rules.platform :as platform] + [clojure.set :as set] [clara.tools.internal.inspect :as i] [clojure.main :refer [demunge]] [schema.core :as s] @@ -17,7 +19,8 @@ HashJoinNode ExpressionJoinNode NegationNode - NegationWithJoinFilterNode])) + NegationWithJoinFilterNode] + [clara.rules.platform FactIdentityWrapper])) (s/defschema ConditionMatch "A structure associating a condition with the facts that matched them. The fields are: @@ -167,6 +170,50 @@ {insertion [{:rule rule :explanation (first (to-explanations session [token]))}]})))) +(defn get-root-facts + "Returns all root facts in the session that were not derived from rules." + [session] + ;;; If there are any root elements at all then attempt to find them in the memory. + ;;; Old sessions may not have any root elements stored in memory when serialized. + (let [{:keys [rulebase memory pending-operations]} (eng/components session) + {:keys [alpha-memory beta-memory accum-memory]} memory + {:keys [production-nodes]} rulebase + pending-facts (->> (group-by :type pending-operations) + (:insert) + (mapcat :facts) + (map platform/fact-id-wrap)) + ;;; Gather facts that were inserted by rules + rule-facts (for [rule-node production-nodes + match-token (keys (mem/get-insertions-all memory rule-node)) + insertion-group (mem/get-insertions memory rule-node match-token) + fact insertion-group] + (platform/fact-id-wrap fact)) + ;;; Gather facts from alpha memory + alpha-facts (->> (vals alpha-memory) + (mapcat vals) + (mapcat identity) + (map :fact) + (map platform/fact-id-wrap)) + ;;; Gather facts from beta memory + beta-facts (->> (vals beta-memory) + (mapcat vals) + (mapcat identity) + (mapcat :matches) + (map first) + (map platform/fact-id-wrap)) + accum-facts (->> (vals accum-memory) + (mapcat vals) + (mapcat vals) + (mapcat first) + (map platform/fact-id-wrap)) + ;;; Combine all gathered facts and remove duplicates, using their identity wrappers + unique-facts (set (concat pending-facts rule-facts alpha-facts beta-facts accum-facts)) + ;;; Root facts are those that are not derived from rules + root-facts (set/difference unique-facts (set rule-facts))] + ;;; Return the unwrapped root facts + (for [^FactIdentityWrapper wrapper root-facts] + (.wrapped wrapper)))) + (def ^{:doc "Return a new session on which information will be gathered for optional inspection keys. This can significantly increase memory consumption since retracted facts cannot be garbage collected as normally."} @@ -236,7 +283,7 @@ (keys (mem/get-insertions-all memory rule-node)))]) (into {})) condition-matches (get-condition-matches (vals id-to-node) memory) - root-facts (mem/get-root-facts memory) + root-facts (get-root-facts session) insertions (->> (for [[rule rule-node] rule-to-nodes] [rule (for [token (keys (mem/get-insertions-all memory rule-node)) @@ -381,7 +428,7 @@ {:keys [fact-type-fn ancestors-fn]} (meta get-alphas-fn) {:keys [production-nodes]} rulebase - root-facts (for [fact (mem/get-root-facts memory) + root-facts (for [fact (get-root-facts session) :let [fact-type (fact-type-fn fact) ancestors (ancestors-fn fact-type)]] {:fact fact diff --git a/src/test/clojure/clara/tools/test_inspect.clj b/src/test/clojure/clara/tools/test_inspect.clj index 2ef5a3b4..b5646b18 100644 --- a/src/test/clojure/clara/tools/test_inspect.clj +++ b/src/test/clojure/clara/tools/test_inspect.clj @@ -102,12 +102,12 @@ (frequencies (get-in rule-dump [:condition-matches (first (:lhs cold-rule))]))) "Condition matches test") - (is (= [{:fact (->Temperature 15 "MCI") + (is (= [{:fact (->Temperature 10 "MCI") :rule-id nil :bindings nil :fact-type Temperature :ancestors true} - {:fact (->Temperature 10 "MCI") + {:fact (->Temperature 15 "MCI") :rule-id nil :bindings nil :fact-type Temperature @@ -140,7 +140,8 @@ :rule-id rule-id :bindings bindings :fact-type (first fact-types) - :ancestors (boolean (seq (rest fact-types)))}))) + :ancestors (boolean (seq (rest fact-types)))}) + (sort-by (comp (juxt :rule-id (comp :temperature :fact)))))) "Rule facts test") ;; Test the :fact->explanations key in the inspected session data. From 4e198c89f68609901da2885dd70f33c0f96eee74 Mon Sep 17 00:00:00 2001 From: Jose Gomez Date: Mon, 12 Jan 2026 10:11:02 -0600 Subject: [PATCH 6/9] chore: remove unused get-unmatched-root-facts --- src/main/clojure/clara/rules/memory.clj | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/main/clojure/clara/rules/memory.clj b/src/main/clojure/clara/rules/memory.clj index bb7d00e7..eab66821 100644 --- a/src/main/clojure/clara/rules/memory.clj +++ b/src/main/clojure/clara/rules/memory.clj @@ -20,10 +20,6 @@ ;; Returns the rulebase associated with the given memory. (get-rulebase [memory]) - ;; Returns the unmatched root element facts in the working memory, - ;; useful to get facts that were inserted but did not match any alpha roots. - (get-unmatched-root-facts [memory]) - ;; Returns the elements assoicated with the given node. (get-elements [memory node bindings]) @@ -466,10 +462,6 @@ IMemoryReader (get-rulebase [memory] rulebase) - (get-unmatched-root-facts [memory] - (->> (get-elements-all memory ROOT_NODE) - (map :fact))) - (get-elements [memory node bindings] (get (get alpha-memory (:id node) {}) bindings @@ -824,10 +816,6 @@ IMemoryReader (get-rulebase [memory] rulebase) - (get-unmatched-root-facts [memory] - (->> (get-elements-all memory ROOT_NODE) - (map :fact))) - (get-elements [memory node bindings] (get (get alpha-memory (:id node) {}) bindings From 4ff981db39ae72e8c843fd2e384ff1c7ecaeccb9 Mon Sep 17 00:00:00 2001 From: Jose Gomez Date: Mon, 12 Jan 2026 10:13:22 -0600 Subject: [PATCH 7/9] chore: cleanup unused namespace require --- src/main/clojure/clara/rules/compiler.clj | 2 +- src/main/clojure/clara/rules/engine.clj | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/clojure/clara/rules/compiler.clj b/src/main/clojure/clara/rules/compiler.clj index 7bda65d3..bc55127b 100644 --- a/src/main/clojure/clara/rules/compiler.clj +++ b/src/main/clojure/clara/rules/compiler.clj @@ -1889,7 +1889,7 @@ (defn create-get-alphas-fn "Returns a function that given a sequence of facts, - returns a tuple of:: + returns a tuple of: - a list of tuples with each containing alpha nodes and the facts they accept. - a list of facts that did not match any alpha nodes." [fact-type-fn ancestors-fn alpha-roots] diff --git a/src/main/clojure/clara/rules/engine.clj b/src/main/clojure/clara/rules/engine.clj index a597fd84..6943c1da 100644 --- a/src/main/clojure/clara/rules/engine.clj +++ b/src/main/clojure/clara/rules/engine.clj @@ -7,7 +7,6 @@ [clara.rules.platform :as platform] [clara.rules.update-cache.core :as uc] [clara.rules.update-cache.cancelling :as ca] - [ham-fisted.api :as hf] [futurama.core :refer [async async? async-cancelled? From 68cb0673ee4d21fdcf65adaebd62e72e28f984a3 Mon Sep 17 00:00:00 2001 From: Jose Gomez Date: Mon, 12 Jan 2026 10:51:08 -0600 Subject: [PATCH 8/9] feat: simplify get-alphas implementation and add/remove root facts --- src/main/clojure/clara/rules/compiler.clj | 22 +++++------- src/main/clojure/clara/rules/engine.clj | 44 ++++++++++++++--------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/src/main/clojure/clara/rules/compiler.clj b/src/main/clojure/clara/rules/compiler.clj index bc55127b..d7ed408b 100644 --- a/src/main/clojure/clara/rules/compiler.clj +++ b/src/main/clojure/clara/rules/compiler.clj @@ -1889,9 +1889,7 @@ (defn create-get-alphas-fn "Returns a function that given a sequence of facts, - returns a tuple of: - - a list of tuples with each containing alpha nodes and the facts they accept. - - a list of facts that did not match any alpha nodes." + returns a list of tuples with each containing alpha nodes and the facts they accept." [fact-type-fn ancestors-fn alpha-roots] (let [;; If a customized fact-type-fn is provided, ;; we must use a specialized grouping function @@ -1946,16 +1944,12 @@ :ancestors-fn wrapped-ancestors-fn} (fn do-get-alphas [facts] - (let [roots->facts (java.util.LinkedHashMap.) - unmatched-facts (hf/mut-list)] - (doseq [fact facts] - ;;; For each fact, find the matching alpha roots based on its type and ancestors. - (if-let [match-roots (seq (fact-type->roots (wrapped-fact-type-fn fact)))] - (doseq [roots-group match-roots] - ;;; Update the map of roots to facts - (update-roots->facts! roots->facts roots-group fact)) - ;;; No matching roots, add to orphans - (.add unmatched-facts fact))) + (let [roots->facts (java.util.LinkedHashMap.)] + (doseq [fact facts + ;;; For each fact, find the matching alpha roots based on its type and ancestors. + roots-group (fact-type->roots (wrapped-fact-type-fn fact))] + ;;; Update the map of roots to facts + (update-roots->facts! roots->facts roots-group fact)) (let [matched-alphas (hf/mut-list) entries (.entrySet roots->facts) @@ -1973,7 +1967,7 @@ (.add matched-alphas [(-> e ^AlphaRootsWrapper (.getKey) (.wrapped)) (hf/persistent! (.getValue e))]) (recur)))) - [(hf/persistent! matched-alphas) (hf/persistent! unmatched-facts)]))))) + (hf/persistent! matched-alphas)))))) (defn create-ancestors-fn [{:keys [ancestors-fn diff --git a/src/main/clojure/clara/rules/engine.clj b/src/main/clojure/clara/rules/engine.clj index 6943c1da..b07b8c50 100644 --- a/src/main/clojure/clara/rules/engine.clj +++ b/src/main/clojure/clara/rules/engine.clj @@ -242,7 +242,7 @@ ;; the cache. The retractions we execute may cause new retractions to be queued ;; up, in which case the loop will execute again. (reset! *pending-external-retractions* []) - (doseq [[alpha-roots fact-group] (first (get-alphas-fn retractions)) + (doseq [[alpha-roots fact-group] (get-alphas-fn retractions) root alpha-roots] (alpha-retract root fact-group memory transport listener)) (when (-> *pending-external-retractions* deref not-empty) @@ -264,7 +264,7 @@ (do (doseq [partition pending-updates :let [facts (mapcat :facts partition)] - [alpha-roots fact-group] (first (get-alphas-fn facts)) + [alpha-roots fact-group] (get-alphas-fn facts) root alpha-roots] (if (= :insert (:type (first partition))) @@ -307,7 +307,7 @@ (when listener (l/retract-facts! listener node token facts)) - (doseq [[alpha-roots fact-group] (first (get-alphas-fn facts)) + (doseq [[alpha-roots fact-group] (get-alphas-fn facts) root alpha-roots] (alpha-retract root fact-group transient-memory transport listener)))) @@ -1977,23 +1977,29 @@ :insert (do (l/insert-facts! listener nil nil facts) + + (when (seq facts) + (mem/add-elements! memory mem/ROOT_NODE {} + (map ->RootElement facts))) + (binding [*pending-external-retractions* (atom [])] ;; Bind the external retractions cache so that any logical retractions as a result ;; of these insertions can be cached and executed as a batch instead of eagerly realizing ;; them. An external insertion of a fact that matches ;; a negation or accumulator condition can cause logical retractions. - (let [[matched-alphas unmatched-facts] (get-alphas-fn facts)] - (doseq [[alpha-roots fact-group] matched-alphas - root alpha-roots] - (alpha-activate root fact-group memory transport listener)) - (when (seq unmatched-facts) - (mem/add-elements! memory mem/ROOT_NODE {} - (map ->RootElement unmatched-facts)))) + (doseq [[alpha-roots fact-group] (get-alphas-fn facts) + root alpha-roots] + (alpha-activate root fact-group memory transport listener)) (external-retract-loop get-alphas-fn memory transport listener))) :retract (do (l/retract-facts! listener nil nil facts) + + (when (seq facts) + (mem/remove-elements! memory mem/ROOT_NODE {} + (map ->RootElement facts))) + (binding [*pending-external-retractions* (atom facts)] (external-retract-loop get-alphas-fn memory transport listener))))) @@ -2011,21 +2017,25 @@ (= (:type pending-op) :retract))) (mapcat :facts)) - pending-operations) - [matched-alphas unmatched-facts] (get-alphas-fn insertions)] + pending-operations)] ;; Insertions should come before retractions so that if we insert and then retract the same ;; fact that is not already in the session the end result will be that the session won't have that fact. ;; If retractions came first then we'd first retract a fact that isn't in the session, which doesn't do anything, ;; and then later we would insert the fact. - (doseq [[alpha-roots fact-group] matched-alphas + + (when (seq insertions) + (mem/add-elements! memory mem/ROOT_NODE {} + (map ->RootElement insertions))) + + (doseq [[alpha-roots fact-group] (get-alphas-fn insertions) root alpha-roots] (alpha-activate root fact-group memory transport listener)) - (when (seq unmatched-facts) - (mem/add-elements! memory mem/ROOT_NODE {} - (map ->RootElement unmatched-facts))) + (when (seq retractions) + (mem/remove-elements! memory mem/ROOT_NODE {} + (map ->RootElement retractions))) - (doseq [[alpha-roots fact-group] (first (get-alphas-fn retractions)) + (doseq [[alpha-roots fact-group] (get-alphas-fn retractions) root alpha-roots] (alpha-retract root fact-group memory transport listener)) From 1e53efb2c3d6167bd8d913394cbdecf19a1e8b31 Mon Sep 17 00:00:00 2001 From: Jose Gomez Date: Mon, 12 Jan 2026 17:01:19 -0600 Subject: [PATCH 9/9] chore: use identity hash code --- src/main/clojure/clara/rules/platform.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/clojure/clara/rules/platform.clj b/src/main/clojure/clara/rules/platform.clj index ca96bdd8..f116cf1d 100644 --- a/src/main/clojure/clara/rules/platform.clj +++ b/src/main/clojure/clara/rules/platform.clj @@ -65,7 +65,7 @@ (defn fact-id-wrap "Wraps the value with a FactIdentityWrapper" ^FactIdentityWrapper [value] - (FactIdentityWrapper. value (hash value))) + (FactIdentityWrapper. value (System/identityHashCode value))) (defn group-by-seq "Groups the items of the given coll by f to each item. Returns a seq of tuples of the form