Repository: AI-Planning/macq (language: python)

Issue:
API Usage Ideas
### General Ideas
- Serialize and unserialize problem configuration
- Optional parameter for the session ID, so the user can use their own files instead of the default PDDL problems
- Maybe integrating with other aspects of the planning domains library? (For example, retrieving a domain and then listing the possible problems)
- In general, using variables that autocomplete so the user doesn’t have to search the docs

### Generate
- `make_generator` is less ambiguous then `Generator` for the other generators
- Let `Generate` take a model or add a function for generating from models
- How does `Generate` know what type of `generator` it got?

### Trace
- Individual `trace` should be indexable by step (not fluent)
- `traces[0][step]` == `trace[step]`
- For structure, each Trace object consists of a list of dictionaries, where each index in the list is a step, and the dictionary for each step holds the action taken and the fluents true at that step
- `trace.get_action(action)` - returns a list of dictionaries: each time the action was taken in the trace and its effects and/or state prior to action (could split in 2)
- `trace.get_cost(step=None)` - cost up to step (default: whole trace)
- `trace.steps` - number of steps
- `trace.num_fluents` - number of fluents
- `trace.fluents` - list of fluents (just the list of all fluents used in this particular trace; no true/false)
- `trace.actions` - similarly, list of actions taken in this trace
- `trace.unused_actions()` - returns a list of actions not used in this trace
- `trace.get_usage(action)` - percentage of total actions the action appears in
- `trace.test_preconditions(action, [(fluent, true/false), ... ])` - return num/percentage action was taken with those conditions in the trace

### Traces
- name `traces` class `TraceList` to avoid ambiguity with `Trace`
- sort traces by cost (in a min heap if traces > optimal threshold), then before returning, poll the heap so they are stored in a sorted list
- `traces.generate_more(num)` - preserves current traces, converts list back to min heap if the list is large enough
- `traces.unused_actions()` - unused actions
- `traces.get_usage(action)` - percentage of traces the action appears in
- `traces.test_preconditions(action, [(fluent, true/false), ... ])` - return num/percentage action was taken with those conditions across all traces

### Model
- `model.fluents`
- `model.initial_state`
- `model.goal`
- `model.serialize()`
- `model.unserialize()`
- `model.solve_from(state, steps=None, traces=1)` - generate trace(s) from a state

### Features (Possible Ideas):
- Determinism
    - Non-determinism
        - Allow the user to specify the preferred effects of nondeterministic actions (i.e. would prefer that action A results in effect B) before generating traces
        - Or set probabilities
    - Probabilistic
        - After the traces are generated, a function in Trace/Traces could give the user information on how these probabilities actually performed (i.e. the predicted probability of effect C of action A being executed was 30%, but when run it was closer to 50%).
- Fluent Observability
    - Some fluents missing
        - Allow the user to dynamically specify which fluents they would like to make invisible, so they can easily specify some fluents, generate some traces, change the fluents, generate more traces, etc.
    - Random fluents missing
        - Similarly, allow the user to re-generate random missing fluents with ease
- Action Observability
    - Allow the user to specify:
        - Which actions are non-observable
        - Which parameters for a given action are unknown
        - Which actions have unknown preconditions or effects or both
- State Noise
    - Allow the user to specify the associated probability of a fluent being correct (defaults to 100% - no noise - if none specified)
 - Action Noise
    - Same as above, but with actions





More Detailed API/Structure
#### Functions (no class):
- make_generator (from initial Usage: Generator)
- generate (from initial Usage: Generate)
- extract (from initial Usage: Extract)

#### Other Types:
- state: list of fluents
 

#### Class: Generator
- Attributes:
    - determinism
        - model.determinism.FULLY
        - model.determinism.NON
        - model.determinism.PROB
    - fluent_observability:
        - model.fluentobs.FULLY
        - model.fluentobs.PARTIAL
        - model.fluentobs.RANDOM
    - parameterized: {atomic_fluents, atomic_actions}
    - action_observability: {names, params, pre, eff}
    - state_noise: some int between 0-100%
    - action_noise: some int between 0-100%
    - rationality
        - model.rational.YES
        - model.rational.NO
        - model.rational.BOUNDED
- Functions:
    - configure(length, use_goal, diversity, method, action_probs=None, missing_fluents=None)
        - action_probs: {action: {effect: prob, ...}, ...} - used only in non-deterministic models to (optionally) specify the probabilities of the effect of each action
        - missing_fluents - list of (optional) fluents the user wants to make invisible if using partial observability.
        - no parameter needed, but if fluent observability is set to RANDOM, then randomly generate which fluents are missing
        - no parameter needed, but remove the visibility of action names, parameters, preconditions and effects appropriately according to the action_observability attribute
        - no parameter needed, but take rationality into account


 #### Class: Predicate
- Attributes:
    - name
    - object(s)



#### Class: Effect
- Attributes:
    - name
    - object(s)
    - probability
    - func

 

#### Class: Action
- Attributes:
    - name
    - parameters/objects(that the action is acting on)
    - preconditions
    - effects

 

#### Class: Step
- Attributes:
    - action
    - fluents (that the action is acting on)
    - state (all fluents prior to action)

 

#### Class: ObservationToken
- Attributes:
    - method
- Functions:
    - __init__(method) - takes an enum to determine the method to use (or a function ref / lambda func?)
    - tokenize(action, state) - use self.method to generate an obs token for the action-state pair

 

