r"""This is rule-based deduction system for SymPy The whole thing is split into two parts - rules compilation and preparation of tables - runtime inference For rule-based inference engines, the classical work is RETE algorithm [1], [2] Although we are not implementing it in full (or even significantly) it's still worth a read to understand the underlying ideas. In short, every rule in a system of rules is one of two forms: - atom -> ... (alpha rule) - And(atom1, atom2, ...) -> ... (beta rule) The major complexity is in efficient beta-rules processing and usually for an expert system a lot of effort goes into code that operates on beta-rules. Here we take minimalistic approach to get something usable first. - (preparation) of alpha- and beta- networks, everything except - (runtime) FactRules.deduce_all_facts _____________________________________ ( Kirr: I've never thought that doing ) ( logic stuff is that difficult... ) ------------------------------------- o ^__^ o (oo)\_______ (__)\ )\/\ ||----w | || || Some references on the topic ---------------------------- [1] https://en.wikipedia.org/wiki/Rete_algorithm [2] http://reports-archive.adm.cs.cmu.edu/anon/1995/CMU-CS-95-113.pdf https://en.wikipedia.org/wiki/Propositional_formula https://en.wikipedia.org/wiki/Inference_rule https://en.wikipedia.org/wiki/List_of_rules_of_inference """ from collections import defaultdict from typing import Iterator from .logic import Logic, And, Or, Not def _base_fact(atom): """Return the literal fact of an atom. Effectively, this merely strips the Not around a fact. """ if isinstance(atom, Not): return atom.arg else: return atom def _as_pair(atom): if isinstance(atom, Not): return (atom.arg, False) else: return (atom, True) # XXX this prepares forward-chaining rules for alpha-network def transitive_closure(implications): """ Computes the transitive closure of a list of implications Uses Warshall's algorithm, as described at http://www.cs.hope.edu/~cusack/Notes/Notes/DiscreteMath/Warshall.pdf. """ full_implications = set(implications) literals = set().union(*map(set, full_implications)) for k in literals: for i in literals: if (i, k) in full_implications: for j in literals: if (k, j) in full_implications: full_implications.add((i, j)) return full_implications def deduce_alpha_implications(implications): """deduce all implications Description by example ---------------------- given set of logic rules: a -> b b -> c we deduce all possible rules: a -> b, c b -> c implications: [] of (a,b) return: {} of a -> set([b, c, ...]) """ implications = implications + [(Not(j), Not(i)) for (i, j) in implications] res = defaultdict(set) full_implications = transitive_closure(implications) for a, b in full_implications: if a == b: continue # skip a->a cyclic input res[a].add(b) # Clean up tautologies and check consistency for a, impl in res.items(): impl.discard(a) na = Not(a) if na in impl: raise ValueError( 'implications are inconsistent: %s -> %s %s' % (a, na, impl)) return res def apply_beta_to_alpha_route(alpha_implications, beta_rules): """apply additional beta-rules (And conditions) to already-built alpha implication tables TODO: write about - static extension of alpha-chains - attaching refs to beta-nodes to alpha chains e.g. alpha_implications: a -> [b, !c, d] b -> [d] ... beta_rules: &(b,d) -> e then we'll extend a's rule to the following a -> [b, !c, d, e] """ x_impl = {} for x in alpha_implications.keys(): x_impl[x] = (set(alpha_implications[x]), []) for bcond, bimpl in beta_rules: for bk in bcond.args: if bk in x_impl: continue x_impl[bk] = (set(), []) # static extensions to alpha rules: # A: x -> a,b B: &(a,b) -> c ==> A: x -> a,b,c seen_static_extension = True while seen_static_extension: seen_static_extension = False for bcond, bimpl in beta_rules: if not isinstance(bcond, And): raise TypeError("Cond is not And") bargs = set(bcond.args) for x, (ximpls, bb) in x_impl.items(): x_all = ximpls | {x} # A: ... -> a B: &(...) -> a is non-informative if bimpl not in x_all and bargs.issubset(x_all): ximpls.add(bimpl) # we introduced new implication - now we have to restore # completeness of the whole set. bimpl_impl = x_impl.get(bimpl) if bimpl_impl is not None: ximpls |= bimpl_impl[0] seen_static_extension = True # attach beta-nodes which can be possibly triggered by an alpha-chain for bidx, (bcond, bimpl) in enumerate(beta_rules): bargs = set(bcond.args) for x, (ximpls, bb) in x_impl.items(): x_all = ximpls | {x} # A: ... -> a B: &(...) -> a (non-informative) if bimpl in x_all: continue # A: x -> a... B: &(!a,...) -> ... (will never trigger) # A: x -> a... B: &(...) -> !a (will never trigger) if any(Not(xi) in bargs or Not(xi) == bimpl for xi in x_all): continue if bargs & x_all: bb.append(bidx) return x_impl def rules_2prereq(rules): """build prerequisites table from rules Description by example ---------------------- given set of logic rules: a -> b, c b -> c we build prerequisites (from what points something can be deduced): b <- a c <- a, b rules: {} of a -> [b, c, ...] return: {} of c <- [a, b, ...] Note however, that this prerequisites may be *not* enough to prove a fact. An example is 'a -> b' rule, where prereq(a) is b, and prereq(b) is a. That's because a=T -> b=T, and b=F -> a=F, but a=F -> b=? """ prereq = defaultdict(set) for (a, _), impl in rules.items(): if isinstance(a, Not): a = a.args[0] for (i, _) in impl: if isinstance(i, Not): i = i.args[0] prereq[i].add(a) return prereq ################ # RULES PROVER # ################ class TautologyDetected(Exception): """(internal) Prover uses it for reporting detected tautology""" pass class Prover: """ai - prover of logic rules given a set of initial rules, Prover tries to prove all possible rules which follow from given premises. As a result proved_rules are always either in one of two forms: alpha or beta: Alpha rules ----------- This are rules of the form:: a -> b & c & d & ... Beta rules ---------- This are rules of the form:: &(a,b,...) -> c & d & ... i.e. beta rules are join conditions that say that something follows when *several* facts are true at the same time. """ def __init__(self): self.proved_rules = [] self._rules_seen = set() def split_alpha_beta(self): """split proved rules into alpha and beta chains""" rules_alpha = [] # a -> b rules_beta = [] # &(...) -> b for a, b in self.proved_rules: if isinstance(a, And): rules_beta.append((a, b)) else: rules_alpha.append((a, b)) return rules_alpha, rules_beta @property def rules_alpha(self): return self.split_alpha_beta()[0] @property def rules_beta(self): return self.split_alpha_beta()[1] def process_rule(self, a, b): """process a -> b rule""" # TODO write more? if (not a) or isinstance(b, bool): return if isinstance(a, bool): return if (a, b) in self._rules_seen: return else: self._rules_seen.add((a, b)) # this is the core of processing try: self._process_rule(a, b) except TautologyDetected: pass def _process_rule(self, a, b): # right part first # a -> b & c --> a -> b ; a -> c # (?) FIXME this is only correct when b & c != null ! if isinstance(b, And): sorted_bargs = sorted(b.args, key=str) for barg in sorted_bargs: self.process_rule(a, barg) # a -> b | c --> !b & !c -> !a # --> a & !b -> c # --> a & !c -> b elif isinstance(b, Or): sorted_bargs = sorted(b.args, key=str) # detect tautology first if not isinstance(a, Logic): # Atom # tautology: a -> a|c|... if a in sorted_bargs: raise TautologyDetected(a, b, 'a -> a|c|...') self.process_rule(And(*[Not(barg) for barg in b.args]), Not(a)) for bidx in range(len(sorted_bargs)): barg = sorted_bargs[bidx] brest = sorted_bargs[:bidx] + sorted_bargs[bidx + 1:] self.process_rule(And(a, Not(barg)), Or(*brest)) # left part # a & b -> c --> IRREDUCIBLE CASE -- WE STORE IT AS IS # (this will be the basis of beta-network) elif isinstance(a, And): sorted_aargs = sorted(a.args, key=str) if b in sorted_aargs: raise TautologyDetected(a, b, 'a & b -> a') self.proved_rules.append((a, b)) # XXX NOTE at present we ignore !c -> !a | !b elif isinstance(a, Or): sorted_aargs = sorted(a.args, key=str) if b in sorted_aargs: raise TautologyDetected(a, b, 'a | b -> a') for aarg in sorted_aargs: self.process_rule(aarg, b) else: # both `a` and `b` are atoms self.proved_rules.append((a, b)) # a -> b self.proved_rules.append((Not(b), Not(a))) # !b -> !a ######################################## class FactRules: """Rules that describe how to deduce facts in logic space When defined, these rules allow implications to quickly be determined for a set of facts. For this precomputed deduction tables are used. see `deduce_all_facts` (forward-chaining) Also it is possible to gather prerequisites for a fact, which is tried to be proven. (backward-chaining) Definition Syntax ----------------- a -> b -- a=T -> b=T (and automatically b=F -> a=F) a -> !b -- a=T -> b=F a == b -- a -> b & b -> a a -> b & c -- a=T -> b=T & c=T # TODO b | c Internals --------- .full_implications[k, v]: all the implications of fact k=v .beta_triggers[k, v]: beta rules that might be triggered when k=v .prereq -- {} k <- [] of k's prerequisites .defined_facts -- set of defined fact names """ def __init__(self, rules): """Compile rules into internal lookup tables""" if isinstance(rules, str): rules = rules.splitlines() # --- parse and process rules --- P = Prover() for rule in rules: # XXX `a` is hardcoded to be always atom a, op, b = rule.split(None, 2) a = Logic.fromstring(a) b = Logic.fromstring(b) if op == '->': P.process_rule(a, b) elif op == '==': P.process_rule(a, b) P.process_rule(b, a) else: raise ValueError('unknown op %r' % op) # --- build deduction networks --- self.beta_rules = [] for bcond, bimpl in P.rules_beta: self.beta_rules.append( ({_as_pair(a) for a in bcond.args}, _as_pair(bimpl))) # deduce alpha implications impl_a = deduce_alpha_implications(P.rules_alpha) # now: # - apply beta rules to alpha chains (static extension), and # - further associate beta rules to alpha chain (for inference # at runtime) impl_ab = apply_beta_to_alpha_route(impl_a, P.rules_beta) # extract defined fact names self.defined_facts = {_base_fact(k) for k in impl_ab.keys()} # build rels (forward chains) full_implications = defaultdict(set) beta_triggers = defaultdict(set) for k, (impl, betaidxs) in impl_ab.items(): full_implications[_as_pair(k)] = {_as_pair(i) for i in impl} beta_triggers[_as_pair(k)] = betaidxs self.full_implications = full_implications self.beta_triggers = beta_triggers # build prereq (backward chains) prereq = defaultdict(set) rel_prereq = rules_2prereq(full_implications) for k, pitems in rel_prereq.items(): prereq[k] |= pitems self.prereq = prereq def _to_python(self) -> str: """ Generate a string with plain python representation of the instance """ return '\n'.join(self.print_rules()) @classmethod def _from_python(cls, data : dict): """ Generate an instance from the plain python representation """ self = cls('') for key in ['full_implications', 'beta_triggers', 'prereq']: d=defaultdict(set) d.update(data[key]) setattr(self, key, d) self.beta_rules = data['beta_rules'] self.defined_facts = set(data['defined_facts']) return self def _defined_facts_lines(self): yield 'defined_facts = [' for fact in sorted(self.defined_facts): yield f' {fact!r},' yield '] # defined_facts' def _full_implications_lines(self): yield 'full_implications = dict( [' for fact in sorted(self.defined_facts): for value in (True, False): yield f' # Implications of {fact} = {value}:' yield f' (({fact!r}, {value!r}), set( (' implications = self.full_implications[(fact, value)] for implied in sorted(implications): yield f' {implied!r},' yield ' ) ),' yield ' ),' yield ' ] ) # full_implications' def _prereq_lines(self): yield 'prereq = {' yield '' for fact in sorted(self.prereq): yield f' # facts that could determine the value of {fact}' yield f' {fact!r}: {{' for pfact in sorted(self.prereq[fact]): yield f' {pfact!r},' yield ' },' yield '' yield '} # prereq' def _beta_rules_lines(self): reverse_implications = defaultdict(list) for n, (pre, implied) in enumerate(self.beta_rules): reverse_implications[implied].append((pre, n)) yield '# Note: the order of the beta rules is used in the beta_triggers' yield 'beta_rules = [' yield '' m = 0 indices = {} for implied in sorted(reverse_implications): fact, value = implied yield f' # Rules implying {fact} = {value}' for pre, n in reverse_implications[implied]: indices[n] = m m += 1 setstr = ", ".join(map(str, sorted(pre))) yield f' ({{{setstr}}},' yield f' {implied!r}),' yield '' yield '] # beta_rules' yield 'beta_triggers = {' for query in sorted(self.beta_triggers): fact, value = query triggers = [indices[n] for n in self.beta_triggers[query]] yield f' {query!r}: {triggers!r},' yield '} # beta_triggers' def print_rules(self) -> Iterator[str]: """ Returns a generator with lines to represent the facts and rules """ yield from self._defined_facts_lines() yield '' yield '' yield from self._full_implications_lines() yield '' yield '' yield from self._prereq_lines() yield '' yield '' yield from self._beta_rules_lines() yield '' yield '' yield "generated_assumptions = {'defined_facts': defined_facts, 'full_implications': full_implications," yield " 'prereq': prereq, 'beta_rules': beta_rules, 'beta_triggers': beta_triggers}" class InconsistentAssumptions(ValueError): def __str__(self): kb, fact, value = self.args return "%s, %s=%s" % (kb, fact, value) class FactKB(dict): """ A simple propositional knowledge base relying on compiled inference rules. """ def __str__(self): return '{\n%s}' % ',\n'.join( ["\t%s: %s" % i for i in sorted(self.items())]) def __init__(self, rules): self.rules = rules def _tell(self, k, v): """Add fact k=v to the knowledge base. Returns True if the KB has actually been updated, False otherwise. """ if k in self and self[k] is not None: if self[k] == v: return False else: raise InconsistentAssumptions(self, k, v) else: self[k] = v return True # ********************************************* # * This is the workhorse, so keep it *fast*. * # ********************************************* def deduce_all_facts(self, facts): """ Update the KB with all the implications of a list of facts. Facts can be specified as a dictionary or as a list of (key, value) pairs. """ # keep frequently used attributes locally, so we'll avoid extra # attribute access overhead full_implications = self.rules.full_implications beta_triggers = self.rules.beta_triggers beta_rules = self.rules.beta_rules if isinstance(facts, dict): facts = facts.items() while facts: beta_maytrigger = set() # --- alpha chains --- for k, v in facts: if not self._tell(k, v) or v is None: continue # lookup routing tables for key, value in full_implications[k, v]: self._tell(key, value) beta_maytrigger.update(beta_triggers[k, v]) # --- beta chains --- facts = [] for bidx in beta_maytrigger: bcond, bimpl = beta_rules[bidx] if all(self.get(k) is v for k, v in bcond): facts.append(bimpl)