samovar-py 0.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- samovar/__init__.py +0 -0
- samovar/ast.py +56 -0
- samovar/database.py +74 -0
- samovar/generators/__init__.py +0 -0
- samovar/generators/base.py +64 -0
- samovar/generators/breadthfirst.py +57 -0
- samovar/generators/depthfirst.py +73 -0
- samovar/generators/stochastic.py +62 -0
- samovar/main.py +138 -0
- samovar/parser.py +166 -0
- samovar/randomness.py +20 -0
- samovar/scanner.py +120 -0
- samovar/terms.py +127 -0
- samovar/tests.py +209 -0
- samovar_py-0.6.dist-info/METADATA +123 -0
- samovar_py-0.6.dist-info/RECORD +19 -0
- samovar_py-0.6.dist-info/WHEEL +5 -0
- samovar_py-0.6.dist-info/entry_points.txt +2 -0
- samovar_py-0.6.dist-info/top_level.txt +1 -0
samovar/__init__.py
ADDED
|
File without changes
|
samovar/ast.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
|
|
3
|
+
from collections import namedtuple
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Python 2/3
|
|
7
|
+
try:
|
|
8
|
+
unicode = unicode
|
|
9
|
+
except NameError:
|
|
10
|
+
unicode = str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
World = namedtuple('World', ['scenarios'])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Rule(namedtuple('Rule', ['pre', 'words', 'post'])):
|
|
17
|
+
__slots__ = ()
|
|
18
|
+
|
|
19
|
+
def format(self, unifier):
|
|
20
|
+
return join_sentence_parts([unicode(t.subst(unifier)) for t in self.words])
|
|
21
|
+
|
|
22
|
+
def to_json(self):
|
|
23
|
+
return join_sentence_parts([unicode(t) for t in self.words])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Scenario = namedtuple('Scenario', ['name', 'propositions', 'rules', 'goal'])
|
|
27
|
+
Cond = namedtuple('Cond', ['exprs', 'bindings'])
|
|
28
|
+
Assert = namedtuple('Assert', ['term'])
|
|
29
|
+
Retract = namedtuple('Retract', ['term'])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def join_sentence_parts(parts):
|
|
33
|
+
acc = u''
|
|
34
|
+
quote_open = False
|
|
35
|
+
for part in parts:
|
|
36
|
+
last = '' if not acc else acc[-1]
|
|
37
|
+
if last == '':
|
|
38
|
+
acc += part
|
|
39
|
+
elif last == '"' and quote_open:
|
|
40
|
+
acc += part
|
|
41
|
+
elif last == '"' and not quote_open:
|
|
42
|
+
if part in (u'.', u',', u'!', u'?'):
|
|
43
|
+
acc += part
|
|
44
|
+
else:
|
|
45
|
+
acc += ' ' + part
|
|
46
|
+
elif last == ',' and part == u'"' and not quote_open:
|
|
47
|
+
acc += ' ' + part
|
|
48
|
+
elif last in (u'.', u',', u'!', u'?', u"'") and part == u'"' and not quote_open:
|
|
49
|
+
acc += part
|
|
50
|
+
elif part in (u'.', u',', u'!', u'?', u"'", u'"'):
|
|
51
|
+
acc += part
|
|
52
|
+
else:
|
|
53
|
+
acc += u' ' + part
|
|
54
|
+
if part == '"':
|
|
55
|
+
quote_open = not quote_open
|
|
56
|
+
return acc
|
samovar/database.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from samovar.ast import Assert, Retract
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Database(object):
|
|
5
|
+
"""A database is a set of things true at some point in time in a world."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, propositions, sorted_search=True):
|
|
8
|
+
"""Create a new Database object with the given propositions.
|
|
9
|
+
|
|
10
|
+
While the construction and iteration of the underlying set in Python 2 seems to be
|
|
11
|
+
stable, when running under Python 3, we get an unpredictable order when iterating
|
|
12
|
+
over the set. The `sorted_search` option controls whether we sort the iterable
|
|
13
|
+
before making matches, so that we can accumulate them in a predictable order.
|
|
14
|
+
This is valuable for tests, but can be turned off to improve performance.
|
|
15
|
+
|
|
16
|
+
"""
|
|
17
|
+
self.sorted_search = sorted_search
|
|
18
|
+
self.contents = set()
|
|
19
|
+
for term in propositions:
|
|
20
|
+
self.contents.add(term)
|
|
21
|
+
|
|
22
|
+
def clone(self):
|
|
23
|
+
return Database(self.contents, sorted_search=self.sorted_search)
|
|
24
|
+
|
|
25
|
+
def add(self, term):
|
|
26
|
+
self.contents.add(term)
|
|
27
|
+
|
|
28
|
+
def remove(self, term):
|
|
29
|
+
self.contents.remove(term)
|
|
30
|
+
|
|
31
|
+
def match_all(self, patterns, env):
|
|
32
|
+
"""Find all matches for the given patterns in the database, and return a list of unifiers.
|
|
33
|
+
|
|
34
|
+
`database` is an iterable of `Term`s.
|
|
35
|
+
`patterns` is a list of `Assert`s and `Retract`s.
|
|
36
|
+
`env` is a dict mapping `Var` names to `Term`s (a previous unifier.)
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
if not patterns:
|
|
40
|
+
return [env]
|
|
41
|
+
envs = []
|
|
42
|
+
pattern = patterns[0]
|
|
43
|
+
|
|
44
|
+
if isinstance(pattern, Assert):
|
|
45
|
+
for proposition in (sorted(self.contents) if self.sorted_search else self.contents):
|
|
46
|
+
unifier = pattern.term.match(proposition, env, unique_binding=True)
|
|
47
|
+
if unifier is None:
|
|
48
|
+
continue
|
|
49
|
+
envs.extend(self.match_all(patterns[1:], unifier))
|
|
50
|
+
|
|
51
|
+
elif isinstance(pattern, Retract):
|
|
52
|
+
expanded_pattern = pattern.term.subst(env)
|
|
53
|
+
|
|
54
|
+
# To test a negative match, we require that there are
|
|
55
|
+
# no free variables remaining in our pattern. If there are,
|
|
56
|
+
# it will simply not match. TODO add a flag to turn this
|
|
57
|
+
# check on to support better debugging.
|
|
58
|
+
|
|
59
|
+
# free_vars = set()
|
|
60
|
+
# expanded_pattern.collect_variables(free_vars)
|
|
61
|
+
# if free_vars:
|
|
62
|
+
# # TODO: better exception than this
|
|
63
|
+
# raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
# now we simply check if the term exists in the database.
|
|
66
|
+
# if it does not, we recurse down to the next clause in the pattern.
|
|
67
|
+
|
|
68
|
+
if not (expanded_pattern in self.contents):
|
|
69
|
+
envs.extend(self.match_all(patterns[1:], env))
|
|
70
|
+
|
|
71
|
+
else:
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
return envs
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from samovar.ast import Assert, Retract
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Python 2/3
|
|
9
|
+
try:
|
|
10
|
+
xrange = xrange
|
|
11
|
+
except NameError:
|
|
12
|
+
xrange = range
|
|
13
|
+
|
|
14
|
+
# Python 2/3
|
|
15
|
+
try:
|
|
16
|
+
unicode = unicode
|
|
17
|
+
except NameError:
|
|
18
|
+
unicode = str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Event(object):
|
|
22
|
+
def __init__(self, rule, unifier):
|
|
23
|
+
self.rule = rule
|
|
24
|
+
self.unifier = unifier
|
|
25
|
+
|
|
26
|
+
def to_json(self):
|
|
27
|
+
u = dict([(unicode(k), unicode(v)) for k, v in self.unifier.items()])
|
|
28
|
+
return [self.rule.to_json(), u]
|
|
29
|
+
|
|
30
|
+
def __str__(self):
|
|
31
|
+
return self.rule.format(self.unifier)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BaseGenerator(object):
|
|
35
|
+
def __init__(self, world, scenario, **kwargs):
|
|
36
|
+
self.world = world
|
|
37
|
+
self.scenario = scenario
|
|
38
|
+
|
|
39
|
+
def goal_is_met(self, state):
|
|
40
|
+
matches = state.match_all(self.scenario.goal.exprs, self.scenario.goal.bindings)
|
|
41
|
+
return len(matches) > 0
|
|
42
|
+
|
|
43
|
+
def candidate_rules(self, state, require_change=False):
|
|
44
|
+
"""A generator."""
|
|
45
|
+
for rule in self.scenario.rules:
|
|
46
|
+
for unifier in state.match_all(rule.pre.exprs, rule.pre.bindings):
|
|
47
|
+
if require_change and not rule.post.exprs:
|
|
48
|
+
# Rules that don't change the state of the world are not worth considering.
|
|
49
|
+
continue
|
|
50
|
+
yield (rule, unifier)
|
|
51
|
+
|
|
52
|
+
def update_state(self, state, env, rule):
|
|
53
|
+
for expr in rule.post.exprs:
|
|
54
|
+
term = expr.term.subst(env)
|
|
55
|
+
if isinstance(expr, Assert):
|
|
56
|
+
state.add(term)
|
|
57
|
+
elif isinstance(expr, Retract):
|
|
58
|
+
state.remove(term)
|
|
59
|
+
|
|
60
|
+
def debug_state(self, state, label):
|
|
61
|
+
sys.stderr.write(":::: {} State [\n".format(label))
|
|
62
|
+
for term in sorted(state.contents):
|
|
63
|
+
sys.stderr.write(":::: {}\n".format(term))
|
|
64
|
+
sys.stderr.write(":::: ]\n")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
|
|
3
|
+
"""A breadth-first searching Generator that finds the
|
|
4
|
+
shortest path of events that leads to the goal state
|
|
5
|
+
(if any such path exists).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from samovar.ast import Assert, Retract
|
|
12
|
+
from samovar.database import Database
|
|
13
|
+
|
|
14
|
+
from .base import Event, BaseGenerator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BreadthFirstGenerator(BaseGenerator):
|
|
18
|
+
def __init__(self, world, scenario, verbosity=0, sorted_search=True, randomness=None):
|
|
19
|
+
self.world = world
|
|
20
|
+
self.scenario = scenario
|
|
21
|
+
self.verbosity = verbosity
|
|
22
|
+
self.sorted_search = sorted_search
|
|
23
|
+
self.random = randomness
|
|
24
|
+
|
|
25
|
+
def generate_events(self, **kwargs):
|
|
26
|
+
situations = [
|
|
27
|
+
([], Database(self.scenario.propositions, sorted_search=self.sorted_search))
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
seen_states = set()
|
|
31
|
+
|
|
32
|
+
while True:
|
|
33
|
+
new_situations = []
|
|
34
|
+
if self.verbosity >= 1:
|
|
35
|
+
sys.stderr.write("Considering {} situations\n".format(len(situations)))
|
|
36
|
+
start_time = time.time()
|
|
37
|
+
for (events, state) in situations:
|
|
38
|
+
for rule, unifier in self.candidate_rules(state, require_change=True):
|
|
39
|
+
new_event = Event(rule, unifier)
|
|
40
|
+
new_state = state.clone()
|
|
41
|
+
self.update_state(new_state, unifier, rule)
|
|
42
|
+
froz = frozenset(new_state.contents)
|
|
43
|
+
if froz in seen_states:
|
|
44
|
+
continue
|
|
45
|
+
seen_states.add(froz)
|
|
46
|
+
new_events = events + [new_event]
|
|
47
|
+
if self.goal_is_met(new_state):
|
|
48
|
+
return new_events
|
|
49
|
+
new_situations.append(
|
|
50
|
+
(new_events, new_state)
|
|
51
|
+
)
|
|
52
|
+
if self.verbosity >= 1:
|
|
53
|
+
end_time = time.time()
|
|
54
|
+
duration = end_time - start_time
|
|
55
|
+
sys.stderr.write("Considered {} situations in {} seconds ({} situations / second)\n".format(len(situations), duration, float(len(situations))/duration))
|
|
56
|
+
sys.stderr.write("Installing {} new situations\n".format(len(new_situations)))
|
|
57
|
+
situations = new_situations
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
|
|
3
|
+
# FIXME: just a sketch for now
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from samovar.ast import Assert, Retract
|
|
9
|
+
from samovar.database import Database
|
|
10
|
+
|
|
11
|
+
from .base import Event, BaseGenerator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DepthFirstGenerator(BaseGenerator):
|
|
15
|
+
def __init__(self, world, scenario, verbosity=0, sorted_search=True, randomness=None):
|
|
16
|
+
self.world = world
|
|
17
|
+
self.scenario = scenario
|
|
18
|
+
self.verbosity = verbosity
|
|
19
|
+
self.sorted_search = sorted_search
|
|
20
|
+
self.random = randomness
|
|
21
|
+
self.seen_states = set()
|
|
22
|
+
|
|
23
|
+
def generate_events(self, **kwargs):
|
|
24
|
+
self.seen_states = set()
|
|
25
|
+
|
|
26
|
+
# Create initial stacks.
|
|
27
|
+
state = Database(self.scenario.propositions, sorted_search=self.sorted_search)
|
|
28
|
+
candidate_rules = list(self.candidate_rules(state, require_change=True))
|
|
29
|
+
stack = [
|
|
30
|
+
[None, state, candidate_rules, 0]
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# Loop, doing work at the top of the stack.
|
|
34
|
+
done = False
|
|
35
|
+
counter = 0
|
|
36
|
+
while not done:
|
|
37
|
+
counter += 1
|
|
38
|
+
[last_event, state, candidate_rules, cr_index] = stack[-1]
|
|
39
|
+
if self.verbosity >= 1 and counter % 1000 == 0:
|
|
40
|
+
sys.stderr.write("Stack depth {}, candidate count {}, index {}\n".format(
|
|
41
|
+
len(stack), len(candidate_rules), cr_index
|
|
42
|
+
))
|
|
43
|
+
if cr_index > (len(candidate_rules) - 1):
|
|
44
|
+
# we've exhausted all the candidates on this level. backtrack.
|
|
45
|
+
if self.verbosity >= 1:
|
|
46
|
+
sys.stderr.write("*** Backtracking\n")
|
|
47
|
+
stack.pop()
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
# otherwise, try to make a match.
|
|
51
|
+
rule, unifier = candidate_rules[cr_index]
|
|
52
|
+
|
|
53
|
+
new_event = Event(rule, unifier)
|
|
54
|
+
new_state = state.clone()
|
|
55
|
+
self.update_state(new_state, unifier, rule)
|
|
56
|
+
if self.verbosity >= 2:
|
|
57
|
+
self.debug_state(new_state, "Intermediate")
|
|
58
|
+
if self.goal_is_met(new_state):
|
|
59
|
+
return [e[0] for e in stack if e[0] is not None] + [new_event]
|
|
60
|
+
|
|
61
|
+
froz = frozenset(new_state.contents)
|
|
62
|
+
if froz in self.seen_states:
|
|
63
|
+
stack[-1][3] += 1 # inc cr_index. TODO this is so ugly
|
|
64
|
+
continue
|
|
65
|
+
self.seen_states.add(froz)
|
|
66
|
+
|
|
67
|
+
# otherwise, descend to try to extend the sequence of events we've got.
|
|
68
|
+
new_candidate_rules = list(self.candidate_rules(new_state, require_change=True))
|
|
69
|
+
stack.append(
|
|
70
|
+
[new_event, new_state, new_candidate_rules, 0]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return None
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from samovar.database import Database
|
|
6
|
+
|
|
7
|
+
from .base import xrange, Event, BaseGenerator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StochasticGenerator(BaseGenerator):
|
|
11
|
+
def __init__(self, world, scenario, verbosity=0, sorted_search=True, randomness=None):
|
|
12
|
+
self.world = world
|
|
13
|
+
self.scenario = scenario
|
|
14
|
+
self.verbosity = verbosity
|
|
15
|
+
self.sorted_search = sorted_search
|
|
16
|
+
self.random = randomness
|
|
17
|
+
self.reset_state()
|
|
18
|
+
|
|
19
|
+
def reset_state(self):
|
|
20
|
+
self.state = Database(self.scenario.propositions, sorted_search=self.sorted_search)
|
|
21
|
+
|
|
22
|
+
def generate_events(self, min_count=0, max_count=100, lengthen_factor=1.5):
|
|
23
|
+
acceptable = False
|
|
24
|
+
count = min_count
|
|
25
|
+
while not acceptable:
|
|
26
|
+
if self.verbosity >= 1:
|
|
27
|
+
sys.stderr.write("Generating {} events\n".format(count))
|
|
28
|
+
self.reset_state()
|
|
29
|
+
if self.verbosity >= 2:
|
|
30
|
+
self.debug_state(self.state, "Initial")
|
|
31
|
+
events = []
|
|
32
|
+
for i in xrange(0, count):
|
|
33
|
+
event = self.generate_event()
|
|
34
|
+
if event is None:
|
|
35
|
+
break
|
|
36
|
+
events.append(event)
|
|
37
|
+
if self.verbosity >= 2:
|
|
38
|
+
self.debug_state(self.state, "Final")
|
|
39
|
+
acceptable = self.goal_is_met(self.state)
|
|
40
|
+
if not acceptable:
|
|
41
|
+
count = int(float(count) * lengthen_factor)
|
|
42
|
+
if count > max_count:
|
|
43
|
+
raise ValueError("{}: count exceeds maximum".format(self.scenario.name))
|
|
44
|
+
return events
|
|
45
|
+
|
|
46
|
+
def generate_event(self):
|
|
47
|
+
candidates = list(self.candidate_rules(self.state))
|
|
48
|
+
|
|
49
|
+
if self.verbosity >= 3:
|
|
50
|
+
sys.stderr.write("Candidate rules:\n")
|
|
51
|
+
for rule, unifiers in candidates:
|
|
52
|
+
sys.stderr.write("-> " + rule.to_json() + "\n")
|
|
53
|
+
sys.stderr.write("---> {}\n".format(unifiers))
|
|
54
|
+
sys.stderr.write("")
|
|
55
|
+
|
|
56
|
+
if not candidates:
|
|
57
|
+
return None
|
|
58
|
+
rule, unifier = self.random.choice(candidates)
|
|
59
|
+
self.update_state(self.state, unifier, rule)
|
|
60
|
+
if self.verbosity >= 3:
|
|
61
|
+
self.debug_state(self.state, "Intermediate")
|
|
62
|
+
return Event(rule, unifier)
|
samovar/main.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from argparse import ArgumentParser
|
|
2
|
+
import codecs
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from samovar.parser import Parser
|
|
7
|
+
from samovar.generators.breadthfirst import BreadthFirstGenerator
|
|
8
|
+
from samovar.generators.depthfirst import DepthFirstGenerator
|
|
9
|
+
from samovar.generators.stochastic import StochasticGenerator
|
|
10
|
+
from samovar.randomness import CannedRandomness
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sys_main():
|
|
14
|
+
return main(sys.argv[1:])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(args):
|
|
18
|
+
argparser = ArgumentParser()
|
|
19
|
+
|
|
20
|
+
argparser.add_argument(
|
|
21
|
+
'input_files', nargs='+', metavar='FILENAME', type=str,
|
|
22
|
+
help='Source files containing the scenario descriptions'
|
|
23
|
+
)
|
|
24
|
+
argparser.add_argument(
|
|
25
|
+
"--verbosity", type=int, default=0,
|
|
26
|
+
help="Show some information about the world as generation takes place"
|
|
27
|
+
)
|
|
28
|
+
argparser.add_argument(
|
|
29
|
+
"--verbose", action="store_true",
|
|
30
|
+
help="Show some progress information (alias for --verbosity=1)"
|
|
31
|
+
)
|
|
32
|
+
argparser.add_argument(
|
|
33
|
+
"--debug", action="store_true",
|
|
34
|
+
help="Show state before and after each move (alias for --verbosity=2)"
|
|
35
|
+
)
|
|
36
|
+
argparser.add_argument(
|
|
37
|
+
"--dump-ast", action="store_true",
|
|
38
|
+
help="Just show the AST and stop"
|
|
39
|
+
)
|
|
40
|
+
argparser.add_argument(
|
|
41
|
+
"--generator", choices=('stochastic', 'breadthfirst', 'depthfirst'), default='stochastic',
|
|
42
|
+
help="Specify which generator engine to use"
|
|
43
|
+
)
|
|
44
|
+
argparser.add_argument(
|
|
45
|
+
"--generate-scenarios", type=str, default=None,
|
|
46
|
+
help="If given, generate only these scenarios"
|
|
47
|
+
)
|
|
48
|
+
argparser.add_argument(
|
|
49
|
+
"--lengthen-factor", type=float, default=2.0,
|
|
50
|
+
help="When scenario goal was not met, multiply number of events to generate by this"
|
|
51
|
+
)
|
|
52
|
+
argparser.add_argument(
|
|
53
|
+
"--min-events", type=int, default=1,
|
|
54
|
+
help="Generate at least this many events for each scenario"
|
|
55
|
+
)
|
|
56
|
+
argparser.add_argument(
|
|
57
|
+
"--max-events", type=int, default=1000000,
|
|
58
|
+
help="Conclude that something has gone wrong and abort if more than this many events are generated"
|
|
59
|
+
)
|
|
60
|
+
argparser.add_argument(
|
|
61
|
+
"--output-type", choices=('naive-text', 'events-json', 'scenarios-json',), default='naive-text',
|
|
62
|
+
help="Specify what to output and in what format"
|
|
63
|
+
)
|
|
64
|
+
argparser.add_argument(
|
|
65
|
+
"--deterministic", action="store_true",
|
|
66
|
+
help="Make the output deterministic, at the cost of performance. "
|
|
67
|
+
"Primarily useful for reproducibility during testing."
|
|
68
|
+
)
|
|
69
|
+
argparser.add_argument(
|
|
70
|
+
"--seed", type=int, default=None,
|
|
71
|
+
help="Set random seed of the Python random number generator, when it is in use."
|
|
72
|
+
)
|
|
73
|
+
argparser.add_argument('--version', action='version', version="%(prog)s 0.6")
|
|
74
|
+
|
|
75
|
+
options = argparser.parse_args(args)
|
|
76
|
+
|
|
77
|
+
verbosity = options.verbosity
|
|
78
|
+
if options.verbose:
|
|
79
|
+
verbosity = max(verbosity, 1)
|
|
80
|
+
if options.debug:
|
|
81
|
+
verbosity = max(verbosity, 2)
|
|
82
|
+
|
|
83
|
+
generator_cls = {
|
|
84
|
+
'breadthfirst': BreadthFirstGenerator,
|
|
85
|
+
'depthfirst': DepthFirstGenerator,
|
|
86
|
+
'stochastic': StochasticGenerator,
|
|
87
|
+
}[options.generator]
|
|
88
|
+
|
|
89
|
+
text = ''
|
|
90
|
+
for arg in options.input_files:
|
|
91
|
+
with codecs.open(arg, 'r', encoding='UTF-8') as f:
|
|
92
|
+
text += f.read()
|
|
93
|
+
|
|
94
|
+
p = Parser(text)
|
|
95
|
+
ast = p.world()
|
|
96
|
+
if options.dump_ast:
|
|
97
|
+
print(ast)
|
|
98
|
+
sys.exit(0)
|
|
99
|
+
|
|
100
|
+
if options.deterministic:
|
|
101
|
+
randomness = CannedRandomness()
|
|
102
|
+
else:
|
|
103
|
+
import random
|
|
104
|
+
if options.seed is not None:
|
|
105
|
+
random.seed(options.seed)
|
|
106
|
+
randomness = random
|
|
107
|
+
|
|
108
|
+
event_buckets = []
|
|
109
|
+
for n, scenario in enumerate(ast.scenarios):
|
|
110
|
+
if verbosity >= 1:
|
|
111
|
+
sys.stderr.write("{}. {}\n".format(n, scenario.name))
|
|
112
|
+
if scenario.goal is None:
|
|
113
|
+
continue
|
|
114
|
+
if options.generate_scenarios is not None and scenario.name not in options.generate_scenarios:
|
|
115
|
+
continue
|
|
116
|
+
g = generator_cls(
|
|
117
|
+
ast, scenario,
|
|
118
|
+
verbosity=verbosity, sorted_search=options.deterministic, randomness=randomness
|
|
119
|
+
)
|
|
120
|
+
events = g.generate_events(
|
|
121
|
+
min_count=options.min_events, max_count=options.max_events, lengthen_factor=options.lengthen_factor
|
|
122
|
+
)
|
|
123
|
+
event_buckets.append(events)
|
|
124
|
+
|
|
125
|
+
if options.output_type == 'naive-text':
|
|
126
|
+
for b in event_buckets:
|
|
127
|
+
for e in b:
|
|
128
|
+
sys.stdout.write("%s\n" % e)
|
|
129
|
+
sys.stdout.write("\n")
|
|
130
|
+
elif options.output_type == 'events-json':
|
|
131
|
+
def jsonify_bucket(b):
|
|
132
|
+
return [e.to_json() for e in b]
|
|
133
|
+
jsonified_buckets = [jsonify_bucket(b) for b in event_buckets]
|
|
134
|
+
sys.stdout.write(json.dumps(jsonified_buckets, indent=4, sort_keys=True))
|
|
135
|
+
elif options.output_type == 'scenarios-json':
|
|
136
|
+
raise NotImplementedError("'scenarios-json' output-type not currently implemented")
|
|
137
|
+
else:
|
|
138
|
+
raise NotImplementedError('Not a valid output-type: {}'.format(options.output_type))
|
samovar/parser.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
|
|
3
|
+
from samovar.ast import World, Scenario, Rule, Cond, Assert, Retract
|
|
4
|
+
from samovar.terms import Term, Var
|
|
5
|
+
from samovar.scanner import Scanner
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# World ::= {Scenario}.
|
|
9
|
+
# Scenario ::= "scenario" Atom "{" {Import | Proposition | Rule | Goal} ["." | ","] "}".
|
|
10
|
+
# Import ::= "import" Atom.
|
|
11
|
+
# Goal ::= "goal" Cond.
|
|
12
|
+
# Proposition ::= Term.
|
|
13
|
+
# Rule ::= Cond {Var | Atom | Punct} Cond.
|
|
14
|
+
# Cond ::= "[" Expr {"," Expr} ["where" {Var "=" Term [","]}"]".
|
|
15
|
+
# Expr ::= Term | NotSym Term.
|
|
16
|
+
# Term ::= Var | Atom ["(" Term {AndSym Term} ")"].
|
|
17
|
+
# Var ::= Qmark | Greek.
|
|
18
|
+
# Qmark ::= '?' Atom.
|
|
19
|
+
# Greek ::= <<one of: αβγδεζθικλμνξοπρστυφχψω>>.
|
|
20
|
+
# Atom ::= <<A-Za-z_>> <<A-Za-z0-9_-'>>*.
|
|
21
|
+
# Punct ::= <<"',.;:?!>>.
|
|
22
|
+
# NotSym ::= '~' | '¬'.
|
|
23
|
+
# AndSym ::= ',' | '∧'.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SamovarSyntaxError(ValueError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def variables_in_cond(cond):
|
|
31
|
+
vars_ = set()
|
|
32
|
+
for expr in cond.exprs:
|
|
33
|
+
expr.term.collect_variables(vars_)
|
|
34
|
+
for key, value in cond.bindings.items():
|
|
35
|
+
vars_.add(Var(key))
|
|
36
|
+
return vars_
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Parser(object):
|
|
40
|
+
def __init__(self, text):
|
|
41
|
+
self.scanner = Scanner(text)
|
|
42
|
+
self.scenario_map = {}
|
|
43
|
+
|
|
44
|
+
def world(self):
|
|
45
|
+
scenarios = []
|
|
46
|
+
while self.scanner.on('scenario'):
|
|
47
|
+
scenario = self.scenario()
|
|
48
|
+
self.scenario_map[scenario.name] = scenario
|
|
49
|
+
scenarios.append(scenario)
|
|
50
|
+
return World(scenarios=scenarios)
|
|
51
|
+
|
|
52
|
+
def scenario(self):
|
|
53
|
+
propositions = []
|
|
54
|
+
rules = []
|
|
55
|
+
goal = None
|
|
56
|
+
self.scanner.expect('scenario')
|
|
57
|
+
self.scanner.check_type('word')
|
|
58
|
+
name = self.scanner.token
|
|
59
|
+
self.scanner.scan()
|
|
60
|
+
self.scanner.expect('{')
|
|
61
|
+
while not self.scanner.on('}'):
|
|
62
|
+
if self.scanner.consume('import'):
|
|
63
|
+
self.scanner.check_type('word')
|
|
64
|
+
from_name = self.scanner.token
|
|
65
|
+
self.scanner.scan()
|
|
66
|
+
from_scenario = self.scenario_map[from_name]
|
|
67
|
+
rules.extend(from_scenario.rules)
|
|
68
|
+
propositions.extend(from_scenario.propositions)
|
|
69
|
+
elif self.scanner.consume('goal'):
|
|
70
|
+
assert goal is None
|
|
71
|
+
goal = self.cond()
|
|
72
|
+
elif self.scanner.on('['):
|
|
73
|
+
rules.append(self.rule())
|
|
74
|
+
else:
|
|
75
|
+
propositions.append(self.proposition())
|
|
76
|
+
self.scanner.consume('.')
|
|
77
|
+
self.scanner.consume(',')
|
|
78
|
+
self.scanner.expect('}')
|
|
79
|
+
return Scenario(name=name, propositions=propositions, rules=rules, goal=goal)
|
|
80
|
+
|
|
81
|
+
def proposition(self):
|
|
82
|
+
return self.term()
|
|
83
|
+
|
|
84
|
+
def rule(self):
|
|
85
|
+
words = []
|
|
86
|
+
pre = self.cond()
|
|
87
|
+
while not self.scanner.on('['):
|
|
88
|
+
words.append(self.word())
|
|
89
|
+
post = self.cond()
|
|
90
|
+
|
|
91
|
+
if post.bindings:
|
|
92
|
+
raise SamovarSyntaxError("Consequences of a rule cannot include a `where` clause")
|
|
93
|
+
|
|
94
|
+
pre_variables = variables_in_cond(pre)
|
|
95
|
+
|
|
96
|
+
words_variables = set(w for w in words if isinstance(w, Var))
|
|
97
|
+
text = ' '.join([str(w) for w in words])
|
|
98
|
+
if '?_' in [w.name for w in words_variables]:
|
|
99
|
+
raise SamovarSyntaxError('Text "{}" contains wildcard'.format(text))
|
|
100
|
+
extra_vars_in_words = words_variables - pre_variables
|
|
101
|
+
if extra_vars_in_words:
|
|
102
|
+
extra_vars = ', '.join([str(v) for v in sorted(extra_vars_in_words)])
|
|
103
|
+
raise SamovarSyntaxError('Text "{}" contains unbound variables: {}'.format(text, extra_vars))
|
|
104
|
+
|
|
105
|
+
post_variables = variables_in_cond(post)
|
|
106
|
+
if '?_' in [w.name for w in post_variables]:
|
|
107
|
+
raise SamovarSyntaxError("Consequences contains wildcard")
|
|
108
|
+
extra_vars_in_post = post_variables - pre_variables
|
|
109
|
+
if extra_vars_in_post:
|
|
110
|
+
extra_vars = ', '.join([str(v) for v in sorted(extra_vars_in_words)])
|
|
111
|
+
raise SamovarSyntaxError("Consequences contains unbound variables: {}".format(extra_vars))
|
|
112
|
+
|
|
113
|
+
return Rule(pre=pre, words=words, post=post)
|
|
114
|
+
|
|
115
|
+
def cond(self):
|
|
116
|
+
exprs = []
|
|
117
|
+
bindings = {}
|
|
118
|
+
self.scanner.expect('[')
|
|
119
|
+
if not self.scanner.on(']') and not self.scanner.on('where'):
|
|
120
|
+
exprs.append(self.expr())
|
|
121
|
+
while self.scanner.consume(',', u'∧'):
|
|
122
|
+
exprs.append(self.expr())
|
|
123
|
+
if self.scanner.consume('where'):
|
|
124
|
+
while not self.scanner.on(']'):
|
|
125
|
+
v = self.var()
|
|
126
|
+
self.scanner.expect('=')
|
|
127
|
+
t = self.term()
|
|
128
|
+
bindings[v.name] = t
|
|
129
|
+
self.scanner.consume(',', u'∧')
|
|
130
|
+
self.scanner.expect(']')
|
|
131
|
+
return Cond(exprs=exprs, bindings=bindings)
|
|
132
|
+
|
|
133
|
+
def expr(self):
|
|
134
|
+
if self.scanner.consume('~', u'¬', '!'):
|
|
135
|
+
return Retract(term=self.term())
|
|
136
|
+
else:
|
|
137
|
+
return Assert(term=self.term())
|
|
138
|
+
|
|
139
|
+
def term(self):
|
|
140
|
+
if self.scanner.on_type('variable'):
|
|
141
|
+
return self.var()
|
|
142
|
+
self.scanner.check_type('word')
|
|
143
|
+
constructor = self.scanner.token
|
|
144
|
+
self.scanner.scan()
|
|
145
|
+
subterms = []
|
|
146
|
+
if self.scanner.consume('('):
|
|
147
|
+
subterms.append(self.term())
|
|
148
|
+
while self.scanner.consume(','):
|
|
149
|
+
subterms.append(self.term())
|
|
150
|
+
self.scanner.expect(')')
|
|
151
|
+
return Term(constructor, *subterms)
|
|
152
|
+
|
|
153
|
+
def word(self):
|
|
154
|
+
if self.scanner.on_type('variable'):
|
|
155
|
+
return self.var()
|
|
156
|
+
self.scanner.check_type('word', 'punct', 'operator')
|
|
157
|
+
constructor = self.scanner.token
|
|
158
|
+
self.scanner.scan()
|
|
159
|
+
return Term(constructor)
|
|
160
|
+
|
|
161
|
+
def var(self):
|
|
162
|
+
self.scanner.check_type('variable')
|
|
163
|
+
name = self.scanner.token
|
|
164
|
+
self.scanner.scan()
|
|
165
|
+
v = Var(name)
|
|
166
|
+
return v
|
samovar/randomness.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Sources of randomness, for Samovar.
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CannedRandomness(object):
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self.counter = 0
|
|
7
|
+
self.limit = 1
|
|
8
|
+
|
|
9
|
+
def advance(self):
|
|
10
|
+
self.counter += 1
|
|
11
|
+
if self.counter > self.limit:
|
|
12
|
+
self.counter = 0
|
|
13
|
+
self.limit += 1
|
|
14
|
+
|
|
15
|
+
def choice(self, iterable):
|
|
16
|
+
length = len(iterable)
|
|
17
|
+
index = self.counter % length
|
|
18
|
+
selection = iterable[index]
|
|
19
|
+
self.advance()
|
|
20
|
+
return selection
|
samovar/scanner.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# Python 2/3
|
|
5
|
+
try:
|
|
6
|
+
unicode = unicode
|
|
7
|
+
except NameError:
|
|
8
|
+
unicode = str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
GREEK = [
|
|
15
|
+
(u'?alpha', u'α'),
|
|
16
|
+
(u'?beta', u'β'),
|
|
17
|
+
(u'?gamma', u'γ'),
|
|
18
|
+
(u'?delta', u'δ'),
|
|
19
|
+
(u'?epsilon', u'ε'),
|
|
20
|
+
(u'?zeta', u'ζ'),
|
|
21
|
+
(u'?theta', u'θ'),
|
|
22
|
+
(u'?iota', u'ι'),
|
|
23
|
+
(u'?kappa', u'κ'),
|
|
24
|
+
(u'?lambda', u'λ'),
|
|
25
|
+
(u'?mu', u'μ'),
|
|
26
|
+
(u'?nu', u'ν'),
|
|
27
|
+
(u'?xi', u'ξ'),
|
|
28
|
+
(u'?omicron', u'ο'),
|
|
29
|
+
(u'?pi', u'π'),
|
|
30
|
+
(u'?rho', u'ρ'),
|
|
31
|
+
(u'?sigma', u'σ'),
|
|
32
|
+
(u'?tau', u'τ'),
|
|
33
|
+
(u'?upsilon', u'υ'),
|
|
34
|
+
(u'?phi', u'φ'),
|
|
35
|
+
(u'?chi', u'χ'),
|
|
36
|
+
(u'?psi', u'ψ'),
|
|
37
|
+
(u'?omega', u'ω'),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Scanner(object):
|
|
42
|
+
def __init__(self, text):
|
|
43
|
+
self.text = unicode(text)
|
|
44
|
+
self.token = None
|
|
45
|
+
self.type = None
|
|
46
|
+
self.pos = 0
|
|
47
|
+
self.scan()
|
|
48
|
+
|
|
49
|
+
def near_text(self, length=10):
|
|
50
|
+
return self.text[self.pos:self.pos + length]
|
|
51
|
+
|
|
52
|
+
def scan_pattern(self, pattern, type, token_group=1, rest_group=2):
|
|
53
|
+
pattern = r'(' + pattern + r')'
|
|
54
|
+
regexp = re.compile(pattern, flags=re.DOTALL)
|
|
55
|
+
match = regexp.match(self.text, pos=self.pos)
|
|
56
|
+
if not match:
|
|
57
|
+
return False
|
|
58
|
+
else:
|
|
59
|
+
self.type = type
|
|
60
|
+
self.token = match.group(token_group)
|
|
61
|
+
self.pos += len(self.token)
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
def scan(self):
|
|
65
|
+
self.scan_pattern(r'[ \t\n\r]*', 'whitespace')
|
|
66
|
+
while self.scan_pattern(r'\/\/.*?[\n\r]', 'comment'):
|
|
67
|
+
self.scan_pattern(r'[ \t\n\r]*', 'whitespace')
|
|
68
|
+
if self.pos >= len(self.text):
|
|
69
|
+
self.token = None
|
|
70
|
+
self.type = 'EOF'
|
|
71
|
+
return
|
|
72
|
+
if self.scan_pattern(u'\\~|→|=|¬|∧|∨', 'operator'):
|
|
73
|
+
return
|
|
74
|
+
# Note that '?' appears in both `variable` and in `punct`.
|
|
75
|
+
# This is acceptable however, because in normal prose, the
|
|
76
|
+
# '?' symbol is never immediately followed by a letter.
|
|
77
|
+
if self.scan_pattern(r'\?[a-zA-Z_]+', 'variable'):
|
|
78
|
+
return
|
|
79
|
+
if self.scan_pattern(r'\,|\.|\;|\:|\?|\!|\"', 'punct'):
|
|
80
|
+
return
|
|
81
|
+
if self.scan_pattern(r'\(|\)|\{|\}|\[|\]', 'bracket'):
|
|
82
|
+
return
|
|
83
|
+
if self.scan_pattern(r"[a-zA-Z_]['a-zA-Z0-9_-]*", 'word'):
|
|
84
|
+
return
|
|
85
|
+
if self.scan_pattern(u'[αβγδεζθικλμνξοπρστυφχψω]', 'variable'):
|
|
86
|
+
for varname, letter in GREEK:
|
|
87
|
+
if letter == self.token:
|
|
88
|
+
self.token = varname
|
|
89
|
+
break
|
|
90
|
+
assert self.token.startswith('?'), repr(self.token)
|
|
91
|
+
return
|
|
92
|
+
if self.scan_pattern(r'.', 'unknown character'):
|
|
93
|
+
return
|
|
94
|
+
else:
|
|
95
|
+
raise AssertionError("this should never happen, self.text=(%s), self.pos=(%s)" % (self.text, self.pos))
|
|
96
|
+
|
|
97
|
+
def expect(self, token):
|
|
98
|
+
if self.token == token:
|
|
99
|
+
self.scan()
|
|
100
|
+
else:
|
|
101
|
+
raise SyntaxError(u"Expected '%s', but found '%s' (near '%s')" %
|
|
102
|
+
(token, self.token, self.near_text()))
|
|
103
|
+
|
|
104
|
+
def on(self, *tokens):
|
|
105
|
+
return self.token in tokens
|
|
106
|
+
|
|
107
|
+
def on_type(self, *types):
|
|
108
|
+
return self.type in types
|
|
109
|
+
|
|
110
|
+
def check_type(self, *types):
|
|
111
|
+
if not self.on_type(*types):
|
|
112
|
+
raise SyntaxError(u"Expected %s, but found %s ('%s') (near '%s')" %
|
|
113
|
+
(types, self.type, self.token, self.near_text()))
|
|
114
|
+
|
|
115
|
+
def consume(self, *tokens):
|
|
116
|
+
if self.token in tokens:
|
|
117
|
+
self.scan()
|
|
118
|
+
return True
|
|
119
|
+
else:
|
|
120
|
+
return False
|
samovar/terms.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# Python 2/3
|
|
5
|
+
try:
|
|
6
|
+
unicode = unicode
|
|
7
|
+
except NameError:
|
|
8
|
+
unicode = str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AbstractTerm(object):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Term(AbstractTerm):
|
|
16
|
+
def __init__(self, constructor, *subterms):
|
|
17
|
+
self.t = tuple([constructor] + list(subterms))
|
|
18
|
+
|
|
19
|
+
def __lt__(self, other):
|
|
20
|
+
if isinstance(other, Term):
|
|
21
|
+
return self.t < other.t
|
|
22
|
+
else:
|
|
23
|
+
raise TypeError("'<' not supported between instances of 'Term' and '{}'".format(other.__class__.__name__))
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def constructor(self):
|
|
27
|
+
return self.t[0]
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def subterms(self):
|
|
31
|
+
return self.t[1:]
|
|
32
|
+
|
|
33
|
+
def __str__(self):
|
|
34
|
+
if len(self.subterms) == 0:
|
|
35
|
+
return self.constructor
|
|
36
|
+
return u"%s(%s)" % (self.constructor, ', '.join([unicode(s) for s in self.subterms]))
|
|
37
|
+
|
|
38
|
+
def __repr__(self):
|
|
39
|
+
if self.subterms:
|
|
40
|
+
return "%s(%r, *%r)" % (
|
|
41
|
+
self.__class__.__name__, self.constructor, self.subterms
|
|
42
|
+
)
|
|
43
|
+
else:
|
|
44
|
+
return "%s(%r)" % (
|
|
45
|
+
self.__class__.__name__, self.constructor
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def __eq__(self, other):
|
|
49
|
+
return isinstance(other, Term) and self.t == other.t
|
|
50
|
+
|
|
51
|
+
def __hash__(self):
|
|
52
|
+
return hash(self.t)
|
|
53
|
+
|
|
54
|
+
def is_atom(self):
|
|
55
|
+
return len(self.subterms) == 0
|
|
56
|
+
|
|
57
|
+
def is_ground(term):
|
|
58
|
+
for subterm in term.subterms:
|
|
59
|
+
if not subterm.is_ground():
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
def match(self, term, env, **kwargs):
|
|
64
|
+
"""Returns None if no match."""
|
|
65
|
+
if env is None or self.constructor != term.constructor or len(self.subterms) != len(term.subterms):
|
|
66
|
+
return None
|
|
67
|
+
for (subpat, subterm) in zip(self.subterms, term.subterms):
|
|
68
|
+
env = subpat.match(subterm, env, **kwargs)
|
|
69
|
+
return env
|
|
70
|
+
|
|
71
|
+
def subst(self, env):
|
|
72
|
+
return Term(self.constructor, *[subterm.subst(env) for subterm in self.subterms])
|
|
73
|
+
|
|
74
|
+
def collect_atoms(self, atoms):
|
|
75
|
+
if self.is_atom():
|
|
76
|
+
atoms.add(self)
|
|
77
|
+
else:
|
|
78
|
+
for subterm in self.subterms:
|
|
79
|
+
subterm.collect_atoms(atoms)
|
|
80
|
+
|
|
81
|
+
def collect_variables(self, vars_):
|
|
82
|
+
for subterm in self.subterms:
|
|
83
|
+
subterm.collect_variables(vars_)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Var(AbstractTerm):
|
|
87
|
+
def __init__(self, name):
|
|
88
|
+
self.name = unicode(name)
|
|
89
|
+
|
|
90
|
+
def __str__(self):
|
|
91
|
+
return self.name
|
|
92
|
+
|
|
93
|
+
def __repr__(self):
|
|
94
|
+
return "%s(%r)" % (self.__class__.__name__, self.name)
|
|
95
|
+
|
|
96
|
+
def __eq__(self, other):
|
|
97
|
+
return isinstance(other, Var) and self.name == other.name
|
|
98
|
+
|
|
99
|
+
def __hash__(self):
|
|
100
|
+
return hash(self.name)
|
|
101
|
+
|
|
102
|
+
def is_atom(self):
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
def is_ground(term):
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
def match(self, term, env, **kwargs):
|
|
109
|
+
if env is None or self.name == '?_':
|
|
110
|
+
return env
|
|
111
|
+
if self.name in env:
|
|
112
|
+
bound_to = env[self.name]
|
|
113
|
+
return bound_to.match(term, env, **kwargs)
|
|
114
|
+
if kwargs.get('unique_binding'):
|
|
115
|
+
if term in env.values():
|
|
116
|
+
# raise ValueError("Not unique")
|
|
117
|
+
return None
|
|
118
|
+
return dict(list(env.items()) + [(self.name, term)])
|
|
119
|
+
|
|
120
|
+
def subst(self, env):
|
|
121
|
+
return env[self.name]
|
|
122
|
+
|
|
123
|
+
def collect_atoms(self, atoms):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
def collect_variables(self, vars_):
|
|
127
|
+
vars_.add(self)
|
samovar/tests.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest import TestCase
|
|
3
|
+
|
|
4
|
+
from samovar.ast import Assert, Retract, join_sentence_parts
|
|
5
|
+
from samovar.database import Database
|
|
6
|
+
from samovar.terms import Term, Var
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def t(s, *args):
|
|
10
|
+
if s.startswith('?'):
|
|
11
|
+
return Var(s)
|
|
12
|
+
else:
|
|
13
|
+
return Term(s, *args)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def a(t):
|
|
17
|
+
return Assert(term=t)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def r(t):
|
|
21
|
+
return Retract(term=t)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TermTestCase(TestCase):
|
|
25
|
+
def test_term_basic_properties(self):
|
|
26
|
+
t1 = Term('alice')
|
|
27
|
+
t2 = Term('actor', t1)
|
|
28
|
+
v1 = Var('?A')
|
|
29
|
+
t3 = Term('actor', v1)
|
|
30
|
+
|
|
31
|
+
self.assertTrue(t1.is_atom())
|
|
32
|
+
self.assertFalse(t2.is_atom())
|
|
33
|
+
self.assertFalse(v1.is_atom())
|
|
34
|
+
self.assertFalse(t3.is_atom())
|
|
35
|
+
|
|
36
|
+
self.assertTrue(t1.is_ground())
|
|
37
|
+
self.assertTrue(t2.is_ground())
|
|
38
|
+
self.assertFalse(v1.is_ground())
|
|
39
|
+
self.assertFalse(t3.is_ground())
|
|
40
|
+
|
|
41
|
+
self.assertEqual(t2, Term('actor', Term('alice')))
|
|
42
|
+
|
|
43
|
+
def test_term_match_ground(self):
|
|
44
|
+
t1 = Term('actor', Term('alice'))
|
|
45
|
+
p1 = Term('actor', Term('alice'))
|
|
46
|
+
u = p1.match(t1, {})
|
|
47
|
+
self.assertEqual(u, {})
|
|
48
|
+
|
|
49
|
+
def test_term_no_match_ground(self):
|
|
50
|
+
t1 = Term('actor', Term('alice'))
|
|
51
|
+
p1 = Term('actor', Term('bob'))
|
|
52
|
+
self.assertIsNone(p1.match(t1, {}))
|
|
53
|
+
|
|
54
|
+
def test_term_match_bind_var(self):
|
|
55
|
+
t1 = Term('actor', Term('alice'))
|
|
56
|
+
p1 = Term('actor', Var('?A'))
|
|
57
|
+
e = {}
|
|
58
|
+
u = p1.match(t1, e)
|
|
59
|
+
self.assertEqual(u, {u'?A': Term('alice')})
|
|
60
|
+
self.assertEqual(e, {})
|
|
61
|
+
|
|
62
|
+
def test_term_match_already_bound_var(self):
|
|
63
|
+
t1 = Term('actor', Term('alice'))
|
|
64
|
+
p1 = Term('actor', Var('?A'))
|
|
65
|
+
u = p1.match(t1, {u'?A': Term('alice')})
|
|
66
|
+
self.assertEqual(u, {u'?A': Term('alice')})
|
|
67
|
+
|
|
68
|
+
def test_term_no_match_already_bound_var(self):
|
|
69
|
+
t1 = Term('actor', Term('alice'))
|
|
70
|
+
p1 = Term('actor', Var('?A'))
|
|
71
|
+
u = {u'?A': Term('bob')}
|
|
72
|
+
self.assertIsNone(p1.match(t1, u))
|
|
73
|
+
|
|
74
|
+
def test_term_subst(self):
|
|
75
|
+
t = Term('actor', Var('?A'))
|
|
76
|
+
r = t.subst({u'?A': Term('alice')})
|
|
77
|
+
self.assertEqual(r, Term('actor', Term('alice')))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RenderTestCase(TestCase):
|
|
81
|
+
def test_join_sentence_parts_1(self):
|
|
82
|
+
self.assertEqual(
|
|
83
|
+
join_sentence_parts(['"', "Hello", ",", '"', "said", "the", "mouse", "."]),
|
|
84
|
+
'"Hello," said the mouse.'
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def test_join_sentence_parts_2(self):
|
|
88
|
+
self.assertEqual(
|
|
89
|
+
join_sentence_parts(["The", "mouse", "asked", ",", '"', "What", "is", "it", "?", '"']),
|
|
90
|
+
'The mouse asked, "What is it?"'
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def test_join_sentence_parts_3(self):
|
|
94
|
+
self.assertEqual(
|
|
95
|
+
join_sentence_parts(["It", "was", "very", ",", "very", "dark", '.']),
|
|
96
|
+
'It was very, very dark.'
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class DatabaseTestCase(unittest.TestCase):
|
|
101
|
+
|
|
102
|
+
def setUp(self):
|
|
103
|
+
self.database = Database([
|
|
104
|
+
t('actor', t('alice')),
|
|
105
|
+
t('actor', t('bob')),
|
|
106
|
+
|
|
107
|
+
t('drink', t('gin')),
|
|
108
|
+
|
|
109
|
+
t('weapon', t('revolver')),
|
|
110
|
+
t('weapon', t('knife')),
|
|
111
|
+
t('weapon', t('club')),
|
|
112
|
+
|
|
113
|
+
t('holding', t('bob'), t('revolver')),
|
|
114
|
+
t('holding', t('alice'), t('gin')),
|
|
115
|
+
t('holding', t('alice'), t('knife')),
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestMatchAll(DatabaseTestCase):
|
|
120
|
+
|
|
121
|
+
def assertMatchAll(self, query, result):
|
|
122
|
+
self.assertEqual(self.database.match_all(query, {}), result)
|
|
123
|
+
|
|
124
|
+
def test_match_all(self):
|
|
125
|
+
# Find all actors who are Cody. Since there is no such actor, this will return no matches.
|
|
126
|
+
self.assertMatchAll(
|
|
127
|
+
[a(t('actor', t('cody')))],
|
|
128
|
+
[]
|
|
129
|
+
)
|
|
130
|
+
# Find all actors who are Alice. This will return one match, but no bindings.
|
|
131
|
+
self.assertMatchAll(
|
|
132
|
+
[a(t('actor', t('alice')))],
|
|
133
|
+
[{}]
|
|
134
|
+
)
|
|
135
|
+
# Find all drinks. This will return one match, with ?D bound to the result.
|
|
136
|
+
self.assertMatchAll(
|
|
137
|
+
[a(t("drink", t("?D")))],
|
|
138
|
+
[{'?D': Term('gin')}] # there was a match, in which ?D was bound
|
|
139
|
+
)
|
|
140
|
+
# Find all actors.
|
|
141
|
+
self.assertMatchAll(
|
|
142
|
+
[a(t('actor', t('?C')))],
|
|
143
|
+
[{'?C': Term('alice')}, {'?C': Term('bob')}]
|
|
144
|
+
)
|
|
145
|
+
# Find all actors who are holding the revolver.
|
|
146
|
+
self.assertMatchAll(
|
|
147
|
+
[a(t('actor', t('?C'))), a(t('holding', t('?C'), t('revolver')))],
|
|
148
|
+
[{'?C': t('bob')}]
|
|
149
|
+
)
|
|
150
|
+
# Find all actors who are holding a weapon.
|
|
151
|
+
self.assertMatchAll(
|
|
152
|
+
[a(t('actor', t('?C'))), a(t('weapon', t('?W'))), a(t('holding', t('?C'), t('?W')))],
|
|
153
|
+
[{'?W': t('knife'), '?C': t('alice')}, {'?W': t('revolver'), '?C': t('bob')}]
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def test_match_all_with_unique_binding(self):
|
|
157
|
+
# Find all pairs of actors. Because the actors must be different, there are only 2 matches.
|
|
158
|
+
self.assertMatchAll(
|
|
159
|
+
[a(t('actor', t('?A'))), a(t('actor', t('?B')))],
|
|
160
|
+
[{'?A': Term('alice'), '?B': Term('bob')}, {'?A': Term('bob'), '?B': Term('alice')}]
|
|
161
|
+
)
|
|
162
|
+
# Find all pairs of drinks. Since there is only one, and we can't return (gin,gin),
|
|
163
|
+
# there will be no matches.
|
|
164
|
+
self.assertMatchAll(
|
|
165
|
+
[a(t('drink', t('?A'))), a(t('drink', t('?B')))],
|
|
166
|
+
[]
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def test_match_all_with_negation(self):
|
|
170
|
+
# Find all actors who are not holding the revolver.
|
|
171
|
+
self.assertMatchAll(
|
|
172
|
+
[a(t('actor', t('?C'))), r(t('holding', t('?C'), t('revolver')))],
|
|
173
|
+
[{'?C': t('alice')}]
|
|
174
|
+
)
|
|
175
|
+
# Find all actors who are not holding a weapon. Or rather, all pairs
|
|
176
|
+
# of (actor, weapon) where the actor is not holding that weapon.
|
|
177
|
+
self.assertMatchAll(
|
|
178
|
+
[a(t('actor', t('?C'))), a(t('weapon', t('?W'))), r(t('holding', t('?C'), t('?W')))],
|
|
179
|
+
[
|
|
180
|
+
{'?W': t('club'), '?C': t('alice')},
|
|
181
|
+
{'?W': t('revolver'), '?C': t('alice')},
|
|
182
|
+
{'?W': t('club'), '?C': t('bob')},
|
|
183
|
+
{'?W': t('knife'), '?C': t('bob')},
|
|
184
|
+
]
|
|
185
|
+
)
|
|
186
|
+
# Note that we can't say "Find all actors who aren't Alice".
|
|
187
|
+
# We can say this:
|
|
188
|
+
self.assertMatchAll(
|
|
189
|
+
[a(t('actor', t('?C'))), r(t('actor', t('alice')))],
|
|
190
|
+
[]
|
|
191
|
+
)
|
|
192
|
+
# ... but what this is saying is "Find all actors if Alice isn't an actor."
|
|
193
|
+
|
|
194
|
+
# For a one-off case, we can do something like this:
|
|
195
|
+
self.database.add(t('is_alice', t('alice')))
|
|
196
|
+
self.assertMatchAll(
|
|
197
|
+
[a(t('actor', t('?C'))), r(t('is_alice', t('?C')))],
|
|
198
|
+
[{'?C': t('bob')}]
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# For the general case, we'll need to think about equality tests.
|
|
202
|
+
|
|
203
|
+
# Note also that we can't search on negative clauses with free variables:
|
|
204
|
+
with self.assertRaises(KeyError):
|
|
205
|
+
self.database.match_all([a(t('actor', t('?C'))), r(t('weapon', t('?W')))], {})
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if __name__ == '__main__':
|
|
209
|
+
unittest.main()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: samovar-py
|
|
3
|
+
Version: 0.6
|
|
4
|
+
Summary: Samovar: model worlds using propositions and run simulations in them
|
|
5
|
+
Author-email: Chris Pressey <packages@catseye.tc>
|
|
6
|
+
License: This is free and unencumbered software released into the public domain.
|
|
7
|
+
|
|
8
|
+
Anyone is free to copy, modify, publish, use, compile, sell, or
|
|
9
|
+
distribute this software, either in source code form or as a compiled
|
|
10
|
+
binary, for any purpose, commercial or non-commercial, and by any
|
|
11
|
+
means.
|
|
12
|
+
|
|
13
|
+
In jurisdictions that recognize copyright laws, the author or authors
|
|
14
|
+
of this software dedicate any and all copyright interest in the
|
|
15
|
+
software to the public domain. We make this dedication for the benefit
|
|
16
|
+
of the public at large and to the detriment of our heirs and
|
|
17
|
+
successors. We intend this dedication to be an overt act of
|
|
18
|
+
relinquishment in perpetuity of all present and future rights to this
|
|
19
|
+
software under copyright law.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
22
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
23
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
24
|
+
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
25
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
26
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
27
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
|
28
|
+
|
|
29
|
+
For more information, please refer to <http://unlicense.org/>
|
|
30
|
+
|
|
31
|
+
Project-URL: website, https://catseye.tc/node/Samovar
|
|
32
|
+
Classifier: License :: OSI Approved :: The Unlicense (Unlicense)
|
|
33
|
+
Classifier: Operating System :: OS Independent
|
|
34
|
+
Classifier: Programming Language :: Python :: 2.7
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Topic :: Artistic Software
|
|
37
|
+
Classifier: Topic :: Games/Entertainment :: Simulation
|
|
38
|
+
Requires-Python: >=2.7,>=3.5
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
Samovar
|
|
42
|
+
=======
|
|
43
|
+
|
|
44
|
+
Version 0.6 | _Entry_ [@ catseye.tc](https://catseye.tc/node/Samovar)
|
|
45
|
+
| _See also:_ [The League of Extraordinarily Dull Gentlemen](https://github.com/catseye/NaNoGenMo-Entries-2018/tree/master/league#readme)
|
|
46
|
+
∘ [The Swallows](https://github.com/catseye/The-Swallows#readme)
|
|
47
|
+
|
|
48
|
+
- - - -
|
|
49
|
+
|
|
50
|
+
**Samovar** is a domain-specific language (DSL) for modelling a world using
|
|
51
|
+
propositions (facts), and possible events that can occur based on those facts,
|
|
52
|
+
changing them.
|
|
53
|
+
|
|
54
|
+
Here is a short example of a Samovar description:
|
|
55
|
+
|
|
56
|
+
scenario IgnatzWithBrick {
|
|
57
|
+
|
|
58
|
+
[actor(?A),item(?I),~holding(?A,?I)] ?A picks up the ?I. [holding(?A,?I)]
|
|
59
|
+
[actor(?A),item(?I),holding(?A,?I)] ?A puts down the ?I. [~holding(?A,?I)]
|
|
60
|
+
|
|
61
|
+
actor(Ignatz).
|
|
62
|
+
item(brick).
|
|
63
|
+
|
|
64
|
+
goal [].
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
And an implementation of Samovar could take this scenario and use it to,
|
|
68
|
+
among other things, generate textual descriptions of chains of events like
|
|
69
|
+
|
|
70
|
+
Ignatz picks up the brick. Ignatz puts down the brick.
|
|
71
|
+
|
|
72
|
+
Of course, this is a very simple example. (It doesn't even prevent two
|
|
73
|
+
actors from picking up the same item at the same time!) For more complex
|
|
74
|
+
examples, and a fuller description of the language, see
|
|
75
|
+
[doc/Samovar.md](doc/Samovar.md), which also serves as a test suite.
|
|
76
|
+
|
|
77
|
+
### Implementation
|
|
78
|
+
|
|
79
|
+
The reference implementation of Samovar, `samovar`, is written in Python.
|
|
80
|
+
It can run under either Python 2.7 or Python 3.x (tested with 3.5 or higher.)
|
|
81
|
+
|
|
82
|
+
### Discussion
|
|
83
|
+
|
|
84
|
+
This looks like logic programming but the internals are actually much simpler,
|
|
85
|
+
and what it's doing is not logical inference.
|
|
86
|
+
|
|
87
|
+
The internals are far simpler than an inference engine or a theorem
|
|
88
|
+
prover: there are no logical rules in the database, only propositions, so
|
|
89
|
+
they can be selected by simple pattern-matching rather than full unification.
|
|
90
|
+
|
|
91
|
+
I originally described it as an "assertion-retraction engine", which could
|
|
92
|
+
be thought of as a highly stylized form of Prolog programming. Alternately,
|
|
93
|
+
it could be thought of as assigning preconditions and postconditions, like
|
|
94
|
+
in [Hoare logic][], to actions in a world-model. Instead of constructing a
|
|
95
|
+
proof, though, we simply chain actions together, by selecting any next one
|
|
96
|
+
whose preconditions hold, and altering the world so that its postconditions hold.
|
|
97
|
+
|
|
98
|
+
In (I think) autumn 2019, I came across the concept of a [production system][],
|
|
99
|
+
and I realized that is basically what Samovar implements. Researching it
|
|
100
|
+
further, I discovered [CLIPS][], a production system built in 1985,
|
|
101
|
+
and I realized that Samovar is not essentially different from an
|
|
102
|
+
inefficiently-implemented, stripped-down version of CLIPS.
|
|
103
|
+
|
|
104
|
+
The main innovation in Samovar is the ability to "narrate" the choices the
|
|
105
|
+
production system makes, with human-readable text.
|
|
106
|
+
|
|
107
|
+
This isn't too surprising, since my goal with Samovar was to make an expressive
|
|
108
|
+
language, a syntax in which the author could write a story generator without
|
|
109
|
+
the undue effort of switching context between prose and code. Picking logical
|
|
110
|
+
propositions for the "code" and placing them before and after each fragment of
|
|
111
|
+
prose was a reasonably "ergonomic" choice, and happens to coincide with the
|
|
112
|
+
structure of a production system.
|
|
113
|
+
|
|
114
|
+
[Hoare logic]: https://en.wikipedia.org/wiki/Hoare_logic
|
|
115
|
+
[production system]: https://en.wikipedia.org/wiki/Production_system_(computer_science)
|
|
116
|
+
[CLIPS]: https://en.wikipedia.org/wiki/CLIPS
|
|
117
|
+
|
|
118
|
+
### TODO
|
|
119
|
+
|
|
120
|
+
* Maybe allow variables to be notated so that they can bind reflexively,
|
|
121
|
+
e.g. `?*A looks at ?*B` can bind both variables to Alice.
|
|
122
|
+
* Make `?_` work such that you can say `¬holding(?_, club)` to mean
|
|
123
|
+
"if no one is holding the club".
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
samovar/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
samovar/ast.py,sha256=AFLiqpr9bBkgQ84UeQhV06nWcA17n41kdIoowpJOOew,1509
|
|
3
|
+
samovar/database.py,sha256=eV0x8JGjAiUq697YvkK0gjIzj8OJMln6IWpT8Dsqdug,2796
|
|
4
|
+
samovar/main.py,sha256=RmUtRT3O7R_IumHDJT6xJel1GTtCrWwqYvN-6DMTHZc,4934
|
|
5
|
+
samovar/parser.py,sha256=bYrtRhXHTAkEj7g9z7Lfua85VcRpzpFxg84_LN4BBwY,5847
|
|
6
|
+
samovar/randomness.py,sha256=4scvXciSZ7W2hINWnLcAZf0ov4RYO8BJ7Zr4tzhnx6Q,475
|
|
7
|
+
samovar/scanner.py,sha256=V9bYcY0n_oFUqk87SBBToE-XtTALcXtmE3RmiZoab_s,3575
|
|
8
|
+
samovar/terms.py,sha256=aYTEJynkCu_ecDm7WpETT_XSBl-Wl4JPEV7unn5AqDE,3360
|
|
9
|
+
samovar/tests.py,sha256=T-clqS77aRloIt5tkm56ffeY05oYIB0JUeUWyjqMUj8,7017
|
|
10
|
+
samovar/generators/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
samovar/generators/base.py,sha256=-oeffjcGkVyDr0ltIY9W0OUQrG84LYbqJWZiWTkQ4Qk,1799
|
|
12
|
+
samovar/generators/breadthfirst.py,sha256=JA9ZcGA6Nj0YM8f5QQPVgL04l-uCmfE6ntlo65B25B8,2181
|
|
13
|
+
samovar/generators/depthfirst.py,sha256=7HfkMKb0RUiObC2Y9nzY7U2bg7z33Vtcx1dKhGVv4d4,2605
|
|
14
|
+
samovar/generators/stochastic.py,sha256=6ca1OjpjocCBInq81tuKv_HLBgbLwgN_cZhaSrizbw0,2218
|
|
15
|
+
samovar_py-0.6.dist-info/METADATA,sha256=tf1Z8B5hHVEi51MECtnajfjbse8st1lHVIxqXOEEC0I,5656
|
|
16
|
+
samovar_py-0.6.dist-info/WHEEL,sha256=5sUXSg9e4bi7lTLOHcm6QEYwO5TIF1TNbTSVFVjcJcc,92
|
|
17
|
+
samovar_py-0.6.dist-info/entry_points.txt,sha256=tpU5DZJ34s4hpNFCobyu6E-SdWQyxfQBy_-oMZFqMxk,50
|
|
18
|
+
samovar_py-0.6.dist-info/top_level.txt,sha256=78kn4qDuoyXOHtnHgEl6PtZaxBAQ7Basrn2mBbW8i_Y,8
|
|
19
|
+
samovar_py-0.6.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
samovar
|