Part 3: Implementing your own Events

This guide is the third part in a series of guides on how to link an agent to an environment. Typically, this is implemented through sensing (perceiving the state of the environment) and acting (effecting the environment). However, while ASTRA supports sensing as is described in the second part of this series, our preference for perceiving the state of the environment is to use a combination of custom events, terms and formulae. This guide focuses on how to create custom events.

We prefer to use custom events over sensors because it is our view that events more naturally model how systems sense the environment. Typically, raw sensor feeds are processed in a way that allows key changes in the the sensed environment to be detected. These changes are passed to a sensor that must update its model which then requires an update to the agents beliefs that results ultimately in events that in some way reflects the original change detected in the raw data stream. Our view is that the initial event should be used to update an internal (non-belief) model and then forwarded on to the agent as a custom event. Custom terms and formulae can then be provided to query the internal model in the same way that the beliefs would have been queried without the complex series of data transformations that are required with sensors. The other benefit is that events are pushed to the agent. This means that the agent does not need to do anything (it can sleep) until something happens (it receives an event). This is a far more efficient model in terms of the use of a computers processing resources.

What you’ll build

We will adapt the simple light switch program that was developed in part 1. The completed source code for this prior version of the code can be downloaded from the ASTRA Gitlab Repository.

The code implements a module that models a light switch and provides the ability for the agent to turn the light switch on or off as required. The actual behaviour shown here will be the agent flashing the light 3 times (turning it on / off repeatedly 3 times).

What you’ll need

  • About 15 minutes
  • A favorite text editor or IDE
  • JDK 1.8 or later
  • Maven 3.3+

Creating a project from scratch

In a project directory of your choosing, create the following directory structure (for example, on *nix systems, type mkdir -p src/main/astra):

|-- src
|    |-- main
|          |-- astra
|-- pom.xml

Now create the maven build file (pom.xml):

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>examples</groupId>
    <artifactId>module-events</artifactId>
    <version>1.4.0</version>

    <parent>
        <groupId>com.astralanguage</groupId>
        <artifactId>astra-base</artifactId>
        <version>1.4.0</version>
    </parent>

    <build>
        <defaultGoal>clean compile astra:deploy</defaultGoal>
        <plugins>
            <plugin>
                <groupId>com.astralanguage</groupId>
                <artifactId>astra-maven-plugin</artifactId>
                <version>1.4.0</version>
            </plugin>
        </plugins>
    </build>
</project>

You should change the groupId and artifactId to reflect your own project.

Creating a project from an archetype

To use the maven archtetype mechanism to create an ASTRA project, go to a folder of your choice, and type in the following command:

mvn archetype:generate \
    -DarchetypeGroupId=com.astralanguage \
    -DarchetypeArtifactId=astra-archetype \
    -DarchetypeVersion=1.0.2 \
    -DgroupId=examples \
    -DartifactId=module-events \
    -Dversion=0.1.0

You should change the groupId and artifactId to reflect your own project.

Source Code

The full source code for this guide can be downloaded from the ASTRA Gitlab repository.

Starting Point

The starting point for this guide is the code created in the first part of the series which explored how to create custom actions in ASTRA. The source code for the Switch module is given below. As can be seen, the state of the switch is managed through the updateBeliefs() method, which is called when the module is initialized or whenever the flip() action is performed because this changes the state of the switch.

package example;

public class Switch extends Module {
  private boolean on = false;
  private Predicate belief;

  public void setAgent(Agent agent) {
    super.setAgent(agent);
    updateBeliefs();
  }

  @ACTION
  public boolean flip() {
    on = !on;
    updateBeliefs();
    return true;
  }

  private void updateBeliefs() {
    if (belief != null) {
      agent.beliefs().dropBelief(belief);
    }
    belief = new Predicate("switch", new Term[] {
      Primitive.newPrimitive(on ? "on":"off")
    });
    agent.beliefs().addBelief(belief);
  }
}

As described in the previous guide, this approach only works in closed environments where all changes are driven by the agent. This is not realistic in many practical applications. Events require a little more work to implement than sensors or the above approach. We will explore the main components over the next few sections.

Defining a Custom Event

In ASTRA, events are created by implementing the astra.core.Event interface. This interface requires the implementation of three methods. The getSource() method should be ignored (it should return null) as events are not linked directly to source code. The accept(..) and signature() methods require some implementation. An outline of a custom SwitchEvent class is given below.

public class SwitchEvent implements Event {
    @Override
    public Event accept(LogicVisitor arg0) {
        return null;
    }

    @Override
    public Object getSource() {
        return null;
    }

    @Override
    public String signature() {
        return "$swe;";
    }
}

In the above code, we have implemented only the signature() method. This method should return a string that uniquely identifies the event. Convention is that the string starts with a dollar sign ($) as this is prefix is used to identify custom events in ASTRA code. This is not mandatory though and any unique string will do. The purpose of the signature is to match events to applicable rules which are grouped by signature in the underlying interpreter.

