4. Core ASTRA Concepts

This guide provides basic information about many of the core ASTRA language.

4.1 Variables in ASTRA

ASTRA is a typed language. This means that all variables declared in ASTRA must have an associated type. ASTRA does not allow global variables or constants. All variables are defined locally within plan rules.

ASTRAs type system is based on the Java type system. This has been done to simplify make it easy to understand how values in ASTRA are passed to methods in Java. The full set of ASTRA types are listed in the table below:

|------------|-----------------------------|-----------------|-------------------------------|
| Type       | Description                 | Example Value   | Java Mapping                  |
|------------|-----------------------------|-----------------|-------------------------------|
| int        | An integer number (4 bytes) | 5               | int                           |
| long       | An integer number (8 bytes) | 4000l           | long                          |
| float      | A real number (4 bytes)     | 3.14f           | float                         |
| double     | A real number (8 bytes)     | 4.01            | double                        |
| char       | A character                 | 'c'             | char                          |
| string     | A string of characters      | "hello"         | java.lang.String              |
| list       | A list of values            | ['a', 4, "hi"]  | java.util.List (indirectly)   |
| boolean    | true or false               | true            | boolean                       |
| funct      | A logical function          | fatherOf("rem") | None (astra.term.Funct)       |
| Java Class | Reference to a Java object  | -               | Any Java Class                |
|------------|-----------------------------|-----------------|-------------------------------|

4.1.1 Declaring Variables in Triggering Events

Perhaps the most common way is to declare the variable implicitly within the triggering event or context of a rule. The purpose if this is to allow the programmer to extract information from the matched event so that it may be used in the associated plan. For example, if you have a rule whose triggering event is a goal event related to the addition of two integer numbers, you will need to be able to get those two numbers any time the rule is used to handle a goal event. To illustrate this, the snippet of code below is an example of such a rule that generates a belief containing the result:

rule +!addition(int X, int Y) {
    +result(X, Y, X+Y);
}

In the above rule, we declare two variables X and Y as part of the rules triggering event. this rule stores the result of the addition in a belief with predicate “result”.

''NOTE: This example is trivial, and you would simply use the expression X+Y to perform the calculation in practice.''

4.1.2 Declaring Variable in a Rules Context

A second way of declaring variables is within the context of a plan rule. The purpose of this is to allow the programmer to extract information from the agents beliefs that has been used as part of the rule selection process to identify that the rule is applicable in the current context. For example, we can expand on our simple previous example about addition to develop a second rule (it should be written before the above rule) that attempts to reuse the knowledge of previous calculations which are stored in the result belief:

rule +!addition(int X, int Y) : result(X, Y, int R) {
    // do nothing because we already know the result!
}

In the above example, the rule states that: if we are adding two integer numbers X and Y, and we already have a belief that contains some result for X and Y, R, then use this rule in preference over the earlier rule because we already know the result of the calculation.

Another example that uses the information extracted from the context is given below:

rule +!tick() : time(int T) {
    C.println("The time is: " + T);
}

The context of this rule is that we know the current time, and if this is true, we extract the value representing the current time in to a variable T which we then print to the console.

4.1.3 Declaring Variables in Statement Guards

A third way of declaring variables is in the guard of some of the statements. For the moment, the only place that we can do this is in the guard of a query statement:

rule +!tick() {
    query(time(int T));
    C.println("The time is: " + T);
}

The above rule is basically the same as the previous example. The difference is that the presence of a belief about the current time is assumed (the agent must always have a belief about the current time). The query simply matches the guard against that belief and binds the variable T to the corresponding value.

As will be seen later, variables can also be introduced in while loops and if statements.

4.1.4 Declaring Variables Explicitly

The final way of declaring variables is by using an explicit variable declaration statement (in the same way that local variables are normally declared in procedural programming languages). The motivation for this is the same as in any procedural language given the body of a plan rule is simply a procedural block of code. For example, let us consider an alternative to our addition goal that calculates the hypotenuse of a right-angled triangle:

rule +!hypotenuse(double X, double Y) {
    double hypotenuse = math.sqrt(X*X+Y*Y);
    +result(X, Y, hypotenuse);
}

Naturally, it is possible to write the above code in a single line, but if the calculation were more complex, then the above structure may make the code more readable.

