You are here

Developing for jACT-R: Custom Conditions and Actions

Developing for jACT-R: Custom Conditions and Actions

All models require some amount of atheoretic code to address interfacing or behavior that resides outside of the modeled behavior. While jACT-R supports scripting (equivalent to !eval! in the canonical Lisp implementation), the faster option is to use compiled java code. Developers are free to create any custom conditions or actions which can be accessed via the proxy-condition and proxy-action directives (in the jACT-R syntax). (apologies that my blogging tool screws up code formatting)

      <production name="custom-short-cut">
        <conditions>
          <match buffer="goal" type="add">
            <slot name="arg1" equals="=num1"/>
            <slot name="arg2" equals="=num2"/>
          </match>
          <query buffer="retrieval">
            <slot name="state" equals="error"/>
          </query>
          <proxy-condition class="org.jactr.examples.custom.AdditionCondition">
            <slot name="arg1" equals="=num1" />
            <slot name="arg2" equals="=num2" />
            <slot name="sum" equals="=sum" />
          </proxy-condition>
        </conditions>
        <actions>
          <proxy-action class="org.jactr.examples.custom.SetResultAction">
            <slot name="add-chunk" equals="=goal" />
            <slot name="sum" equals="=sum"/>
          </proxy-action>
          <proxy-action class="org.jactr.examples.custom.OutputSumAction">
            <slot name="output" equals="=sum"/>
          </proxy-action>
          <!-- reset the retrieval buffer -->
          <add buffer="retrieval" type="clear"/>
        </actions>
      </production>

While one can implement the full interfaces for IAction or ICondition, it is usually easier to extend one of the abstract or default implementations. AbstractSlotCondition is the best starting point for conditions, and DefaultSlotAction for the actions. However, the class must provide a zero-arg constructor to be properly instantiated by the builder.

Unit 1 Addition

The Unit 1 addition model uses declarative retrievals to add two numbers. The model's success depends upon it having sufficient count-order chunks to cover both numeric arguments. If it doesn't, the model will have a retrieval failure and halt. Let's make a simple modification so that upon retrieval failure, a production kicks in to save the day. The full production is below. It uses one custom condition and two custom actions.

AdditionCondition

In this model the AdditionCondition does just that. It takes the two arguments, adds them together and will resolve the sum slot. The condition can also be used to test the sum if the sum slot is not a variable. To implement it we start by extending AbstractSlotCondition which provides us the majority of the logic required.We still have to provide two key pieces clone() and bind().

During conflict resolution, the procedural module first attempts to instantiate each of the productions. The first step is to clone the production so that it can be modified. Each of its conditions are cloned during which the condition can perform any tests to see if it is even possible to instantiate it.


  /**
   * check that the required slots are present and numbers/variables then copy
   * 
   * @see ICondition#clone(IModel, Map) for details
   */
  public ICondition clone(IModel model, Map<String, Object> variableBindings)
      throws CannotMatchException
  {
    /*
     * let's make sure arg1, arg2, and sum exist and that they are variables or
     * numbers
     */
    for (String slotName : new String[] { "arg1", "arg2", "sum" })
    {
      ISlot slot = getSlot(slotName);
      if (slot == null)
        throw new CannotMatchException(String.format(
            "slot %s must be defined (number or variable)", slotName));


      if (!slot.isVariableValue() && !(slot.getValue() instanceof Number))
        throw new CannotMatchException(String.format(
            "slot %s must be a number or variable", slotName));
    }


    AdditionCondition clone = new AdditionCondition();
    /*
     * the slot based request manages all the slots for us, but we do need to
     * copy them.
     */
    clone.setRequest(new SlotBasedRequest(getSlots()));
    return clone;
  }