The first step is to determine what arguments the event must have and to create fields that will hold them. For the light switch code, we require our event to return the new state of the light switch, which has to date been modelled as a string. The code below introduces this parameter.

public class SwitchEvent implements Event {
    private Term state;

    public SwitchEvent(Term state) {
        this.state = state;
    }

    @Override
    public Event accept(LogicVisitor visitor) {
        return new SwitchEvent(
            (Term) visitor.visit(state)
        );
    }

    @Override
    public Object getSource() {
        return null;
    }

    @Override
    public String signature() {
        return "$swe;";
    }

    public Term state() {
        return state;
    }
}

There are three new things in this code: (1) this introduction of the state field to model the state parameter and a method to get the current value of that parameter; (2) the introduction of a constructor that takes one argument and can be used to create an event with a given state; and (3) the modification of the accept(...) method. This method is used by internal processes within the ASTRA interpreter. It always results in the creation of a new event object whose parameters are defined by the visitor. What the visitor does to the parameters depends on the actual visitor because astra.reasoner.util.LogicVisitor is an interface.

Implementing an EventUnifier

EventUnifiers are another internal piece of the ASTRA interpreter jigsaw. They are used by ASTRAs unification algorithm which is responsible for matching events. The implementation of an astra.reasoner.EventUnifier is quite straightforwards – it simply involves matching parameters for comparison. We adopt this manual mapping over an automated approach because our goal with ASTRA is to minimise the use of the Reflection API in deployed code. The SwitchEventUnifier required for our SwitchEvent is given below:

public class SwitchEventUnifier implements EventUnifier<SwitchEvent> {
    public Map<Integer, Term> unify(SwitchEvent source, SwitchEvent target, Agent agent) {
        return Unifier.unify(
                    new Term[] { source.state() },
                    new Term[] { target.state() }, 
                    new HashMap<Integer, Term>(), 
                    agent
                );
    }
}

As can be seen with the above code, the unify(...) method basically calls the Unifier.unify(...) method defining which terms of the event should be matched and against what (here the source event state term is matched against the target event state term).

Linking the event to the module

To link the event and the module, we need to add an @EVENT method and we need to register the SwitchEventUnifier. This is illustrated in the code below.

public class Switch extends Module {
  static {
    Unifier.eventFactory.put(SwitchEvent.class, new SwitchEventUnifier());
  }

  private boolean on = false;

  @ACTION
  public boolean flip() {
    on = !on;
    agent.addEvent(new SwitchEvent(
      Primitive.newPrimitive(on ? "on":"off")
    ));
    return true;
  }


  @EVENT( symbols={}, types = {"string"}, signature = "$swe")
  public Event event(Term state) {
      return new SwitchEvent(state);
  }
}

The static initialization block at the start of the module registers the SwitchEventUnifier, associating it with the SwitchEvent. The new event(...) method provides a way to declare rules that can handle the new event type – we will look at this further later. Finally, the flip() action method is modified to generate a SwitchEvent whenever the action is performed.

The @EVENT method annotation includes a number of fields. The first symbols field is an enumerated list of prefix symbols that can be used with the event. This allows you to modify multiple event types with the same event to mimic situations similar to belief update events (where the + and – prefixes are used to indicate that the event models the addition or removal of a belief). The second types field associates ASTRA types with parameters of the event. This is because the only valid type of parameter for an event method is astra.term.Term. The third signature field is used for matching of rules to events and should always be the same as the string returned by the signature() method of the underlying event. The method itself simply returns an instance of the event.

Using the Module

The code provided in the first part of the series needs to be changed for this guide. Specifically, we must change the last rule, which is linked to the update of the belief about the state of the switch. In addition to printing out the new state, this revised rule must update a belief representation of the state of the switch. We also need to declare the initial state of the switch.

agent Main {
  module Switch switch;
  module Console console;

  types switch {
    formula switch(string);
  }

  initial switch("off");

  rule +!main(list args) {
    !flash(3);
    console.println("FINISHED");
  }

  rule +!flash(int N) : N > 0 {
    !switch("on");
    !switch("off");
    !flash(N-1);
  }

  rule +!flash(int N) {}

  rule +!switch(string state) : ~switch(state) {
    switch.flip();
    wait(switch(state));
  }

  rule +!switch(string state) {
    console.println("should not happen");
  }

  rule $switch.state(string state) {
    -+switch(state);
    console.println("switch=" + state);
  }
}

To compile and run the resulting program, simply type:

$ mvn

The output should again look something like this:

[main]switch=on
[main]switch=off
[main]switch=on
[main]switch=off
[main]switch=on
[main]switch=off
[main]FINISHED

It is important to point out that the output of this program is slightly different to the output generated by the programs in parts 1 & 2. Specifically, there is no line for the initial state of the switch. We could rectify this by reintroducing the setAgent(...) method and adding code to generate an initial event about the state of the switch.

Summary

This guide has introduced the concecustom events. It has built on the first part of the series which focused on how to create custom actions. This use of the belief about the state of the switch in this program is a small fudge that will be unnecesary when we combine the use of a custom event with the use of custom terms and formulae in the next part of the guide.