#### Class: Trace (Indexable Class) - Each index is a Step
- Attributes:
    - steps
    - num_fluents
    - fluents
    - actions
- Functions:
    - get_prev_states(action)
    - get_post_states(action)
    - Or get_effects(action)
    - get_total_cost()
    - get_cost_range(start=0, end)
    - get_usage(action)

 

#### Class: TraceList (Indexable Class)
- Attributes:
    - traces - list of Trace objects
    - generator - the generator function used to generate the TraceList, can be None
- Functions:
    - generate_more(num) - uses the generator function to generate more Traces while preserving the original
    - get_usage(action)
    - __iter__
    - __getitem__
    - __len__
    - ... list methods

 

#### Class: Model
- Attributes:
    - fluents
    - actions
    - initial_state
    - goal
    - determinism
        - model.determinism.FULLY
        - model.determinism.NON
        - model.determinism.PROB
    - fluent_observability:
        - model.fluentobs.FULLY
        - model.fluentobs.PARTIAL
        - model.fluentobs.RANDOM
    - parameterized: {atomic_fluents, atomic_actions}
    - action_observability: {names, params, pre, eff}
    - state_noise: true/false
    - action_noise: true/false
    - rationality
        - model.rational.YES
        - model.rational.NO
        - model.rational.BOUNDED
- Functions:
    - serialize()
    - unserialize()
    - to_pddl()


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..f627bbe4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.venv
+.vscode
+__pycache__
+build/
+dist/
+macq.egg-info/
+.pytest_cache/
diff --git a/README.md b/README.md
index cb1f2d38..8f5bf5c8 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,12 @@
 
 This library is a collection of tools for planning-like action model acquisition from state trace data. It contains a reimplementation from many existing works, and generalizes some of them to new settings.
 