Once all of the conditions have been cloned, the procedural module will iteratively resolve the variable bindings (it has to be done iteratively to support variable slot names and capacity buffers). During binding, each condition's bind method is called. It returns the number of unresolved variables or throws a CannotMatchException. Once fully bound, the instantiation copies and resolves the production's actions and the instantiation can then be selected to fire.


  /**
   * @see ICondition#bind(IModel, Map, boolean) for more details
   */
  public int bind(IModel model, Map<String, Object> variableBindings,
      boolean isIterative) throws CannotMatchException
  {
    //let the slot based request handle most of the binding
    int unresolved = getRequest().bind(model, variableBindings, isIterative);
    /*
     * there are three possible unresolved slots here: arg1, arg2, and sum. we
     * can test and calculate once unresolved is <=1 or isIterative is false, as
     * that means sum is unresolved (unresolved=1), needs to be compared in a
     * test (i.e. arg1 1 arg2 2 sum 3), or this is the last iteration
     */
    if (unresolved <= 1 || !isIterative)
    {
      ISlot arg1 = getSlot("arg1");
      ISlot arg2 = getSlot("arg2");


      /*
       * none of those can be null, otherwise we'd never have gotten through
       * clone, but they may have been variables that have been resolved to
       * something other than a number
       */
      double one = getValue(arg1);
      double two = getValue(arg2);
      double trueSum = one + two;


      /*
       * now one of two things could be true about the sum slot. It could still
       * be unresolved, in which case we resolve it ourselves and decrement
       * unresolved. Or we need to compare a resolved sum value to the true
       * value. To do this, we need to snag all the slots that are named sum
       */
      for (IConditionalSlot cSlot : getConditionalSlots())
        if (cSlot.getName().equals("sum"))
        {
          if (cSlot.isVariableValue())
          {
            // resolve
            if (cSlot.getCondition() == IConditionalSlot.EQUALS)
            {
              //add the binding
              variableBindings.put((String) cSlot.getValue(), trueSum);
              //and resolve the value
              cSlot.setValue(trueSum);
              unresolved--;
            }
            else if (!isIterative)
              throw new CannotMatchException(String.format("%s is unresolved",
                  cSlot.getValue()));
          }
          else
          {
            /*
             * the slot already has a value, which means we need to make a
             * comparison against trueSum
             */
            if (!cSlot.matchesCondition(trueSum))
              throw new CannotMatchException(String.format(
                  "%s=%.2f does not match condition %s.", cSlot.getName(),
                  trueSum, cSlot));
          }
        }
    }


    return unresolved;
  }

OutputSumAction

Actions come in two general flavors: immediate and deferred. Immediate actions are fired when the production first starts to fire. Typically you will use these if the action does not depend upon or manipulate specific chunk or buffer states. This simple action merely outputs a value to standard out.

Once an instantiation's conditions have been fully resolved, the production's actions are copied and bound to the instantiation. This is accomplished by calling the action's bind method which returns a fully bound copy of the action.

The OutputSumAction merely extends DefaultSlotAction and provides a bind method that checks to see if the output slot exists and returns a copy of itself.


  /**
   * just check to see if the output slot exists.
   */
  public IAction bind(Map<String, Object> variableBindings)
  throws CannotInstantiateException
  {
    ISlot slot = getSlot("output");
    if(slot==null)
      throw new CannotInstantiateException("slot output must be defined");
    
    /*
     * this constructor will resolve the variables in the slots based on
     * the variable bindings
     */
    return new OutputSumAction(variableBindings, getSlots());
  }

If the instantiation is then select to fire, all of the actions' fire methods are called (in order). The method returns a value which can be used to offset the execution time of the production beyond the default action time. In this case, we merely print the value of output to standard out.


  /**
   * merely print the value of the output slot.
   * @see IAction#fire(IInstantiation, double);
   */
  @Override
  public double fire(IInstantiation instantiation, double firingTime)
  {
    Object value = getSlot("output").getValue();
    
    System.out.println(String.format("Output value %s @ %.2f", value, firingTime));
    
    return 0;
  }

SetResultAction

Delayed actions are used when you need to manipulate chunks or buffers. These occur at the end of the production firing (typically 50ms after the initial firing time). The default add, modify, and remove actions all do this and is recommended for any action that operates on chunks or buffers. If you do not, the actions may not be properly sequenced and strange things can happen.

In this contrived example, SetResultAction takes two parameters: a chunk of type add and a sum. It will then set the sum slot of the chunk to sum and set the count slot to arg2 (the terminal conditions for the model). But first let's look at the bind method. Bind checks to make sure each of the slots are defined and then passes things off to a private constructor. The private constructor then performs some resolution and verifies the types of each of the slot values.


