Bob Marinier : aspects

Introduction

Sometimes you need a way to allow context-sensitive knowledge insert itself at an appropriate place in an algorithm. E.g., "all applicable heuristics compute your values here". There's no way to know ahead of time how many there are. This is essentially a version Aspect-Oriented Programming (AOP), which defines a few terms:

  • join point is a place where code can be inserted. This is defined by an operator or goal.
  • pointcut is a way to determine that a join point matches (i.e., now is the time to insert code). This may be an operator proposal or elaboration rule.
  • advice is the code to run at a join point when it matches. This may be an operator apply or an entire subgoal.

I'm not going to attempt to describe a universal means for supporting AOP in Soar, although if you are interested in that you should read Jacob Crossman's Aspects and Soar presentation. But these terms could be useful in describing how to insert knowledge at runtime in a particular place in your code.

As an example, suppose we want to create a bunch of standing queries to an external system during initialization. We want it to be very easy to add new queries, and some of those queries may only apply to certain domains, so not all authored queries will necessarily get created.

This table summarizes the primary differences among the approaches:

 join pointpointcutadvice
Approach 1: Many proposals, many appliesgoalproposal ruleapply rule
Approach 2: One propose, many appliesoperator proposalapply conditionsapply actions
Approach 3: Many proposals, one applygoalproposal conditionsdefined in proposal actions, executed in shared apply
Approach 4: One propose, one apply, many elaborationsoperator proposaloperator elaboration conditionsdefined in operator elaboration actions, executed in shared apply

Approach 1: Many proposals, many applies

This is very similar to Approach 1 in foreach: the join point is specified by a goal, and matching on that goal is the point cut. The advice is whatever operators propose themselves.

#
# these rules define the queries that will insert themselves in the initialization process when contextually appropriate
# these could be defined in a completely different place in the code, or come out of an authoring tool
# note the proposals are the pointcuts, the applies are the advice
#

sp "init-queries*propose*query1
   (state <s> ^name init-queries
              ^args <args>)
   (<args> ^domain foo # defines context
           ^queries <qs>)
  -(<qs> ^query.name query2)
-->
   (<s> ^operator <o> +)
   (<o> ^name create-query-1
        ^queries <qs>)
"

sp "init-queries*apply*query1
   (state <s> ^name init-queries
              ^operator <o>)
   (<o> ^name create-query-1
        ^queries <qs>)
-->
   (<qs> ^query <q>)
   (<q> ^name query1
        ^parameters whatever1)
"

sp "init-queries*propose*query2
   (state <s> ^name init-queries
              ^args <args>)
   (<args> ^domain << foo bar >> # defines context
           ^queries <qs>)
  -(<qs> ^query.name query2)
-->
   (<s> ^operator <o> +)
   (<o> ^name create-query-2
        ^queries <qs>)
"

sp "init-queries*apply*query2
   (state <s> ^name init-queries
              ^operator <o>)
   (<o> ^name create-query-2
        ^queries <qs>)
-->
   (<qs> ^query <q>)
   (<q> ^name query2
        ^parameters whatever2)
"

#
# Main algorithm that we want advice inserted into
#

# this creates the goal that serves as the join point
sp "propose*init-queries
   (state <s> ^queries <qs> # the place queries will go
              ^domain <d> # other important context
             -^queries-initialized true)
-->
   (<s> ^operator <o> +)
   (<o> ^name init-queries
        ^queries <qs>
        ^domain <d>)
"

# won't get selected until all queries are created, then returns a result to terminate the goal
sp "init-queries*propose*finish
   (state <s> ^name init-queries)
-->
   (<s> ^operator <o> + <)
   (<o> ^name finish)
"
 
sp "init-queries*apply*finish
   (state <s> ^name init-queries
              ^operator.name finish
              ^superstate <ss>)
-->
   (<ss> ^queries-initialized true)
"

In this example, the core algorithm is very simple: just create a goal and propose a worst-pref operator to terminate it (see foreach for discussion of alternatives to worst pref). Depending on the context and queries defined, it may be that no queries get created here, or 100 queries do. In this example, if the domain is foo, then both queries get created, if the domain is bar only query2 gets created, and if it's something else, no queries get created.