+Install for development by cloning the repository and running `pip install .[dev]` (use `pip3` if you have python2 installed).
+
 ## Usage
 ```python
 from macq import generate, extract
+from macq.observation import IdentityObservation
 
 # domain-specific generator: uses api.planning.domains problem_id
 blocks_gen = generate.pddl.Generator(problem_id = 123)
@@ -19,19 +22,25 @@ blocks_gen.configure(
   }
 )
 
-# generate 20 traces
+# generate 100 traces
 traces = generate.Generate(generator=blocks_gen, traces=100)
 
-traces[0]
-...
+more_traces = traces.generate_more(10)
 
-traces.fluents
-...
+traces.get_usage(action)
 
-f = traces.fluents[0]
+trace = traces[0]
+trace.fluents
+trace.actions
+trace.get_prev_states(action) # get the state before each occurance of action
+trace.get_post_states(action) # state after each occurance of action
+trace.get_total_cost()
+trace.tokenize(IdentityObservation)
+trace.tokens
 
-traces[0][f]
-True
+step = trace[0]
+step.base_fluents()
+step.base_action()
 
 model = extract.Extract(traces, extract.modes.OBSERVER)
 
diff --git a/macq/__init__.py b/macq/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/macq/extract/__init__.py b/macq/extract/__init__.py
new file mode 100644
index 00000000..a9a2c5b3
--- /dev/null
+++ b/macq/extract/__init__.py
@@ -0,0 +1,1 @@
+__all__ = []
diff --git a/macq/generate/__init__.py b/macq/generate/__init__.py
new file mode 100644
index 00000000..a9a2c5b3
--- /dev/null
+++ b/macq/generate/__init__.py
@@ -0,0 +1,1 @@
+__all__ = []
diff --git a/macq/observation/__init__.py b/macq/observation/__init__.py
new file mode 100644
index 00000000..13fa34ce
--- /dev/null
+++ b/macq/observation/__init__.py
@@ -0,0 +1,4 @@
+from .observation import Observation
+from .identity_observation import IdentityObservation
+
+__all__ = ["Observation", "IdentityObservation"]
diff --git a/macq/observation/identity_observation.py b/macq/observation/identity_observation.py
new file mode 100644
index 00000000..fcee29b3
--- /dev/null
+++ b/macq/observation/identity_observation.py
@@ -0,0 +1,24 @@
+from ..trace import Step
+from . import Observation
+
+
+class IdentityObservation(Observation):
+    """
+    The identity observation stores the step unmodified.
+    """
+
+    def __init__(self, step: Step):
+        """
+        Creates an IdentityObservation object, storing the step.
+
+        Attributes
+        ----------
+        step : Step
+            The step associated with this observation.
+        """
+        super().__init__(step)
+
+    def __eq__(self, value):
+        if isinstance(value, IdentityObservation):
+            return self.step == value.step
+        return False
diff --git a/macq/observation/observation.py b/macq/observation/observation.py
new file mode 100644
index 00000000..472bf983
--- /dev/null
+++ b/macq/observation/observation.py
@@ -0,0 +1,14 @@
+from ..trace import Step
+
+
+class Observation:
+    """
+    An Observation object stores an observation token representation of a step.
+    """
+
+    def __init__(self, step: Step):
+        """
+        Creates an Observation object, storing the step as a token.
+        """
+        self.step = step
+
diff --git a/macq/trace/__init__.py b/macq/trace/__init__.py
new file mode 100644
index 00000000..b6fa3acc
--- /dev/null
+++ b/macq/trace/__init__.py
@@ -0,0 +1,8 @@
+from .action import Action, Fluent, CustomObject
+from .state import State
+from .step import Step
+from .trace import Trace
+from .trace_list import TraceList
+
+
+__all__ = ["Action", "Fluent", "CustomObject", "State", "Step", "Trace", "TraceList"]
diff --git a/macq/trace/action.py b/macq/trace/action.py
new file mode 100644
index 00000000..6310c1e3
--- /dev/null
+++ b/macq/trace/action.py
@@ -0,0 +1,131 @@
+from typing import List
+from .fluent import CustomObject, Fluent
+
+
+class Action:
+    class InvalidFluent(Exception):
+        """
+        The Exception raised when the user attempts to add fluents (to a precondition or effect) that act on objects
+        outside of the parameters supplied to the action.
+        """
+
+        def __init__(
+            self,
+            message="Cannot add a fluent referencing objects beyond the scope of this action.",
+        ):
+            super().__init__(message)
+
+    def __init__(
+        self,
+        name: str,
+        obj_params: List[CustomObject],
+        precond: List[Fluent],
+        add: List[Fluent],
+        delete: List[Fluent],
+        cost: int = 0,
+    ):
+        """
+        Class to handle each action.
+
+        Arguments
+        ---------
+        name : str
+            The name of the action.
+        obj_params : list
+            The list of objects this action can apply to.
+        precond: list of Fluents
+            The list of preconditions that must pass before this action takes place.
+        add : list of Fluents
+            The list of fluents to be flipped to True after this action takes place.
+        delete : list of Fluents
+            The list of fluents to be flipped to False after this action takes place.
+        cost : int
+            The cost of this action.
+        """
+        self.name = name
+        self.obj_params = obj_params
+        self.precond = []
+        self.add_precond(precond)
+        self.add = []
+        self.add_effect_add(add)
+        self.delete = []
+        self.add_effect_delete(delete)
+        self.cost = cost
+
+    def __repr__(self):
+        string = "Action with Name: " + self.name + "\n\nObject Parameters:\n"
+        for obj in self.obj_params:
+            string += str(obj) + "\n"
+        string += "Preconditions:\n"
+        for fluent in self.precond:
+            string += str(fluent)
+        string += "\nEffects to add:\n"
+        for fluent in self.add:
+            string += str(fluent)
+        string += "\nEffects to delete:\n"
+        for fluent in self.delete:
+            string += str(fluent)
+        string += "\nCost: " + str(self.cost)
+        return string
+
+    def __add_fluent(self, fluents: List[Fluent], condition: List[Fluent]):
+        """
+        Checks the validity of a fluent before adding it to either the action's preconditions,
+        add effects or delete effects.
+
+        Arguments
+        ---------
+        fluents : list of Fluents
+            The list of fluents to be added to the given action condition.
+        condition : list of Fluents
+            Either the action's preconditions, add effects, or delete effects to be added to.
+        """
+        for fluent in fluents:
+            for obj in fluent.objects:
+                if obj not in self.obj_params:
+                    raise self.InvalidFluent()
+        condition.extend(fluents)
+
+    def add_precond(self, fluents: List[Fluent]):
+        """
+        Adds the specified list of fluents to the action's preconditions.
+
+        Arguments
+        ---------
+        fluents : list of Fluents
+            The list of fluents to be added to the action's preconditions.
+        """
+        self.__add_fluent(fluents, self.precond)
+
+    def add_effect_add(self, fluents: List[Fluent]):
+        """
+        Adds the specified list of fluents to the action's add effects.
+
+        Arguments
+        ---------
+        fluents : list of Fluents
+            The list of fluents to be added to the action's add effects.
+        """
+        self.__add_fluent(fluents, self.add)
+
+    def add_effect_delete(self, fluents: List[Fluent]):
+        """
+        Adds the specified list of fluents to the action's delete effects.
+
+        Arguments
+        ---------
+        fluents : list of Fluents
+            The list of fluents to be added to the action's delete effects.
+        """
+        self.__add_fluent(fluents, self.delete)
+
+    def add_parameter(self, obj: CustomObject):
+        """
+        Adds the specified object to the action's list of available parameters.
+
+        Arguments
+        ---------
+        obj : CustomObject
+            The object to be added to the action's list of available parameters.
+        """
+        self.obj_params.append(obj)
diff --git a/macq/trace/fluent.py b/macq/trace/fluent.py
new file mode 100644
index 00000000..0031d609
--- /dev/null
+++ b/macq/trace/fluent.py
@@ -0,0 +1,37 @@
+from typing import List
+
+
+class CustomObject:
+    def __init__(self, obj_type: str, name: str):
+        self.obj_type = obj_type
+        self.name = name
+
+    def __repr__(self):
+        string = "Type: " + self.obj_type + ", Name: " + self.name
+        return string
+
+
+class Fluent:
+    def __init__(self, name: str, objects: List[CustomObject], value: bool):
+        """
+        Class to handle a predicate and the objects it is applied to.
+
+        Arguments
+        ---------
+        name : str
+            The name of the predicate.
+        objects : list
+            The list of objects this predicate applies to.
+        value : bool
+            The value of the fluent (true or false)
+        """
+        self.name = name
+        self.objects = objects
+        self.value = value
+
+    def __repr__(self):
+        string = "Fluent with Name: " + self.name + "\nObjects:\n"
+        for obj in self.objects:
+            string += str(obj) + "\n"
+        string += "Value: " + str(self.value)
+        return string
diff --git a/macq/trace/state.py b/macq/trace/state.py
new file mode 100644
index 00000000..eb7c1026
--- /dev/null
+++ b/macq/trace/state.py
@@ -0,0 +1,23 @@
+from typing import List
+from . import Fluent
+
+
+class State:
+    """
+    Class for a State, which is the set of all fluents and their values at a particular Step.
+
+    Arguments
+    ---------
+    fluents : List of Fluents
+            A list of fluents representing the state.
+    """
+
+    def __init__(self, fluents: List[Fluent]):
+        self.fluents = fluents
+
+    def __repr__(self):
+        string = "State:\n\n"
+        for fluent in self.fluents:
+            string += str(fluent) + "\n"
+        string = string.strip()
+        return string
diff --git a/macq/trace/step.py b/macq/trace/step.py
new file mode 100644
index 00000000..02e1b350
--- /dev/null
+++ b/macq/trace/step.py
@@ -0,0 +1,53 @@
+from . import Action
+from . import State
+
+
+class Step:
+    """
+    A Step object stores the action, and state prior to the action for a step
+    in a trace.
+    """
+
+    def __init__(self, action: Action, state: State):
+        """
+        Creates a Step object. This stores action, and state prior to the
+        action.
+
+        Attributes
+        ----------
+        action : Action
+            The action taken in this step.
+        state : State
+            The state (list of fluents) at this step.
+        """
+        self.action = action
+        self.state = state
+
+    def __repr__(self):
+        string = str(self.action) + "\n\n" + str(self.state)
+        return string
+
+    def base_fluents(self):
+        """
+        Retrieves the names of all fluents used in this step.
+
+        Returns
+        -------
+        list : str
+            Returns a list of the names of all fluents used in this step.
+        """
+        fluents = []
+        for fluent in self.state.fluents:
+            fluents.append(fluent.name)
+        return fluents
+
+    def base_action(self):
+        """
+        Retrieves the name of the action used in this step.
+
+        Returns
+        -------
+        list : str
+            Returns the name of the action used in this step.
+        """
+        return self.action.name
diff --git a/macq/trace/trace.py b/macq/trace/trace.py
new file mode 100644
index 00000000..d38b8e2e
--- /dev/null
+++ b/macq/trace/trace.py
@@ -0,0 +1,315 @@
+from typing import List, Type, Iterable, Callable
+from . import Action
+from . import Step
+from ..observation import Observation
+
+
+class Trace:
+    """
+    Class for a Trace, which consists of each Step in a generated solution.
+
+    """
+
+    class InvalidCostRange(Exception):
+        """
+        Exception raised for incorrect user input for the cost range.
+        """
+
+        def __init__(self, message):
+            super().__init__(message)
+
+    def __init__(self, steps: List[Step] = []):
+        """
+        Creates a Trace object.
+
+        Arguments
+        ---------
+        steps : List of Steps (optional)
+            The list of Step objects that make up the trace.
+
+        Attributes
+        ----------
+        num_steps : int
+            The number of steps used.
+        num_fluents : int
+            The number of fluents used.
+        fluents : List of str
+            The list of the names of all fluents used.
+            Information on the values of fluents are found in the steps.
+        actions: List of Actions
+            The list of the names of all actions used.
+            Information on the preconditions/effects of actions are found in the steps.
+        observations: List of Observations
+            The set of observation tokens, tokenized from the steps.
+        """
+        self.steps = steps
+        self.num_steps = len(steps)
+        self.fluents = self.base_fluents()
+        self.actions = self.base_actions()
+        self.num_fluents = len(self.fluents)
+        self.observations = []
+
+    def __repr__(self):
+        string = "TRACE:\n\nAttributes:\n\nNumber of Steps: " + str(self.num_steps)
+        string += "\nNumber of Fluents: " + str(self.num_fluents)
+        string += "\n\nBase Fluents:\n"
+        for fluent in self.fluents:
+            string += fluent + "\n"
+        string += "\nBase Actions:\n"
+        for action in self.actions:
+            string += action + "\n"
+        string += "\n\nSteps:"
+        for i in range(self.num_steps):
+            string += "\n\nSTEP " + str(i + 1) + ":\n\n" + str(self.steps[i]) + "\n"
+        string += "\nObservations:\n"
+        for obs in self.observations:
+            string += "\n" + str(obs)
+        return string
+
+    def __len__(self):
+        return len(self.steps)
+
+    def __setitem__(self, key: int, value: Step):
+        self.steps[key] = value
+
+    def __getitem__(self, key: int):
+        return self.steps[key]
+
+    def __delitem__(self, key: int):
+        del self.steps[key]
+
+    def __iter__(self):
+        return iter(self.steps)
+
+    def __reversed__(self):
+        return reversed(self.steps)
+
+    def __contains__(self, item: Step):
+        return item in self.steps
+
+    def append(self, item: Step):
+        self.steps.append(item)
+
+    def clear(self):
+        self.steps.clear()
+
+    def copy(self):
+        return self.steps.copy()
+
+    def count(self, value: Step):
+        return self.steps.count(value)
+
+    def extend(self, iterable: Iterable[Step]):
+        self.steps.extend(iterable)
+
+    def index(self, value: Step):
+        return self.steps.index(value)
+
+    def insert(self, index: int, item: Step):
+        self.steps.insert(index, item)
+
+    def pop(self):
+        return self.steps.pop()
+
+    def remove(self, value: Step):
+        self.steps.remove(value)
+
+    def reverse(self):
+        self.steps.reverse()
+
+    def sort(self, reverse: bool = False, key: Callable = lambda e: e.action.cost):
+        self.steps.sort(reverse=reverse, key=key)
+
+    def add_steps(self, steps: List[Step]):
+        """
+        Class for a Trace, which consists of each Step in a generated solution.
+
+        Arguments
+        ---------
+        steps : List of Steps (optional)
+            The list of Step objects to be added to the trace.
+        """
+        self.steps.extend(steps)
+
+    def base_fluents(self):
+        """
+        Retrieves the names of all fluents used in this trace.
+
+        Returns
+        -------
+        list : str
+            Returns a list of the names of all fluents used in this trace.
+        """
+        fluents = []
+        for step in self.steps:
+            for fluent in step.state.fluents:
+                name = fluent.name
+                if name not in fluents:
+                    fluents.append(name)
+        return fluents
+
+    def base_actions(self):
+        """
+        Retrieves the names of all actions used in this trace.
+
+        Returns
+        -------
+        list : str
+            Returns a list of the names of all actions used in this trace.
+        """
+        actions = []
+        for step in self.steps:
+            name = step.action.name
+            if name not in actions:
+                actions.append(name)
+        return actions
+
+    def get_prev_states(self, action: Action):
+        """
+        Returns a list of the states of the trace before this action took place.
+
+        Arguments
+        ---------
+        action : Action
+            An `Action` object.
+
+        Returns
+        -------
+        prev_states : List of States
+            A list of states before this action took place.
+        """
+        prev_states = []
+        for step in self.steps:
+            if step.action == action:
+                prev_states.append(step.state)
+        return prev_states
+
+    def get_post_states(self, action: Action):
+        """
+        Returns a list of the states of the trace after the given action took place.
+
+        Arguments
+        ---------
+        action : Action
+            An `Action` object.
+
+        Returns
+        -------
+        post_states : List of States
+            A list of states after this action took place.
+        """
+        post_states = []
+        for i in range(self.num_steps - 1):
+            if self.steps[i].action == action:
+                post_states.append(self.steps[i + 1].state)
+        return post_states
+
+    def get_sas_triples(self, action: Action):
+        """
+        Returns a list of tuples where each tuple contains the state of the trace
+        before the action, the action, and the state of the trace after the action.
+
+        Arguments
+        ---------
+        action : Action
+            An `Action` object.
+
+        Returns
+        -------
+        sas_triples : List of tuples
+            A list of tuples in the format (previous state, action, post-state).
+        """
+        sas_triples = []
+        triple = []
+        for i in range(self.num_steps):
+            if self.steps[i].action == action:
+                triple.append(self.steps[i].state)
+                triple.append(action)
+                if i + 1 < self.num_steps:
+                    triple.append(self.steps[i + 1].state)
+                triple = tuple(triple)
+                sas_triples.append(triple)
+                triple = []
+
+        return sas_triples
+
+    def get_total_cost(self):
+        """
+        Returns the total cost of all actions used in this Trace.
+
+        Returns
+        -------
+        sum : int
+            The total cost of all actions used.
+        """
+        sum = 0
+        for step in self.steps:
+            sum += step.action.cost
+        return sum
+
+    def get_cost_range(self, start: int, end: int):
+        """
+        Returns the total cost of the actions in the specified range of this Trace.
+        The range starts at 1 and ends at the number of steps.
+
+        Arguments
+        ---------
+        start : int
+            The start of the specified range.
+        end: int
+            The end of the specified range.
+
+        Returns
+        -------
+        sum : int
+            The total cost of all actions in the specified range.
+        """
+
+        if start < 1 or end < 1 or start > self.num_steps or end > self.num_steps:
+            raise self.InvalidCostRange(
+                "Range supplied goes out of the feasible range."
+            )
+        if start > end:
+            raise self.InvalidCostRange(
+                "The start boundary must be smaller than the end boundary."
+            )
+
+        sum = 0
+        for i in range(start - 1, end):
+            sum += self.steps[i].action.cost
+        return sum
+
+    def get_usage(self, action: Action):
+        """
+        Returns the percentage of the number of times this action was used compared to the total
+        number of actions taken.
+
+        Arguments
+        ---------
+        action : Action
+            An `Action` object.
+
+        Returns
+        -------
+        percentage : float
+            The percentage of the number of times this action was used compared to the total
+            number of actions taken.
+        """
+        sum = 0
+        for step in self.steps:
+            if step.action == action:
+                sum += 1
+        return sum / self.num_steps
+
+    def tokenize(self, Token: Type[Observation]):
+        """
+        Creates the observation tokens using the token provided by the Observation.
+
+        Arguments
+        ---------
+        Token : Observation subclass
+            An `Observation` subclass.
+        """
+        for step in self.steps:
+            token = Token(step)
+            self.observations.append(token)
diff --git a/macq/trace/trace_list.py b/macq/trace/trace_list.py
new file mode 100644
index 00000000..73d7db44
--- /dev/null
+++ b/macq/trace/trace_list.py
@@ -0,0 +1,115 @@
+from typing import Iterable, Union, List, Callable
+from . import Action
+from . import Trace
+
+
+class TraceList:
+    """
+    A TraceList object is a list-like object that holds information about a
+    series of traces.
+    """
+
+    class MissingGenerator(Exception):
+        def __init__(
+            self,
+            trace_list,
+            message="TraceList is missing a generate function.",
+        ):
+            self.trace_list = trace_list
+            self.message = message
+            super().__init__(message)
+
+    def __init__(
+        self,
+        traces: Union[List[Trace], None] = None,
+        generator: Union[Callable, None] = None,
+    ):
+        """
+        Creates a TraceList object. This stores a list of traces and optionally
+        the function used to generate the traces.
+
+        Attributes
+        ----------
+        traces : list
+            The list of `Trace` objects.
+        generator : funtion | None
+            (Optional) The function used to generate the traces.
+        """
+        self.traces: List[Trace] = [] if traces is None else traces
+        self.generator = generator
+
+    def __repr__(self):
+        rep = "["
+        for trace in self:
+            rep += "\n"
+            rep += "-" * 100
+            rep += "\n\n"
+            rep += repr(trace)
+        rep += "]"
+        return rep
+
+    def __len__(self):
+        return len(self.traces)
+
+    def __setitem__(self, key: int, value: Trace):
+        self.traces[key] = value
+
+    def __getitem__(self, key: int):
+        return self.traces[key]
+
+    def __delitem__(self, key: int):
+        del self.traces[key]
+
+    def __iter__(self):
+        return iter(self.traces)
+
+    def __reversed__(self):
+        return reversed(self.traces)
+
+    def __contains__(self, item: Trace):
+        return item in self.traces
+
+    def append(self, item: Trace):
+        self.traces.append(item)
+
+    def clear(self):
+        self.traces.clear()
+
+    def copy(self):
+        return self.traces.copy()
+
+    def count(self, value: Trace):
+        return self.traces.count(value)
+
+    def extend(self, iterable: Iterable[Trace]):
+        self.traces.extend(iterable)
+
+    def index(self, value: Trace):
+        return self.traces.index(value)
+
+    def insert(self, index: int, item: Trace):
+        self.traces.insert(index, item)
+
+    def pop(self):
+        return self.traces.pop()
+
+    def remove(self, value: Trace):
+        self.traces.remove(value)
+
+    def reverse(self):
+        self.traces.reverse()
+
+    def sort(self, reverse: bool = False, key: Callable = lambda e: e.get_cost()):
+        self.traces.sort(reverse=reverse, key=key)
+
+    def generate_more(self, num: int):
+        if self.generator is None:
+            raise self.MissingGenerator(self)
+
+        self.traces.extend(self.generator(num))
+
+    def get_usage(self, action: Action):
+        usages = []
+        for trace in self:
+            usages.append(trace.get_usage(action))
+        return usages
diff --git a/setup.py b/setup.py
new file mode 100644
index 00000000..110dffb8
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,52 @@
+from setuptools import setup, find_packages
+
+VERSION = "0.0.1.dev1"
+
+NAME = "macq"
+
+DESCRIPTION = "Action model acquisition from state trace data."
+
+DEPENDENCIES = [
+    "tarski>=0.7.0",
+]
+
+DEV_DEPENDENCIES = [
+    "pytest",
+]
+
+CLASSIFIERS = [
+    "Intended Audience :: Science/Research",
+    "Topic :: Scientific/Engineering",
+    "Topic :: Scientific/Engineering :: Mathematics",
+    "Programming Language :: Python",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3 :: Only",
+    "License :: OSI Approved :: MIT License",
+    "Operating System :: OS Independent",
+]
+
+with open("LICENSE", "r", encoding="utf-8") as f:
+    LICENSE = f.read()
+
+with open("README.md", "r", encoding="utf-8") as f:
+    LONG_DESCRIPTION = f.read()
+
+setup(
+    name=NAME,
+    version=VERSION,
+    author="Ethan Callanan, Rebecca De Venezia, Christian Muise",
+    author_email="e.callanan@queensu.ca, 18rldv@queensu.ca, christian.muise@queensu.ca",
+    license="MIT",
+    description=DESCRIPTION,
+    long_description=LONG_DESCRIPTION,
+    long_description_content_type="text/markdown",
+    keywords="planning model acquisition trace",
+    url="https://github.com/QuMuLab/macq",
+    classifiers=CLASSIFIERS,
+    packages=find_packages("macq/macq"),
+    python_requires=">=3.8",
+    install_requires=DEPENDENCIES,
+    extras_require={"dev": DEV_DEPENDENCIES},
+)


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
new file mode 100644
index 00000000..0d8c615a
--- /dev/null
+++ b/tests/test_core.py
@@ -0,0 +1,379 @@
+from macq.trace import CustomObject, Fluent, Action, Step, State, Trace, TraceList
+from macq.observation import IdentityObservation
+
+InvalidCostRange = Trace.InvalidCostRange
+InvalidFluent = Action.InvalidFluent
+MissingGenerator = TraceList.MissingGenerator
+
+from typing import List
+import pytest
+
+# HELPER FUNCTIONS
+
+
+def generate_test_fluents(num_fluents: int):
+    """
+    Generates basic fluents to be used for testing.
+
+    Arguments
+    ---------
+    num_fluents : int
+        The number of fluents to generate.
+
+    Returns
+    -------
+    fluents : List of Fluents
+        The list of testing fluents generated.
+    """
+    fluents = []
+    objects = [CustomObject("number", str(o)) for o in range(num_fluents)]
+    for i in range(num_fluents):
+        fluent_name = "fluent" + " " + str(i + 1)
+        if i % 2 == 0:
+            value = True
+        else:
+            value = False
+        fluent = Fluent(fluent_name, [objects[i]], value)
+        fluents.append(fluent)
+    return fluents
+
+
+def generate_test_actions(num_actions: int, objects: List[CustomObject]):
+    """
+    Generates basic actions to be used for testing.
+
+    Arguments
+    ---------
+    num_actions : int
+        The number of actions to generate.
+    objects: List of CustomObjects
+        The objects available to these actions.
+
+    Returns
+    -------
+    actions : List of Actions
+        The list of testing actions generated.
+    """
+    actions = []
+    for i in range(num_actions):
+        action_name = "action" + " " + str(i + 1)
+        # action 1 has a cost of 1, etc.
+        action = Action(action_name, objects, [], [], [], i + 1)
+        actions.append(action)
+    return actions
+
+
+def get_fluent_obj(fluents: List[Fluent]):
+    """
+    Extracts the objects used by the given fluents.
+
+    Arguments
+    ---------
+    fluents : List of Fluents
+        The fluents to extract the objects from.
+
+    Returns
+    -------
+    objects : List of CustomObjects
+        The list of objects used by the given fluents.
+    """
+    objects = []
+    for fluent in fluents:
+        for obj in fluent.objects:
+            objects.append(obj)
+    return objects
+
+
+def generate_test_states(num_states: int, fluents: List[Fluent]):
+    """
+    Generate states to be used for testing, using the given fluents (each state will add a fluent)
+
+    Arguments
+    ---------
+    num_states : int
+        The number of states to generate.
+    fluents : List of Fluents
+        The fluents that will be used to make up the states.
+
+    Returns
+    -------
+    states : List of States
+        The list of testing states generated.
+    """
+    states = []
+    next_fluents = []
+    for i in range(num_states):
+        state_name = "state" + " " + str(i + 1)
+        if i < len(fluents):
+            next_fluents.append(fluents[i])
+        state = State(next_fluents)
+        states.append(state)
+    return states
+
+
+def generate_test_steps(num_steps: int, actions: List[Action], states: List[State]):
+    """
+    Generate steps to be used for testing, given the number of steps and possible actions and states.
+
+    Arguments
+    ---------
+    num_steps : int
+        The number of steps to generate.
+    actions : List of Actions
+        The list of possible actions to be used for the generated steps.
+    states : List of States
+        The list of possible states to be used for the generated steps.
+
+    Returns
+    -------
+    steps : List of Steps
+        The list of testing steps generated.
+    """
+    steps = []
+    # indices for actions and states respectively
+    a_index = 0
+    s_index = 0
+    for _ in range(num_steps):
+        step = Step(actions[a_index], states[s_index])
+        # cycle through actions and states
+        if a_index < len(actions):
+            a_index += 1
+        else:
+            a_index = 0
+        if s_index < len(states):
+            s_index += 1
+        else:
+            s_index = 0
+        steps.append(step)
+    return steps
+
+
+def generate_test_trace(complexity: int):
+    """
+    Generate a test trace with the given complexity (number of actions, fluents, states, and steps).
+
+    Arguments
+    ---------
+    complexity : int
+        The number of number of actions, fluents, states, and steps to use in this trace.
+
+    Returns
+    -------
+    trace : Trace
+        The testing trace generated.
+    """
+    fluents = generate_test_fluents(complexity)
+    actions = generate_test_actions(complexity, get_fluent_obj(fluents))
+    states = generate_test_states(complexity, fluents)
+    steps = generate_test_steps(complexity, actions, states)
+    trace = Trace(steps)
+    return trace
+
+
+# TESTS FOR ACTION CLASS
+
+# ensure that invalid fluents can't be added to actions
+def test_action_errors():
+    objects = [CustomObject("number", str(o)) for o in range(6)]
+    action = Action("put down", objects, [], [], [], 1)
+    other = CustomObject("other", 10)
+    fluent_other = Fluent("put down other", [other], True)
+
+    with pytest.raises(InvalidFluent):
+        action.add_precond([fluent_other])
+        action.add_effect_add([fluent_other])
+        action.add_effect_delete([fluent_other])
+
+
+# ensure that valid fluents can be added as action preconditions
+def test_action_add_preconditions():
+    fluents = generate_test_fluents(3)
+    (fl1, fl2, fl3) = tuple(fluents)
+    action = Action("put down", get_fluent_obj(fluents), [], [], [], 1)
+
+    action.add_precond([fl1])
+    assert action.precond == [fl1]
+    action.add_precond([fl2, fl3])
+    assert action.precond == [fl1, fl2, fl3]
+
+
+# ensure that valid fluents can be added as action effects
+def test_action_add_effects():
+    fluents = generate_test_fluents(6)
+    (fl1, fl2, fl3, fl4, fl5, fl6) = tuple(fluents)
+    action = Action("put down", get_fluent_obj(fluents), [], [], [], 1)
+
+    action.add_effect_add([fl4])
+    assert action.add == [fl4]
+    action.add_effect_add([fl5, fl6])
+    assert action.add == [fl4, fl5, fl6]
+    action.add_effect_delete([fl1])
+    assert action.delete == [fl1]
+    action.add_effect_delete([fl2, fl3])
+    assert action.delete == [fl1, fl2, fl3]
+
+
+# ensure that valid object parameters can be added and subsequently referenced
+def test_action_add_params():
+    objects = [CustomObject("number", str(o)) for o in range(6)]
+    action = Action("put down", objects, [], [], [], 1)
+    other = CustomObject("other", 10)
+    fluent_other = Fluent("put down other", [other], True)
+
+    action.add_parameter(other)
+    action.add_precond([fluent_other])
+    action.add_effect_add([fluent_other])
+    action.add_effect_delete([fluent_other])
+    assert action.precond == [fluent_other]
+    assert action.add == [fluent_other]
+    assert action.delete == [fluent_other]
+
+
+# TESTS FOR TRACE CLASS
+
+# test the functionality to add steps to a trace (NOT YET WORKING -- need equality dunders). pass for now
+def test_trace_add_steps():
+    """
+    objects = [CustomObject("number", str(o)) for o in range(6)]
+    action = Action("put down", objects, [], [], [], 1)
+    fluent = Fluent("on table", [objects[0]], True)
+    state = State([fluent])
+    trace = generate_test_trace(3)
+    step4 = generate_test_steps(1, [action], [state])
+
+    result = copy.deepcopy(trace.steps)
+    result.append(step4)
+
+    trace.add_steps([step4])
+    assert trace.steps == result
+    """
+    pass
+
+
+# ensure that the Trace base_fluents() and base_actions() functions work correctly
+def test_trace_base():
+    trace = generate_test_trace(3)
+    assert trace.base_fluents() == ["fluent 1", "fluent 2", "fluent 3"]
+    assert trace.base_actions() == ["action 1", "action 2", "action 3"]
+
+
+# test that the previous states are being retrieved correctly
+def test_trace_prev_states():
+    trace = generate_test_trace(3)
+    # get the first and last action
+    (action1, action3) = (trace.steps[0].action, trace.steps[2].action)
+    # get the first and last state
+    (state1, state3) = (trace.steps[0].state, trace.steps[2].state)
+
+    assert trace.get_prev_states(action1) == [state1]
+    assert trace.get_prev_states(action3) == [state3]
+
+
+# test that the post states are being retrieved correctly
+def test_trace_post_states():
+    trace = generate_test_trace(3)
+    # get the first and last action
+    (action1, action3) = (trace.steps[0].action, trace.steps[2].action)
+    # get the second state
+    state2 = trace.steps[1].state
+
+    assert trace.get_post_states(action1) == [state2]
+    assert trace.get_post_states(action3) == []
+
+
+# test trace SAS triples function
+def test_trace_get_sas_triples():
+    trace = generate_test_trace(3)
+    # get the second and last action
+    (action2, action3) = (trace.steps[1].action, trace.steps[2].action)
+    # get the second and last state
+    (state2, state3) = (trace.steps[1].state, trace.steps[2].state)
+
+    assert trace.get_sas_triples(action2) == [(state2, action2, state3)]
+    assert trace.get_sas_triples(action3) == [(state3, action3)]
+
+
+# test that the total cost is working correctly
+def test_trace_total_cost():
+    trace = generate_test_trace(5)
+    assert trace.get_total_cost() == 15
+
+
+# test that the cost range is working correctly
+def test_trace_valid_cost_range():
+    trace = generate_test_trace(5)
+    assert trace.get_cost_range(1, 3) == 6
+    assert trace.get_cost_range(2, 3) == 5
+    assert trace.get_cost_range(1, 5) == 15
+    assert trace.get_cost_range(4, 5) == 9
+
+
+# test that incorrect provided cost ranges throw errors
+def test_trace_invalid_cost_range():
+    trace = generate_test_trace(3)
+    with pytest.raises(InvalidCostRange):
+        trace.get_cost_range(3, 1)
+        trace.get_cost_range(0, 2)
+        trace.get_cost_range(1, 5)
+
+
+# test trace action usage
+def test_trace_usage():
+    trace = generate_test_trace(3)
+    # get the first action
+    action1 = trace.steps[0].action
+    assert trace.get_usage(action1) == 1 / 3
+
+
+# test trace tokenize function
+def test_trace_tokenize():
+    trace = generate_test_trace(3)
+    (step1, step2, step3) = (trace.steps[0], trace.steps[1], trace.steps[2])
+    trace.tokenize(IdentityObservation)
+    print(trace.observations)
+    print(
+        [
+            IdentityObservation(step1),
+            IdentityObservation(step2),
+            IdentityObservation(step3),
+        ]
+    )
+    assert trace.observations == [
+        IdentityObservation(step1),
+        IdentityObservation(step2),
+        IdentityObservation(step3),
+    ]
+    # test equality dunder by attempting to compare an object of a different type
+    assert trace.observations != step1
+
+
+def generate_test_trace_list(length: int):
+    trace = generate_test_trace(3)
+    traces = [trace] * length
+    return TraceList(traces)
+
+
+def test_trace_list():
+    trace_list = generate_test_trace_list(5)
+
+    assert len(trace_list) == 5
+
+    with pytest.raises(MissingGenerator):
+        trace_list.generate_more(5)
+
+    first = trace_list[0]
+    trace_list.generator = generate_test_trace_list
+    trace_list.generate_more(5)
+    assert len(trace_list) == 10
+    assert trace_list[0] is first
+
+    action = trace_list[0].steps[0].action
+    usages = trace_list.get_usage(action)
+    for i, trace in enumerate(trace_list):
+        assert usages[i] == trace.get_usage(action)
+
+    print(trace_list)
+
+
+if __name__ == "__main__":
+    test_trace_list()