4.2 Choice in ASTRA

The ability to select from a set of choices is a common feature of programming languages. The idea of choice - or more specifically, selecting a course of action - is a core feature of AgentSpeak(L). Within Agent Speak(L) this is achieved through posting of subgoals; leading to the creation of goal events that are handled contextually based on a set of applicable rules. The basic idea is that you create a set of rules – one rule per option – that handle the same event (usually a goal addition event). The selection of the appropriate rule is then left to the interpreter. In ASTRA, we also provide a selection statement type. This has been done to improve readability and to reduce the need for artificially defined subgoals.

42.1 Selection by Rule

To illustrate how rules can be used to implement choice, consider a program that is designed to handle the event that a ball has been lost. This event can be handled in a number of different basic ways:

  • if the player is near the ball and the opposition has is, then the player should tackle the opposition player
  • if the player is near the ball and it is free, then it should try to control the ball
  • if the player is near the ball and its team has the ball, then it should move into space.

There are many more possible scenarios for the decision that the player must make. The above examples are, however, enough to illustrate the idea.

We can encode the decision process as a set of rules with differing contexts that tell the agent when each plan should be chosen. The above example can be implemented as three ASTRA rules:

rule +!decide() :
  is("ball", "near") & has("ball", string X) &
  is(X, "opposition") {
    !tackle(X);
}

rule +!decide() :
  is("ball", "near") & is("ball", "free") {
    !controlBall();
}

rule +!decide() :
  is("ball", "near") & has("ball", string X) &
  is(X, "team_member") {
    !moveToSpace();
}

This type of decomposition is natural and expressive in the sense that there is a clear goal (!decide()); there area many ways of handling that goal; and each approach is encoded as a rule. Unfortunately, there are other scenarios where this type of selection is less friendly:

initial !printIfEven(5);

rule +!printIfEven(int X) : X % 2 == 0 {
    console.println("X = " + X);
}

rule +!printIfEvent(int X) { }

The above program is a simple program to print out a number only if it is even. Unfortunately, implementing this requires 2 rules. The first rule encodes the logic for printing out an even number and the second rule encodes the logic for not printing out odd numbers.Even though you are only required to print the number if it is even, both possibilities must be catered for in the code because failure to match an event to a rule causes the subgoal to automatically fail. This may not be an issue in the program above, but if the !printIfEven(…) goal was called as a subgoal, then it would matter.

4.2.2 Selection by If Statement

For many programs, the rule-based approach can result in an explosion of rules that quickly become unmanageable since each rule must have an associated event with a unique name. This proliferation of rules can make it difficult to locate the rules you are interested in and can make effective organisation of the code more complex. Further, the flow of the program often becomes more difficult to follow which can lead to a increase in coding errors. As a result, ASTRA includes a second, more standard (in a programming languages sense) approach to selection – an if statement.

For example, consider the program to print out a number only if it is even. This program can be rewritten as follows using an if statement:

initial !printIfEven(5);

rule +!printIfEven(int X) {
    if (X % 2 == 0) console.println("X = " + X);
}

The resulting program is far more compact and fits more closely to the standard solution. Of course, it is possible to use only if statements in code and not to make use of rule-based selection, but this also has drawbacks. For example, if we look at the first example above:

rule +!decide() {
    if (is("ball", "near") &
        has("ball", string X) & is(X, "opposition")) {
        !tackle(X);
    } else if (is("ball", "near") & 
        is("ball", "free")) {
        !controlBall();
    } else if (is("ball", "near") &
        has("ball", string X) & 
        is(X, "team_member")) {
        !moveToSpace();
    }
}

While this program does exactly the same thing as the first program, there is an argument that it is less readable because the single rule can become highly complex (especially if each branch was more complex than simply calling a subgoal). It also does not permit extension of the behaviour. For example, if we identified additional choices that the agent could make, then we would have to change this rule. Alternatively, by using multiple rules, we simply add another rule. In cases where inheritance is used, we can even override a specific choice to implement an alternate behaviour for that choice or just add more choices to cater for additional options that need to be handled.

4.2.3 When to use Rule-Based Selection