Pros:

  • Maintains reactivity (one operator per advice)
  • Supports advice as subgoals (i.e., a pointcut that triggers the creation of a subgoal)
  • Supports multiple waves of applies

Cons:

Approach 2: One proposal, many applies

In this approach, the join point is defined by a simple operator proposal, as opposed to a goal, and then the advice is contained in matching apply rules. It is very similar to Approach 2 in foreach.

#
# these rules define the queries that will insert themselves in the initialization process when contextually appropriate
# these could be defined in a completely different place in the code, or come out of an authoring tool
# note the apply conditions are the pointcuts and the apply actions are the advice
#

sp "apply*init-queries*query1
   (state <s> ^operator <o>)
   (<o> ^name init-queries
        ^domain foo # defines context
        ^queries <qs>)
-->
   (<qs> ^query <q>)
   (<q> ^name query1
        ^parameters whatever1)
"

sp "apply*init-queries*query2
   (state <s> ^operator <o>)
   (<o> ^name init-queries
        ^domain << foo bar >> # defines context
        ^queries <qs>)
-->
   (<qs> ^query <q>)
   (<q> ^name query2
        ^parameters whatever2)
"

#
# Main algorithm that we want advice inserted into
#

# this proposes the operator that serves as the join point
sp "propose*init-queries
   (state <s> ^queries <qs> # the place queries will go
              ^domain <d>) # other important context
  -(<qs> ^query) # no queries created yet
-->
   (<s> ^operator <o> +)
   (<o> ^name init-queries
        ^queries <qs>
        ^domain <d>)
"

This approach uses a single operator with parallel applies. In this case, we were able to condition the operator to retract when all the queries were created. If this were impractical, we could have a terminating apply rule that fires in parallel with the advice to cause the operator to retract.

