Part 5: Example - Calculator

In this guide, we build a simple Calculator program that demonstrates all of the features associated with a custom ASTRA module.

Declaring a Module

IDEA: To declare a custom module, simple create a class that extends the astra.core.Module class. This parent class contains the set of annotations necessary to declare actions, terms, formulae, sensors and events. It also contains a protected field called agent that is initialised to contain a reference to the agent that created the module. This field can be used to access the internals of the agent, which includes (but is not limited to):

  • The addition and removal of beliefs
  • The submission of events
  • Querying of current beliefs

CODE: To declare our Calculator module, we simple extend the astra.core.Module class as is shown below:

package example.calculator;

public class Calculator extends astra.core.Module {
}

To use this module in your code, you simple declare an instance of the module:

package example.calculator;

agent MyAgent {
  module Calculator calc;
}

OUTPUT: Of course, the program produces no output because it doesn’t do anything yet…

Creating an Action

IDEA: Actions are the ASTRA equivalent of statements in mainstream programming languages. That is, they are the basic capabilities of the agent. In ASTRA, performing an action has two basic outcomes:

  • It can succeed – this is in terms of the successful execution of the action, and not in terms of the achievement of some expected outcome. For example, a soccer playing agent can successfully execute a kick action, but not achieve the expected outcome (scoring a goal).
  • It can fail – this means the the action was not executed correctly. Failure can be anticipated (e.g. insufficient resources to perform the action, or the occurrence of a checked exception) or unanticipated (e.g. the occurrence of an unchecked exception).

To cater for these outcome types, ASTRA requires that methods annotated as actions return a boolean value – true in the case of success or false in the case of anticipated failure. To cater for unanticipated failure, the code the invokes the action method is wrapped within a try-catch statement that catches anything that implements java.lang.Throwable. They are treated in the same way as anticipated failures.

CODE: To illustrate this, lets create a number that prints out a double to a given number of decimal places. This will require a method that takes 2 parameters: the double number, and the number of decimal places. Lets look at how this is implemented below. First, lets look at the updated module implementation:

package example.calculator;

import java.text.DecimalFormat;

import astra.core.Module;

public class Calculator extends Module {
  @ACTION
  public boolean doublePrint(double value, int places) {
    String pattern = "#.";
    for (int i=0;i<places;i++) pattern +="0";
    DecimalFormat df = new DecimalFormat(pattern);
    System.out.println(df.format(value));
    return true;
  }
}

To invoke this method, we need to add some code to our example agent. Specifically, we implement the main rule which contains a single statement – a call to the newly created action:

package example.calculator;

agent MyAgent {
  module Calculator calc;

  rule +!main(list args) {
    calc.doublePrint(3.1415926, 2);
  }
}

OUTPUT: The output for this program is the number PI to 2 decimal places.

3.14

Creating a Term

IDEA: Term methods provide a mechanism for an agent to dynamically generate a term. In ASTRA, terms refers to any value that can be an argument of a predicate formula. They are basically literal values. A term method can be used to generate a literal value at the point that the term is evaluated. They are typically used to retrieve state information from a module that can be used in an expression. For example, a module that implements a queue could include a term method to return the current size of the queue. Alternatively, for our calculator example, terms could include basic calculations like addition and divide. These are treated as terms rather than actions because they result in a new value being generated and they can be nested to create complex expressions.

CODE: We illustrate the idea of a term method by implementing two simple arithmetic operations:

@TERM
public double add(double X, double Y) {
  return X+Y;
}

@TERM
public double divide(double  X, double Y) {
  return X/Y;
}

We can use this perform a simple calculation in our agent:

agent MyAgent {
  module Calculator calc;

  rule +!main(list args) {
    calc.doublePrint(3+calc.divide(calc.add(1.2, 3.2), 2.3131), 2);
  }
}

To be clear, ASTRA does include basic arthmetic operations, so the correct way to do the above calculation would be: 3 + (1.2+3.2)/2.3131

OUTPUT:

4.90

Creating a Formula