One of the goals in designing ASTRA has been to try to provide flexibility to write code that is easy to maintain and comprehend. Rule-based selections can result in large numbers of rules that have small plan implementations. Conversely, if statement based selections can result in fewer rules but with more complex plan implementations. As a general guide, it is recommended that rules be used to define coherent (partial) behaviours and if statements be used within those behaviours.

An analogy to procedural programming is the idea of a method/function. Methods encapsulate partial object behaviours and rules are the ASTRA equivalent of this. Algorithms, on the other hand define strategies for implementing partial behaviours and plan implementations are the ASTRA equivalent of this. When this analogy is made, there is a natural connect that follows the following general guidance: if you would use a method/function in a procedural language then it should probably be a rule. If you would use an if statement in a procedural language, then you should do the same in ASTRA.

4.3 Repetition in ASTRA

ASTRA provides four basic approaches for implementing loops. One approach is to use multiple rules to implement a recursive style loop. The second approach is to use a while statement which is equivalent to while loops in imperative programming. The third approach is to use a foreach statement, which allows you to iterate over all possible bindings of the variables presented in the guard. Finally, the fourth approach, which allows you to iterate over all the values in a list is the forall statement.

4.3.1 Looping with Rules

The rule-based approach to implementing loops in ASTRA is equivalent to a recursive implementation in imperative programming. Typically, a loop is implemented using 2 rules: 1 for the “loop” body and 1 for the termination of the loop. For example, lets consider printing out the numbers 1 to 5 inclusive. To achieve this, we need a rule that states that if we have not printed out 5 number, then print out the next number and recursively invoke the next iteration of the loop:

agent Loopy {
    module Console console;

    initial !print(5);

    rule +!print(int X) : X > 1 {
        console.println(X);
        !print(X-1);
    }

    rule +!print(int X) : X == 1 {
        console.println(X);
    }
}

The key here is the sub goal statement !print(X-1) and the use of rule contexts. The context of a rule is used to determine whether a rule should be used to handle a given event. Here, two rules are defined that handle the same event, but the context determines which of the rules is applicable for a specific event instance. The sub goal statement causes the looping to occur. Of course, this loop goes from 5 down to 1. To implement the loop in a more traditional way, you simply need to change the context of the rules.

agent UpLoopy {
    module Console console;

    initial !print(1);

    rule +!print(int X) : X < 5 {
        console.println(X);
        !print(X-1);
    }

    rule +!print(int X) : X == 5 {
        console.println(X);
    }
}

The reason why the above code was not presented first is that it is considered to be a worse solution that the first code fragment. The reason why it is viewed as a worse solution is that the number of iterations is hardcoded while in the first code fragment, it was not. To handle variable loop lengths, we need to modify the second example as follows:

agent GoodUpLoopy {
    module Console console;

    initial !print(1, 5);

    rule +!print(int X, int N) : X < N {
        console.println(X);
        !print(X-1);
    }

    rule +!print(int X, int N) : X == N {
        console.println(X);
    }
}

Here, we pass in both the current iteration number and the number of iterations required. You can think of each subgoal as representing iteration X of N where X and N are the variables specified in the event.

Embedding this type of loop in a larger block of code, is relatively trivial:

agent EmbLoopy {
    module Console console;
    module System system;

    initial !init();

    rule +!init() {
        console.println("Started program");
        !print(1, 5);
        console.println("Finished Loop");
        system.exit();
    }

    rule +!print(int X, int N) : X < N {
        console.println(X);
        !print(X-1);
    }

    rule +!print(int X, int N) : X == N {
        console.println(X);
    }
}

As we can see, the loop is triggered by specifying a subgoal in the main plan rule. The main plan blocks until the corresponding subgoal either completes or fails. Also, note here that we have included the System API to allow us to terminate the program using the system.exit() statement.

4.3.2 Looping using While Statements

While loops in ASTRA are basically the same as while loops in any imperative language. Some guard is checked repeatedly, and each time it is evaluated to true, the corresponding statement is executed. When the guard becomes false, the while loop terminates.

Out print example above would be implemented as follows using a while loop:

agent Whiley {
    module Console console;

    initial !print(5);

    rule +!print(int X) {
        int i = 0;
        while (i < X) {
            console.println(i);
            i = i + 1;
        }
    }
}