Pros:

  • Avoids overhead of decision cycle by doing everything in parallel at once (although overhead should be pretty minimal)
  • Ensures that the processing happens "atomically" (i.e., there is no state where some objects are marked and some aren't). This could be important in some cases, although if it is you might reconsider your design as it's pretty brittle.

Cons:

  • Supporting multiple waves of rule firings is difficult. In the example, everything must occur in a single wave (the operator will retract after that, so there will be no second wave). Of course, the proposal could be conditioned on something that won't happen until the nth wave of rule firings, but in complex systems, getting the multiple waves to happen the way you want is often difficult.
  • Reduces reactivity: The agent is stuck in this decision until all the applies have fired – it can't get new input or work on anything else in the meantime. If the number of objects is small, this is not a big deal. But if there are 1000 objects, this could matter.
    • It should be noted that in some cases this could be advantageous – e.g., it gives an opportunity to do something to every object on the input-link before they potentially disappear in the next cycle. But this seems like a brittle design – there may be better ways to deal with disappearing input.
  • If the processing has side effects, doing them all in parallel may not work. E.g., counting is an inherently sequential process, and thus Approach 1 is necessary.
  • Doesn't support complex (i.e., subgoal) processing of each object

Approach 3: Multiple proposals, single parameterized apply

In this approach, there is a single generic apply rule that is used by many proposals. This will presumably be done under a single goal. This is an application of floating operators/subgoals.

#
# these rules define the queries that will insert themselves in the initialization process when contextually appropriate
# these could be defined in a completely different place in the code, or come out of an authoring tool
# note the propose conditions are the pointcuts and the propose actions contain the advice (which is applied by the generic apply rule)
#

sp "init-queries*propose*create-query*query1
   (state <s> ^name init-queries
              ^args.domain foo) # defines context
-->
   (<s> ^operator <o> +)
   (<o> ^name create-query
        ^query <q>)
   (<q> ^name query1
        ^parameters whatever1)
"

sp "init-queries*propose*create-query*query2
   (state <s> ^name init-queries
              ^args.domain << foo bar >>) # defines context
-->
   (<s> ^operator <o> +)
   (<o> ^name create-query
        ^query <q>)
   (<q> ^name query2
        ^parameters whatever2)
"

#
# Main algorithm that we want advice inserted into
#

# this creates the goal that serves as the join point
sp "propose*init-queries
   (state <s> ^queries <qs> # the place queries will go
              ^domain <d>) # other important context
  -(<qs> ^query) # no queries created yet
-->
   (<s> ^operator <o> +)
   (<o> ^name init-queries
        ^queries <qs>
        ^domain <d>)
"

sp "init-queries*apply*create-query
   (state <s> ^name init-queries
              ^args.queries <qs>
              ^operator <o>)
   (<o> ^name create-query
        ^query <q>)
   (<q> ^name <name>
        ^parameters <params>)
-->
   (<qs> ^query <qcopy>) # have to create a copy since the query structure on the operator will go away when the operator retracts
   (<qcopy> ^name <name>
            ^parameters <params>)
"

In this version, a goal is created and then all operators propose themselves to create queries. A generic apply rule applies to all of them. This is similar to Approach 1, but requires fewer rules since the proposal contains all the necessary information (pointcuts and advice) instead of spreading it out over separate propose and apply rules.

Pros:

  • Same as Approach 1
  • Fewer rules than approach 1, which matters if people are writing them by hand (if generated, this doesn't matter)

Cons:

  • Same as Approach 1
  • This means all advice must follow a standard pattern, which may not be desirable or possible in some cases

Approach 4: Single proposal, single parameterized apply, elaborations to create parameters

In this approach, there is a single proposal and apply rule, but elaborations insert parameters on the operator before the apply.

#
# these rules define the queries that will insert themselves in the initialization process when contextually appropriate
# these could be defined in a completely different place in the code, or come out of an authoring tool
# note the elaboration conditions are the pointcuts and the elaborations actions contain the advice (which is applied by the generic apply rule)
#

sp "elaborate-operator*init-queries*query1
   (state <s> ^operator <o>)
   (<o> ^name init-queries
        ^domain foo) # defines context
-->
   (<o> ^query <q>)
   (<q> ^name query1
        ^parameters whatever1)
"

sp "elaborate-operator*init-queries*query2
   (state <s> ^operator <o>)
   (<o> ^name init-queries
        ^domain << foo bar >>) # defines context
-->
   (<o> ^query <q>)
   (<q> ^name query2
        ^parameters whatever2)
"

#
# Main algorithm that we want advice inserted into
#

# this creates the goal that serves as the join point
sp "propose*init-queries
   (state <s> ^queries <qs> # the place queries will go
              ^domain <d>) # other important context
  -(<qs> ^query) # no queries created yet
-->
   (<s> ^operator <o> +)
   (<o> ^name init-queries
        ^queries <qs>
        ^domain <d>)
"

sp "apply*init-queries
   (state <s> ^operator <o>)
   (<o> ^name init-queries
        ^queries <qs>
        ^query <q>)
   (<q> ^name <name>
        ^parameters <params>)
-->
   (<qs> ^query <qcopy>) # have to create a copy since the query structure on the operator will go away when the operator retracts
   (<qcopy> ^name <name>
            ^parameters <params>)
"

In this version, there is a single proposal and apply, and elaboration rules insert any number of query parameters after the operator is selected but before it applies (in the first IE subphase of the apply phase). We could just as easily elaborated these parameters on the proposed operator, in which case they would be created before the operator was selected. The potential downside of this approach is if you have operators that may never get selected (e.g., there's an alternative init operator, and it gets selected instead), you could waste a lot of computation creating all these parameters for nothing. But in many cases it doesn't matter.

Note that in the default o-support mode, the ^query on the operator will get i-support, but it's substructure will get i-support. This may cause Soar to print a warning message. If this bothers you, consider elaborating the proposed operator instead.

Pros:

  • Everything in Approach 2

Cons:

  • Everything in Approach 2
  • This means all advice must follow a standard pattern, which may not be desirable or possible in some cases