IDEA: In ASTRA, a custom formula is something that can be used in a logical expression (such as the context of a rule or the guard in a if, while, or foreach statement). Custom formulae allow you to query the non-logical state of a module in a logical sentence. For example, if the agent is monitoring a buffer, then a custom formula could be used to determine whether or not the buffer was empty. Custom formulae work by returning an instance of a Formula object whenever the method is invoked. This allows the custom formula to return the TRUE/FALSE primitives, a Predicate formula, or even a more complex logical expression using and/or/not). In the example above where the custom formula indicates whether or not the buffer is full, the method returns Predicate.TRUE or Predicate.FALSE.

The evaluation of the custom formula is triggered by the logical reasoning engine that sits underneath ASTRA (it uses a custom Prolog-style engine). When the response is returned, the reasoner processes the formula as it would if the formula had originally been part of the logical expression.

For the calculator example, lets introduce a memory function that consists of a store() action, a retrieve() term and a hasMemory() formula. If the associated memory field is non-zero, then there is a value stored in it, otherwise there is no value.

CODE: The code for the action and formula are given below:

private double memory;

@ACTION
public boolean store(double value) {
  memory = value;
  return true;
}

@TERM
public double retrieve() {
  return memory;
}

@FORMULA
public Formula hasMemory() {
  return memory == 0.0 ? Predicate.FALSE:Predicate.TRUE;
}

In the agent code below, we also use the Console API (module) which is a pre-existing module that comes with ASTRA. It supports printing to the console.

package example.calculator;

agent MyAgent {
  module Calculator calc;
  module Console C;

  rule +!main(list args) {
    calc.store(3+calc.divide(calc.add(1.2, 3.2), 2.3131));
    if (calc.hasMemory()) {
      C.println("stored value: ");
      calc.doublePrint(calc.retrieve(),2);
    } else {
      C.println("There was no stored value...");
    }
  }
}

OUTPUT:

[main]stored value: 
4.90

Creating a Sensor