To execute the algorithm, you would use similar code to the EmbLoopy example above:

agent EmbWhiley {
    module Console console;
    module System system;

    initial !init();

    rule +!init() {
        console.println("Started program");
        !print(5);
        console.println("Finished Loop");
        system.exit();
    }

    rule +!print(int X) {
        int i = 0;
        while (i < X) {
            console.println(i);
            i = i + 1;
        }
    }
}

The above approach is a far more natural and appropriate solution for the printing problem, however it can be viewed as being less AgentSpeak-like (basic AgentSpeak does not include while loops). A key difference between the approaches comes from the way in which iterations are executed. In the rule-based approach, iterations are executed as a result of events being generated and handled. These events can be interleaved between other environment / internal events, meaning that there is no guarantee in terms of how long it will take to execute the loop. In contrast, the while-loop approach here does offer more of a guarantee in terms of completion time – because event handling is not used, iterations of the loop are executed consecutively without delay. While this does not give explicit guarantees on performance, it does ensure that the loop will be executed as quickly as possible.

4.3.3 Looping using Foreach Statements

Foreach statements are quite different to the previous two types of loop. Specifically, for each statements are a type of loop where the guard is evaluated only once. The agent then iterates over all the possible variable bindings that have been generated for the guard. This means that the inner statement of the loop cannot affect the number of iterations of the loop. In many senses, this statement can be viewed as plan expansion rather than looping, because it generates one instance of the inner statement per variable binding.

The example below illustrates how Foreach statements work.

agent Eachy {
    module Console console;
    module System system;

    types local {
        formula balance(string, double);
        formula rate(double);
    }

    initial !init();
    initial rate(1.21);
    initial balance("Rem", 1000.0);
    initial balance("Bob", 500.0);

    rule +!init() : rate(double rate) {
        console.println("before loop");
        foreach (balance(string name, double amt)) {
            -balance(name, amt);
            +balance(name, amt*rate);
            console.println(name + " change from: " + amt + " to: " + (amt*rate));
        }
        console.println("after loop");
        system.exit();
    }
}

When the above example is run, the foreach statement generates two variable bindings for the guard: name=“Rem”;amt=1000.0 and name=“Bob”;amt=500.0. It is these two variable bindings that are iterated over, with the following output being generated:

[main]before loop
[main]Rem change from: 1000.0 to: 1210.0
[main]Bob change from: 500.0 to: 605.0
[main]after loop

4.3.4 Looping using Forall Statements

Forall statements are used to iterate through the elements of a list. It requires that the list be homogeneous (i.e. every element has the same type). The notation for a forall statement is:

forall( <variable> : <list> ) <statement>

The variable must be explicitly declared in the forall statement (you cannot use an existing variable) and the scope of the variable is the statement. The list can be a literal or referenced through a variable. To illustrate this, lets look at how we might print out a list of names:

agent NameDropper {
    module Console C;

    rule +!main(list args) {
        list names = ["Ringo", "John", "George", "Paul"];
        forall(string name : names) {
            C.println(name);
        }
    }
}

If you run this agent, the expected output would be:

[main]Ringo
[main]John
[main]George
[main]Paul

4.4 Using Beliefs in ASTRA

This section of the reference manual explores how logic is represented in ASTRA and provides insights into how you should use it within your programs.

4.4.1 How to model some knowledge as a belief

Beliefs are used to represent and query the state of the agent. Traditionally, in AOP, beliefs are defined as representing facts or knowledge about the agent or its environment. For example, if an agent is playing soccer, then it may have a belief about whether or not it sees the ball. This could be modelling in ASTRA by the belief:

seeBall()

This is a perfectly valid way of representing the knowledge, but is really an example of propositional logic thinking being applied to predicate logic (this is a quite common mistake, and I am sure you can spot examples of this in the ASTRA documentation). While the above belief is syntactically correct, it is not really following the spirit of predicate logic. In predicate logic, facts (like the above belief) are viewed as defining relationships between objects in the world of discourse. What this means is that the fact should include an object that the agent is aware of (like the ball) and the predicate should define a relation that can be applied to the ball (for example, whether or not you can see the ball). So, the above belief would be better modelled as follows:

see(ball)