/**
   * just check to see if the count-chunk and sum slots exists.
   */
  public IAction bind(Map<String, Object> variableBindings)
  throws CannotInstantiateException
  {
    ISlot slot = getSlot("add-chunk");
    if(slot==null)
      throw new CannotInstantiateException("slot add-chunk must be defined");
    
    slot = getSlot("sum");
    if(slot==null)
      throw new CannotInstantiateException("slot sum must be defined");
    
    /*
     * this constructor will resolve the variables in the slots based on
     * the variable bindings
     */
    return new SetResultAction(variableBindings, getSlots());
  }

  
  /**
  * calls the {@link DefaultSlotAction} full constructor which will perform the variable resolution
   * @param variableBindings
   * @param slots
   * @throws CannotInstantiateException
   */
  private SetResultAction(Map<String, Object> variableBindings,
      Collection<? extends ISlot> slots) throws CannotInstantiateException
  {
    super(variableBindings, slots);
    
    Object value = getSlot("sum").getValue();
    if(!(value instanceof Number))
      throw new CannotInstantiateException("sum must be a number");
    
    /*
     * let's make sure count-chunk is correct
     */
    value = getSlot("add-chunk").getValue();
    if(!(value instanceof IChunk))
      throw new CannotInstantiateException("add-chunk needs to be a chunk");
    
    /*
     * it's a chunk, but is it the correct type?
     */
    IChunk chunk = (IChunk) value;
    IChunkType chunkType = chunk.getSymbolicChunk().getChunkType();
    if(!chunkType.getSymbolicChunkType().getName().equals("add"))
      throw new CannotInstantiateException(String.format("%s is not add", chunkType));
  }

Finally, when fire is called, instead of performing the chunk manipulations right there, it queues an ITimedEvent. This goes onto the timed event queue which is processed after the clock but before the conflict resolution phase.


/**
   * here we will change the sum slot of the count-chunk to the actual sum value. 
   * Instead of doing it immediately, we delay until the production finish time (typically
   * 50ms after the firingTime). 
   * @see IAction#fire(IInstantiation, double)
   */
  @Override
  public double fire(IInstantiation instantiation, double firingTime)
  {
    /*
     * first we create the timed event to do the work. This will be fired
     * on the model thread after the clock has advanced but before the conflict
     * resolution phase
     */
    IModel model = instantiation.getModel();
    IProceduralModule procMod = model.getProceduralModule();


    double finishTime = firingTime + procMod.getDefaultProductionFiringTime();
    
    final IChunk chunk = (IChunk) getSlot("add-chunk").getValue();
    final double sum = ((Number)getSlot("sum").getValue()).doubleValue();
    
    ITimedEvent futureEvent = new AbstractTimedEvent(firingTime, finishTime){
      public void fire(double currentTime)
      {
        super.fire(currentTime);
        
        /*
         * we only manipulate unencoded, non disposed chunks. It could
         * have been encoded if it was removed from the buffer before
         * this was called. Disposal is rare, but is good to check for
         * just in case.
         */
        if(!chunk.isEncoded() && !chunk.hasBeenDisposed())
        {
          /*
           * contains the symbolic portion of the chunk: name, type, slots
           */
          ISymbolicChunk sChunk = chunk.getSymbolicChunk();
          /*
           * now we need to get the correct slot
           */
          ISlot sumSlot = sChunk.getSlot("sum");
          /*
           * since the chunk isn't encoded, it's safe to assume that this
           * slot is actually mutable
           */
          ((IMutableSlot)sumSlot).setValue(sum);
          
          /*
           * and the model doesnt terminate until count==arg2
           */
          ((IMutableSlot)sChunk.getSlot("count")).setValue(sChunk.getSlot("arg2").getValue());
        }
      }
    };
    
    /*
     * now we queue up the timed event
     */
    model.getTimedEventQueue().enqueue(futureEvent);
    
    /*
     * we still return 0 because the production still only takes 50ms to fire
     */
    return 0;
  }

Running the Model

Now when you run the model, if it has sufficient count-order chunks, it will run as before. However, if it does not, the custom-short-cut production will fire, which will do the addition, update the chunk and output the answer to standard out. If you were doing this coding yourself instead of importing the provided code or using the new project wizard, it wouldn't actually run..

Java-specific Nuisances

jACT-R is built on top of Equinox (an implementation of OSGi). This provides it with many benefits, but it does expose the modeler to a built of Java specific irritation: classpaths. If you were coding this yourself and tried to run the model, you'd see some ClassNotFoundExceptions in the console. That is because the parser and builders do not automatically have access to the custom code. To make the code visible, the relevant packages need to be exported to the runtime (making them visible to other classes). This can be accomplished by opening the META-INF/MANIFEST.MF file, selecting the Runtime tab, and adding the packages to the export set.

The manifest file handles all of the meta information for jACT-R projects (bundles or plugins). As these developer examples progress, we will keep returning to this file as the final step in putting everything together.

References