IDEA: Sensors are the ASTRA equivalent of a perceptor (a piece of code the transforms raw data into predicate formulae that are used to update the agents beliefs). For example, the state of a light bulb (whether it is on or off) might be modeled a belief of the form state(<bulb_id>, ["on"|"off"]). The job of the sensor (perceptor) is to ensure that this belief accurately reflects the current state of the physical light bulb (i.e. when “bulb1” is on, the agent has the belief state("bulb1", "on") and when it is off, the agent has the belief state("bulb1", "off").

In ASTRA, it is not only the responsibility of the sensor to add new beliefs to reflect the current state but also to remove existing beliefs that no longer reflect that state. ASTRA does it this way to minimise the number of updates performed on the agents beliefs in an attempt to optimise the performance of the agent interpreter.

Sensors represent the traditional approach to monitoring the environment. The agent activates all its sensors at the start of each execution cycle. Each sensor checks the current state of the aspect of the environment that it is monitoring and updates its beliefs accordingly. So, in contrast with the other custom extensions, all sensors associated with an agent are activated automatically at the start of each execution cycle.

To illustrate how sensors work, we are going to introduce a sensor into our Calculator example that will generate a belief about the current value stored in the memory, This belief will take the form memory(). It is worth noting that this functionality is also provided by the retrieve() custom term. A nice feature of the use of the sensor is that belief updates can be used to implement side effect behaviours (e.g. in the ASTRA code below we a rule to monitor when the memory has changed).

CODE: First, lets look at the additional code required to implement a sensor:

private Predicate memoryBelief;
private double lastMemory;

@SENSOR 
public void memory() {
   if (lastMemory != memory) {
      if (memoryBelief != null) {
         agent.beliefs().dropBelief(memoryBelief);
      }
      memoryBelief = new Predicate("memory", new Term[] {Primitive.newPrimitive(memory)});
      agent.beliefs().addBelief(memoryBelief);
      lastMemory = memory;
   }
}

As can be seen, implementing even a simple sensor like the one above is quite a lot of work. Also, this is the first place in which the protected agent field has been used in the module – in this case, it is being used to add the new belief and remove the old belief.

package example.calculator;

agent MyAgent {
   module Calculator calc;
   module Console C;

   types calculator {
      formula memory(double);
   }

   rule +!main(list args) {
      calc.store(3+calc.divide(calc.add(1.2, 3.2), 2.3131));
      if (calc.hasMemory()) {
         C.println("stored value: ");
         calc.doublePrint(calc.retrieve(),2);
      } else {
         C.println("There was no stored value...");
      }
   }

   rule +memory(double value) {
      C.println("Memory changed: "+value);
   }
}

Notice that, in the above code, we must now use the types construct to specify that we will have a belief of the form memory(double).

OUTPUT:

The output adds an additional line which is printed out because of the side effect rule that we added to the program.

[main]Memory changed: 4.902209156543167
[main]stored value: 
4.90

NOTE: Sensing is basically a polling operation where the agent repeatedly checks for a change in its environment. This must be done even when there is no change in the environment, which can lead to wasted CPU cycles being assigned to an agent that will do nothing. As can be seen from the example, the sensor replicates the functionality offered by the retrieve() term. Similarly, the hasMemory() formula could be modeled by extending the sensor to generate a belief that indicates whether there is something in the memory or not (actually, in the case of the sensor, you know this simply through the existence (or not) of a memory(...) belief. In fact, the development of support for custom terms and formulae is a direct attempt to alleviate the need to be permanently active by removing the need for sensors. Of course, without sensors, there also needs to be a mechanism to allow changes in the environment to be passed up to the agent. This is achieved through the use of custom events which are described next.

Creating a Custom Event

IDEA: Events are a core concept within ASTRA. They drive the behaviour of the agent through the activation of plan rules. By default, three types of event exist: (1) goal events (generated on the adoption / retraction of a goal), (2) belief events (generated on the adoption / retraction of a belief), and (3) message events (generated whenever a message is received). Custom events have been introduced as part of a triumvirate of features that enable developers to avoid the use of computationally expensive sensors. As was hinted at in the note at the end of the section of creating sensors; a sensor can be replaced by a combination of custom types, formulae, and events.

Using terms and formulae to access environmental state is far more efficient than the expensive belief update process that underpins sensors. On their own, they are insufficient because they do not provide a way to trigger behaviour (for sensors, this is done through the handling of belief update events). Technically, it is trivial to create any of the three main event types, but doing so breaks the conceptual model: you should not have a belief update event without the addition/removal of the corresponding belief and you should not have a message event without a valid sender. You could have a goal update event (this is what we do to trigger the main goal), but it reduces the readability of the code as it is not clear whether the rule has been designed to handle a declared goal or a “fake” goal (i.e a goal update event that was not created as a result of adopting a goal).

Consequently, we have designed ASTRA to support custom event types, allowing you to create events that model changes in the environment clearly. Creating a custom event is probably the trickiest of the five customisations. It required the creation of an event object, a event unifier and an event method (which is used to specify triggering events).

To demonstrate how to create an event, we will create a memory event for the calculator example. This event should be generated whenever a value is successfully stored in the calculators memory.

CREATING THE EVENT CLASS The event class is a Java class that implements the astra.event.Event interface. When created, it requires the implementation of two abstract methods: the getSource() method should return null (this is used to link an event to an intention); and the signature() method, which should return a string that uniquely determines this type of event (typically, a short form of the event prefixed by a dollar sign is used – e.g. “$mem” for a memory event).

We add to this fields to hold any parameters that are associated with the event and a constructor that can be used to initialise those fields (the convention is that they should match the ordering used in the event method). For our example, we will add a single field to allow the current value of the memory to be associated with the event. The resultant code is below:

package example.calculator;

import astra.event.Event;
import astra.term.Term;

public class MemoryEvent implements Event {
   public Term value;

   public MemoryEvent(Term value) {
      this.value = value;
   }

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

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

Notice that we have used a public field in this class. We do this for simplicity. It is also possible to add get (accessor) methods to this class to make the fields read only. We do this for the core events. At this point, you have the ability to post your custom event to the agents event queue, but you cannot match it or even create rules to handle it.

CREATING THE EVENT UNIFIER: The event unifier is a piece of code that is used by the agent interpreter to match the current event being processed against a triggering event of a rule. Most of the heavy lifting for this has already been implemented, the event unifier code simply implements support for the new custom event. You create an event unifier by implementing the astra.reasoner.EventUnifier interface. This interface specifies a single method that implements the unification code.

package example.calculator;

import java.util.HashMap;
import java.util.Map;

import astra.core.Agent;
import astra.event.Event;
import astra.reasoner.EventUnifier;
import astra.reasoner.Unifier;
import astra.term.Term;

public class MemoryEventUnifier implements EventUnifier<MemoryEvent> {
   @Override
   public Map unify(MemoryEvent source, MemoryEvent target, Agent agent) {
      return Unifier.unify(
         new Term[] {source.value}, 
         new Term[] {target.value}, 
         new HashMap(),
         agent);
   }
}

As can be seen in the above code, there is only 1 line. The main task in creating this class is to correctly modify the enumerated arrays of Term objects. The first enumerated array should reference the fields of the source event in the same order as they appear in the constructor. The second enumerated array should do the same for the target event.

To make ASTRA use the class, you need to register it with the astra.reasoner.Unifier class. By convention, this is done in a static initialisation block within the module, but it can be put in any class the is guaranteed to load BEFORE the first instance of the custom event is posted to the agents event queue (otherwise the event will be ignored). The registration code you need to add is:

static {
   Unifier.eventFactory.put(MemoryEvent.class, new MemoryEventUnifier());
}

At this point, you can post custom events to the agents event queue and match them against other custom event instances, but you still have no way to declare rules to handle the event. This is achieved by defining the custom event method in your module.

DECLARING THE CUSTOM EVENT: The final step involved in creating a custom event is to define an annotated method in the module class. This method is used to expose the event to the ASTRA level code, allowing developers to create rules to handle the event. The method itself should take the same parameters as the event class constructor (again, convention is that they listed are in the same order). The @EVENT annotation is parameterised to provide additional information required by the interpreter. Specifically, the annotation defines:

  • types: This parameter is an enumerated list of strings specifying the types of the parameters for the event method (the types of all parameters for the event method MUST BE of type astra.term.Term). These are used as part of the type checking process performed by the ASTRA compiler. signature: This parameter should be the same as the string given in the signature() method of the custom event class (it is used by the ASTRA interpreter to select the applicable set of rules).
  • symbols: this is an enumerated array of strings specifying any prefix symbols that can be applied to the event (an example of prefix symbols are the +/- symbols that are prefixed to the belief and goal update events). For now, we will leave this blank, but you can look at the astra.lang.EIS module to see an example of how they can be applied. To illustrate how this works, we finish the calculator custom event example:
@EVENT(types = {"double"}, signature="$mem", symbols = {})
public Event event(Term value) {
   return new MemoryEvent(value);
}

Creating rules to handle the event are straight forwards. All custom event rules are prefixed by a dollar ($) sign as is shown in the code below:

package example.calculator;

agent MyAgent {
   module Calculator calc;
   module Console C;

   types calculator {
      formula memory(double);
   }

   rule +!main(list args) {
      calc.store(3+calc.divide(calc.add(1.2, 3.2), 2.3131));
      if (calc.hasMemory()) {
         C.println("stored value: ");
         calc.doublePrint(calc.retrieve(),2);
      } else {
         C.println("There was no stored value...");
      }
   }

   rule +memory(double value) {
      C.println("Memory changed: "+value);
   }

   rule $calc.event(double value) {
      C.println("Memory changed (custom event): "+value);
   }
}

The ASTRA agent is now able to handle a calculator event, but nowhere in the code have we actually generated an event. To close the loop on the example, we update the store() action method to generate the event as below:

@ACTION
public boolean store(double value) {
   memory = value;
   agent.addEvent(new MemoryEvent(Primitive.newPrimitive(value)));
   return true;
}

This completes the steps involved in creating a custom event.

OUTPUT:

[main]Memory changed (custom event): 4.902209156543167
[main]Memory changed: 4.902209156543167
[main]stored value: 
4.90

NOTE: The custom event is handled before the belief update, this is because all belief update events are generated during the belief update process which occurs at the start of the agent interpreter cycle, while the custom even is added during the execution of the custom action.