Here, ball is a term that represents an object in the universe of discourse, and see is a relation defined on that object.

The problem is that, the ball object is not valid in Java. The identifier "ball" is basically treated as a variable; one that has not been declared in this case. So, while the second form of our belief is better from a modelling perspective, it is still not quite there from an ASTRA perspective. This is because logic is typically untyped and, as we saw earlier, ASTRA is typed. So, we need to decide on how to represent the ball. This really depends on how you are implementing your solution. If you have a Java class called Ball and you create an instance of that class when you see the ball, then you could simply use the Java object to represent the ball. A belief representing this cannot be written down because objects. If you were to print out the belief, it could look something like this:

see(1244defw@Ball)

Here the term is represented by the object reference for the instance of the Ball class.

If you don't have a Java object representing the ball, then you need to use one of ASTRAs built in data types. Assuming there is only one ball, then the easiest way to model the ball is often to use a string:

see("ball")

This allows the agent to reason about a "ball", but you, as the developer, maintain the mapping between the string "ball" and the ball object that exists in the environment.

4.4.2 Logical Expressions

A logical expression is something that can be evaluated to true or false. Typically, it involves some combination of beliefs and comparisons that are joined together using boolean operators (and, or, not). Logical expressions are used in the context of a plan rule, a query statement, and in any other place where the execution of a statement depends on the state of the agent.

|----------|--------|-----------------------------------------------------------------|
| OPERATOR | SYMBOL | EXAMPLE                                                         |
|----------|--------|-----------------------------------------------------------------|
| Not      |   ~    | ~likes(string X, "icecream")                                    |
|          |        | ~result(int X, int Y, int Z)                                    |
|----------|--------|-----------------------------------------------------------------|
| And      |   &    | likes(string X, "icecream") & is(X, "happy")                    |
|----------|--------|-----------------------------------------------------------------|
| Or       |   |    | has(X, "icecream") | has(X, "beer")                             |
|----------|--------|-----------------------------------------------------------------|
| Brackets |   ()   | hungry(string X) & (likes(X, "icecream") | likes(X, "burgers")) |
|----------|--------|-----------------------------------------------------------------|

For example, lets consider a football player who has do decide what to do next. The decision can be modeled as a goal !decde() that has a number of associated rules – one for each option available to the player.

If the player has the ball and is being closed down, but there is a player from the same team near the player then one option is to pass the ball to that player. On the other hand, if the player is in shooting range of the oppositions goal, then they should take a shot. This behaviour can be encoded as the following pair of rules:

rule +!decide() : 
        has("ball") & is("closed_down") & 
        close("opposition_goal") {
    shoot();
}

rule +!decide() : 
        has("ball") & is("closed_down") &
        near("team_player", string P) {
    pass(P);
}

Here, the contexts is a conjunction of belief literals (some beliefs that are “anded” together).

In addition to conjunction, we can also introduce disjunction (or operators). For example, consider somebody who will eat food only if the like that food, or at least tolerate that food:

rule +!eat(string F) : likes(F) | tolerates(F) {
    body.eat(F);
}

4.4.3 Inference Rules

Inference rules allow you to define new beliefs in terms of existing beliefs. An inference rule maps the new belief to a logical statement that expresses what must be true for the new belief to be true. Lets explore this idea by looking at the first two rules in the previous section. The rules are repeated below for ease of reading:

rule +!decide() : 
        has("ball") & is("closed_down") & 
        close("opposition_goal") {
    shoot();
}

rule +!decide() : 
        has("ball") & is("closed_down") &
        near("team_player", string P) {
    pass(P);
}

Together, the rules define how a football agent makes a decision about what to do based on their current context. The first rule states that, if they have the ball, have been closed down, and are close to the goal, then they should shoot at the goal. The second rule states that, if they have the ball, have been closed down and there is a teammate who is close, then they should pass to that teammate. Understanding these rules can be a little difficult because it involves analysing the logical statement provided in each context. We have to read each formula and understand what the combination means. Informally, we could state that the first context identifies when they should shoot, and the second context identifies when they should pass to another player.

Why are we discussing this? Well, instead of using the complex beliefs statements, we could introduce new beliefs to model the two contexts. We do this by using inference rules. In ASTRA, an inference rule takes the form:

inference <new belief> :- <belief statement>;

It is read: if the belief statement is true, then the new belief is true. For example, we could model the first rule context as follows:

inference should("shoot") :-
        has("ball") & is("closed_down") & 
        close("opposition_goal");

If we add this inference rule, we can simplify the corresponding rule:

rule +!decide() : should("shoot") {
    shoot();
}

We can do something similar for the second rule:

inference should("passTo", string P) :-
        has("ball") & is("closed_down") &
        near("team_player", P);

rule +!decide() : should("passTo", string P)) {
    pass(P);
}

So, to recap, the purpose of inference rules is to introduce new beliefs that capture more complex belief statements. This allows us to express a specific state of the environment more succinctly.

Another example of this can be explored in a blocks/tower world scenario. Here the agent is organising blocks on the table into towers. One piece of knowledge that can be useful to have is whether or not a block is free (i.e. whether or not another block is on top of it). We can express this through the belief sentence:

~on(string Y, X)

This states that there is no block on top of X. This can be used as part of a rule that, for example, involves picking up a block:

rule +!holding(string X) : ~holding(string Y) & ~on(string Z, X) {
    pickup(X);
}

This rule states that the agent can only achieve the goal to be holding X by picking X up if it is not holding a block and there is no block on top of X. It would be less of a mouthful if we could say that the agent can only achieve the goal to be holding X by picking X up if it is not holding a block and X is free. To model the fact that X is free, we can use an inference rule:

inference free(string X) :- ~on(string Y, X);

This inference rule states that the agent believes X is free if there is no block on X, and it can be used to make the plan rule easier to understand:

rule +!holding(string X) : ~holding(string Y) & free(X) {
    pickup(X);
}

We could do something like this for the gripper:

inference empty("gripper") :- ~holding(string Y);

rule +!holding(string X) : empty("gripper") & free(X) {
    pickup(X);
}

This refined rule is definitely easier to understand. However, ultimately, the same beliefs must hold (or not) for this rule to be selected as for the first version of the rule.

Inference rules can be useful in situations where there are many possible states that can trigger a rule (i.e. do something if X is true or Y is true or Z is true). Inference rules can be a nice way of simplify such situations by saying do something if A is true, and A is true if X is true, or Y is true or Z is true:

inference A :- X;
inference A :- Y;
inference A :- Z;

Obviously, the above is propositional logic and is not valid ASTRA code. A nice example that illustrates this idea is Tic-Tac-Toe. The game can be modelled as a set of beliefs about locations on the board. We can model the board as nine numbered locations:

-------------
| 1 | 2 | 3 |
-------------
| 4 | 5 | 6 |
-------------
| 7 | 8 | 9 |
-------------

We can model the presence of counters (played by players) by the belief contains(int, string), where the first argument is a location and the second is the token played at that location. In a typical game, the players may make moves, which could be represented by the following beliefs (this is not a program but state):

contains(1, "X")
contains(4, "O")
contains(2, "X")
contains(6, "O")
contains(3, "X")

We can design our approach so that the lack of a contains belief means that there is no token played at that location. This can be nicely modelled using the inference rule:

inference free(int X) :- ~contains(X, string Y);

Note that there is a downside to this: you cannot use an unbound variable here - you must ask is location X free (e.g. free(5) would work, but free(int x) would not).

Where inference rules bevome useful is identifying winning states of the board. Any line of the same token is a winning state, for example if an X is places in locations 1, 2 and 3, then X wins. Inference allows us to enumerate all these possible configurations, meaning we can simply ask if there is a winner:

inference winner(string X) :- contains(1, X) & contains(2, X) & contains (3, X);
inference winner(string X) :- contains(4, X) & contains(5, X) & contains (6, X);
inference winner(string X) :- contains(7, X) & contains(8, X) & contains (9, X);
inference winner(string X) :- contains(1, X) & contains(5, X) & contains (9, X);
inference winner(string X) :- contains(1, X) & contains(4, X) & contains (7, X);

Obviously, there are more scenarios than the ones given above, but we can produce an inference for each possible winning position and then we can query the agents beliefs to see if there is a winner.

The idea is to allow you to introduce more abstract concepts into the beliefs of the agent.