ai-content-maker/.venv/Lib/site-packages/sympy/core/facts.py

635 lines
19 KiB
Python

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)