Herramientas de usuario

Herramientas del sitio


Barra lateral

integracion_de_soar_con_ros_tomas

1. Introducción

1.1. Motivación y objetivos

El control inteligente de un agente software dista de ser una tarea trivial. A lo largo de la corta historia de la inteligencia artificial se han empleado una gran cantidad de aproximaciones y tecnologías diferentes para resolver problemas concretos, logrando en su mayoría una eficiencia mucho mayor de la que obtendría un usuario humano en la realización de la misma tarea. Sin embargo, esto ha dado lugar a que el estado actual del control inteligente sea más parecido a una colección hetereógenea de tecnologías que una disciplina estructurada, metódica y unificada.

En la literatura podemos encontrar varias definiciones de inteligencia artificial. A continuación veremos algunas, ordenadas según el criterio de los autores Stuart Russel y Peter Norvig. Opinan que la mayoría de estas definiciones se pueden agrupar en dos categorías o dimensiones diferentes: aquellas relacionadas con los procesos de pensamiento y razonamiento, y aquellas centradas en el comportamiento. A su vez, estas categorías pueden ser divididas en su forma de medir el éxito obtenido en la tarea a desarrollar. Algunas definiciones buscan obtener un rendimiento similar al humano, mientras otras creen que la inteligencia artificial debería intentar acercarse al rendimiento óptimo para cada tarea, lo que llaman racionalidad. Veremos a continuación ocho de estas definiciones, agrupadas según su categoría:

rendimiento humano rendimiento óptimo
Razonamiento, pensamiento Pensar de forma humana Pensar racionalmente
Comportamiento Actuar de forma humana Actuar racionalmente
  • Pensar de forma humana:
    • “El excitante nuevo esfuerzo por hacer que los computadores piensen… máquinas con mente, en su sentido más completo y literal.”
    • “[La automatización de] las actividades que asociamos con el pensamiento humano, tales como la toma de decisiones, resolución de problemas, aprendizaje, …”
  • Pensar racionalmente:
    • “El estudio de las facultades mentales mediante el uso de modelos computacionales”
    • “El estudio de los cálculos que hacen posible la percepción, el razonamiento y la toma de decisiones”
  • Actuar de forma humana:
    • “El arte de crear máquinas que realicen funciones que requieran inteligencia cuando son realizadas por personas”
    • “El estudio acerca de cómo hacer que los ordenadores hagan cosas que los humanos son, por el momento, mejores a la hora de realizarlas”
  • Actuar racionalmente:
    • “La inteligencia computacional es el estudio del diseño de agentes inteligentes”
    • “La inteligencia artificial … se preocupa del comportamiento inteligente en artefactos”

Históricamente, la totalidad de estas cuatro aproximaciones han sido tomadas, cada una de ellas por diferentes personas siguiendo distintos métodos. Este proyecto de fin de carrera se sitúa en la necesidad de encontrar una solución al problema de realizar la intersección entre la inteligencia artificial y los sistemas de control genéricos. Visto esto, podemos concluir que es necesario encontrar un sistema inteligente que encaje en la segunda categoría de las definiciones anteriores, es decir, que sea capaz de pensar racionalmente. De esta forma obtendríamos un sistema de control que procuraría controlar el sistema de forma óptima en entornos complejos, en los cuales sea necesario exhibir un comportamiento inteligente y no puramente metódico.

Como hemos comentado, durante los últimos años el campo de la inteligencia artificial ha realizado con éxito diferentes aproximaciones algorítmicas a la resolución de problemas concretos. Sin embargo, en nuestro caso buscamos un sistema que sea capaz de realizar multitud de tareas heterogéneas, siendo asimismo capaz de tomar sus propias decisiones, enfrentarse a tareas nuevas, usar distintos cuerpos de conocimiento e incluso de aprender a partir de experiencias propias en entornos dinámicos. Es decir, buscamos una entidad generalmente inteligente. Por esto se ha elegido la plataforma SOAR. Aunque hablaremos de SOAR con mayor profundidad en los capítulos que siguen, en resumen diremos que se trata de una arquitectura cognitiva general para el desarrollo de sistemas que muestran comportamiento inteligente.

Éste módulo cognitivo habrá de ser integrado en un sistema de control de forma que pueda ser gobernado de forma automática e inteligente, ya que de otra manera sólo podríamos obtener razonamiento interno, sin ninguna interacción con el exterior. En nuestro caso, se ha decidido que SOAR sea integrado en un módulo ROS para controlar un robot Erratic de la compañía Videre. ROS (Robot Operating System) es un metasistema operativo que provee una gran cantidad de librerías y herramientas (abstracción del hardware, controladores de dispositivos, visualizadores, gestión de mensajes, control de paquetes, etc.) para ayudar a los desarrolladores de software en la creación de aplicaciones robóticas. ROS será explicado con detalle en un capítulo posterior.

Esquema de la aplicación a desarrollar

Figura 1.1: Esquema de la aplicación a desarrollar

Por tanto, el objetivo del presente se podría resumir en pocas palabras como implementar un módulo (wrapper) entre las arquitecturas SOAR y ROS para lograr controlar (de forma inteligente) un robot Erratic (veáse figura 1.1).

1.2. Contexto

El presente proyecto ha formado parte del trabajo realizado gracias a la obtención de una beca como alumno colaborador en el departamento de Arquitectura y Tecnología de Computadores de la Universidad de Sevilla durante el segundo cuatrimestre del curso 2010-2011. Se trata de uno de los primeros pasos que se han dado en dicho departamento en la búsqueda de un objetivo a largo plazo: la creación de una infraestructura de software especializada en robots asistenciales (a personas de la tercera edad, discapacitados, etc.) bajo el metasistema operativo de robótica ROS.

En dicha infraestructura se pretenden integrar diversos módulos de toda índole que sean de ayuda, algunos ya desarrollados y tales como visión en 2 y 3 dimensiones (usando la Kinect de Microsoft), sistemas de reconocimiento de voz (CMU-Phinx), drivers de alto nivel para brazos manipuladores (brazo Cython Alpha), sistemas de navegación, etc. Al tratarse de un sistema que debe tratar con personas de forma directa, estaría trabajando en entornos no estructurados, en los que se pueden dar circunstancias imprevistas y para las que no existen rutinas definidas. Aquí es donde la necesidad de incorporar una arquitectura cognitiva que sea capaz de actuar de forma inteligente se hace necesaria. Por tanto, el elemento más importante de todo el sistema sería la arquitectura cognitiva encargada de controlarlo inteligentemente, en nuestro caso SOAR.

En el departamento ya se han ido desarrollando una serie de stacks (aplicaciones) para ROS que en la actualidad ofrecen formas de abordar tareas concretas utilizando los distintos robots de los que dispone. Dichos stacks se encuentran a disposición de quien los quiera consultar en http://sourceforge.net/projects/rtc-us-ros-pkg/. En este proyecto de fin de carrera será añadido un nuevo stack al repositorio, encargado de abordar la integración de la arquitectura cognitiva SOAR con ROS para controlar uno de los robots del departamento, el Erratic de Videre.

1.3. Estrategia

Como ejemplo de control del robot Erratic por parte del módulo SOAR, realizaremos una aplicación que se encargue de mover al robot a través de una habitación que puede presentar obstáculos. El robot deberá ser capaz de moverse por dicha habitación evitando los obstáculos, siguiendo sus paredes y cruzando aquellas puertas que encuentre a su paso. En el futuro, y gracias a la incorporación del módulo cognitivo, éste podrá aprender a tomar mejores decisiones aprendiendo de las ya tomadas, así como decidir qué hacer en caso de que no se le especifiquen reglas de actuación para casos inesperados. No obstante, debido a la dificultad intrínseca que presenta la incorporación de aprendizaje al agente, ésta se escapa del alcance de este proyecto de fin de carrera, aunque discutiremos posibles estrategias para abordarla más adelante.

La estrategia para conseguir este objetivo comienza por estudiar de forma independiente el funcionamiento de SOAR, decidiendo si es una arquitectura adecuada para la resolución del problema. También deberemos conocer de que forma gobernar el robot Erratic mediante ROS, haciendo uso de los paquetes adecuados y aprendiendo a usarlos. Finalmente, habrá que estudiar los mecanismos de comunicación disponibles en ROS y SOAR e integrarlos.

Entrando en más detalle, el desarrollo de la aplicación tiene varias partes bien diferenciadas. En primer lugar, habrá que crear un paquete en ROS que contenga las librerías necesarias para poder ejecutar agentes SOAR. Luego, haciendo uso de éste y otros paquetes, habrá que desarrollar un wrapper que se encargará de las siguientes tareas:

  • Inicializar el agente SOAR encargado del control del robot.
  • Cargar las reglas SOAR que el agente ejecutará para su gobierno.
  • Mantener un sistema de comunicación adecuado entre los nodos ROS del Erratic y el agente SOAR, de forma que el módulo cognitivo reciba las entradas sensoriales desde el robot, tome decisiones en consecuencia y envíe órdenes que serán recibidas por el robot.
  • Finalmente, apagar el agente SOAR cuando ya no sea necesario.

El lenguaje elegido para la realización del wrapper ha sido C++, debido a que es el único lenguaje para el que existe soporte tanto por ROS (usando el paquete roscpp) como por SOAR (usando la implementación en C++ de SML).

Por último, habrá que crear una serie de reglas escritas en el lenguaje de programación de SOAR que realicen la tarea escogida. En nuestro caso, las reglas actuarán según los datos recibidos por los sensores del robot, evitando obstáculos y buscando paredes y puertas. Todo esto será visto con detalle en el capítulo dedicado a la implementación del proyecto.

2. Arquitecturas cognitivas: SOAR y otras

En esta sección definiremos qué es una arquitectura cognitiva, asi como por qué se ha elegido SOAR como módulo cognitivo dentro de este proyecto. También compararemos algunas arquitecturas cognitivas existentes en la actualidad, centrándonos en explicar los mecansimos de SOAR, por ser la pieza clave del proyecto.

2.1. ¿Qué son las arquitecturas cognitivas?

Muchas disciplinas intelectuales contribuyen al campo de la ciencia cognitiva: psicología, lingüística, antropología e inteligencia artificial, por ejemplo. La ciencia cognitiva se originó en el deseo de integrar el conocimiento experto de estas disciplinas tradicionalmente separadas para conseguir una mejor comprensión de los fenómenos cognitivos (fenómenos tales como resolución de problemas, toma de decisiones, lenguaje, memoria o aprendizaje). Cada disciplina realiza cierto tipo de preguntas y acepta un determinado tipo de respuestas. Y esto, según Allen Newell, uno de los fundadores del campo de la inteligencia artificial, supone a su vez una ventaja y un inconveniente.

La ventaja de la existencia de disciplinas individuales que contribuyan al estudio de la cognición es que cada una de ellas provee conocimiento experto referente a las cuestiones que se encuentran dentro de su campo. De esta forma ciertos comportamientos humanos quedan claramente descritos y explicados por disciplinas concretas. Por ejemplo, la psicología nos ha dado descripciones y teorías acerca de las llamadas regularidades robustas, i.e. comportamientos que todo el mundo parece exhibir de la misma manera. De esta forma, dentro de la rama del comportamiento motor podemos tomar como ejemplo la ley de Fitt, o los estudios de Sternberg en el campo del reconocimiento de objetos.

Sin embargo, surge un problema debido a la existencia de todas estas teorías independientes. Para Newell, cada disciplina individual realmente aporta una determinada microteoría: pequeñas piezas independientes englobadas en un marco mayor, pero desarrolladas sin tener en cuenta todas las demás piezas que lo componen. Y aunque es cierto que las estructuras y mecanismos que subyacen en una regularidad dada no necesitan subyacer a su vez en el resto de regularidades, al menos deberían ser compatibles con sus estructuras y mecanismos. Debido a este problema, según Nowell la única manera de hacer encajar todas estas pequeñas piezas en su marco común es el intentar construir teorías unificadas cognitivas.

Las arquitecturas cognitivas se basan en el concepto de arquitectura (ver sección 2.2.1) para intentar representar teorías unificadas cognitivas. Dicho de otra forma, una arquitectura cognitiva especifica la infraestructura subyacente en un sistema inteligente general. De forma resumida, una arquitectura incluye aquellos aspectos de un agente cognitivo que permanecen constantes en el tiempo y a través de diferentes dominios de aplicación. Estos aspectos típicamente incluyen:

  • Las memorias a corto y largo plazo que almacenan contenidos sobre el conocimiento, objetivos y creencias del agente.
  • La representación de los elementos contenidos en estas memorias, y su organización formando estructuras mentales a mayor escala.
  • Los procesos funcionales que operan en dichas estructuras, incluyendo los mecanismos de funcionamiento del agente que los usan, y los mecanismos de aprendizaje que los modifican.

Los conocimientos y creencias del agente pueden cambiar a través del tiempo, y por tanto no son codificados en una arquitectura cognitiva. De la misma forma que diferentes programas pueden ser ejecutados en la misma arquitectura de un computador, diferentes conocimientos y creencias pueden ser interpretados por una misma arquitectura cognitiva. Veremos que distintas arquitecturas cognitivas pueden diferir en las suposiciones específicas que realizan sobre estos aspectos, pero en todas subyace la búsqueda de implementar una arquitectura software que interprete teorías unificadas cognitivas.

La investigación en arquitecturas cognitivas se hace pues importante debido a que respaldan uno de los principales objetivos de la inteligencia artificial y la ciencia cognitiva: la creación y comprensión de agentes sintéticos que lleguen a poseer las mismas capacidades que los seres humanos. De alguna manera, las arquitecturas cognitivas constituyen la antítesis de los sistemas expertos, que ofrecen comportamientos especializados en contextos muy definidos, mientras que este tipo de investigación arquitectural tiene por objetivo el ampliar lo máximo posible la cantidad de tareas y dominios que pueden ser gestionados por el agente. Y lo que es más importante, considera los comportamientos a nivel de sistema, en lugar de a nivel de componentes diseñados para tareas especializadas.

Desde la creación del término, ha habido un flujo continuo de investigación en arquitecturas cognitivas. El movimiento fue asociado originalmente con un tipo específico de arquitecturas conocidas como sistemas de producción, y ponía énfasis en la explicación de fenómenos psicológicos; muchas arquitecturas hoy en día aún siguen tomando esta aproximación. Sin embargo, a lo largo de las últimas tres décadas ha surgido un gran número de clases arquitecturales distintas, algunas menos preocupadas por el comportamiento humano, que realizan suposiciones diferentes acerca de la forma en la que representar, organizar y adquirir el conocimiento. Además, el movimiento ha ido más allá del campo de la investigación hacia el sector comercial, con aplicaciones de agentes inteligentes en entornos de entrenamiento simulados, sistemas de enseñanza computarizados, y videojuegos interactivos.

2.2. SOAR

Desarrollada por John Laird, Paul Rosenbloom y Allen Newell a principios de 1982, SOAR surge como una arquitectura cognitiva con el objetivo de embeber dicha teoría unificada cognitiva en una arquitectura software. A continuación describiremos su estructura y funcionamiento, y veremos cómo SOAR en efecto es una arquitectura apropiada para la resolución de nuestro problema.

2.2.1 La idea de arquitectura

Antes de nada, debemos definir qué entendemos por arquitectura en el campo de las arquitecturas cognitivas. En principio, la idea de arquitectura no es nueva; cuando hablamos de computadoras a menudo se describen y comparan diferentes arquitecturas hardware, esto es, el conjunto de elecciones que el fabricante de un determinado producto hardware realiza, tales como tamaño de memoria, conjunto de instrucciones de un microprocesador, etc. Una vez terminada, una arquitectura hardware puede ser evaluada o comparada con otra arquitectura, siendo necesario para ello ejecutar procesos software en ellas. De la misma manera en la que hablamos de como una arquitectura hardware en particular procesa aplicaciones software, podemos pensar de que manera una aplicación software en particular procesa un conjunto de tareas de alto nivel. Es decir, podemos decir que una aplicación software también posee una arquitectura, sobre la cual se ejecutan diversas tareas. Por ejemplo, a la hora de crear documentos de texto podemos comparar diferentes aplicaciones de procesamiento de texto según diferentes criterios, de forma que diferentes programas serán más eficientes para determinadas suposiciones de las tareas que pueden realizar. De esta manera, si nuestro conjunto de tareas de alto nivel fuera el escribir los capítulos de un libro de cálculo, sería mejor elegir un programa de procesamiento de texto que tenga comandos para dar formato a ecuaciones matemáticas. Por el contrario, si lo que queremos escribir es una novela, estos comandos no son necesarios.

Por tanto, para cualquier nivel de un sistema complejo, podemos distinguir entre el conjunto de mecanismos y estructuras fijos de la propia arquitectura, y los contenidos que dicha arquitectura procesa. Otra forma de ver esto es que la arquitectura no puede realizar nada de forma independiente, sino que necesita contenido para producir comportamientos. Esto lo podemos expresar de la siguiente manera:

COMPORTAMIENTO = ARQUITECTURA + CONTENIDO

Otra conclusión a la que llegamos es que cualquier arquitectura refleja suposiciones por parte de su diseñador acerca de las características del contenido que dicha arquitectura procesará. En general, la idea de arquitectura es útil porque nos permite tener en consideración algunos aspectos comunes dentro del amplio espectro de comportamientos que caracterizan el contenido. Una arquitectura en particular, es decir, un determinado conjunto de mecanismos y estructuras fijos, representa una teoría de aquello que es común entre gran parte del comportamiento de su nivel superior.

Usando esta idea podemos definir una arquitectura cognitiva como una teoría de los mecanismos y estructuras fijos (arquitectura) que subyacen la condición humana. Identificar aquello que es común entre los comportamientos cognitivos, es decir, entre los fenómenos explicados por las distintas microteorías existentes, es un paso muy importante en la producción de una teoría unificada cognitiva. Por lo tanto, veremos qué tienen los diferentes comportamientos cognitivos en común, y de qué forma SOAR es capaz de representarlos.

2.2.2. Lo que los comportamientos cognitivos tienen en común

Este apartado responde a la pregunta ¿qué tipos de comportamiento modela SOAR?. Una arquitectura cognitiva debe poder producir comportamientos cognitivos, pero ésto abarca un gran número de diferentes tareas, tales como leer, resolver ecuaciones, cocinar, conducir un coche, y la gran mayoría del comportamiento que un ser humano exhibe día a día. SOAR es una teoría de aquello que los comportamientos cognitivos tienen en común, puesto que toda arquitectura se puede ver como una teoría de lo que es común en aquello que procesa. En particular, la teoría de SOAR postula que los comportamientos cognitivos tienen al menos las siguientes características en común:

  1. Acción orientada al objetivo. En nuestro día a día, actuamos de formas que están relacionadas con nuestros deseos e intenciones, y nunca arbitrariamente.
  2. Tienen lugar en un entorno rico, complejo y detallado. Aunque las formas en las que podemos percibir y actuar en el mundo son limitadas, el mundo en sí mismo dista de ser simple. Existen una enorme cantidad de objetos, propiedades de esos objetos, acciones, etcétera, y cualquiera de ellos puede ser un elemento clave a la hora de alcanzar alguno de nuestros objetivos.
  3. Requieren de una gran cantidad de conocimiento. Por ejemplo, podríamos pensar en todo aquello que sabemos sobre cómo resolver ecuaciones. Algunas de estas cosas son obvias: llevar la variable a un lado de la igualdad, o mover los términos constantes al otro. Pero además de esto, también es necesario saber por ejemplo como sumar y restar, escribir números y letras, sujetar un lápiz, qué hacer si la punta del lápiz se rompe, etc.
  4. Requieren el uso de símbolos y abstracciones. De alguna forma, parte del conocimiento que tenemos puede ser aprendido de otra forma que no sea mediante su percepción con todo detalle.
  5. Son flexibles, y varían en función de su entorno. La cognición humana no es simplemente cuestión de seguir planes prefijados, sino de pensar según evolucione el entorno. Si una pelota se cruza a nuestro paso mientras vamos conduciendo al trabajo, frenamos y miramos en la dirección desde la que vino, por si tras ella viene un niño en su búsqueda.
  6. Requieren aprender del entorno y las experiencias propias. Los humanos nacemos sin saber gastar bromas, resolver ecuaciones, cocinar o jugar al fútbol. Sin embargo, la mayoría acabamos siendo competentes (algunos incluso expertos) en algunas de estas actividades, entre cientos de otras.

También existen otras propiedades que subyacen en nuestras capacidades cognitivas (por ejemplo, la conciencia de nosotros mismos), y otras maneras de interpretar los mismos comportamientos de los que hemos hablado anteriormente. Entonces, ¿qué supone esto para SOAR como una arquitectura para reflejar esta forma en particular de identificar aquello que es común en la cognición?. Supone que los mecanismos y estructuras que se han incluido en SOAR harán que estos aspectos comunes sean fáciles de implementar, mientras que otros aspectos o puntos de vista diferentes serán más difíciles de ser implementados.

2.2. Ejemplos de arquitecturas cognitivas

En esta sección compararemos cuatro entornos (frameworks) distintos que pertenecen a puntos distintos dentro del espacio de las arquitecturas cognitivas. Cada uno de ellos muestra una forma distinta de explicar el comportamiento humano.

3. Tutorial de SOAR

Introducción

Éste es un tutorial para la creación de agentes software con SOAR, versión 9. No se asumen conocimientos previos de SOAR o de programación. Es un resumen traducido de “The SOAR 9 Tutorial”, por John E. Laird. La versión original se puede encontrar en la wiki oficial de SOAR.

Los objetivos de este documento son:

  • Introducir los principios básicos del funcionamiento de SOAR
  • Enseñarte cómo ejecutar programas SOAR y qué es lo que hacen
  • Enseñarte a escribir tus propios programas SOAR

¿Qué es SOAR?

SOAR es una arquitectura unificada para desarrollar sistemas inteligentes. Es decir, SOAR ofrece una serie de estructuras computacionales fijas en las que el conocimiento puede ser codificado y usado para producir acciones en la búsqueda de sastisfacer objetivos. En gran medida, SOAR es como cualquier otro lenguaje de programación, aunque más especializado. Se diferencia de otros lenguajes de programación en que en su interior subyacen teorías específicas sobre las primitivas apropiadas para ofrecer razonamiento, aprendizaje, planificación, y otras características que suponemos que son necesarias para que pueda darse comportamiento inteligente. SOAR no se desarrolló con la intención de crear otro lenguaje de propósito general. Descubrirás que algunas computaciones son difíciles o poco naturales de realizar en SOAR (por ejemplo, cálculos matemáticos complejos), para las cuales sería más apropiado utilizar otros lenguajes de programación como podría ser C, C++ o Java. Nuestra hipótesis es que SOAR es un lenguaje apropiado para la construcción de agentes autónomos que usen grandes cantidades de conocimiento para generar acciones para llegar a cumplir objetivos.

Parte 1: Programas SOAR simples

En esta parte del tutorial, escribiremos programas simples en SOAR que no utilizan mecanismos complejos como subobjetivos o troceado (chunking).

1. Instalación de SOAR

Antes de continuar, deberías instalar SOAR y todo su software asociado para poder ejecutar los ejemplos y ejercicios que encontrarás de aquí en adelante. En la web oficial de SOAR hay instrucciones específicas para la instalacción de SOAR en distintas plataformas. Se recomienda instalar todo el software SOAR para que así dispongas de la última versión de SOAR junto a Visual Soar (el editor de programas SOAR que usaremos en este tutorial), Soar Debugger (el depurador que usaremos para ejecutar los programas SOAR) y algunos ejemplos de programas realizados con SOAR (Eaters game, TankSoar game, ejemplo de las jarras, etc.)

2. Creación de un agente SOAR simple mediante el uso de reglas

Todo el conocimiento en un agente SOAR se representa como un conjunto de reglas if-then. Estas reglas se llaman también producciones, y usaremos ambos términos indistintamente. Las reglas se usan para la selección y aplicación de los llamados operadores, y gran parte de este tutorial describirá de qué forma las reglas y los operadores trabajan conjuntamente. Pero antes de empezar a usar operadores, vamos a aprender a escribir reglas para un agente muy simple, que no hace más que escribir “¡Hola Mundo!” por pantalla. Para la ejecución de programas SOAR, vamos a usar el SOAR debugger, incluido en la instalación estándar de SOAR. En la sección 3.2 encontrarás detalles sobre cómo usar Soar Debugger para ejecutar los ejemplos que iremos viendo.

2.1. Regla "Hola Mundo"

A continuación veremos la regla para imprimir por pantalla la línea “¡Hola Mundo!”. Primero en lenguaje natural, y después su traducción a SOAR:

hola-mundo:
Si yo existo, entonces escribe "¡Hola Mundo!" por pantalla y termina. 

Lo primero que hace SOAR al interpretar una regla es comprobar si su primera parte (“si yo existo”) se cumple. Estas partes “if” se llaman condiciones. Si todas las condiciones de una regla son ciertas, entonces la parte “then” de la regla se ejecuta. Estas partes “then” se llaman acciones. Las acciones normalmente realizan cambios en la memoria de trabajo (working memory) del agente, que contiene todas las estructuras dinámicas de datos de un programa SOAR. Las acciones también pueden eliminar elementos de la memoria de trabajo, o crear preferencias para la selección de distintos operadores (ésto se verá más adelante). Otras realizan tareas secundarias como imprimir texto por pantalla o detener el agente, como las que veremos en este ejemplo. Al hecho de ejecutar las acciones de una regla se le llama disparar (to fire) la regla. Para determinar si las condiciones son ciertas, SOAR las compara con las estructuras de datos almacenadas en su memoria de trabajo. La memoria de trabajo define la situación actual para el agente, es decir, la percepción que éste tiene del mundo que le rodea, los resultados de cálculos recientes, objetivos activos, y operadores.

La regla hola-mundo traducida a SOAR es:

sp {hola-mundo
   (state <s> ^type state)
-->
   (write |¡Hola Mundo!|)
   (halt)}

A la hora de escribir una regla (producción) en SOAR, lo primero que hay que escribir es “sp”, iniciales de “Soar Production”. A continuación, el cuerpo de la regla se encierra entre corchetes, { y }. El cuerpo de una regla está formado por el nombre de la regla (hola-mundo), seguido de una o más condiciones, tras las cuales aparece el símbolo “–>”, y luego una o más acciones. Una plantilla para la creación de reglas sería:

sp {nombre*de*la*regla
   (condición_1)
   (condición_2)
    ...
   (condición_N)
-->
   (acción_1)
   (acción_2)
    ...
   (acción_N)}

El nombre de una regla puede ser cualquier combinación de letras, números, barras bajas (“_”) y asteriscos. La única excepción es que un nombre no puede ser una única letra seguida de un número, como S1 o O45, ya que SOAR reserva estos nombres para otros usos. Antes de detenernos a ver qué significan los distintos símbolos que hemos visto en la regla hola-mundo, debemos comprender la estructura de la memoria de trabajo en SOAR.

2.2. La memoria de trabajo

La memoria de trabajo (o WM, de working memory) contiene toda la información dinámica de un agente SOAR sobre el mundo que lo rodea y su razonamiento interno. Contiene datos del exterior obtenido mediante sensores, cálculos intermedios, y operadores y objetivos actuales. En SOAR, todos los elementos de la WM están organizados como estados formando un grafo. De este modo, cada elemento de la WM está conectado directa o indirectamente a un símbolo de estado. Para los primeros agentes que veremos en este tutorial, sólo habrá un único estado. A continuación veremos un ejemplo simple de cómo un agente SOAR representaría en su WM la existencia de dos bloques, uno encima de otro, situados encima de una mesa:

SOAR también crea siempre automáticamente algunas estructuras para cada agente, mostradas en la siguiente figura:

nodos_comunes.jpg

Como cualquier grafo, están compuestos por nodos (p.ej. S1, B1, B2) y aristas (p.ej. block (bloque), table (mesa), color, superstate (superestado)). Existen dos tipos de nodo en SOAR: identificadores y constantes. Aquellos nodos que tienen aristas saliendo de ellos (nodos no terminales) como B1 o S1, se llaman identificadores. Los demás (nodos terminales), como state, blue, block o nil, se llaman constantes.

En el ejemplo anterior, S1 es el identificador para el estado. Todos los nombres de los indentificadores se crean automáticamente por SOAR, y consisten en una letra seguida de un número. Aunque los nodos I2 e I3 de la segunda figura no tengan aristas saliendo de ellos también son identificadores, y pueden tener otras subestructuras añadidas más tarde. Por otra parte, el símbolo state no es un identificador, por lo que no podrá tener enlaces (aristas) saliendo de él. Las aristas se llaman atributos en SOAR, y son precedidas por el símbolo “^”. Sólo los identificadores pueden tener atributos. En la segunda figura, S1 tiene tres atributos: superstate, io y type. I1 tiene dos atributos: output-link y input-link.

La memoria de trabajo está compuesta de tripletes (3-tuplas) individuales. Los elementos de cada tupla son un identificador y un atributo seguido de su valor, donde el valor es el nodo apuntado por el atributo. Un valor puede ser una constante o un identificador. Por tanto, en la segunda figura habrían cinco elementos en la memoria de trabajo, mostrados a continuación. Éstos son los contenidos mínimos de la WM, y a medida que tus programas se vayan haciendo más grandes y complejos, irán manipulando muchos más elementos de la memoria de trabajo.

S1 ^superstate nil
S1 ^io I1
S1 ^type state
I1 ^output-link I2
I1 ^input-link I3

A cada conjunto de elementos de la memoria de trabajo que comparten el mismo primer elemento (identificador) se les llama objeto. Por ejemplo, las tres tuplas anteriores que comparten el identificador S1 son todas parte del objeto del estado S1. Los elementos de la memoria de trabajo que conforman un objeto se llaman aumentos (augmentations). Los objetos por regla general se escriben como una lista de sus aumentos entre paréntesis, siendo el primer elemento de la lista el identificador que todos los aumentos comparten. Por ejemplo, existen dos objetos en la lista anterior de elementos de la WM:

(S1 ^io I1 ^superstate nil ^type state)
(I1 ^input-link I3 ^output-link I2)

Si queremos aislar un aumento concreto lo podemos escribir de esta forma:

(S1 ^type state)

Los objetos suelen representar algo del mundo del agente, como podría ser un bloque, una pared, un trozo de comida, etc. Cada aumento representa alguna propiedad (p.ej. color, tamaño o peso), o relaciones con otros objetos (p.ej. encima de, detrás de, o dentro de). La WM también suele contener objetos que son únicamente conceptuales y no tangibles, como el estado S1, que organiza todos los demás objetos, sus relaciones y propiedades. La representación exacta de los objetos dependerá de ti como programador de SOAR.

SOAR no necesita ninguna declaración de los posibles atributos y constantes. De hecho, algunos programas SOAR van generando nuevos atributos y constantes a la misma vez que se ejecutan. Visual Soar sin embargo sí requiere declaraciones para la estructura de los elementos de la memoria de trabajo, pero estas declaraciones sólo se usan para comprobar la existencia de errores en las reglas y no serán usadas posteriormente por SOAR cuando ejecute el programa.

2.3. Regla "Hola Mundo". Detalles.

Tras ver la estructura de la memoria de trabajo, podemos ahora analizar con detalle la regla “Hola Mundo” vista en el punto 2.1. La primera parte de la regla comprobaba si se cumplía una condición (si yo existo). Tal y como vimos en el apartado 2.2, de forma automática, cada vez que un agente SOAR se crea, se tiene (s1 ^type state) en la memoria de trabajo. Por tanto, podemos comprobar si dicha estructura existe en memoria para certificar la existencia del agente. Sin embargo, s1 no es más que un símbolo arbitrario, y podría ser posible que SOAR le diese otro identificador al estado, p.ej s2 o s38 (aunque en este caso concreto, debido a la implementación de SOAR, s1 siempre es el identificador del primer estado). Por tanto, necesitamos comprobar la existencia de un identificador cualquiera, sin un valor en concreto. Para ello, usaremos una variable, que puede encajar con un identificador, atributo o valor según qué posición ocupe en una condición (si se encuentra en la primera posición encajará con un identificador, en la segunda encajará con un atributo, y en la tercera con un valor). En SOAR las variables se definen entre “<” y “>”, p.ej. <s>. Por último, en SOAR cada regla debe empezar con la palabra “state”, como recordatorio de que todas las reglas deben comenzar encajando en un estado. La condición si yo existo escrita en SOAR queda entonces de la siguiente forma:

(state <s> ^type state)

El resto de la regla son las acciones. La primera de ellas llama al método write, que escribe una constante escrita entre barras “|”. La segunda acción, halt, detiene el agente. El código completo de la regla, de nuevo, es:

sp {hola-mundo
   (state <s> ^type state)
-->
   (write |¡Hola Mundo!|)
   (halt)}

3. Construcción de agentes simples usando operadores.

En esta sección, aprenderemos a usar reglas que seleccionen y apliquen operadores. Los operadores realizan acciones, tanto en el mundo exterior como en la “mente” interna del agente. Por ejemplo, en el problema clásico de las jarras de agua, se encargan de echar agua de una jarra a otra. En el juego “Eaters” que también veremos más adelante, los operadores crean comandos que mueven a los jugadores por el tablero de juego, entre otras funciones. En un videojuego de fútbol se encargarían de mover o girar a los jugadores, hacer que chutaran el balón, enviar mensajes a otros jugadores, seleccionar la estrategia de juego, etc.

Los operadores son el lugar en donde se toman las decisiones, donde se aplica el conocimiento del agente para decidir qué hacer (o qué no hacer). El funcionamiento básico de SOAR es por tanto un ciclo contínuo en el cual se van proponiendo, seleccionando y aplicando operadores. Las reglas proponen y aplican operadores, y el proceso de decisión se encarga de seleccionar el operador a aplicar. Aunque esto pueda parecer algo restrictivo, sin embargo fuerza a distinguir entre aquellos lugares en los que se toman las decisiones (proposición y selección) y aquellos lugares en los que se desarrollan las acciones (aplicación).

ciclo.jpg

Para que SOAR pueda usar los operadores, primero deben ser creados en la WM mediante las reglas de proposición (proposal rules). Estas reglas comprueban determinados aspectos del estado para asegurarse de que el operador es el apropiado, tras lo cual crean una representación de dicho operador el la WM junto con una preferencia aceptable para él. La preferencia es una forma de indicarle a la arquitectura que se trata de un operador que es candidato a ser seleccionado. Una vez que el operador es seleccionado, las reglas que lo aplican encajarán con los contenidos de la WM y realizarán las acciones apropiadas mediante la creación y/o eliminación de elementos de la WM.

3.1. Operador "Hola Mundo".

En esta sección escribiremos un operador en vez de una única regla para imprimir “¡Hola Mundo!” por pantalla. Los operadores nos permiten que una misma acción pueda ser considerada en múltiples situaciones (reglas que proponen el operador), múltiples motivos para seleccionar una acción (reglas que seleccionan operadores) y múltiples formas de realizarla (reglas que aplican el operador). Para imprimir “¡Hola Mundo!” por pantalla no es necesario que haya ningún operador, puesto que no existe ninguna alternativa posible, pero en cuanto otras acciones sean posbiles y el agente tenga que tomar decisiones, los operadores se vuelven imprescindibles.

Para usar un operador, necesitamos dos reglas; una para proponer el operador, y otra que lo aplique:

Propose*hola-mundo
Si yo existo, sugiere el operador hola-mundo.
Apply*hola-mundo
Si el operador hola-mundo está seleccionado, escribe "¡Hola Mundo!" por pantalla y termina.

La primera regla sugiere el operador hola-mundo, y la segunda realiza las acciones apropiadas una vez que el operador ha sido seleccionado. Nótese como la primera regla sólo sugiere (propone) el operador hola-mundo. Como se dijo anteriormente, los operadores no son seleccionados mediante reglas, sino por el propio proceso de decisión de SOAR, que se encarga de seleccionar un operador de entre todos los que hayan sido propuestos.

Ahora veamos estas reglas en el lenguaje SOAR. La regla propose*hola-mundo comprueba la misma condición que la regla que ya vimos anteriormente (si yo existo); sin embargo, realiza acciones diferentes. En este caso la regla se encarga de proponer el operador hola-mundo, mediante la creación de una preferencia aceptable para el operador. Una preferencia aceptable es una declaración de que un operador es un candidato para ser seleccionado. La siguiente regla propone el operador hola-mundo. La primera acción de la regla crea una preferencia aceptable para un operador nuevo (que se añade a la WM), y la segunda acción crea un elemento de la WM que aumenta el operador con su nombre:

sp {propose*hola-mundo
   (state <s> ^type state)
-->
   (<s> ^operator <o> +) # El "+" indica que es una preferencia aceptable
   (<o> ^name hola-mundo)}

Una preferencia tiene el aspecto de cualquier otro elemento (tupla) de la memoria de trabajo, salvo que tiene un cuarto elemento: el tipo de preferencia, que en este caso es “+”. El identificador de la preferencia es <s>, lo que significa que el identificador que encajase en la condición con <s> será también usado en la creación de la acción. El valor de la preferencia, <o>, es una nueva variable que no aparecía en la condición. Cuando aparecen nuevas variables en las acciones, SOAR crea automáticamente un identificador nuevo y lo usa para todas las demás ocurrencias de esa variable que haya en la acción. Por ejemplo, si la memoria de trabajo originalmente contuviese (s1 ^type state), y SOAR reemplazase <o> por o1, entonces esta regla añadiría los siguientes elementos a la memoria de trabajo:

(s1 ^operator o1 +)
(o1 ^name hola-mundo)

El proceso de decisión de SOAR selecciona un único operador cada vez basándose en todas las preferencias que han sido creadas por las reglas de proposición. Si sólo se ha propuesto un único operador, entonces dicho operador será el elegido, como sucede en este caso con el operador hola-mundo. En este ejemplo, el proceso de decisión tras seleccionar o1 como operador actual, añade el elemento (s1 ^operator o1) a la WM (nótese que este aumento de la WM no tiene el símbolo “+” tras el valor, ya que el operador ya ha sido elegido y no es necesario conservar su preferencia).

Una vez que el operador ha sido seleccionado, la regla apply*hola-mundo debería dispararse:

sp {apply*hola-mundo
   (state <s> ^operator <o>) # Comprueba si un operador ha sido seleccionado
   (<o> ^name hola-mundo) # Comprueba que el operador seleccionado se llame hola-mundo
-->
   (write |¡Hola Mundo!|)
   (halt)}

Esta regla tiene exactamente las mismas acciones que la regla del apartado 2.1, pero con nuevas condiciones que comprueban que el operador hola-mundo haya sido seleccionado. La primera condición comprueba que algún operador haya sido seleccionado. Al no conocer su identificador, usamos la variable <o> para representarlo. La segunda condición comprueba que algún elemento de la memoria de trabajo tenga como nombre hola-mundo. La regla sólo se disparará si ambos <o> encajan en el mismo identificador (p.ej. o1). En este caso, como ambos encajan en o1, la regla se dispara.

Si la misma variable se usa en reglas diferentes, puede que encaje con identificadores o constantes completamente diferentes, es decir, la identidad de una variable sólo importa dentro de una regla. Los símbolos exactos a usar para las variables son irrelevantes, aunque como regla general se suelen usar <s> para variables que encajen con identificadores de estados, y <o> para variables que encajen con identificadores de operadores.

3.2. Ejecución de los operadores y análisis de la WM con Soar Debugger

Para ejecutar el código SOAR que hemos escrito, abre el Soar Debugger, que se encuentra en la carpeta que descargaste previamente en el punto 1 de este tutorial. Escribe el código de las dos reglas de la sección 3.1 en un fichero con extensión .soar usando cualquier editor de textos (aunque posteriormente usaremos Visual Soar), y ábrelo con Soar Debugger haciendo clic en el menú File → Load source file. En caso de error deberás mover el archivo fuente a un directorio con los permisos necesarios. Una vez el fichero haya sido cargado correctamente, se puede ejecutar su contenido haciendo click en el botón “Run” o escribiendo “run” en la ventana de comandos. Verás como en la ventana de interacción aparece (entre otras cosas), la línea “¡Hola Mundo!”, tal y como se esperaba.

Podemos ver con más detalle cómo las reglas se van disparando. Antes de nada, hay que eliminar todas las reglas ya existentes en el agente haciendo clic en el botón “Excise all”, que elimina todas las reglas de memoria e inicializa SOAR de nuevo. Para que podamos ver como las producciones individuales se disparan, tendremos que modificar el nivel de observación (watch level). Existen diferentes botones en Soar Debugger para cada uno de estos niveles (Watch 1, Watch 3 y Watch 5), siendo 5 el más detallado de todos. Para este ejemplo vamos a activar el nivel 5: haz clic en “Watch 5”. A continuación ejecuta el agente haciendo click en el botón “Run” de nuevo, tras cargar el archivo fuente.

En la ventana de interacción podemos seguir una traza de la ejecución de SOAR expandiéndola o encogiéndola haciendo clic en los iconos “+” y “-” a la izquierda de la traza. Tras haber hecho clic en “Run”, expande las líneas “run” y “1: O: O1 (hola-mundo)” haciendo click en sus respectivos “+”. La traza muestra cómo la regla propose*hola-mundo se dispara en primer lugar durante la etapa de proposición (propose phase), proponiendo el operador hola-mundo (O1). Tal y como se vio en la sección 3.1, la regla añade los elementos (s1 ^operator o1 +) y (o1 ^name hola-mundo) a la memoria de trabajo. A continuación, durante la etapa de decisión (decision phase), el proceso de decisión de SOAR selecciona el único operador disponible, hola-mundo (O1). Una vez O1 ha sido seleccionado, durante la etapa de aplicación (apply phase) la regla apply*hola-mundo se dispara y ejecuta sus acciones, es decir, imprime “¡Hola Mundo!” por pantalla y detiene el agente:

traza-1.jpg

Podemos obtener una visión aún más detallada del procesamiendo de SOAR examinando las estructuras de datos almacenadas en la memoria de trabajo. Se pueden imprimir por pantalla los elementos de la WM usando el comando print o, equivalentemente, p. Para imprimir todos los atributos y valores que tienen S1 como su identificador, introduce en la caja de comandos print s1 (o p s1), y pulsa intro. El depurador también tiene algunas ventanas adicionales a la derecha de la ventana de interacción que muestran estrucutras comunes, como el estado actual, el operador actual, etc. La ventana de arriba a la derecha debería mostrar el estado actual, y también dispone de pestañas en su borde inferior para ver rápidamente otras estructuras de la memoria de trabajo. También puedes añadir nuevas pestañas para mostrar comandos personalizados haciendo clic en algún sitio vacío de la zona de pestañas.

Podemos examinar también los aumentos de otros elementos. Por ejemplo, I1 es el valor del atributo ^io de S1. io significa input-output (entrada-salida). Para ver más sobre la entrada-salida, imprime por pantalla I1 mediante print I1. Verás que io tiene dos atributos: input-link y output-link. El input-link (enlace de entrada) es a través de donde la información sensorial del agente está disponible en la memoria de trabajo. El enlace de salida (output-link) es donde los comandos de acción deberán ser creados para que el agente sea capaz de moverse en su mundo. Ahora puedes explorar la estructura del enlace de entrada (I2).

Ya conoces la estructura básica de las reglas y operadores en SOAR. A partir de ahora deberías ser capaz de crear tus propios agentes que imprimen mensajes por pantalla mediante el uso de operadores.

4. Creando agentes para resolver el problema de las jarras de agua

En esta parte del tutorial veremos cómo diseñar programas SOAR que resuelven problemas a través de resolución interna, es decir, sin interactuar con el exterior. Este tipo de programas SOAR resuelven los problemas mediante la búsqueda y manipulación de estructuras de datos internas. Empezaremos por construir los operadores, descripciones de estado y análisis de objetivos que son necesarios para definir el problema. También introduciremos algo más sobre la teoría de resolución de problemas basada en búsqueda en espacios de problema.

4.1. El problema de las jarras de agua

El problema de las jarras de agua se enuncia de la siguiente forma:

“Se dispone de dos jarras inicialmente vacías, una de 5 litros de capacidad y la otra de 3 litros de capacidad. Hay un pozo cercano con agua ilimitada que puede ser usado para llenar cualquiera de las dos jarras completamente tantas veces como se desee. También se puede volcar el contenido de una jarra en la otra. No existen marcas en las jarras que indiquen la cantidad de agua que contienen. El objetivo es llegar a tener exactamente un litro de agua en la jarra de 3 litros de capacidad.”

Lo primero que hay que hacer a la hora de formular un problema es definir el espacio de posibles estados en los que nos podemos encontrar durante el camino hasta resolver el problema. Este espacio de estados, o espacio de problema, se determina por el número de objetos que se pueden manipular (las dos jarras de agua), y sus posibles valores (un valor de entre 0 y 5 litros). A partir de aquí, un problema se define como un estado inicial por el que comenzamos a solucionarlo, y un conjunto de estados deseados o finales (en este caso cualquier estado en el que la jarra de 3 litros de capacidad contenga 1 litro de agua). Resolver el problema consiste en, comenzando por el estado inicial, llegar a encontrar un estado final mediante la aplicación de operadores, que transforman unos estados en otros. Para el problema de las jarras de agua, los operadores son llenar una jarra, vaciar una jarra, y volcar el contenido de una jarra en la otra.

Además del conocimiento necesario para formular el problema, también podemos usar conocimiento para seleccionar el operador apropiado a aplicar en cada estado. Por ejemplo, especificar que no se debe vaciar una jarra justo después de haberla llenado. En SOAR, este segundo tipo de conocimiento es a menudo llamado conocimiento de control de búsqueda (search control knowledge). Una parte importante del diseño de SOAR es que hace posible separar el conocimiento acerca de la formulación del problema del conocimiento que se usa para controlar la búsqueda.

4.2. Representación de los estados

¿Qué partes del problema deben ser representados en el estado? Existen dos tipos de información: información dinámica, que cambia durante el proceso de resolución del problema (como la cantidad de agua que contiene cada jarra), e información estática, que no varía (como la capacidad de cada jarra). De forma que una representación de un estado debe incluir:

  • La cantidad de agua que cada jarra contiene actualmente
  • La capacidad máxima de cada jarra

Usando una representación general del problema, podemos representar cada jarra como un objeto con dos atributos: la cantidad actual de agua que contiene (^contenido), y su capacidad (^capacidad). Siguiendo este esquema, el estado inicial quedaría así:

(state <s> ^jarra <j1>
           ^jarra <j2>)
(<j1> ^capacidad 5 ^contenido 0)
(<j2> ^capacidad 3 ^contenido 0)

Nótese que ^jarra es un atributo que puede tener más de un valor a la vez. Este tipo de atributos se llaman atributos multivaluados. Los atributos multivaluados te permiten representar conjuntos de objetos, como el conjunto formado por dos jarras de agua.

Para simplificar más tarde las reglas de proposición de operadores, vamos a añadir un nuevo atributo que indique cuántos litros restantes quedan en cada jarra hasta llenarla (^quedan). Este atributo no lo hemos escrito en el esquema anterior porque, como veremos, su valor puede ser calculado dinámicamente durante la ejecución mediante el uso de reglas de elaboración de estado, restando el contenido de la jarra de su capacidad total en tiempo de ejecución. En resumen, los estados estarán formados por las siguientes estructuras:

  • Un objeto por cada jarra (^jarra)
  • La capacidad total de cada jarra (^capacidad)
  • La cantidad actual que contiene cada jarra (^contenido)
  • El espacio libre que queda en cada jarra (^quedan)

Aunque esta representación es suficiente, hay otro elemento de la memoria de trabajo que resulta de gran utilidad: una descripción de la tarea que se está desarrollando, en este caso lo llamaremos jarras-de-agua. De esta forma, podremos crear reglas específicas para cada tarea, y podremos combinarlas fácilmente con reglas de otras tareas sin que haya interferencias. Por tanto, es una convención en SOAR el etiquetar el estado con información sobre la tarea a resolver. La forma más fácil de hacerlo es añadiendo un atributo ^name (nombre), con valor el nombre de la tarea. Añadimos:

  • El nombre de la tarea (^name jarras-de-agua).
4.3. Creación del estado inicial: el operador inicializa-jarras-de-agua

Para que SOAR pueda comenzar a resolver un problema, las estructuras del estado inicial deben estar en la memoria de trabajo. En aquellas tareas que incluyan interacción con el exterior, gran parte del estado inicial será creado por la información procedente de los sensores. Dicha información sería creada en la estructura input-link del estado. Para la tarea de las jarras de agua, sin embargo, hemos asumido que no existe un ambiente externo al agente, por lo que el estado inicial deberá ser creado por reglas que añadiremos al sistema.

Por tanto, el primer paso es definir el operador que creará el estado inicial. Al usar un operador en lugar de una regla aislada, la inicialización del problema podrá ser controlada, es decir, el operador que inicia el problema será propuesto y se decidirá si es seleccionado o no. Si existieran más de una tarea a abordar, el agente podrá decidir que tarea intentará resolver eligiendo entre los diferentes operadores de inicialización propuestos haciendo uso del conocimiento de control que disponga.

A la hora de nombrar una regla, se puede usar casi cualquier combinación de caracteres, aunque sería buena idea que el nombre fuera un resumen significativo del propósito de la regla. Como convención, se suele separar el nombre en partes separadas por asteriscos “*”. La primera parte del nombre es el nombre de la tarea. La segunda parte es la función que realiza: si la regla propone un operador, escribiremos propose (proponer); si aplica un operador, pondremos apply (aplicar); y en caso de ser una regla de elaboración de estado, escribiremos elaborate (elaborar). La tercera parte del nombre será el nombre del operador. Se podrían añadir otras partes que indicasen más detalles sobre la función de la regla.

Como con cualquier otro operador, debemos definir dos tipos de regla: una que proponga el operador y otra que lo aplique. Comencemos por la regla de proposición del operador:

jarras-de-agua*propose*inicializa-jarras-de-agua
Si no se ha seleccionado ninguna tarea,
entonces proponer el operador inicializa-jarras-de-agua

¿Cómo comprobamos si no hay ninguna tarea seleccionada? En SOAR se puede comprobar la ausencia de elementos de memoria si precedemos el atributo con un menos “-”. Todas las reglas en SOAR deben tener al menos una condición positiva, por lo que seguimos necesitando comprobar que el estado existe, para lo que usaremos el atributo ^superstate del estado inicial:

sp {jarras-de-agua*propose*inicializa-jarras-de-agua
   (state <s> ^superstate nil)
  -(<s> ^name)  # Comprobamos que no haya ninguna tarea seleccionada
-->
   (<s> ^operator <o> +)
   (<o> ^name inicializa-jarras-de-agua)
   }

Recordemos que el orden en que las condiciones aparecen en una regla no importan siempre y cuando la primera condición sea una comprobación positiva del estado. SOAR de forma automática reordena las condiciones para mejorar la eficiencia a la hora de encajar las reglas. La segunda condición encajará con el estado sólo si no hay ningún elemento en la WM con un atributo name. El valor del atributo ^name no importa, por lo que no se añade a la regla, aunque si se hubiese escrito una variable para que encajase con cualquier valor, por ejemplo, -(<s> ^name <nombre>), la regla seguiría haciendo lo mismo.

Podemos simplificar las reglas agrupando las condiciones que comprueban atributos del mismo objeto en una única estructura en donde el identificador va seguido de sus atributos:

sp {jarras-de-agua*propose*inicializa-jarras-de-agua
   (state <s> ^superstate nil)
             -^name)
-->
   (<s> ^operator <o> +)
   (<o> ^name inicializa-jarras-de-agua)
   }

Ahora habrá que definir la regla que aplica el operador inicializa-jarras-de-agua. Esta regla añadirá el nombre de la tarea al estado y creará los objetos que representan las jarras con contenido 0. La regla necesitará añadir elementos de la memoria de trabajo para los valores iniciales de las jarras y sus atributos, pero no incluirá la creación del atributo ^quedan, que será creado por otra regla que lo calculará en tiempo de ejecución.

sp {jarras-de-agua*apply*inicializa-jarras-de-agua
   (state <s> ^operator <o>)
   (<o> ^name inicializa-jarras-de-agua)
-->
   (<s> ^name jarras-de-agua
        ^jarra <j1>
        ^jarra <j2>)
   (<j1> ^capacidad 5
         ^contenido 0)
   (<j2> ^capacidad 3
         ^contenido 0)
  }

Esta regla también la podemos simplificar, combinando aquellas condiciones que están conectadas por variables. En vez de usar la variable <o> para conectar las dos condiciones, podemos usar un punto “.” para reemplazarla:

sp {jarras-de-agua*apply*inicializa-jarras-de-agua
   (state <s> ^operator.name inicializa-jarras-de-agua) # "." reemplaza a <o>
-->
   (<s> ^name jarras-de-agua
        ^jarra <j1>
        ^jarra <j2>)
   (<j1> ^capacidad 5
         ^contenido 0)
   (<j2> ^capacidad 3
         ^contenido 0)
  }
4.4. Persistencia de los elementos de la memoria de trabajo. I-soporte y O-soporte

Cuando la regla jarras-de-agua*apply*inicializa-jarras-de-agua se dispara, todas las estructuras de sus acciones se añaden a la memoria de trabajo. Llegados a este punto, sería deseable eliminar el operador inicializa-jarras-de-agua de la memoria de trabajo para que se pudieran seleccionar otros operadores. SOAR lo elimina de forma automática, ya que las condiciones de la regla de proposición dejan de encajar (porque la condición que comprobaba la ausencia de una tarea deja de encajar al haberse añadido el atributo ^name al estado). Una vez que la regla se ha retraído, la regla que aplicaba el operador también deja de encajar con los elementos de WM, ya que el operador desaparece de WM. Sin embargo, no queremos que la regla de aplicación del operador elimine las estructuras que creó en WM, ya que en tal caso no avanzaríamos (se entraría en un bucle infinito en el que se propondía y aplicaría un operador, se retraerían sus resultados, y se volvería a proponer y aplicar el mismo operador).

Para soportar las diferentes necesidades que tienen las distintas funciones para resolver un problema (en este caso la proposición y aplicación de un operador), SOAR distingue entre la persistencia de los elementos de WM creados por las reglas de aplicación de operadores, y la persistencia de los elementos de WM creados por cualquier otro tipo de regla. Las reglas de aplicación de operadores necesitan crear resultados persistentes en memoria, puesto que los operadores aplicados son las decisiones deliberadamente tomadas por el sistema para desplazarse a un nuevo estado del espacio de problema. Sin embargo, el resto de reglas se encargan de las vinculaciones o las elaboraciones del estado actual sin llegar a cambiarlo, y deben retraerse cuando dejen de encajar con el estado actual. Esta es una característica muy importante de SOAR; realiza una distinción entre aquel conocimiento que modifica el estado actual (el conocimiento en la aplicación de operadores), y el conocimiento que sólo calcula las vinculaciones de la situación actual (incluyendo qué operadores deberían ser considerados para el estado actual).

De forma automática, SOAR clasifica las reglas según si forman parte de la aplicación de un operador o no. Se dice que una regla es de aplicación de operador si comprueba el operador seleccionado y modifica el estado. Los elementos de la memoria de trabajo creados por reglas de aplicación de operador persisten en memoria, y se dice que tienen soporte de operador (operator-support), u o-soporte (o-support), ya que se crean como parte de un operador. Este tipo de elementos de WM pueden ser eliminados por otras reglas de aplicación de operador (más adelante veremos esta forma de eliminar elementos), o si se desconectan del estado debido a la eliminación de otros elementos de la memoria de trabajo.

En el caso de las reglas que no son de aplicación de operador (por ejemplo las reglas que proponen un operador, comparan operadores, elaboran operadores, o elaboran el estado) los elementos de memoria de trabajo creados por estas reglas son eliminados cuando la regla deja de encajar. Estos elementos de WM se dice que tienen soporte de instancia (instantiation-support), o i-soporte (i-support), indicando que sólo persistirán en memoria mientras que la regla que los creó encaje.

Examinaremos con más detalle la persistencia de los elementos de la memoria de trabajo cuando creemos las demás reglas para el problema de las jarras de agua, pero ésta es una parte fundamental y a veces dificil de aprender del funcionamiento de SOAR. Hace que SOAR se diferencie de la mayoría de sistemas basados en reglas que no disponen de reglas que se retractan automáticamente cuando dejan de encajar, y le da a SOAR su propio sistema de mantenimiento de confianza (TMS).

4.5. Elaboración de estados

El operador de inicialización creaba jarras que incluían una capacidad y un contenido. Como se comentó anteriormente, sería útil disponer de otro atributo (quedan) que indique los litros restantes de agua que se pueden añadir a cada jarra antes de llenarse. En principio este atributo podría ser creado por el propio operador de inicialización, pero esto haría que quedan tuviese que ser actualizado por cada operador de aplicación, lo que complicaría las reglas de aplicación de operadores. Una alternativa es calcular su valor mediante otra regla independiente. Dicha regla comprobará el estado actual y creará una nueva estructura en él. Este tipo de reglas se llaman reglas de elaboración de estado. Las reglas de elaboración de estado suelen estar muy presentes en la mayoría de sistemas SOAR de gran tamaño, ya que pueden crear abstracciones útiles de combinaciones de otros elementos de la memoria de trabajo y representarlas directamente en el estado como un nuevo aumento de éste. Estos aumentos nuevos pueden ser comprobados en otras reglas en lugar de tener que hacer combinaciones complicadas de las condiciones, lo que simplifica las reglas y dota de mayor semántica a las estructuras en la memoria de trabajo. Un aspecto fundamental de las reglas de elaboración de estado es que crean elementos de WM con i-soporte, de forma que cuando las partes del estado que comprueban cambian, recalculan sus acciones de forma automática.

Para la tarea de las jarras de agua, la elaboración de estado calculará la cantidad de espacio disponible en cada jarra:

jarras-de-agua*elaborate*quedan
Si el estado actual se llama jarras-de-agua y,
una jarra tiene una capacidad de "<capacidad>" y un contenido actual de "<contenido>", 
entonces añade que a la jarra le quedan "<capacidad> - <contenido>" litros disponibles. 

La primera condición de la regla comprueba que el estado se llame jarras-de-agua, para la regla sólo sea aplicada a tareas jarras-de-agua. Esta comprobación se hará en todas las reglas del problema jarras-de-agua. A continuación, se comprueba que exista alguna jarra y se añade el atributo quedan adecuadamente.

Se puede traducir esta regla a lenguaje SOAR de forma casi inmediata. Las operaciones matemáticas en SOAR (restar en este caso) se realizan mediante notación prefija, en la que el operador se sitúa en primer lugar, seguido por sus argumentos, todo ello entre paréntesis. Distintas operaciones se pueden anidar mediante el uso de los paréntesis, como p.ej. (+ 2 (* <var1> <var2>)). La regla anterior escrita en SOAR pues quedaría:

sp {jarras-de-agua*elaborate*quedan
   (state <s> ^name jarras-de-agua
              ^jarra <j>)
   (<j> ^capacidad <v>
        ^contenido <c>)
-->
   (<j> ^quedan (- <v> <c>)) #restamos el contenido a la capacidad
}

Cuando el contenido de una jarra cambie debido a una regla de aplicación de operador, esta regla retraerá el antiguo valor de ^quedan y se volverá a disparar calculando el nuevo valor de ^quedan.

4.6. Inicialización y elaboración de estados en el problema de las jarras de agua

Una vez hayas escrito las reglas de inicialización y elaboración del estado para el problema de las jarras de agua, cárgalas en SOAR Debugger usando el comando source. Ajusta el nivel de observación a 5 (Watch 5) y expande las trazas para poder ver cómo se disparan las diferentes reglas y como se va modificando la memoria de trabajo. Haciendo clic sucesivamente en el botón step, podrás ver paso a paso cómo la ejecución del programa va teniendo lugar:

  1. La regla jarras-de-agua*propose*inicializa-jarras-de-agua se dispara para proponer el operador de inicialización. Expande el signo “+” junto a 1:O:O1 (inicializa-jarras-de-agua).
  2. El operador y su preferencia aceptable se añaden a la memoria de trabajo: (s1 ^operator o1 +), (o1 ^name inicializa-jarras-de-agua). El “+” indica que el operador ha sido propuesto pero aún no seleccionado.
  3. El operador de inicialización es seleccionado por el proceso de decisión de SOAR. Un elemento de memoria de trabajo se añade a la WM para indicar que el operador se ha seleccionado: (s1 ^operator o1). Nótese como la preferencia para el operador aún sigue en la WM.
  4. El operador de aplicación se dispara y crea los atributos para el nombre y las jarras en el estado.
  5. La proposición del operador se retracta porque la condición que comprueba la ausencia de nombre falla, y la regla jarras-de-agua*elaborate*quedan se dispara dos veces, una por cada jarra, y calcula el aumento ^quedan para cada una de las jarras.
  6. Se crea un subestado (por ahora lo ignoraremos) porque no hay más reglas que disparar.

En el punto 4, jarras-de-agua*elaborate*quedan se dispara dos veces porque hay dos jarras y la regla encaja en cada una de ellas. Cada uno de estos emparejamientos se llama una instancia. Una instancia está formada por un conjunto de elementos de WM que encajan con una regla. En este caso, habrán dos conjuntos de tres elementos de WM que son completamente distintos. Algunas veces una regla tendrá múltiples instancias que compartan algunos elementos de WM, y en otros casos como este, todos serán diferentes.

Dadas dos instancias, ¿cuál de ellas debería dispararse en primer lugar?. No se puede decir que una instancia sea más o menos importante que la otra, y en SOAR intentamos evitar tomar decisiones que no necesitan realmente ser tomadas, así que en SOAR todas las reglas nuevas que encajan en alguna parte se disparan al mismo tiempo, en paralelo. Aunque normalmente en realidad el programa SOAR se esté ejecutando en un computador de forma serializada, a todos los efectos se considera que las reglas se disparan a la misma vez. Este es un aspecto bastante diferente a la mayoría de los lenguajes de programación y puede resultar un poco confuso, ya que verás que todas las reglas se disparan en cuanto encajan.

A continuación aparece una figura un poco más compleja que representa el ciclo básico de ejecución de SOAR. Aún no hemos visto la entrada y salida, por lo que sus cajas aparecen vacías. Empezando por la izquierda, las reglas que elaboran el estado o proponen operadores se disparan. Puede haber varias reglas disparándose (o retrayéndose) en paralelo, y los resultados del disparo de una regla o retracción pueden hacer que adicionalmente otras reglas se disparen o retraigan. SOAR seguirá disparando y retrayendo reglas hasta que no queden reglas por disparar o retraer. Una vez que ésto sucede, se alcanza un momento de inactividad y se pasa al proceso de decisión para seleccionar el operador. De esta forma se asegura que cuando se tome una decisión se disponga de todo el conocimiento disponible a partir de las reglas.

Después de que el operador haya sido seleccionado, las reglas de aplicación del operador se pueden disparar. La aplicación del operador normalmente hará que reglas de elaboración de estado o de proposición de operadores se disparen o retraigan, y ésto también sucederá durante la fase de aplicación del operador. Cuando la fase alcance la inactividad, los datos de salida (output) y luego los de entrada (input) serán procesados, tras lo cual el ciclo vuelve a empezar por la primera fase descrita previamente. En las tareas en las que existe un ambiente externo al agente, muchos operadores requieren que se realice alguna acción en el mundo exterior, lo que hará que aparezcan cambios en el estado en la entrada de datos desde el exterior. En el problema de las jarras de agua, todos los cambios del estado suceden durante la aplicación de los operadores, así que la fase de elaboración del estado y proposición de operadores no hará que nuevas reglas se disparen tras la primera ejecución del ciclo.

ciclo-ejecucion-2.jpg

4.7. Proposición de operadores

En el problema de las jarras de agua, existen tres operadores: llenar, vaciar y volcar. En esta sección crearemos las reglas que proponen estos operadores. SOAR nos permite calcular de forma separada cuando un operador puede aplicarse (reglas de proposición que crean preferencias aceptables para los operadores) de cuando debería aplicarse (reglas de control de búsqueda que crean otro tipo de preferencias).

En la siguiente lista se indican los operadores con las condiciones bajo las cuales se pueden ejecutar. Estas condiciones serán la base para la proposición de los operadores.

  • Llenar una jarra con agua del pozo, si la jarra no está llena
  • Vaciar una jarra al pozo, si la jarra tiene agua
  • Volcar una jarra en la otra, si hay agua en la jarra fuente y la jarra destino no está llena

Por ejemplo no se podría intentar llenar una jarra ya llena, ya que en SOAR los operadores deben cambiar el estado de alguna manera para que otro operador pueda ser seleccionado. Por lo tanto, debemos impedir que se llene una jarra ya llena. Las proposiciones en lenguaje natural son:

jarras-de-agua*propose*llenar
Si la tarea es jarras-de-agua y hay una jarra que no esté llena,
entonces proponer llenar esa jarra.
jarras-de-agua*propose*vaciar
Si la tarea es jarras-de-agua y hay una jarra que no esté vacía,
entonces proponer vaciar esa jarra
jarras-de-agua*propose*volcar
Si la tarea es jarras-de-agua y hay una jarra que no esté llena y la otra no está vacía,
entonces proponer volcar agua de la segunda jarra a la primera

Cada una de estas reglas puede potencialmente encajar en cualquiera de las dos jarras.

A la hora de traducir estas reglas a SOAR, necesitamos escribir las comprobaciones en las condiciones, y además acciones que creen el operador. Los operadores hola-mundo y los operadores de inicialización no eran más que un nombre; sin embargo, con estos nuevos operadores harán falta algunos parámetros que indiquen qué jarras están siendo llenadas, vaciadas o volcadas. Si quisieras, también podrías indicar todo esto en el nombre de los operadores, pero harían falta más operadores, del tipo de “llenar-jarra-tres” o “volcar-jarra-cinco-en-jarra-tres”, lo que requeriría reglas separadas para aplicar cada uno de estos operadores. En lugar de esto, al añadir parámetros en los operadores podemos tener menos reglas que apliquen los cambios necesarios al estado. Para esta tarea, crearemos los siguientes parámetros y nombres para los operadores:

  • El nombre del operador: ^name {llenar|vaciar|volcar}
  • La jarra que llenará el operador llenar: ^llena-jarra <j>
  • La jarra que vaciará el operador vaciar: ^vacia-jarra <j>
  • La jarra desde la que se vuelca agua en el operador volcar: ^vacia-jarra <j1>
  • La jarra a la que se vuelca agua en el operador volcar: ^llena-jarra <j2>

Aparte de esta hay muchas otras posibilidades de representar los operadores, y no es necesario que los operadores de volcar compartan el nombre de los parámetros con los demás operadores. Con la representación que hemos visto, la representación del operador que vuelca agua de la jarra <j1> a la jarra <j2> sería:

(<o> ^name volcar
     ^vacia-jarra <j1>
     ^llena-jarra <j2>

Una forma de escribir en lenguaje SOAR la regla de proposición del operador llenar es:

sp {jarras-de-agua*propose*llenar
   (state <s> ^name jarras-de-agua
              ^jarra <j>)
   (<j> -^quedan 0) # Comprueba que la jarra no esté llena
-->
   (<s> ^operator <o> +)
   (<o> ^name llenar
        ^llena-jarra <j>)}

Esta regla comprueba que la jarra <j> no tenga un aumento quedan de cero. Una alternativa a esta comprobación es comprobar el aumento quedan tenga un valor mayor que cero. SOAR soporta comprobaciones simples en las condiciones, que incluyen los operadores mayor que (>), mayor o igual que (>= ), menor que (<), menor o igual que (⇐), y distinto (<>). Estas comprobaciones están asociadas con el valor o variable que les sigue inmediatamente después. Por tanto, otra forma de escribir la regla es:

sp {jarras-de-agua*propose*llenar
   (state <s> ^name jarras-de-agua
              ^jarra <j>)
   (<j> ^quedan > 0)
-->
   (<s> ^operator <o> +)
   (<o> ^name llenar
        ^llena-jarra <j>)}

Otra forma sería comprobar que el contenido no sea igual a la capacidad: (<j> ^capacidad <v> ^contenido <c> <> <v>). Una alternativa que no es posible sería comprobar que la capacidad menos el contenido sea igual a cero, ya que SOAR no permite que se realicen operaciones matemáticas en las condiciones (para ello deberías usar elaboraciones, de forma que los cáculos matemáticos siempre se realizan en las acciones).

La regla de proposición para vaciar una jarra se realiza de forma similar:

sp {jarras-de-agua*propose*vaciar
   (state <s> ^name jarras-de-agua
              ^jarra <j>)
   (<j> ^contenido > 0)
-->
   (<s> ^operator <o> +)
   (<o> ^name vaciar
        ^vacia-jarra <j>)}

Para escribir la tercera proposición (volcar) hará falta comprobar que una jarra no esté vacía, y que la otra no esté llena. En principio se podría pensar en hacerla de la siguiente manera, combinando las comprobaciones de las dos reglas anteriores:

jarras-de-agua*propose*volcar*MAL
   (state <s> ^name jarras-de-agua
              ^jarra <i>
              ^jarra <j>)
   (<i> ^contenido > 0)
   (<j> ^quedan > 0)
-->
   (<s> ^operator <o> +)
   (<o> ^name volcar
        ^vacia-jarra <i>
        ^llena-jarra <j>)}

Sin embargo, esta regla no es correcta porque las comprobaciones para la jarra <i> y la jarra <j> pueden encajar en la misma jarra. Esto se debe a que no hay nada en SOAR que impida que diferentes condiciones encajen en el mismo elemento de la memoria de trabajo. Por ejemplo, si tenemos lis siguientes elementos en la WM: (s1 ^jarra j1) (j1 ^contenido 3 ^quedan 2 ^capacidad 5), esta regla crearía una proposición del operador volcar que volcaría agua desde la jarra j1 a sí misma, ya que encajaría en ambas condiciones tanto para <i> como para <j>. Para corregir esta regla, debemos modificar las condiciones de forma que <i> y <j> no puedan encajar en el mismo identificador, es decir, que <i> sea distinto de <j>; para ello usaremos <>. Pero, ¿dónde añadimos esta comprobación en la regla? En SOAR, múltiples comprobaciones de un valor (y múltiples comprobaciones de un identificador o un atributo) se pueden especificar encerrando todas las comprobaciones entre corchetes. La regla correcta entonces sería:

jarras-de-agua*propose*volcar
   (state <s> ^name jarras-de-agua
              ^jarra <i>
              ^jarra { <j> <> <i> }) # Debe haber un espacio antes y después de <>
   (<i> ^contenido > 0)
   (<j> ^quedan > 0)
-->
   (<s> ^operator <o> +)
   (<o> ^name volcar
        ^vacia-jarra <i>
        ^llena-jarra <j>)}

Ahora que hemos escrito todas las reglas de proposición, podemos cargarlas junto al resto de reglas. ¿Qué sucede al ejecutarlas? Verás como sólo la regla jarras-de-agua*propose*llenar encaja en el estado inicial (las dos jarras vacías). Se dispara dos veces ya que encaja en ambas jarras, justo después de que el aumento ^quedan haya sido añadido a cada jarra. A continuación, cuando SOAR no dispone de suficiente conocimiento como para seleccionar entre varios operadores, llega a un punto muerto de empate, o tie impasse, y crea automáticamente un subestado nuevo en el que intenta resolver el problema de qué hacer a continuación. Hablaremos de los subestados más adelante. Por ahora, simplemente haremos que SOAR elija aleatoriamente entre los operadores. Le podemos indicar a SOAR que no importa qué operador elija creado una preferencia indiferente, usando el símbolo '=' para el operador. Podemos crearla en la misma regla que propone el operador:

sp {jarras-de-agua*propose*llenar
   (state <s> ^name jarras-de-agua
              ^jarra <j>)
   (<j> ^quedan > 0)
-->
   (<s> ^operator <o> +
        ^operator <o> =) # evita el impasse mediante selección aleatoria
   (<o> ^name llenar
        ^llena-jarra <j>)}

Las dos acciones para ^operator se pueden resumir en:

(<s> ^operator + =)

lo que significa: crea una preferencia aceptable e indiferente (siempre se necesita crear la preferencia aceptable, incluso si hay otras preferencias creadas). Ahora deberías modificar todas las proposiciones de operadores incluyendo la preferencia =.

4.8 Aplicación de operadores

El siguiente paso es escribir las reglas de aplicación de operadores que se dispararán una vez que se haya seleccionado un operador. En el problema de las jarras de agua, estas reglas aplicarán operadores añadiendo y eliminando elementos de la memoria de trabajo para representar los movimientos de agua de un sitio a otro.

¿Y qué es lo que impide que las producciones de aplicación de operadores se ejecuten una y otra vez continuamente? Cuando las reglas de operador se aplican, cambian parte del estado ya sea directamente (mediante reglas que cambian la WM), o indirectamente (realizando alguna acción en el mundo exterior que cambia lo que SOAR percibe). Estos cambios harán que la preferencia aceptable para el operador seleccionado se retraiga, porque al menos uno de los elementos de WM que encajaban con la regla de proposición habrá cambiado. Estos cambios en la memoria de trabajo causarán que nuevas reglas de proposición encajen, que irán seguidas de la selección de un operador, su aplicación, y así una y otra vez.

Dada la representación de estados usada en este problema, existen dos formas diferentes por las que los operadores pueden cambiar la representación del estado. La más directa es añadir y borrar los atributos ^contenido de los objetos jarra. Sin embargo, los operadores también podrían eliminar una jarra y crear un objeto jarra completamente nuevo con los atributos contenido y capacidad apropiados. Aunque esto es menos eficiente, presenta algunas ventajas de planificación, y volveremos a ver esta propuesta en la parte IV del tutorial. Por ahora deberías crear reglas de aplicación de operador que modifiquen el atributo ^contenido de los objetos jarra (el atributo ^quedan no tiene que ser modificado porque será recalculado de forma automática cuando ^contenido cambie).

En el problema de las jarras de agua, existen tres operadores independientes, y cada uno necesita sus propias reglas de aplicación. Iremos viéndolas una a una. La primera es llenar. Una forma de definirla en lenguaje natural es:

jarras-de-agua*apply*llenar
Si la tarea es jarras-de-agua y el operador llenar está seleccionado para una jarra determinada,
entonces establece el contenido de la jarra igual a su capacidad.

Esta regla necesita que se reemplace el valor actual del contenido por un nuevo valor (la capacidad). Para reemplazar un valor en SOAR, se debe eliminar el elemento de WM que ya estaba y crear uno nuevo. No existe ninguna forma de de reemplazar el valor de un elemento ya incluido en la memoria de trabajo. Esta es una característica importante de SOAR: todos los cambios requieren que se añadan o eliminen elementos de WM. No se puede modificar un elemento de la memoria de trabajo una vez haya sido creado. Para eliminar un elemento de WM, se escribe dicho elemento en la regla pero con un símbolo menos “-” al final.

sp {jarras-de-agua*apply*llenar
   (state <s> ^name jarras-de-agua
              ^operator <o>
              ^jarra <j>)
   (<o> ^name llenar
        ^llena-jarra <j>)
   (<j> ^contenido <contenido>
        ^capacidad <capacidad>)
-->
   (<j> ^contenido <capacidad>) # Añadimos un nuevo elem. de WM
   (<j> ^contenido <contenido> -)} # Eliminamos el elem. de WM antiguo

La regla para aplicar el operador vaciar es en esencia la inversa de llenar:

jarras-de-agua*apply*vaciar
Si la tarea es jarras-de-agua y el operador vaciar está seleccionado para una jarra determinada,
entonces establece el contenido de la jarra a 0
sp {jarras-de-agua*apply*vaciar
   (state <s> ^name jarras-de-agua
              ^operator <o>
              ^jarra <j>)
   (<o> ^name vaciar
        ^vacia-jarra <j>)
   (<j> ^contenido <contenido>
        ^capacidad <capacidad>)
-->
   (<j> ^contenido 0)
        ^contenido <contenido> -)}

Hemos acortado la regla ya que cuando dos cambios de WM afectan al mismo identificador, se pueden escribir sin repetir el identificador.

El operador volcar es más complejo puesto que existen dos situaciones diferentes. La primera se da cuando, al volcar agua de una jarra a otra, la jarra a la que se está añadiendo agua puede contener todo el agua de la otra jarra, por ejemplo cuando volcamos la jarra de tres litros en la jarra de cinco litros estando vacía. El caso contrario se da cuando la jarra no puede contener todo el agua de la otra, como por ejemplo si volcamos la jarra de cinco litros llena en la de tres litros. Por tanto, deberemos escribir dos reglas para cubrir estas dos situaciones:

jarras-de-agua*apply*volcar-jarra-queda-vacia
Si la tarea es jarras-de-agua y el operador volcar está seleccionado,
y el contenido de la jarra siendo volcada es menor o igual que los litros que quedan por rellenar en la otra jarra,
entonces establece el contenido de la jarra siendo volcada a 0,
y establece el contenido de la otra jarra igual a la suma del contenido de ambas jarras
sp {jarras-de-agua*apply*volcar-jarra-queda-vacia
   (state <s> ^name jarras-de-agua
               ^operator <o>)
   (<o> ^name volcar
        ^vacia-jarra <i>
        ^llena-jarra <j>)
   (<j> ^capacidad <jcap>
        ^contenido <jcon>
        ^quedan <jquedan>)
   (<i> ^capacidad <icap>
        ^contenido { <icon> <= <jquedan> })
-->
   (<i> ^contenido 0
        ^contenido <icon> -)
   (<j> ^contenido (+ <jcon> <icon>)
        ^contenido <jcon> -)}

En esta regla se aprecia la utilidad de tener el aumento ^quedan para las jarras. Sin él, hubiera sido muy difícil comprobar que la jarra siendo llenada pueda contener todo el agua de la otra jarra.

jarras-de-agua*apply*volcar-jarra-no-queda-vacia
Si la tarea es jarras-de-agua y el operador volcar está seleccionado,
y el contenido de la jarra siendo volcada es mayor que los litros que quedan por rellenar en la otra jarra,
entonces establece el contenido de la jarra siendo volcada igual a su contenido menos lo que queda por llenar de la otra jarra,
y establece el contenido de la otra jarra igual a su capacidad
sp {jarras-de-agua*apply*volcar-jarra-no-queda-vacia
   (state <s> ^name jarras-de-agua
               ^operator <o>)
   (<o> ^name volcar
        ^vacia-jarra <i>
        ^llena-jarra <j>)
   (<j> ^capacidad <jcap>
        ^contenido <jcon>
        ^quedan <jquedan>)
   (<i> ^capacidad <icap>
        ^contenido { <icon> > <jquedan> })
-->
   (<i> ^contenido (- <icon> <jquedan>)
        ^contenido <icon> -)
   (<j> ^contenido <jcap>
        ^contenido <jcon> -)}

Ahora que hemos escrito tanto las reglas de proposición como las de aplicación de operadores, las podemos cargar en SOAR y probarlas. Puede ser difícil seguir el proceso de resolución directamente, por lo que definiremos en la siguiente sección algunas reglas de monitorización.

4.9. Monitorización de estados y operadores

Las reglas de monitorización resultan útiles para mostrar los detalles del operador que está siendo aplicado y los contenidos de cada estado. A continuación aparecen cuatro reglas que observan el operador seleccionado y el estado (una regla para cada operador y una para el estado). Una de las cosas buenas de SOAR es que todas las reglas de monitorización se disparan en paralelo con las demás reglas y para nada interfieren en la resolución del problema.

La orden write concatena todos sus argumentos, que pueden ser constantes o variables. Para poder leer mejor la salida, se creará un comienzo de línea antes de escribir el texto mediante el comando crlf.

sp {jarras-de-agua*monitor*estado
   (state <s> ^name jarras-de-agua
              ^jarra <i> <j>)
   (<i> ^capacidad 3 ^contenido <icon>)
   (<j> ^capacidad 5 ^contenido <jcon>)
-->
   (write (crlf) | 3:| <icon> | 5:| <jcon> )}

sp {jarras-de-agua*monitor*aplicacion-operador-vaciar
   (state <s> ^name jarras-de-agua
           ^operator <o>)
   (<o> ^name vaciar
        ^vacia-jarra.capacidad <cap>)
-->
   (write | VACIAR(| <cap> |)|)}

sp {jarras-de-agua*monitor*aplicacion-operador-llenar
   (state <s> ^name jarras-de-agua
              ^operator <o>)
   (<o> ^name llenar
        ^llena-jarra.capacidad <cap>)
-->
   (write | LLENAR(| <cap> |)|)}

sp {jarras-de-agua*monitor*aplicacion-operador-volcar
   (state <s> ^name jarras-de-agua
              ^operator <o>)
   (<o> ^name volcar
        ^vacia-jarra <i>
        ^llena-jarra <j>)
   (<i> ^capacidad <icap> ^contenido <icon>)
   (<j> ^capacidad <jcap> ^contenido <jcon>)
-->
   (write | VOLCAR(| <icap> |:| <icon> |,| <jcap> |:| <jcon> |)|)} 

Con las reglas que hemos escrito hasta ahora, nuestro programa irá moviendo agua de una jarra a otra aplicando operadores. Puede que incluso llegue al estado desado, pero no será capaz de reconocerlo y seguirá ejecutándose.

4.10. Reconocimiento del estado deseado o final

El último paso para crear un programa que no sólo resuelva el problema de las jarras de agua, sino que además sepa cuando lo ha resuelto, es generar una regla que reconozca cuando el estado deseado ha sido alcanzado. Para ello hará falta escribir una regla que reconozca cuando la jarra de tres litros de capacidad tenga un litro de agua en ella. Las acciones de la regla deben ser imprimir por pantalla un mensaje diciendo que el problema ha sido resuelo, y detener el agente:

jarras-de-agua*detectar*objetivo*alcanzado
Si la tarea es jarras-de-agua y hay una jarra con capacidad tres y contenido igual a uno
entonces, imprime que el problema se ha resuelto y para

La traducción a SOAR es bastante directa:

sp {jarras-de-agua*detectar*objetivo*alcanzado
   (state <s> ^name jarras-de-agua
              ^jarra <j>)
   (<j> ^capacidad 3
        ^contenido 1)
-->
  (write (crlf) |El problema ha sido resuelto!|)
  (halt)}

Otra forma que se suele emplear es crear una representación del estado deseado en la memoria de trabajo y escribir una regla que compare ese estado deseado con el estado actual. Esta forma tiene dos ventajas. En primer lugar, si quisieras intentar resolver muchos problemas de las jarras de agua diferentes, un nuevo estado deseado puede ser intentado mediante la modificación de la memoria de trabajo (posiblemente a través de datos de entrada desde sensores) en vez de tener que cambiar una regla. La segunda ventaja es que en muchos problemas la descripción del estado deseado puede ser usada para conducir la búsqueda usando una técnica llamada means-ends analysis, que será incluida en un tutorial posterior.

A continuación aparece un ejemplo de regla que crea el estado deseado, que es la jarra de 3 litros conteniendo 1 litro. Esta regla se disparará cuando el operador de inicialización sea seleccionado, en paralelo con la otra regla de inicialización:

sp {jarras-de-agua*apply*inicializa*crear*estado*deseado
   (state <s> operator.name inicializa-jarras-de-agua)
-->
   (<s> ^deseado.jarra <k>)
   (<k> ^capacidad 3
        ^contenido 1)}

La regla de reconocimiento del estado deseado habrá que modificarla como aparece a continuación. Lo que hace es comparar la capacidad y el contenido de la jarra deseada con alguna de las del estado actual. Esta regla sólo sirve para estados deseados que describen una única jarra. Si el estado deseado comprobase que hubiera unos contenidos concretos en cada una de las dos jarras, haría falta escribir una regla más compleja.

sp {jarras-de-agua*detectar*objetivo*alcanzado
   (state <s> ^name jarras-de-agua
              ^deseado.jarra <dj>
              ^jarra <j>)
   (<dj> ^capacidad <cap> ^contenido <con>)
   (<j> ^capacidad <cap> ^contenido <con>)
-->
   (write (crlf) |El problema ha sido resuelto!|)
   (halt)}

Ahora, si añadimos estas reglas al código anterior el problema se detendrá al alcanzar el resultado deseado. Sin embargo, puede que tarde un tiempo muy grande en alcanzarlo.

4.11. Control de búsqueda

Para hacer la búsqueda más eficiente, incluiremos reglas que hagan que se prefieran los operadores cuya aplicación es más probable que nos conduzca a uno de los estados deseados. Existen algunas heurísticas generales que se pueden aplicar al problema de las jarras de agua. Sólo existen 16 estados posibles, aunque están fuertemente interconectados, por lo cual es difícil evitar que se revisite el mismo estado una y otra vez a menos que se mantenga una lista con los estados ya visitados. Mantener esta lista es posible en SOAR, aunque no es fácil, ya que requeriría que se creara una copia de cada estado tras haber aplicado un operador, y después comprarar el nuevo estado con los estados de la lista. En la sección sobre planificación de este tutorial aprenderás otra forma alternativa y más natural de que los programas SOAR eviten realizar visitas repetidas a los mismos estados. Por ahora, simplemente nos centraremos en evitar deshacer el último operador que se aplicó, como por ejemplo vaciar una jarra justo tras haberla llenado.

Para evitar deshacer el último operador, el programa debe recordar el operador en el estado después de haberlo aplicado. En SOAR, recordar ésto no es automático. El operador seleccionado se retrae justo tras aplicarse. Para mantener el registro del operador previo, deberemos añadir algunas reglas que de forma deliberada registren el operador cada vez que un operador se aplique. Estas reglas formarán parte de los operadores de aplicación, ya que comprueban el operador para registrarlo, lo que hace que el registro sea persistente en memoria (que es lo que queremos).

Registrar un operador consta de dos partes. La primera consiste en crear una estructura en el estado, que es el registro del operador más reciente. La segunda es eliminar cualquier registro de cualquier operador más antiguo. Dada la representación de los operadores del problema de las jarras de agua en la memoria de trabajo, tendremos que crear tres reglas para almacenar el último operador (una por cada operador). Si todos los operadores tuvieran exactamente los mismos aumentos, entonces ésto se podría realizar con una sola regla, y podríamos cambiar las representaciones de operadores para hacer esto más sencillo, pero por ahora, seguiremos con las representaciones de operadores que ya tenemos. Las acciones de estas reglas deben crear un aumento del estado que incluya los aumentos del operador seleccionado (lo que corresponda en name, vacia-jarra y llena-jarra). No debería crear un enlace al operador original puesto que todas las subestructuras de dicho operador serán eliminadas de WM tan pronto como la regla que lo creó se retraigan. Una versión el lenguaje natural de la regla que registra el operador volcar es (el resto deberás escribirlas tú):

jarras-de-agua*apply*operator*registra*ultimo*operador*volcar
Si la tarea es jarras-de-agua y el operador volcar está seleccionado,
entonces crea un aumento del estado (ultimo-operador) con el nombre del operador y una copia de sus demás aumentos

Convertida a una regla SOAR:

sp {jarras-de-agua*apply*operator*registra*ultimo*operador*volcar
   (state <s> ^name jarras-de-agua
              ^operator <o>)
   (<o> ^name volcar
        ^llena-jarra <jl>
        ^vacia-jarra <jv>)
-->
   (<s> ^ultimo-operador <ultimo-op>)
   (<ultimo-op> ^name volcar
                ^llena-jarra <jl>
                ^vacia-jarra <jv>)}

La regla para eliminar los registros antiguos sólo debe comprobar si el nombre del operador actual y el aumento jarra son diferentes, porque no es posible aplicar un operador dos veces seguidas:

jarras-de-agua*apply*operator*elimina*antiguos*ultimo-operador*volcar
Si la tarea es jarras-de-agua y un operador volcar está seleccionado y ultimo-operador no tiene el mismo nombre y llena-jarra,
entones elimina el ultimo operador
sp {jarras-de-agua*apply*operator*elimina*antiguos*ultimo-operador*volcar
   (state <s> ^name jarras-de-agua
              ^operator <o>
              ^ultimo-operador <ultimo-op>)
   (<o> ^name volcar
        ^llena-jarra <jl>
        ^vacia-jarra <jv>)
  -(<ultimo-op> ^name volcar
                ^llena-jarra <jl>
                ^vacia-jarra <jv>)
-->
   (<s> ^ultimo-operador <ultimo-op> -)}

A continuación escribiremos las reglas que evitan que se aplique un operador que deshaga el último operador aplicado.

jarras-de-agua*selecciona*operador*evita*llenar*tras*vaciar
Si la tarea es jarras-de-agua y el último operador es vaciar,
entonces evita aplicar el operador llenar
sp {jarras-de-agua*selecciona*operador*evita*llenar*tras*vaciar
   (state <s> ^name jarras-de-agua
              ^operator <o> +
              ^ultimo-operador <ult>)
   (<o> ^name llenar ^llena-jarra <i>)
   (<ult> ^name vaciar ^vacia-jarra <i>)
-->
   (<s> operator <o> <)}
jarras-de-agua*selecciona*operador*evita*vaciar*tras*llenar
Si la tarea es jarras-de-agua y el último operador es llenar,
entonces evita aplicar el operador vaciar
sp {jarras-de-agua*selecciona*operador*evita*vaciar*tras*llenar
   (state <s> ^name jarras-de-agua
              ^operator <o> +
              ^ultimo-operador <ult>)
   (<o> ^name vaciar ^vacia-jarra <i>)
   (<ult> ^name llenar ^llena-jarra <i>)
-->
   (<s> operator <o> <)}
jarras-de-agua*selecciona*operador*evita*inversa*volcar
Si la tarea es jarras-de-agua y el último operador es volcar de una jarra a la otra,
entonces evita aplicar el operador volcar de la forma inversa
sp {jarras-de-agua*selecciona*operador*evita*inversa*volcar
   (state <s> ^name jarras-de-agua
              ^operator <o> +
              ^ultimo-operador <ult>)
   (<o> ^name volcar ^llena-jarra <i>)
   (<ult> ^name volcar ^vacia-jarra <i>)
-->
   (<s> operator <o> <)}

Añadiendo estas reglas, la búsqueda debería acortarse en la media, ya que no se dehacen operadores anteriores. Con esto termina la primera parte del tutorial.

Parte 2: Interacción simple con el exterior

1. El juego "Eaters"

En esta parte del tutorial usaremos un juego llamado “Eaters”, parecido a PACMAN en el que los jugadores (eaters) compiten para consumir comida en un tablero de juego sencillo. El mundo de juego consiste en un tablero rectangular de 15×15 celdas, con cuatro paredes una a cada lado. También existen paredes interiores creadas aleatoriamente para cada juego nuevo. Las paredes interiores se diponen de forma que no haya esquinas o callejones sin salida. Cada jugador empieza el juego en una posición aleatoria. Hay bolas de comida en el resto de celdas del tablero, que pueden ser de dos tipos: comida normal (círculos azules que valen 5 puntos) o comida especial (cuadrados rojos que valen 10 puntos). Para consumir comida un jugador se tiene que mover a su celda, que una vez comida pasa a quedar vacía. Un jugador puede ver el contenido de las celdas que hay a su alrededor a una distancia de dos celdas en cualquier dirección. En cada turno, un jugador se puede mover una celda al norte, sur, este u oeste. También puede saltar para cruzar una pared u otro jugador, aunque este movimiento tiene un coste de 5 puntos y no se consume comida de la celda en la que se aterriza. Cuando dos jugadores intentan ocupar la misma celda a la vez, se produce una colisión, que hace que la puntuación de ambos jugadores se convierta en la media de sus dos puntuaciones anteriores, y que los jugadores sean teletransportados a dos nuevas posiciones aleatorias del tablero.

Para cargar el juego, en el directorio de la instalación de SOAR busca un archivo “Eaters.bat” o similar, y haz doble clic en él. En la nueva ventana podrás crear nuevos jugadores que se muevan según diferentes programas SOAR guardados, o controlados por un humano. Para ello haz clic en el botón “New”, tras lo cual se abrirá una nueva ventana donde podrás elegir el archivo fuente con las reglas a cargar para controlar el comportamiento del jugador, haciendo clic en el botón “Soar”. En la carpeta agents/eaters/tutorial existen algunos programas ya creados para controlar los jugadores. Si la casilla “Spawn debugger” está marcada, cada vez que se cree un nuevo jugador se abrirá una ventana del SOAR Debugger donde se podrá examinar la traza de ejecución del jugador. Para que el jugador que acabas de crear se empiece a mover, puedes hacer clic en el botón “Run”, que ejecutará el programa SOAR que lo gobierna, o “Step”, que lo ejecutará paso a paso. Todo esto aparece resumido en la siguiente figura:

eaters.jpg

2. Construyendo un jugador simple mediante reglas

2.1. Operador mover-norte

A diferencia del problema de las jarras de agua que vimos en la primera parte del tutorial, un jugador no necesita inicializar el estado mediante un operador, ya que obtendrá la información inicial de su situación a través de una estructura de entrada llamada input-link (enlace de entrada). El primer operador que crearemos hará simplemente que el jugador se desplace una celda hacia el norte, si no está ocupada por una pared.

Propose*mover-norte:
Si yo existo, entonces proponer el operador mover-norte

Apply*mover-norte
Si el operador mover-norte está seleccionado, 
entonces crea una orden de salida para mover el jugador hacia el norte

Para escribir la regla apply*mover-norte, antes necesitamos saber cómo hacer que un jugador se mueva por el tablero. Todas las acciones externas en SOAR se generan mediante la creación de elementos de la memoria de trabajo que sean aumentos de la estructura output-link. El output-link es un aumento del objeto io, que a su vez es un aumento del estado. En el caso del juego “Eaters”, una orden de movimiento se realiza creando un aumento llamado move en el objeto output-link, que a su vez tiene otro aumento llamado direction con un valor que indica la dirección del movimiento a realizar: north, south, east o west. Para cada tarea en SOAR, habrá definido un conjunto de comandos de salida, y en el caso de Eaters hay dos comandos de salida: move (mover) y jump (saltar).

aumento-move.jpg

A continuación escribiremos las reglas de proposición y aplicación del operador mover:

sp {apply*mover-norte
   (state <s> ^type state)
-->
   (<s> ^operator <o> +)
   (<o> ^name mover-norte)}

La regla de aplicación añadirá el comando mover en output-link, por lo que tendremos que hacer que el identificador del output-link encaje en las condiciones de la regla:

sp {apply*mover-norte
   (state <s> ^operator <o>
              ^io <io>)
   (<io> ^output-link <out>)
   (<o> ^name mover-norte)
-->
   (<out> ^move <move>)
   (<move> ^direction north)}

El orden exacto de las condiciones (y de las acciones) no importa, siempre y cuando la primera condición encaje con el identificador del estado. Además, se pueden usar “atajos” para simplificar la escritura y lectura de muchas reglas eliminando las variables que sólo sirven de conexión entre atributos, usando la notación punto “.” en su lugar. La regla apply*mover-norte quedaría:

sp {apply*mover-norte
   (state <s> ^operator.name mover-norte
              ^io.output-link <out>)
-->
   (<out> ^move.direction north)}

Sin embargo hay que tener cuidado a la hora de usar estos atajos, que no se deben usar cuando se quieran crear varios subatributos de un objeto nuevo, ya que si se usa la notación punto se crearán varios objetos nuevos en vez de uno, cada uno con un único atributo. Por ejemplo, si quisiéramos añadir un nuevo aumento en el objeto move con la velocidad (speed) del jugador, no deberías hacer lo siguiente en las acciones:

-->
    (<out> ^move.direction north
           ^move.speed fast)

Ya que equivaldría a hacer:

-->
    (<out> ^move <move1>
           ^move <move2>)
    (<move1> ^speed fast)
    (<move2> ^direction north)

En su lugar, deberías escribir lo siguiente, para crear un único objeto nuevo con dos atributos:

-->
    (<out> ^move <move>)
    (<move> ^speed fast)
            ^direction north)

Ahora vamos a ejecutar las dos reglas que hemos creado previamente. Si seleccionas tu fichero .soar con estas reglas para controlar el movimiento de un jugador, verás que algo falla: el jugador sólo se desplaza una vez, tras lo cual no tiene nada más que hacer, y se empiezan a crear subestados sin que el jugador se mueva. Para hacer que el jugador se mueva continuamente hacia el norte habrá que modificar las reglas.

2.2. Operador mover-norte: varios movimientos

¿Por qué el jugador nunca llega a dar un segundo paso hacia el norte? En SOAR, cada acción que se realiza en el mundo, como mover un jugador, debería ser llevada a cabo por una instancia de un operador. Una instancia de operador es un operador en forma de un elemento de memoria de trabajo concreto que ha sido creado por el disparo de una regla de proposición de operador. Por tanto, cada acción debería comprobar que se haya creado un nuevo objeto de operador en la memoria de trabajo. Distintas instancias de un mismo operador comparten siempre el mismo nombre, e incluso pueden tener los mismos atributos, pero una instancia en concreto se usará para realizar un solo movimiento. Algunas instancias de operador pueden incluir varias acciones, pero sólo serán seleccionadas y aplicadas una única vez.

Por tanto, deberían crearse nuevas instancias del operador mover-norte en WM para cada movimiento. Para ello, deberíamos diseñar el jugador de forma que cada vez que se mueva se cree una nueva instancia del operador mover-norte. Esto se puede hacer haciendo que la regla de proposición del operador se dispare cada vez que el jugador se mueva. Para que esta regla se dispare en cada movimiento, debemos hacer que compruebe en sus condiciones algún elemento de WM que cambie cada ve que el jugador se mueve, esto es, aquellos elementos de memoria que corresponden a lo que el jugador “siente” a través del input-link. En la regla de proposición que hemos escrito antes lo único que se comprueba es ^type state, que está siempre en WM y no cambia, y por tanto no se crean instancias nuevas del operador. El diseño de SOAR hace que los cambios en el input-link se realicen justo tras las operaciones de salida (output), justo a tiempo para que afecten a la proposicón de operadores.

La información que le llega al jugador a través del input-link es en parte la siguiente, consistente en una serie de atributos con sus respectivos valores:

(I2 ^eater I4 ^my-location I5)
(I4 ^direction south ^name red ^score 25 ^x 1 ^y 10)

input-link-eater.jpg

El objeto input-link tiene dos aumentos. El primero de ellos, ^eater, contiene información sobre el jugador: su dirección actual, su nombre, su puntuación (score) actual, y sus coordenadas x e y. El segundo, ^my-location, contiene una subestructura adicional (no mostrada en la figura) que incluye la información de que dispone el jugador sobre sus celdas cercanas. El aumento ^x de ^eater cambia cada vez que el jugador se desplaza hacia el este o el oeste, y el aumento ^y cuando se desplaza hacia el norte o el sur. Por tanto, cada vez que el jugador se mueva el valor de alguno de estos dos aumentos cambiará. Entonces podemos modificar las condiciones de la regla de proposición para el operador mover-norte de forma que compruebe el valor de estos elementos de WM, eliminando la condición que comprobaba la existencia de ^type state:

sp {propose*mover-norte
   (state <s> ^io.input-link.eater <e>)
   (<e> ^x <x> ^y <y>) # Encaja con la posición actual del jugador, que cambia tras cada movimiento
-->
   (<s> operator <o> +)
   (<o> ^name mover-norte)}

¿Qué sucederá ahora? En primer lugar, cuando los elementos de WM originales para x e y sean eliminados de memoria de trabajo, el operador mover-norte original será eliminado de la memoria de trabajo porque la instacia de la regla que lo creó ya no encajará, pues algunos de los elementos de WM que hicieron que encajase han desaparecido. A esto lo llamamos que el disparo de la regla se retrae. Algunas reglas como propose*mover-norte mantienen sus acciones en WM únicamente mientras encajen exactamente con los mismos elementos de memoria de trabajo (como vimos en la sección 4.4 de la primera parte del tutorial).

En segundo lugar, una nueva instancia del operador mover-norte será creada, ya que la regla proprose*mover-norte encajará con los nuevos valores de x e y, y se disparará. Esto se puede observar en el SOAR Debugger cargando la regla anterior junto con la de aplicación del operador mover-norte, y ejecutando el programa con un nivel de observación de 4 (watch 4). Cada vez que un elemento es añadido a WM se precede con “⇒” en Soar Debugger, y cada vez que se elimina se precede con un “⇐”. El número que aparece junto a cada elemento es una marca temporal (timetag) de dicho elemento, creada automáticamente.

A continuación, el jugador se moverá una segunda vez (y después una tercera, una cuarta, etc. mientras el juador no se bloquee) porque la proposición encaja con un nuevo par de coordenadas x e y, apply*mover-norte se dispara de nuevo, y un nuevo comando mover-norte es creado. Pero todavía existen algunos problemas. Si ejecutas el jugador unos cuantos pasos más y seleccionas la pestaña “output” en la ventana de arriba a la derecha del debugger, verás el contenido de la estructura output-link. Como observarás, el comando mover-norte original (I3 ^move M2) sigue estando ahí, no ha sido eliminado del output-link, y el output-link irá acumulando más y más comandos según el jugador se vaya moviendo. El antiguo comando de movimiento no es eliminado porque es la acción de una regla de aplicación de operador, y tal y como vimos en el primer tutorial estas acciones permanecen en memoria incluso cuando la regla que los creó deja de encajar (son acciones con O-soporte).

Por tanto, para eliminar los comandos move antiguos tendremos que crear una regla que se encargue de quitar estos elementos de WM. En el juego Eaters, se crea un aumento en el objeto move después de que la acción se haya ejecutado: ^status complete. Por tanto, crearemos una regla de aplicación (para que sus acciones sean o-supported y la eliminación del elemento sea permanente) del operador mover-norte que elimine un movimiento una vez que se haya completado (i.e. tenga el aumento ^status complete):

sp {apply*mover-norte*elimina-move
   (state <s> ^operator.name mover-norte)
              ^io.output-link <out>)
   (<out> ^move <move>)
   (<move> ^status complete)
-->
   (<out> ^move <move> -)} #Eliminamos ("-") el antiguo movimiento

Si esta regla eliminara el elemento de WM (i3 ^move m1), todos los aumentos de m1 serían también eliminados automáticamente, ya que pasarían a dejar de estar enlazados con el resto de elementos de la memoria de trabajo. Esta es una regla de aplicación de operador, por lo que no se disparará durante la fase de proposición de operadores, sino durante la siguiente fase de aplicación de operadores, después de que un operador mover-norte haya sido seleccionado. No interferirá con otras reglas de aplicación puesto que todas se disparan en paralelo, tal y como ya vimos anteriormente. Ahora ejecuta las reglas que hemos creado con Soar Debugger, y podrás ver como durante la fase de aplicación dos reglas se disparan al mismo tiempo: apply*mover-norte, que añade un nuevo comando al output-link, y apply*mover-norte*elimina-move, que elimina el comando move anterior.

3. Operador mover-a-comida. Tipos de preferencia de operadores

3.1. El operador mover-a-comida

En esta sección crearemos un jugador que vorazmente se mueva a cualquier celda de comida que detecte. Cada jugador puede “sentir” la comida y paredes que lo rodean en dos celdas a la redonda (en una de las ventanas del juego se puede ver gráficamente esta información), y haremos que el jugador se desplace a alguna de sus celdas vecinas que contengan comida, ya sea de tipo normal (normalfood) o especial (bonusfood). Como puede haber comida en más de una celda vecina, más de un operador podría ser propuesto. SOAR no selecciona automáticamente de forma aleatoria entre un conjunto de operadores propuestos que tengan únicamente preferencias aceptables (alcanzaría un impasse de empate). Para evitar estos empates, existen diferentes tipos de preferencias en SOAR.

Necesitaremos por tanto cuatro reglas para el operador mover-a-comida:

  • Una regla para proponer el operador cuando haya comida normal en alguna celda vecina y otra regla para proponer el operador cuando haya comida especial en alguna celda vecina. A diferencia del operador mover-norte, estas reglas de proposición no tienen que comprobar las coordenadas del jugador, ya que los contenidos de las celdas vecinas cambiarán cada vez que el jugador se mueva. Los elementos de memoria de trabajo para los contenidos de las celdas vecinas son eliminados y añadidos en cada movimiento, incluso si las nuevas celdas vecinas tienen el mismo tipo de contenido que tenían antes de que el jugador se moviera. Estas dos reglas también crearan las preferencias indiferentes para los operadores que harán que se elijan de forma aleatoria.
  • Una tercera regla que aplique el operador seleccionado y mueva el jugador en la dirección correcta.
  • Una regla que elimine el comando move del output-link.

Una posible forma de definir estas reglas en lenguaje natural es:

Propose*mover-a-comida*normalfood
Si en alguna celda adyacente hay comida de tipo normalfood,
entonces proponer el operador mover-a-comida en la dirección de dicha celda
e indicar que este operador puede ser seleccionado de forma aleatoria

Propose*mover-a-comida*bonusfood
Si en alguna celda adyacente hay comida de tipo bonusfood,
entonces proponer el operador mover-a-comida en la dirección de dicha celda
e indicar que este operador puede ser seleccionado de forma aleatoria

Apply*mover-a-comida
Si el operador mover-a-comida con una determinada dirección se ha seleccionado,
entonces generar una orden de salida para mover al jugador en dicha dirección

Apply*mover-a-comida*elimina-move
Si el operador mover-a-comida se ha seleccionado y hay un comando move completo en el output-link,
entonces elimina dicho comando de WM

Antes de convertir estas reglas a SOAR, necesitamos conocer más a fondo la estructura del input-link, y conocer como definir preferencias indiferentes. El input-link (enlace de entrada) tiene dos atributos: eater y my-location. Recuerda que el valor de my-location representa la posición actual del jugador, una celda en el centro del conjunto de 5×5 celdas que el jugador detecta a su alrededor. Las celdas adyacentes a otra vienen dadas por los aumentos north, east, south y west. Cada celda también tiene un aumento ^content, cuyo valor puede ser wall (pared), empty (vacía), eater (jugador), normalfood o bonusfood. En la siguiente figura se ve un ejemplo de todo esto, dada una determinada percepción sensorial de un jugador. Además, aunque no aparezcan en la figura, si el contenido de la celda es eater (un jugador), tendrá algunos atributos adicionales: el color del jugador (^eater-color) y la puntuación actual del jugador (^eater-score).

celdas-vecinas.jpg

En cuanto a las posibles preferencias para los operadores, en este caso tendremos que seleccionar un tipo de preferencia llamada indiferente, para que SOAR elija aleatoriamente entre los operadores propuestos. Para indicar una preferencia indiferente se usa el símbolo “=”, de la misma forma que usábamos “+” para indicar una preferencia aceptable. Es importante tener en cuenta que aunque creemos preferencias indiferentes para estos operadores, la preferencia aceptable sigue siendo necesaria para cada uno de ellos, ya que un operador no puede ser seleccionado si no tiene una preferencia aceptable.

Ahora veremos de qué forma se podrían escribir las reglas de proposición y aplicación para los operadores mover-a-comida*normalfood y mover-a-comida*bonusfood. En primer lugar escribiremos la regla de proposición para la regla mover-a-comida*normalfood sin usar la notación punto para mayor claridad. Presenta la novedad de que usa una variable (<direction>) que encaje con cualquier atributo de ^my-location que indique una celda adyacente. Esta variable encjará con los atributos north, east, south o west según el caso, siempre y cuando a su vez contenga un aumento ^content con valor normalfood:

sp {propose*mover-a-normalfood
   (state <s> ^io <io>)
   (<io> ^input-link <input-link>)
   (<input-link> ^my-location <my-loc>)
   (<my-loc> ^<direction> <cell>)
   (<cell> ^content normalfood)
-->
   (<s> ^operator <o> +)
   (<s> ^operator <o> =)
   (<o> ^name mover-a-comida
        ^direction <direction>) # Usamos en la acción la dirección que encajó en la condición.
   }

La variable <direction> de la acción aumenta el operador con la dirección de una celda adyacente que contiene normalfood. Cuando un jugador esté totalmente rodeado de comida, la variable <direction> encajará con las cuatro direcciones posibles, lo que hará que la regla se dispare cuatro veces. En SOAR, todas las reglas que encajan se disparan en paralelo, creando nuevos operadores, cada uno de ellos con un aumento ^direction distinto. Por ejemplo, si hubiera comida normal en las celdas adyacentes al norte y sur del jugador, se crearían dos nuevos operadores, uno con ^direction north y el otro con ^direction south. Usando la notación punto podemos simplificar mucho esta regla:

sp {propose*mover-a-normalfood
   (state <s> ^io.input-link.my-location.<dir>.content normalfood)
-->
   (<s> ^operator <o> + =) # Se crean dos preferecias: aceptable e indiferente
   (<o> ^name mover-a-comida
        ^direction <dir>)
   }

De forma similar se escribe la regla que propone el operador para moverse a una celda con comida especial:

sp {propose*mover-a-bonusfood
   (state <s> ^io.input-link.my-location.<dir>.content bonusfood)
-->
   (<s> ^operator <o> + =) # Se crean dos preferecias: aceptable e indiferente
   (<o> ^name mover-a-comida
        ^direction <dir>)
   }

También podemos combinar estas dos reglas en una sola, ya que podemos escribir una regla que compruebe la existencia tanto de normalfood como de bonusfood en sus condiciones. Estos valores alternativos se escriben en el mismo lugar en el que se escriben los atributos únicos, pero entre « y »: « normalfood bonusfood » (los espacios entre «, » y los valores se deben escribir). Se pueden añadir más de dos valores, pero no pueden ser variables. La regla quedaría:

sp {propose*mover-a-comida
   (state <s> ^io.input-link.my-location.<dir>.content << normalfood bonusfood >>)
-->
   (<s> ^operator <o> + =)
   (<o> ^name mover-a-comida
        ^direction <dir>)
   }

La regla de aplicación, que crea el comando move en el output-link es muy similar a la usada en el operador mover-norte. La única diferencia es que en lugar de usar siempre north en la dirección, usará la dirección creada por la regla de proposición del operador, que haremos encajar en la variable <dir>:

sp {apply*mover-a-comida
   (state <s> ^io.ouput-link <out>
              ^operator <o>)
   (<o> ^name mover-a-comida
        ^direction <dir>)
-->
   (<out> ^move.direction <dir>)}

Por último, crearemos una regla que elimine el comando del output-link cuando se haya completado.

sp {apply*mover-a-comida*elimina-move
   (state <s> ^io.ouput-link <out>
              ^operator.name mover-a-comida)
   (<out> ^move <move>)
   (<move> ^status complete)
-->
   (<out> ^move <move> -)}

También podemos crear una regla de monitorización que indique la dirección del operador seleccionado en cada momento. La regla comprobará que compruebe que el operador mover-a-comida haya sido seleccionado y que escriba mediante el comando write su dirección. Esta regla se disparará en parelelo con apply*mover-a-comida porque encaja con un operador seleccionado:

sp {monitor*mover-a-comida
   (state <s> ^operator <o>)
   (<o> ^name mover-a-comida
        ^direction <dir>)
-->
   (write |Direccion: | <dir>)}
3.2. Preferencias de operadores

Ya hemos visto dos tipos distintos de preferencias para los operadores: preferencias aceptables y preferencias indiferentes, pero hay más. A continuación veremos un resumen de los distintos tipos de preferencias existentes en SOAR. Las preferencias se pueden ver como una secuencia de filtros, procesados en el siguiente orden:

  • Aceptable (+): Una preferencia aceptable expresa que un valor es candidato para ser seleccionado. Sólo aquellos valores con una preferencia aceptable tienen el potencial de ser seleccionados. Si únicamente hay un valor con preferencia aceptable, dicho valor será el seleccionado a menos que también tenga una preferencia de rechazo.
  • Rechazo (-): Una preferencia de rechazo expresa que el valor no es candidato para ser seleccionado. Un valor no podrá ser seleccionado si tiene una preferencia de rechazo.
  • Mejor que (>), Peor que (<): Estas preferencias expresan que un valor no debería ser seleccionado si el valor mejor que él es un candidato a ser seleccionado. Si el mejor valor no tiene una preferencia aceptable o la tiene de rechazo, la preferencia mejor que/peor que se ignora. En caso contrario, el peor valor no se tiene en consideración. Mejor que y peor que son inversas simple del otro, es decir, decir que A es mejor que B equivale a decir que A es peor que B.
  • Mejor (>): Una preferencia mejor expresa que el valor debería ser seleccionado si no es rechazado, o si no existe otro valor mejor que él. Si un valor es mejor (y no rechazado o peor que otro), será seleccionado sobre cualquier otro valor que no sea también mejor. Si dos valores son mejor, entonces el resto de preferencias (indiferente, peor) serán examinadas para determinar cual es seleccionado. Si un valor (que no es rechazado) es mejor que un valor mejor, el valor mejor que el otro será seleccionado (aunque este resultado sea antiintuitivo, permite que el conocimiento explícito acerca del valor relativo de dos valores domine sobre el conocimiento de un único valor).
  • Peor (<): Una preferencia peor expresa que el valor debería ser seleccionado sólo si no quedan otras alternativas. Una preferencia peor sólo debe considerarse cuando ninguna de las preferencias anteriores hayan reducido las posibles elecciones a un único valor. En este caso, cualquier valor con una preferencia peor será descartado, a no ser que todos los valores tengan preferencia peor.
  • Indiferente (= ): Una preferencia indiferente expresa que se sabe con seguridad que no importa qué valor sea seleccionado. Puede ser una preferencia binaria, que indique que dos valores son mutuamente indiferentes, o una preferencia unaria, que indique que un único valor es tan buena o mala elección como cualquier otra alternativa esperada. Las preferencias indiferentes se usan para indicar que no importa que operador se seleccione, lo que hace que la selección se realice de forma aleatoria entre todas las alternativas posibles.
3.3. Operador mover generalizado

El operador mover-a-comida que creamos en la sección 3.1 se atascará cuando no haya comida en ninguna de las celdas adyacentes al jugador, y tampoco preferirá la comida especial sobre la normal. En esta sección, generalizaremos el operador mover-a-comida para que sea un operador que permita al jugador moverse a una celda con cualquier tipo de contenido. Una vez hayamos creado dicho operador generalizado, aplicaremos otros de los tipos de preferencias que hemos visto que nos permitan crear un jugador voraz y que nunca se quede parado.

En primer lugar crearemos como de costumbre la regla de proposición del operador. Esta regla necesita comprobar que existe alguna celda adyacente a la que el jugador se pueda mover. Por tanto, no debería proponer que el jugador se mueva hacia una casilla que contenga un trozo de pared. Podemos hacer esto de dos formas distintas. La primera es comprobar todos los valores de las celdas adyacentes hacia los que podemos movernos: normalfood, bonusfood, eater o empty. La segunda consiste comprobar que el contenido no sea igual a wall. Además, para simplificar las futuras reglas de selección, también copiaremos el contenido de la celda en un atributo del operador. Tomando la primera aproximación obtenemos la siguiente versión en lenguaje natural de la regla.

Propose*mover*1
Si una celda adyacente contiene un elemento de tipo normalfood, bonusfood, eater o empty,
propón un movimiento en la dirección de dicha celda, junto con el contenido de la celda,
e indica que este operador debe ser seleccionado aleatoriamente

Para traducir esta regla a SOAR es necesario hacer que el contenido de la celda encaje con una variable y luego usar esta variable en la acción como un atributo del operador, por ejemplo ^contenido <contenido>. Sin embargo, hay que hacer que dicha variable encaje con un valor de entre una lista de posibles valores. En SOAR esto se puede conseguir incluyendo las dos (o más) cosas a encajar con el mismo elemento (en nuestro caso, la variable <contenido> y la lista de posibles valores) entre llaves: { y }. Por tanto, la versión en SOAR de la regla queda:

sp {propose*mover*1
   (state <s> ^io.input-link.my-location.<dir>.content
              { <content> << empty normalfood bonusfood eater >> })
-->
   (<s> ^operator <o> + =)
   (<o> ^name mover
        ^direction <dir>
        ^content <content>)} #Copiamos el contenido en el operador

Aunque esta regla sea adecuada, te obliga a enumerar todos los contenidos excepto las paredes. Esto nos obligará a cambiar la regla si añadimos nuevos tiempos de comida u otros contenidos a los que el jugador se pueda mover. Sería mejor crear una regla que compruebe que el contenido de la celda es distinto a una pared. Podemos hacer esto mediante el operador “<>”, de cualquiera de estas dos formas:

sp {propose*mover*2a
   (state <s> ^io.input-link.my-location.<dir>.content
               { <content> <> wall }) #<content> encajará con cualquier valor salvo wall
-->
   (<s> ^operator <o> + =)
   (<o> ^name mover
        ^direction <dir>
        ^content <content>)}
sp {propose*mover*2a
   (state <s> ^io.input-link.my-location.<dir>.content
                { <> wall <content> }) #El orden de las comprobaciones entre {} no importa
-->
   (<s> ^operator <o> + =)
   (<o> ^name mover
        ^direction <dir>
        ^content <content>)}

También se puede comprobar si los contenidos de dos variables son distintos. Por ejemplo, si quisiéramos comprobar que el contenido de las celdas al norte y sur sean distintos, podríamos usar las siguientes condiciones:

(state <s> ^io.input-link.my-location <my-loc>)
(<my-loc> ^north.content <contenido-norte>
          ^south.content <> <contenido-norte>)

Y si quisieras que tanto el contenido de la celda norte como la sur encajaran en sendas variables para usarlas en las acciones, podrías usar las siguientes condiciones:

(state <s> ^io.input-link.my-location <my-loc>)
(<my-loc> ^north.content <north>
          ^south.content { <south> <> <north> })

Recuerda que el operador distinto de, <>, debe preceder directamente el símbolo o variable al que se refiere. SOAR también tiene operadores para comprobar las condiciones mayor que (>), menor que (<), mayor o igual que (>= ) y menor o igual que (⇐). Estos se pueden usar a la hora de comparar números y también preceden al valor al que se refieren. Por ejemplo, para comprobar que el marcador del jugador es mayor que 25, se puede usar la siguiente condición:

(state <s> ^io.input-link.eater.score > 25)

En cuanto a las reglas de aplicación no habrá más que copiar las que ya teníamos del operador mover-a-comida. Lo único que habrá que cambiar serán los nombres de las reglas y los operadores:

sp {apply*mover
   (state <s> ^io.ouput-link <out>
              ^operator <o>)
   (<o> ^name mover
        ^direction <dir>)
-->
   (<out> ^move.direction <dir>)}
sp {apply*mover*elimina-move
   (state <s> ^io.ouput-link <out>
              ^operator.name mover-a-comida)
   (<out> ^move <move>)
   (<move> ^status complete)
-->
   (<out> ^move <move> -)}

La regla de proposición junto a estas dos reglas de aplicación harán que el jugador se mueva aleatoriamente a su alrededor, evitando las paredes. Pero podemos mejorar mucho su comportamiento gracias a algunas de las preferencias de operadores vistas en la sección anterior. Para ello, crearemos reglas que prefieran un operador hacia una celda con bonusfood que hacia una vacía, con normalfood o con otro jugador, y que prefieran un operador hacia una celda con normalfood antes que hacia una vacía o con otro jugador en ella. El lenguaje de preferencias en SOAR es lo suficientemente rico para soportar una gran variedad de maneras de ordenar las diferentes elecciones que puedan darse, tal y como vimos anteriormente.

En primer lugar, crearemos una regla que prefiera las celdas con comida especial a las celdas que tengan comida normal, otro jugador o estén vacías. Las condiciones de la regla deben encajar con proposiciones de operador, mientras que las acciones deben indicar que se prefiera el operador que realiza un movimiento hacia la celda con bonusfood. En lenguaje natural quedaría:

Select*mover*bonusfood-mejor-que-normalfood-vacio-eater
Si hay un operador propuesto para moverse a una celda con bonusfood y
hay un segundo operador propuesto para moverse a una celda que está vacía o
contiene normalfood u otro jugador (eater),
entonces dale mayor prioridad al primer operador

Las condiciones de este operador deben encajar con operadores propuestos antes de que sean seleccionados. Podemos encajar un operador propuesto encajando su preferencia aceptable, lo que se escribe en la condición de una regla como el atributo ^operator del estado, seguido de un valor para el identificador del operador, y a continuación un signo más “+”: (state <s> ^operator <o> +)

Las preferencias aceptables son las únicas que se añaden a la memoria de trabajo. Todas las demás preferencias (mejor, peor, mejor que, peor que, etc.) no se incluyen en ella. Son almacenadas en la memoria de preferencias y persisten en ella mientras las instancias de las reglas que las crearon sigan encajando con sus condiciones.

Para traducir la regla anterior a SOAR, habremos de usar la preferencia mejor que, de forma que el proceso de decisión de SOAR la usará a la hora de determinar qué operador seleccionar. La preferencia la colocaremos en el mismo lugar que pusimos las preferencias aceptables o indiferentes, pero teniendo en cuenta que hay que situar una variable que contenga el identificador del operador con mayor prioridad delante del signo “>”, y una variable que contenga el identificador del otro operador detrás suyo. Por tanto, la regla en SOAR queda:

sp {select*mover*bonusfood-mejor-que-normalfood-vacio-eater
   (state <s> ^operator <o1> +  # Preferencia aceptable
              ^operator <o2> +) # Preferencia aceptable
   (<o1> ^name mover
         ^content bonusfood)
   (<o2> ^name mover
         ^content << normalfood empty eater >>)
-->
   (<s> ^operator <o1> > <o2>)} # > indica la preferencia mejor que

Podemos usar el mismo enfoque para preferir movernos a celdas con comida normal antes que con otro jugador o vacías. Esta vez usaremos el operador peor, que indica que no se seleccione dicho operador a no ser que no quede otra alternativa. En este caso, podemos crear preferencias peores para los operadores que muevan al jugador hacia una celda vacía o con otro jugador en ella. De esta forma, los operadores que realicen movimientos hacia celdas con comida (normal o especial) siempre serán elegidos si existen, y en caso contrario el jugador elegirá aleatoriamente si moverse a una celda vacía o con que contenga otro jugador. La preferencia peor la escribimos con un sigo “<”, y se sitúa detras de la variable que contiene el identificador del operador al que queremos darle la peor preferencia. Por tanto, la regla en SOAR queda:

sp {select*mover*evitar-vacia-o-jugador
   (state <s> ^operator <o> +)
   (<o> ^name mover
        ^content << empty eater >>)
-->
   (<s> ^operator <o> <) # < indica la peor preferencia para <o>
}

También podríamos haber usado una preferencia mejor que prefiriese los operadores que mueven al jugador a una celda con comida normal. De esta forma, dichos operadores serían elegidos sobre aquellos que realizan movimientos a celdas vacías o con otro jugador. Además, gracias a la regla select*mover*bonusfood-mejor-que-normalfood-vacio-eater, si hay un operador que mueva al agente a una celda con comida especial será elegido sobre otro que lo mueva hacia comida normal, debido a que la preferencia mejor que es prioritaria a la preferencia mejor. La regla alternativa a la anterior queda:

sp {select*mover*preferir-normalfood
   (state <s> ^operator <o> +)
   (<o> ^name mover
        ^content normalfood)
-->
   (<s> ^operator <o> >) # > indica la mejor preferencia para <o>
}

El comportamiento del jugador ahora es algo más inteligente, aunque se puede seguir mejorando. Se pueden ver operadores de movimiento más avanzados que los que acabamos de ver, así como operadores de salto, en la segunda parte del tutorial de SOAR original.

Aprendizaje por refuerzo

SOAR implementa aprendizaje por refuerzo (reinforcement learning, SOAR-RL), lo que permite que los agentes cambien su comportamiento en el tiempo mediante el cambio dinámico de preferencias indiferentes numéricas en la memoria procedural como respuesta a la obtención de recompensas. Éste es un mecanismo de aprendizaje completamente distinto al troceado (chunking). Mientras que el troceado es una forma de aprendizaje paso a paso que va mejorando el rendimiento del agente mediante una síntesis de los resultados obtenidos en subobjetivos, Soar-RL es una forma de aprendizaje incremental que altera el comportamiento del agente de forma probabilística.

1. SOAR-RL en acción

En primer lugar veamos un ejemplo de los efectos del aprendizaje por refuerzo. Consideremos un agente simple llamado izquierda-derecha que puede elegir realizar un movimiento a la izquierda o a la derecha. Sin que el agente lo sepa, una de estas direcciones es preferible a la otra. Tras decidir su destino, el agente recibirá una “recompensa” (reward), que le indicará cuán buena fue su elección. En este ejemplo, le será ofrecida una recompensa de -1 si se mueve a la izquierda y una recompensa de +1 si se mueve a la derecha. Usando SOAR-RL, el agente aprenderá rápidamente que moverse a la derecha es preferible a moverse a la izquierda.

1.1. El agente izquierda-derecha

Dada la descripción previa del agente, a continuación lo crearemos. El agente tendrá un operador move que eligirá si debe moverse a la izquierda o a la derecha. Ya que el agente no sabe a priori qué dirección es la mejor, la preferencia entre estas acciones será indiferente. El código del agente viene dado a continuación.

  • Incialización. El agente almacena las direcciones y las recompensas asociadas a éstas en el estado.
sp {propose*inicializa-izquierda-derecha
   (state <s> ^superstate nil
             -^name)
-->
   (<s> ^operator <o> +)
   (<o> ^name inicializa-izquierda-derecha)
}

sp {apply*inicializa-izquierda-derecha
   (state <s> ^operator <op>)
   (<op> ^name inicializa-izquierda-derecha)
-->
   (<s> ^name izquierda-derecha
        ^direccion <d1> <d2>
        ^posicion comienzo)
   (<d1> ^name izquierda ^recompensa -1)
   (<d2> ^name derecha ^recompensa 1)
}
  • Operador move. El agente puede moverse en cualquier dirección disponible. La dirección elegida se almacena en el estado y el agente se detiene.
sp {izquierda-derecha*propose*move
   (state <s> ^name izquierda-derecha
              ^direccion.name <dir>
              ^posicion comienzo)
-->
   (<s> ^operator <op> +)
   (<op> ^name move
         ^dir <dir>)
}

sp {izquierda-derecha*rl*izquierda
   (state <s> ^name izquierda-derecha
              ^operator <op> +)
   (<op> ^name move
         ^dir izquierda)
-->
   (<s> ^operator <op> = 0)
}

sp {izquierda-derecha*rl*derecha
   (state <s> ^name izquierda-derecha
              ^operator <op> +)
   (<op> ^name move
         ^dir derecha)
-->
   (<s> ^operator <op> = 0)
}

sp {apply*move
   (state <s> ^operator <op>)
   (<op> ^name move
         ^dir <dir>)
-->
   (<s> ^posicion <dir>)
   (write (crlf) |Movimiento: | <dir>)
   (halt)
}
  • Recompensa. Cuando un agente elige una dirección se le proporciona la recompensa correspondiente.
sp {elaborate*recompensa
   (state <s> ^name izquierda-derecha
              ^reward-link <r>
              ^posicion <d-name>
              ^direccion <dir>)

   (<dir> ^name <d-name> ^recompensa <d-recompensa>)
-->
   (<r> ^reward.value <d-recompensa>)
}

En este código se observan elementos que no se han visto con anterioridad: las reglas de aprendizaje por refuerzo (reglas rl) para el operador move y la elaboración de la recompensa. El motivo por el que se usan estos componentes quedará claro en las secciones posteriores.

1.2. Ejecutando el agente izquierda-derecha

En primer lugar habrá que cargar las reglas correspondientes al agente izquierda-derecha en el SOAR debugger. Por defecto, el aprendizaje por refuerzo se encuentra deshabilitado. Para activarlo, hay que escribir la orden:

rl --set learning on

Ahora, para ejecutar el primer ciclo de decisión de SOAR hacemos clic en el botón Step del depurador. El operador de inicialización habrá sido seleccionado, como era de esperar. Para ver las preferencias indiferentes numéricas en la memoria procedural, sujetas a actualización por SOAR-RL, escribiremos:

print --rl

Lo cual muestra la siguiente salida:

izquierda-derecha*rl*derecha 0.  0
izquierda-derecha*rl*izquierda 0.  0

Este resultado muestra que la preferencia para las dos instancias del operador tras cero actualizaciones tienen un valor de 0. Si ejecutamos el agente por dos ciclos más de decisión (haciendo clic dos veces en el botón Step) y luego ejecutamos print –rl de nuevo, veremos el comportamiento de SOAR-RL en acción:

izquierda-derecha*rl*derecha 1.  0.3
izquierda-derecha*rl*izquierda 0.  0

Después de haber aplicado la orden halt del operador move, el valor de la indiferencia numérica para la regla asociada con el movimiento a la derecha ha sido actualizada una vez a un valor de 0.3. Nótese que como las preferencias del operador move son indiferentes, y por tanto el proceso de decisión se hace de forma probabilística, otro agente podría haber decidido moverse a la izquierda en lugar de a la derecha. En este caso la preferencia izquierda-derecha*rl*izquierda hubiera sido actualizada 1 vez con un valor de -0.3.

A continuación reinicia el agente haciendo clic en el botón Init-soar. Ejecuta ahora print –rl. Se observa como los valores numéricos indiferentes no han cambiado desde la última ejecución. Este almacenamiento de los valores entre ejecuciones es la forma que tienen los agentes SOAR-RL de aprender. Ejecuta ahora el agente 20 veces más, haciendo clic en el botón Init-soar tras cada ejecución completada. Se debería notar como el valor numérico indiferente para moverse a la derecha va aumentando, al mismo tiempo que el valor para moverse a la izquierda va disminuyendo. Correspondientemente, el agente debería ir eligiendo moverse a la izquierda menos frecuentemente.

2. Construyendo un agente que aprende

La conversión de la mayoría de los agentes de forma que puedan aprovecharse de las características de SOAR-RL se realiza en dos etapas: (1) Usar preferencias compatibles con SOAR-RL y (2) implementar una o más reglas de recompensa. A modo de ejemplo, actualizaremos el agente básico del problema de las jarras de agua de la primera parte del tutorial para que aproveche la funcionalidad de aprendizaje de SOAR-RL.

2.1. Preferencias compatibles con SOAR-RL

Las preferencias de operador que son reconocidas como actualizables por SOAR-RL deben ser propuestas de una forma especial:

sp {mi*regla*de*proposicion
   (state <s> ^operator <op> +
              ^condicion <c>)
-->
   (<s> ^operator <op> = 2.3)
}

El nombre de la regla puede ser cualquiera, y sus condiciones (LHS, left-hand side o parte izquierda de la regla) pueden tomar cualquier forma. Sin embargo, la parte derecha de la regla (RHS, right-hand side) debe ser de la siguiente forma:

(<s> ^operator <op> = numero)

Concretamente, la RHS de la regla sólo puede tener una sentencia (acción) y numero debe ser un valor numérico constante (como 2.3 en el ejemplo anterior). Cualquier otra acción de proposición de operador, incluyendo la proposición de la aceptabilidad del operador, debe tomar parte en otra regla separada.

Volviendo al problema de las jarras de agua, nuestro objetivo será hacer que SOAR-RL aprenda las mejores condiciones bajo las cuales vaciar una jarra (de un volumen dado), llenar una jarra (de un volumen dado) y volcar una jarra (de un volumen dado) en otra. Por tanto tendremos que modificar los operadores volcar, llenar y vaciar para proporcionarles preferencias actualizables por SOAR-RL.

Modificar los operadores de los agentes del problema de las jarras de agua para hacerlos compatibles con el modelo de preferencias de SOAR-RL se hará en dos pasos: modificar las reglas de proposición existentes y crear reglas especiales. La modificación de las reglas de proposición existentes es algo trivial: no hay más que eliminar el signo de igual (“=”) de la línea de creación de operador en la RHS de la regla:

sp {jarras-de-agua*propose*llenar
   (state <s> ^name jarras-de-agua
              ^jarra <j>)
   (<j> ^quedan > 0)
-->
   (<s> ^operator <o> +)
   (<o> ^name llenar
        ^llena-jarra <j>)}

sp {jarras-de-agua*propose*vaciar
   (state <s> ^name jarras-de-agua
              ^jarra <j>)
   (<j> ^contenido > 0)
-->
   (<s> ^operator <o> +)
   (<o> ^name vaciar
        ^vacia-jarra <j>)}

jarras-de-agua*propose*volcar
   (state <s> ^name jarras-de-agua
              ^jarra <i>
              ^jarra { <j> <> <i> })
   (<i> ^contenido > 0)
   (<j> ^quedan > 0)
-->
   (<s> ^operator <o> +)
   (<o> ^name volcar
        ^vacia-jarra <i>
        ^llena-jarra <j>)}

Estas reglas modificadas proponen sus respectivos operadores con una preferencia aceptable. A continuación, escribiremos reglas SOAR-RL cuyas condiciones detecten estas preferencias aceptables y las complementen con preferencias indiferentes numéricas.

El segundo paso para modificar el agente puede ser mucho más laborioso. Para conseguir que SOAR-RL genere información sobre cada acción en cada estado del problema, debe tener una regla de proposición SOAR-RL para cada par estado-acción. En el problema de las jarras de agua, un estado se puede representar por el volumen de cada una de las jarras y la acción (vaciar, llenar o volcar) que se realiza con una de las dos jarras. Por ejemplo, una regla de proposición SOAR-RL para el vaciado de la jarra de 3 litros (actualmente almacenando 2 litros) cuando la jarra de 5 litros contiene 4 litros podría escribirse de la siguiente manera:

sp {jarras-de-agua*vaciar*3*2*4
   (state <s> ^name jarras-de-agua
              ^operator <op> +
              ^jarra <j1> <j2>)
   (<op> ^name vaciar
         ^vacia-jarra.capacidad 3)
   (<j1> ^capacidad 3
         ^contenido 2)
   (<j2> ^capacidad 5
         ^contenido 4)
-->
   (<s> ^operator <op> = 0)
}

Para agentes sencillos, como el agente izquierda-derecha, es posible enumerar a mano todos los pares acción-estado como reglas SOAR-RL. Sin embargo, en el caso del problema de las jarras de agua, el agente necesita (3 * 2 * 4 * 6) = 144 reglas SOAR-RL para representar completamente todos los pares. No obstante, como podemos expresar estas reglas como un modelo combinatorio simple, se usará el comando gp de SOAR para generar todas las reglas que necesitamos:

sp {rl*jarras-de-agua*vaciar
   (state <s> ^name jarras-de-agua
              ^operator <op> +
              ^jarra <j1> <j2>)
   (<op> ^name vaciar
         ^vacia-jarra.capacidad [3 5])
   (<j1> ^capacidad 3
         ^contenido [0 1 2 3])
   (<j2> ^capacidad 5
         ^contenido [0 1 2 3 4 5])
-->
   (<s> ^operator <op> = 0)
}

sp {rl*jarras-de-agua*llenar
   (state <s> ^name jarras-de-agua
              ^operator <op> +
              ^jarra <j1> <j2>)
   (<op> ^name llenar
         ^llena-jarra.capacidad [3 5])
   (<j1> ^capacidad 3
         ^contenido [0 1 2 3])
   (<j2> ^capacidad 5
         ^contenido [0 1 2 3 4 5])
-->
   (<s> ^operator <op> = 0)
}

sp {rl*jarras-de-agua*volcar
   (state <s> ^name jarras-de-agua
              ^operator <op> +
              ^jarra <j1> <j2>)
   (<op> ^name volcar
         ^vacia-jarra.capacidad [3 5])
   (<j1> ^capacidad 3
         ^contenido [0 1 2 3])
   (<j2> ^capacidad 5
         ^contenido [0 1 2 3 4 5])
-->
   (<s> ^operator <op> = 0)
}

Nótese que si las reglas hubieran requerido de un patrón más complejo para ser generadas, o no hubieramos conocido todas las reglas necesarias a la hora de diseñar el agente, tendriamos que haber usado reglas de plantilla (template rules). Ésto se explica con detalle en el “Soar-RL Manual”.

2.2 Reglas de recompensa

Las reglas de recompensa (reward rules) en SOAR-RL son como cualquier otra regla de SOAR, excepto en que modifican el atributo reward-link del estado para reflejar la asociación de una recompensa con la decisión de operador actual del agente. Los valores de las recompensas deben guardarse en el elemento value del atributo reward del identificador reward-link (state.reward-link.reward.value).

SOAR-RL no elimina o modifica automáticamente ninguna estructura en el reward-link, incluyendo recompensas antiguas. Es responsabilidad del programador mantener la estructura reward-link de forma que ofrezca información apropiada a SOAR-RL. En la mayoría de casos, esto significa que las reglas de recompensa tendrán i-soporte, para que creen valores de recompensa no persistentes en memoria. Si un atributo permanece en la estructura reward-link, como sucede en una regla con o-soporte, la recompensa será tomada en cuenta múltiples veces en el aprendizaje por refuerzo.

Para el agente del problema de las jarras de agua, le ofreceremos una recompensa sólo cuando haya alcanzado el objetivo. Esto supone realizar una pequeña modificación a la regla de comprobación del objetivo, o estado deseado:

sp {jarras-de-agua*detectar*objetivo*alcanzado
   (state <s> ^name jarras-de-agua
              ^jarra <j> ^reward-link <rl>)
   (<j> ^capacidad 3 ^contenido 1)
-->
  (write (crlf) |El problema ha sido resuelto!|)
  (<rl> ^reward.value 10)
  (halt)}

Ahora puedes cargar este código en el depurador y ejecutarlo unas cuantas veces (recuerda habilitar SOAR-RL). Tras aproximadamente cinco ejecuciones el agente debería haber adoptado una estrategia cercana a la óptima. En cualquier momento durante las ejecuciones puedes ejecutar el comando print –rl para ver los valores numéricos indiferentes de las reglas SOAR-RL generadas por las reglas de plantilla. Puedes hacer clic derecho y elegir imprimir por pantalla cualquiera de estas reglas para verlas con más detalle.

3. Detalles de la exploración

Consideremos la siguiente salida de una ejecución (con nivel de observación, watch level, a 0) del agente izquierda-derecha de la primera sección:

run
Movimiento: derecha
This Agent halted.
An agent halted during the run.

init-soar
Agent reinitialized.

run
Movimiento: derecha
This Agent halted.
An agent halted during the run.

init-soar
Agent reinitialized.

run
Movimiento: izquierda
This Agent halted.
An agent halted during the run.

En la tercera ejecución se observa como el agente seleccionó moverse a la izquierda. En ese momento, moverse a la derecha tenía una ventaja obvia sobre moverse a la izquierda según sus valores numéricos de preferencia. Entonces, ¿por qué se eligió moverse a la izquierda?. La respuesta está en la política de exploración de SOAR-RL.

Existen algunas ocasiones durante el aprendizaje en las que explorar operaciones que actualmente se consideran menos buenas que otras puede conducir al agente a un camino útil. SOAR-RL te permite configurar el nivel de exploración de estos caminos alternativos usando el comando indifferent-selection. En el SOAR debugger, introduce indifferent-selection –stat. El resultado debería tener este aspecto:

Exploration policy: epsilon-greedy
Automatic Policy Parameter Reduction: off

epsilon: 0.1
epsilon Reduction Policy: exponential
epsilon Reduction Rate (exponential/linear): 1/0

temperature: 25
temperature Reduction Policy: exponential
temperature Reduction Rate (exponential/linear): 1/0

Este comando imprime por pantalla la política de exploración actual de SOAR-RL, junto con una serie de parámetros de ajuste. Existen cinco políticas de exploración: boltzmann, epsilon-greedy, softmax, first y last. Puedes cambiar la política de exploración mediante el siguiente comando (en el que “politica-expl” debe cambiarse por una de las cinco políticas vistas):

indifferent-selection --politica-expl

En este tutorial sólo veremos la política epsilon-greedy. Se puede encontrar información sobre el resto de políticas en el manual de SOAR-RL. Epsilon greedy (Epsilon voraz) es una política de aprendizaje por refuerzo para permitir exploración, controlada por parámetros, de operadores que no sean reconocidos actualmente como el favorito. Esta política es controlada por el parámetro epsilon. Un resumen de esta política es: Con probabilidad (1 - epsilon), será elegido el operador favorito. Con probabilidad epsilon, se realizará una selección aleatoria de entre todos los operadores indiferentes.

Cuando SOAR se inicia por primera vez, la política de exploración por defecto es softmax. Sin embargo, la primera vez que se habilite SOAR-RL, la arquitectura cambia automáticamente la política de exploración a epsilon-greedy, una política más apropiada para los agentes RL. El valor por defecto de epsilon es 0.1, lo que significa que el 90% de las veces será elegido el operador con el mayor valor numérico de preferencia, mientras que el 10% de las veces se realizará una selección aleatoria de entre todos los operadores aceptables propuestos. Puedes cambiar el valor de epsilon mediante el siguiente comando:

indifferent-selection --epsilon <valor>

Epsilon puede tomar valores de entre 0 y 1 (ambos inclusive). Por definición, un valor de 0 eliminará la exploración y un valor de 1 resultará en una selección uniformemente aleatoria de operadores. Con esta explicación, deberías experimentar con diferentes valores de epsilon para diferentes ejecuciones de los agentes que se han visto en este tutorial.

SML: Guía rápida

Resumen

SML (Soar Markup Language, Lenguaje de Marcado de SOAR) ofrece una interfaz para la comunicación con SOAR basada en el envío y recibo de comandos SOAR empaquetados como estructuras XML. La interfaz ha sido dieñada para soportar la conexión de otros entornos con SOAR (en la que las estructuras de datos de entrada y salida son enviadas en ambas direcciones) y para soportar depuradores (donde los comandos para imprimir por pantalla reglas específicas o elementos de memoria de trabajo se envían en ambas direcciones). A estos entornos y depuradores los denominaremos “clientes”.

Los detalles y motivaciones detrás del desarrollo del lenguaje SML se describen en la "Soar XML Interface Specification", que describe con mucho más detalle las características de este dialecto de XML. Sin embargo, para la mayoría de los usuarios esta guía debería ser suficiente.

Clientes SML

Un cliente puede decidir enviar y recibir comandos XML directamente, enviándolos a un socket mantenido por SOAR (por defecto, puerto 12121). El formato usado es de 4 bytes de longitud seguido de una serie de caracteres que representan el mensaje XML.

Sin embargo, la mayoría de clientes no tendrán que llegar a trabajar a tan bajo nivel. En su lugar, se ofrecen una serie de clases que al ser usadas ocultan los detalles del sistema de mensajes XML, permitiendo a su vez que el cliente tenga un control total sobre SOAR. Esta guía ofrece una introducción rápida para usar estas clases, a las que llamamos el módulo ClientSML.

Librerías necesarias

ClientSML se encuentra actualmente disponible en C++, Java y Tcl. Para la implementación actual en C++ las librerías necesarias para construir un cliente C++ (en Windows) son:

  • Librerías estáticas: ClientSML.lib, ElementXML.lib y ConnectionSML.lib
  • Librerías compartidas (cargadas dinámicamente): SoarKernelSML.dll, ElementXML.dll

Si estás trabajando en Java, también necesitarás la librería Java_sml_ClientInterface.dll. En Tcl se necesita la librería Tcl_sml_ClientInterface.dll.

Los archivos include necesarios se encuentran en ClientSML/include.

Un ejemplo SML simple

Este ejemplo es una especie de “Hola Mundo” acerca de cómo usar los principales elementos de ClientSML. Una vez comprendas este ejemplo, estarás listo para usar SML con más profundidad.

// Normalmente solo se necesita este header
#include "sml_Client.h"
 
using namespace sml ;
 
void main() {
 
// Crea una instancia del kernel de Soar en nuestro proceso
Kernel* pKernel = Kernel::CreateKernelInNewThread() ;
 
// Comprueba que nada haya ido mal. Siempre devolveremos un objeto de tipo kernel
// incluso si ha habido errores y tenemos que abortar
if (pKernel->HadError())
{
cout << pKernel->GetLastErrorDescription() << endl ;
        return ;
}
 
// Crea un nuevo agente Soar llamado "test"
// NOTA: no eliminamos el puntero al agente. Le pertenece al kernel
sml::Agent* pAgent = pKernel->CreateAgent("test") ;
 
// Comprueba que nada haya ido mal
// NOTA: ningún agente se crea si ha habido errores, así que tenemos que
// comprobar los errores a través del objeto kernel
if (pKernel->HadError())
{
cout << pKernel->GetLastErrorDescription() << endl ;
        return ;
}
 
// Carga algunas reglas
pAgent->LoadProductions("testsml.soar") ;
 
if (pAgent->HadError())
{
        cout << pAgent->GetLastErrorDescription() << endl ;
        return ;
}
Identifier* pInputLink = pAgent->GetInputLink() ;
 
// Crea los elem. (I3 ^plane P1) (P1 ^type Boeing747 ^speed 200 ^direction 50.5) en
// el input-link.
Identifier* pID          = pAgent->CreateIdWME(pInputLink, "plane") ;
StringElement* pWME1 = pAgent->CreateStringWME(pID, "type", "Boeing747") ;
IntElement* pWME2    = pAgent->CreateIntWME(pID, "speed", 200) ;
FloatElement* pWME3  = pAgent->CreateFloatWME(pID, "direction", 50.5) ;
 
// Envía los cambios a la memoria de trabajo de SOAR
// En 8.6.2 esta llamada es opcional, ya que los cambios se envían automáticamente
pAgent->Commit() ;
 
// Ejecutar SOAR para dos decisiones
pAgent->RunSelf(2) ;
 
// Cambia (P1 ^speed) a 300 y envía el cambio a SOAR
pAgent->Update(pWME2, 300) ;
pAgent->Commit() ;
 
// Ejecuta SOAR hasta que genere alguna salida o hayan pasado 15 ciclos de decisión
// (El caso más típico es ejecutar para una decisión en vez de hasta que haya alguna salida)
pAgent->RunSelfTilOutput() ;
 
// Recorre todos los comandos que hemos recibido (si los hay) desde la última vez que ejecutamos SOAR.
int numberCommands = pAgent->GetNumberCommands() ;
for (int i = 0 ; i < numberCommands ; i++)
{
        Identifier* pCommand = pAgent->GetCommand(i) ;
 
        std::string name  = pCommand->GetCommandName() ;
std::string speed = pCommand->GetParameterValue("speed") ;
 
        // Actualizar aquí el entorno para reflejar el comando del agente
 
        // Luego marcar el comando como completado
        pCommand->AddStatusComplete() ;
 
// O también se podría hacer lo mismo manualmente así:
        // pAgent->CreateStringWME(pCommand, "status", "complete") ;
}
 
// Mira si alguien (p.ej. un depurador) ha enviado comandos a SOAR
// Si no llamamos a este metodo periodicamente, las conexiones remotas serán ignoradas si
// elegimos el método "CreateKernelInCurrentThread".
pKernel->CheckForIncomingCommands() ;
 
// Creamos un comando SOAR de ejemplo
std::string cmd = "excise --all" ;
 
// Ejecuta el comando
char const* pResult = pKernel->ExecuteCommandLine(cmd.c_str(),pAgent->GetAgentName()) ;
 
// Apagado y limpieza
pKernel->Shutdown() ;   // Elimina todos los agentes (a menos que se use una conexión remota)
delete pKernel() ;              // Elimina el kernel
 
} // fin main

Ejemplo simple: explicación

Creando el Kernel
// Crea una instancia del kernel de Soar en nuestro proceso
Kernel* pKernel = Kernel::CreateKernelInNewThread() ;

El cliente puede crear un núcleo de SOAR local (es decir, cargar SOAR como una DLL en su propio proceso), o una conexión remota a un kernel de SOAR ya existente (donde los comandos se mandan a través de un socket hacia un proceso separado en la propia máquina o en otra distinta).

El kernel local puede ser creado en el mismo hilo en el que fue llamado o en un hilo nuevo. Usar el mismo hilo será normalmente un poco más rápido, pero hace que el cliente tenga que llamar periódicamente a pKernel→CheckForIncomingCommands() para que el kernel tenga la oportunidad de comprobar los comandos que le vengan de otros procesos remotos (p.ej. desde un depurador). Así que para obtener la mayor velocidad elige la opción del hilo actual, pero el código se complicará un poco. Si la velocidad no es un aspecto crítico entonces elige la opción de crear un nuevo hilo. Por ahora, mientras vayas leyendo este tutorial, es recomendable que uses la opción CreateKernelInNewThread hasta que estés más familiarizado con el sistema.

Identifier* pInputLink = pAgent->GetInputLink() ;
// Crea los elem. (I3 ^plane P1) (P1 ^type Boeing747 ^speed 200 ^direction 50.5) en
// el input-link.
Identifier* pID          = pAgent->CreateIdWME(pInputLink, "plane") ;
StringElement* pWME1 = pAgent->CreateStringWME(pID, "type", "Boeing747") ;

El cliente puede construir un grafo de elementos de memoria de trabajo (WMEs) todo lo complejo que desee unidos al input-link. Cada WME es una tupla de tres elementos: (identificador ^atributo valor). El primer identificador (el del enlace de entrada) viene de “getInputLink”, y el resto de identificadores nuevos son creados por CreateIdWME(). Nuevos WME sencillos se crean mediante CreateStringWME/CreateIntWME/CreateFloatWME, dependiendo del tipo.

El valor de un WME puede ser actualizado mediante el método pAgent→Update() y puede ser eliminado mediante pAgent→DestroyWME(), que también hace el objeto de memoria de trabajo inválido.

Un grafo (en lugar de un árbol sencillo) puede ser creado mediante pAgent→CreateSharedIdWME(). Esto crea un nuevo WME identificador con el mismo valor que un identificador existente. (P.ej. dado pOrig = (P7 ^object O3) entonces CreateSharedIdWME(pInputLink, “same”, pOrig) crearía (I1 ^same O3)).

Realizando cambios en WM
// Envía los cambios a la memoria de trabajo de SOAR
pAgent->Commit(); 

El cliente debe solicitar de forma explícita que los cambios realizados en la memoria de trabajo sean enviados a SOAR. Este comando explícito permite que la capa de comunicación sea más eficiente, al recolectar todos los cambios y enviarlos a la vez mediante un único comando. Con 8.6.2 los cambios son enviados automáticamente justo tras ser creados, por lo que la llamada a Commit() es innecesaria. Esto hace que el rendimiento sea un poco peor, así que para obtener el máximo rendimiento llama a SetAutoCommit(false) y luego haz Commit() cuando sea necesario.

Ejecutando SOAR
// Ejecuta SOAR hasta que genere alguna salida o hayan pasado 15 ciclos de decisión
pAgent->RunSelfTilOutput() ;
 
// Ejecutar SOAR para dos decisiones
pAgent->RunSelf(2) ;

En la mayoría de entornos reales, SOAR debería ejecutarse con pKernel→RunAllAgentsForever() y luego usar una llamada a pKernel→StopAllAgents() para interrumpirlo. Esto permite al usuario ejecutar el entorno desde un depurador o desde el propio entorno.

Obteniendo la salida del agente (output)
// Recorre todos los comandos que hemos recibido (si los hay) desde la última vez que ejecutamos SOAR.
int numberCommands = pAgent->GetNumberCommands() ;
for (int i = 0 ; i < numberCommands ; i++)
{
        Identifier* pCommand = pAgent->GetCommand(i) ;
 
        std::string name  = pCommand->GetCommandName() ;
std::string speed = pCommand->GetParameterValue("speed") ;
 
        // Actualizar aquí el entorno para reflejar el comando del agente
 
        // Luego marcar el comando como completado
        pCommand->AddStatusComplete() ;
 
// O también se podría hacer lo mismo manualmente así:
        // pAgent->CreateStringWME(pCommand, "status", "complete") ;
}

Se ofrece soporte directo para un modelo de salida concreto, en el que cada comando del agente se representa como un identificador distinto en el output-link. Por ejemplo, si el identificador del output-link es O1, entonces el agente podría añadir un comando de movimiento mediante (O1 ^mov M1) (M1 ^velocidad 20).

Si se usa este modelo de salida para las acciones del agente, entonces se pueden realizar consultas directamente al agente sobre el número de comandos que han sido añadidos desde la última vez que SOAR fue ejecutado, y obtener cada comando uno a uno, con su nombre y los valores de sus parámetros. En este ejemplo, pCommand apuntaría a M1, el nombre sería mov y el valor del parámetro velocidad sería 20.

En caso de usar otro modelo diferente para representar las acciones del agente el soporte es menos directo, aunque también existe.

En primer lugar, nótese que GetCommand() devuelve un elemento de memoria de trabajo (WME) estándar de tipo Identifier, por lo que puede ser manipulado de la misma forma que cualquier otro WME. En particular, Identifiers ofrece los métodos GetNumberChildren y GetChild, de forma que usándolos se puede empezar por el output-link y examinar todo lo que haya en memoria a partir de él. Existen otro métodos, como FindByAttribute, que pueden hacer esta búsqueda más eficiente. En segundo lugar, se pueden usar los métodos IsJustAdded() y AreChildrenModified() en aquellos WMEs que “cuelgan” del output-link para determinar qué ha cambiado desde la última vez que se ejecutó SOAR. También se puede llamar a AddOutputHandler() para registrar una función que sea llamada cada vez que un atributo concreto se añada al output-link. Por último, si esto no es suficiente, se puede llamar a GetNumberOutputLinkChanges() y GetOutputLinkChange() para obtener una lista de todos los WMEs que hayan sido añadidos o eliminados desde la última vez que se ejecutó SOAR.

A partir de este conjunto de métodos debería ser posible soportar casi cualquier modelo de salida que se quiera adoptar, aunque se recomienda usar el modelo visto anteriormente.

La línea de comandos
// Creamos un comando SOAR de ejemplo
std::string cmd = "excise --all" ;
 
// Ejecuta el comando
char const* pResult = pKernel->ExecuteCommandLine(cmd.c_str(),pAgent->GetAgentName()) ;

Hasta este punto, se ha discutido únicamente sobre entornos y soporte de entrada-salida. Sin embargo, el método ExecuteCommandLine permite a un cliente enviar cualquier comando a SOAR que pueda ser escrito en la ventana de comandos de SOAR. Usando este método, las reglas pueden ser cargadas, suprimidas, imprimidas por pantalla, etc.

También se dispone del método ExecuteCommandLineXML(), que devuelve la salida como un mensaje XML estructurado, haciendo que sea más fácil y más seguro para un cliente parsear los valores que vengan desde la salida. Los detalles sobre el formato de esta salida XML para cada comando se pueden ver en la documentación.

Capturando la salida de impresión

Para capturar la salida de un agente (realizada por éste mediante el comando print) durante su ejecución es necesario registrarse para el evento smlEVENT_PRINT que será llamado periódicamente durante el transcurso de una ejecución. Para hacer esto, habrá que definir una función controladora (handler) que será llamada durante la ejecución. A continuación veremos un ejemplo simple:

void MyPrintEventHandler(smlPrintEventId id, void* pUserData, Agent* pAgent, char const* pMessage)
{
        // En este caso, los datos de usuario (user data) son una cadena que vamos construyendo
        std::string* pTrace = (std::string*)pUserData ;
 
        (*pTrace) += pMessage ;
}

Este método incluye un trozo de “userData” que es definido por ti cuando te registras para el evento. En este caso tendríamos:

std::string trace ;
int callbackp = pAgent->RegisterForPrintEvent(smlEVENT_PRINT, MyPrintEventHandler, &trace) ;

Nótese como la cadena “trace” se le pasa a la función de registro. Después, este objeto se le pasa a la función controladora, que lo usa para ir construyendo una traza completa. Tras haber registrado esta función controladora, llamar a:

result = pAgent->Run(4) ;

ejecutaría SOAR durante cuatro ciclos de decisión y la traza de su salida sería recogida en la cadena “trace”.

También existe otra forma de obtener esta salida, registrándose para smlEVENT_XML_TRACE_OUTPUT. Este evento envía objetos XML en lugar de cadenas de texto. Mostrar estos objetos requiere de más trabajo por parte del cliente, pero si el cliente desea parsear el texto lo puede hacer de forma mucho más simple gracias a XML. Éste es el enfoque que ha sido tomado en el Java debugger.

Eventos

Existen muchos eventos para los que poder registrarse, y a continuación veremos una lista de ellos, que seguramente irá creciendo con el tiempo. Aparecen los prototipos de los handlers en C++ y Java (los de Java son un poco distintos a los de C++, y son más propensos a errores, ya que son comprobados en tiempo de ejecución en lugar de compilación).

Funciones controladoras de eventos en C++ (para convertir los prototipos a funciones se puede consultar el ejemplo de la controladora de impresión de la sección anterior):

// Handler para eventos de ejecución
// Se le pasa el ID del evento, el agente y la fase junto con cualquier dato de usuario que hayamos registrado con el cliente
typedef void (*RunEventHandler)(smlRunEventId id, void* pUserData, Agent* pAgent, smlPhase phase);
 
// Handler para eventos de agente (como creación, destrucción, etc.).
typedef void (*AgentEventHandler)(smlAgentEventId id, void* pUserData, Agent* pAgent) ;
 
// Handler para eventos de impresión (print)
typedef void (*PrintEventHandler)(smlPrintEventId id, void* pUserData, Agent* pAgent, char const* pMessage) ;
 
// Handler para eventos del Production manager
typedef void (*ProductionEventHandler)(smlProductionEventId id, void* pUserData, Agent* pAgent, char const* pProdName, char const* pInstantion) ;
 
// Handler para eventos del sistema
typedef void (*SystemEventHandler)(smlSystemEventId id, void* pUserData, Kernel* pKernel) ;
 
// Handler para eventos XML. Los datos para el evento se pasan en XML.
// NOTA: Para mantener una copia del ClientXML* que se le pasa, usa ClientXML* pMyXML = new ClientXML(pXML) para crear
// una copia del objeto. Esto es muy eficiente y sólo añade una referencia al objeto del mensaje XML subyacente.
// Es necesario eliminar los objetos ClientXML que crees y no deberías eliminar el objeto pXML que entra como parámetro.
typedef void (*XMLEventHandler)(smlXMLEventId id, void* pUserData, Agent* pAgent, ClientXML* pXML) ;
 
// Handler para disparos de funciones RHS (right hand side)
// pFunctionName y pArgument definen la función RHS que está siendo llamada (el cliente podría parsear pArgument para extraer otros valores).
// El valor de retorno es una cadena que permite a la función RHS crear un símbolo: p.ej. ^att (exec plus 2 2) produciendo ^att 4
typedef std::string (*RhsEventHandler)(smlRhsEventId id, void* pUserData, Agent* pAgent, char const* pFunctionName, char const* pArgument) ;

Los controladores de eventos en Java se basan en implementar una interfaz dentro de un objeto:

Del kernel:

  public interface SystemEventInterface {
     public void systemEventHandler(int eventID, Object data, Kernel kernel) ;
  }
 
  public interface UpdateEventInterface {  
        public void updateEventHandler(int eventID, Object data, Kernel kernel, int runFlags) ;
  }
 
  public interface StringEventInterface {  
        public void stringEventHandler(int eventID, Object userData, Kernel kernel, String callbackData) ;
  }
 
  public interface AgentEventInterface {  
                public void agentEventHandler(int eventID, Object data, String agentName) ;
  }
 
  public interface RhsFunctionInterface {  
                public String rhsFunctionHandler(int eventID, Object data, String agentName, String functionName, String argument) ;
  }
 
  public interface ClientMessageInterface {  
                public String clientMessageHandler(int eventID, Object data, String agentName, String functionName, String argument) ;

Del agente:

  public interface RunEventInterface {
        public void runEventHandler(int eventID, Object data, Agent agent, int phase) ;
  }
 
  public interface ProductionEventInterface {
     public void productionEventHandler(int eventID, Object data, Agent agent, String prodName, String instantiation) ;
  }
 
  public interface PrintEventInterface { 
                public void printEventHandler(int eventID, Object data, Agent agent, String message) ;
  }
 
  public interface xmlEventInterface {
                public void xmlEventHandler(int eventID, Object data, Agent agent, ClientXML xml) ;
  }
 
  public interface OutputEventInterface {  
                public void outputEventHandler(Object data, String agentName, String attributeName, WMElement pWmeAdded) ;
  }
 
  public interface OutputNotificationInterface {
                public void outputNotificationHandler(Object data, Agent agent) ;
  }

Ejemplos de implementaciones:

public void runEventHandler(int eventID, Object data, Agent agent, int phase)
{
        System.out.println("Recibido un evento de ejecución en Java") ;
}
 
// We pass back the agent's name because the Java Agent object may not yet
// exist for this agent yet.  The underlying C++ object *will* exist by the 
// time this method is called.  So instead we look up the Agent object
// from the kernel with GetAgent().
public void agentEventHandler(int eventID, Object data, String agentName)
{
        System.out.println("Received agent event in Java") ;
}
 
public void productionEventHandler(int eventID, Object data, Agent agent, String prodName, String instantiation)
{
        System.out.println("Received production event in Java") ;
}
 
public void systemEventHandler(int eventID, Object data, Kernel kernel)
{
        System.out.println("Received system event in Java") ;
}
 
public void printEventHandler(int eventID, Object data, Agent agent, String message)
{
        System.out.println("Received print event in Java: " + message) ;
}
 
public void xmlEventHandler(int eventID, Object data, Agent agent, ClientXML xml)
{
        String xmlText = xml.GenerateXMLString(true) ;
        System.out.println("Received xml trace event in Java: " + xmlText) ;
 
        String allChildren = "" ;
 
        if (xml.GetNumberChildren() > 0)
        {
                ClientXML child = new ClientXML() ;
                xml.GetChild(child, 0) ;
 
                String childText = child.GenerateXMLString(true) ;
                allChildren += childText ;
 
                child.delete() ;
        }
 
}
 
public String testRhsHandler(int eventID, Object data, String agentName, String functionName, String argument)
{
        System.out.println("Received rhs function event in Java for function: " + functionName + "(" + argument + ")") ;
        return "My rhs result " + argument ;
}

Para ver más sobre eventos se puede consultar el fichero de cabecera sml_ClientEvents.h o el programa de test TestSMLEvents (actualmente situado en la carpeta Tools).

Construyendo un entorno

En esta sección usaremos como referencia la implementación Java para el problema de las torres de Hanoi, por ser un entorno sencillo. Por tanto, los snippets de código estarán escritos en Java, aunque se podría tomar un enfoque similar para cualquier otro lenguaje (actualmente, Tcl o C++).

Inicialización

El primer paso consiste en crear una instancia del kernel de Soar, y después crear un agente. El nombre que se le pasa a CreateKernelInNewThread es el nombre de la librería a cargar (DLL en Windows). Esto es opcional en 8.6.2 y por defecto carga SoarKernelSML.

Es importante comprobar que no haya habido errores, usando el método kernel.HadError(). CreateKernel no devolverá un objeto Kernel vacío incluso cuando la inicialización haya fallado. Esto se ha diseñado a posta así para asegurar que todos los errores importantes puedan ser notificados al usuario.

A continuación veremos un ejemplo de código de inicialización:

 // creamos el kernel de SOAR en un nuevo hilo
 kernel = Kernel.CreateKernelInNewThread();
 
 if (kernel.HadError())
 {
     System.out.println("Error al crear el kernel: " + kernel.GetLastErrorDescription()) ;
     System.exit(1);
 }
 
 agent = kernel.CreateAgent(AGENT_NAME);
 boolean load = agent.LoadProductions("towers-of-hanoi-SML.soar");
 if (!load || agent.HadError()) {
      throw new IllegalStateException("Error al cargar las reglas: "
            + agent.GetLastErrorDescription());
 }
Entrada

El asignar valores de entrada (en el input-link) a los diferentes estados del entorno requiere de llamadas para crear, actualizar y eliminar elementos de memoria de trabajo, usando llamadas como las siguientes:

agent.Create<tipo>WME (por ejemplo, agent.CreateIntWME)
agent.Update()
agent.DestroyWME

Los elementos de memoria de trabajo se enlazan unos con otros formando un árbol (o un grafo) en el que el input-link es la raíz de dicho árbol. Para obtener el identificador del input-link, hay que llamar a agent.GetInputLink().

Un paso clave que hay que realizar tras haber hecho varias llamadas que modifiquen el input-link es llamar a Commit(). Todos los cambios que se hagan a la memoria de trabajo se van almacenando en el entorno hasta que se llame a Commit, que hace que sean enviados al kernel como un único mensaje. Esto mejora mucho el rendimiento, pero debes acordarte de llamar a Commit antes de ejecutar el agente o tus cambios no aparecerán en el input-link. En 8.6.2 ésto fue cambiado de forma que ahora Commit es llamada por defecto cada vez que un WME es modificado en el input-link. Para mejorar la eficiencia haciendo que la llamada a Commit sea manual, llama a SetAutoCommit(false). Hacer Commit múltiples veces en el mismo ciclo de entrada no causa ningún problema.

Salida

La forma más común de examinar la salida (output) de un agente es usar el modelo “Commands” (de comandos). Con este método, el agente sitúa cada comando de salida en su output-link usando el siguiente formato:

(X ^output-link I3)
(I3 ^nombre-del-comando C1)
(C1 ^parametro1 valor ^parametro2 valor)

De esta forma, el nombre de cada comando aparece directamente en el output-lunk, y todos los parámetros se añaden al identificador del comando y sólo pueden estar a un nivel de profundidad. Si el agente usa este formato para situar sus comandos de salida, entonces se pueden obtener fácilmente usando un código como el siguiente:

//Ejemplo de código para actualizar el entorno -- obtiene la salida, cambia el estado del entorno, luego manda comandos de entrada
 
private void actualizaMundo()
   // Mira si se han generado comandos en el output-link
   // (En general, podríamos querer también actualizar el mundo cuando el agente
   // no realizara ninguna acción, en cuyo caso sería necesario escribir algo de
   // código fuera del if, pero para este entorno ésto no es necesario)
        if (agent.Commands())
        {
                // realizamos el comando del output-link        
                Identifier command = agent.GetCommand(0);
                if (!command.GetCommandName().equals(MOVE_DISK)) {
                    throw new IllegalStateException("Comando desconocido "
                            + command.GetCommandName());
                }
                if (command.GetParameterValue(SOURCE_PEG) == null ||
                        command.GetParameterValue(DESTINATION_PEG) == null) {
                    throw new IllegalStateException("A los siguientes parámetros les faltan comandos"
                            + MOVE_DISK);
                }
                int srcPeg = command.GetParameterValue("source-peg").charAt(0) - 'A';
                int dstPeg = command.GetParameterValue("destination-peg").charAt(0) - 'A';
 
                // Cambia el estado del mundo y genera nuevos datos de entrada
                moveDisk(srcPeg, dstPeg);
 
                // Le indicamos al agente que este comando ha sido ejecutado en el entorno
                command.AddStatusComplete();
 
                // Le mandamos los nuevos cambios en el input-link al agente
                agent.Commit();
 
                // "agent.GetCommand(n)" se basa en mirar si ha habido cambios en el output-link,
                // así que antes de ejecutar el agente eliminaremos los últimos cambios realizados
                // (siempre podrías leer el output-link directamente y no usar el modelo
                // "commands" para determinar que ha cambiado entre dos ejecuciones consecutivas)
                agent.ClearOutputLinkChanges() ;
 
                if (isAtGoalState())
                    fireAtGoalState();
        }
}

Los métodos GetCommandName y GetParameterValue extraen los atributos y valores apropiados del output-link y los devuelven al entorno.

El método AddStatusComplete añade (C1 ^status complete) a la estructura del comando, lo que le indica al agente que dicho comando ha sido ejecutado y ya puede ser eliminado por el agente. Nótese que ésta es una forma simple de enviar datos que en este caso se le manda al output-link en vez de al input-link. Esto es más sencillo que pasarle una estructura más grande al input-link y hacer que el agente tenga que emparejarla con el output-link. Esta estructura se añade durante el siguiente ciclo de entrada del agente, como sucede con cualquier entrada que se le pasa al agente.

Para que este método de I/O por comandos funcione, el sistema tiene que hacer un seguimiento de los cambios que se realicen en memoria de trabajo. Para que este proceso funcione, el entorno debe llamar a ClearOutputLinkChanges() antes de ejecutar el agente de nuevo.

Aunque el modelo de comandos visto hasta ahora debería ser suficiente para casi todos los entornos, si se necesita procesar otro tipo de estructuras, se puede elegir ignorar este modelo parcial o completamente. El método GetCommand devuelve un objeto de tipo Identifier (identificador). Se pueden usar los métodos GetNumberChildren y GetChild para ir recorriendo este objeto Identifier y colocar cualquier WME donde se desee, o se puede usar el método FindByAttribute para obtener los valores de los WME que tengan un atributo en concreto. De hecho, un entorno podría abandonar el modelo de comandos que hemos visto por completo, y simplemente llamar a GetOutputLink() para obtener el objeto Identifier en la raíz del output-link, tras lo cual podría examinar el árbol como quisiera a partir de ahí.

Ejecutando el agente

Existen dos formas principales de ejecutar un agente:

  • RunSelfForever() y su gemela RunAllAgentsForever()
  • RunSelf(1) and RunAllAgents(1) (para ejecutar el entorno paso a paso)

Es importante notar que covendría que el código para ejecutar los agentes esté separado del código que actualiza el mundo (es decir, el que recolecta los comandos de salida, actualiza el estado del mundo y envía datos de entrada al agente). Al separar estos dos códigos podemos tratar la ejecución tanto desde el entorno como desde un depurador (u otro cliente), y todo funcionará correctamente.

Consideremos que tenemos el método actualizaMundo() que vimos anteriormente, que debería ser de la forma:

void actualizaMundo()
{
        comprueba-output();
        actualiza-estado-mundo();
        envía-nuevo-input();
}

Entonces, el siguiente código es un ejemplo de cómo conectar ambos sistemas de forma que actualizaMundo() sólo sea llamada una vez que todos los agentes hayan terminado su fase de salida (es decir, al final de un ciclo de decisión).

    public void run()
    {
        m_StopNow = false ;
 
        // Comienza una ejecución
        // (como en este caso sólo hay un agente, se podría usar agent.RunSelfForever() en su lugar)
        kernel.RunAllAgentsForever() ;
    }
 
    public void step()
    {
        // Ejecuta una decisión (paso)
        kernel.RunAllAgents(1) ;
    }
 
    public void stop()
    {
        // Nos gustaría poder llamar a StopSoar() directamente desde aquí, pero estamos en
        // un hilo diferente y ahora mismo lo que hace es esperar a que termine la llamada
        // a runForever antes de que se ejecute, lo cual no es el comportamiento adecuado.
        // Así que usaremos una variable de bandera (flag), y trataremos StopSoar() en un callback
        m_StopNow = true ;
    }

Método basado en eventos para actualizar el mundo:

public void registerForUpdateWorldEvent()
{
   int updateCallback = kernel.RegisterForUpdateEvent(sml.smlUpdateEventId.smlEVENT_AFTER_ALL_OUTPUT_PHASES, this, "updateEventHandler", null) ;
}
 
public void updateEventHandler(int eventID, Object data, Kernel kernel, int runFlags)
{
        // En un entorno más completo, podría no llamarse a actualizaMundo dependiendo de runFlags
        actualizaMundo() ;
 
        // En este momento tenemos un problema al llamar a Stop() desde hilos cualesquiera
        // así que por ahora nos aseguraremos de llamarla dentro de un callback de un evento
        // Realiza esta comprobación tras llamar a acualizaMundo() para que pueda fijar m_StopNow
        // si así lo desea, disparando una detención inmediata del agente.
        if (m_StopNow)
        {
        m_StopNow = false ;             
        kernel.StopAllAgents() ;
        }
}

El código de ejecución es muy simple. Los únicos aspectos a tener en cuenta son:

  • RunAllAgentsForever() es actualmente (8.6.2) una llamada bloqueante, por lo que generalmente crearemos un nuevo hilo y trataremos esta llamada en él.
  • StopAllAgents() no puede ser actualmente llamado desde un hilo cualquiera (o se bloquea esperando a que termine la ejecución). Esto puede que sea arreglado en una versión posterior, pero por ahora llamándolo desde un callback soluciona el problema.

La forma en que se llama a actualizaMundo() es después de que el evento smlEVENT_AFTER_ALL_OUTPUT_PHASES se dispare. ¿Por qué molestarse en hacerlo de este modo en lugar de escribir la ejecución como:

while (!detenido)
{
    run(1) ;
    actualizaMundo() ;
}

Existen dos razones principales. En primer lugar, no existen garantías de que run(1) vaya a ejecutarse durante un ciclo de decisión. Si incluimos breakpoints en las reglas o en las transiciones entre fases, esta llamada podría durar menos tiempo, lo que posiblemente confundiría al entorno (ya que los agentes no habrán avanzado lo que se esperaba). En segundo lugar, al basar la llamada a actualizaMundo() en eventos, podemos tratar comandos run arbitrarios realizados desde un depurador, y el entorno seguirá funcionando correctamente.

Proyectos simples con SML

A continuación veremos una serie de proyectos simples para empezar a abordar la integración de SOAR con ROS, haciendo uso de un wrapper en C++, y usando los métodos disponibles de ClientSML para comunicar el mundo con agentes SOAR.

Compilación del programa

Para compilar nuestro wrapper en Ubuntu, haremos uso de los sistemas de generación de código cmake y make. Por tanto, habremos de definir previamente un fichero CMakeLists.txt en nuestro directorio de trabajo, cuyo contenido veremos a continuación. Tendremos que haber instalado ROS previamente en nuestro sistema siguiendo las instrucciones disponibles en su sitio web. También incluiremos todos los archivos de cabecera (.h) que aparecen en la descarga de SOAR y las copiaremos a la carpeta include de nuestro proyecto. Además deberemos incluir las librerías necesarias para trabajar con SML, que copiaremos en la carpeta lib. Dichas librerías son (disponibles con la descarga estándar de SOAR):

  • libClientSML.a
  • libConnectionSML.a
  • libSoarKernel.a
  • libElementXML.so
  • libSoarKernelSML.so

En el archivo CMakeLists.txt copiaremos lo siguiente, para que el ejecutable pueda ser generado correctamente:

cmake_minimum_required(VERSION 2.4.6)
include($ENV{ROS_ROOT}/core/rosbuild/rosbuild.cmake)

# Set the build type.  Options are:
#  Coverage       : w/ debug symbols, w/o optimization, w/ code-coverage
#  Debug          : w/ debug symbols, w/o optimization
#  Release        : w/o debug symbols, w/ optimization
#  RelWithDebInfo : w/ debug symbols, w/ optimization
#  MinSizeRel     : w/o debug symbols, w/ optimization, stripped binaries
#set(ROS_BUILD_TYPE RelWithDebInfo)

rosbuild_init()

#set the default path for built executables to the "bin" directory
set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin)
#set the default path for built libraries to the "lib" directory
set(LIBRARY_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/lib)

#uncomment if you have defined messages
#rosbuild_genmsg()
#uncomment if you have defined services
#rosbuild_gensrv()

#common commands for building c++ executables and libraries
#rosbuild_add_library(${PROJECT_NAME} src/example.cpp)
#target_link_libraries(${PROJECT_NAME} another_library)
#rosbuild_add_boost_directories()
#rosbuild_link_boost(${PROJECT_NAME} thread)
#rosbuild_add_executable(example examples/example.cpp)
#target_link_libraries(example ${PROJECT_NAME})

link_directories(${PROJECT_SOURCE_DIR}/lib)
rosbuild_add_executable(basic_soar_test example/basic_soar_test.cpp)
target_link_libraries(basic_soar_test ClientSML ConnectionSML SoarKernel ElementXML)

Ahora (una vez hayamos creado nuestro código en C++ en example/basic_soar_test.cpp) no habrá más que situarse en el directorio de trabajo que se haya elegido y ejecutar los comandos:

cmake .
make

para obtener un ejecutable de nuestro código en la carpeta bin del directorio de trabajo.

Leyendo comandos de salida del agente: Hola mundo

Vamos a usar lo aprendido de SML junto con un programa básico en SOAR que proponga y dispare un único operador. Dicho operador se encargará de crear un comando en el output-link del agente llamado simplemente ^comando. Este comando, a su vez, tendrá un único atributo llamado ^mensaje, que tendrá como valor algo que nos indique qué escribir por pantalla desde nuestro entorno, en este caso, “Hola Mundo!” (por tanto, estamos usando el formato de comandos soportado directamente por ClientSML). Las reglas SOAR que se encargan de proponer y aplicar el operador las escribiremos en un fichero llamado testsml.soar que situaremos en la carpeta example de nuestro directorio de trabajo:

sp {propose*hola-mundo
   (state <s> ^type state)
-->
   (<s> ^operator <o> +)
   (<o> ^name hola-mundo)}

sp {apply*hola-mundo
   (state <s> ^operator <o>
              ^io.output-link <out>)
   (<o> ^name hola-mundo)
-->
   (<out> ^comando <cmd>)
   (<cmd> ^mensaje hola-mundo)}

A continuación escribiremos un código en C++ muy similar al visto en la guía rápida de SML, que se encargará de inicializar el agente, cargar las reglas, ejecutar el agente hasta que genere algún comando de salida, analizar dicho comando y eliminar el agente. Dicho código lo escribiremos en el fichero basic_soar_test.cpp de la carpeta example de nuestro proyecto:

#include "sml_Client.h"
#include <iostream>
#include <string.h>
#include <ros/ros.h>
#include <ros/package.h>
 
using namespace std;
using namespace sml;
 
//Con esta función se maneja el evento de que una regla se haya disparado. Se muestra sólo como otro ejemplo más de handler.
void onProduction(smlProductionEventId id, void* pUserData, Agent* pAgent, char const* pProdName, char const* pInstantion){
	cout << "Se ha disparado la producción: " << pProdName << endl;
}
 
 
int main() {
 
	// Crea una instancia del kernel de Soar en nuestro proceso
	Kernel* pKernel = Kernel::CreateKernelInNewThread();
 
	// Comprueba que nada haya ido mal. Siempre devolveremos un objeto de tipo kernel
	// incluso si ha habido errores y tenemos que abortar
	if (pKernel->HadError()) {
		cout << pKernel->GetLastErrorDescription() << endl;
		return -1;
	}
 
	// Crea un nuevo agente Soar llamado "test"
	sml::Agent* pAgent = pKernel->CreateAgent("test");
 
	// Comprueba que nada haya ido mal
	if (pKernel->HadError()) {
		cout << pKernel->GetLastErrorDescription() << endl;
		return -1;
	}
 
	// Carga las reglas que definimos en testsml.soar
	std::string path=ros::package::getPath(ROS_PACKAGE_NAME)+ std::string("/example/testsml.soar");
	pAgent->LoadProductions(path.c_str());
 
	if (pAgent->HadError()) {
		cout << pAgent->GetLastErrorDescription() << endl;
		return -1;
	}
 
        // Registramos el evento smlEVENT_AFTER_PRODUCTION_FIRED con nuestra función onProduction
	int callbackp = pAgent->RegisterForProductionEvent(smlEVENT_AFTER_PRODUCTION_FIRED, onProduction, NULL) ;
 
         // Ejecuta SOAR hasta que genere alguna salida o hayan pasado 15 ciclos de decisión
	 pAgent->RunSelfTilOutput();
 
	cout << "El agente ha generado comandos de salida" << endl;
 
	// Recorre todos los comandos que hemos recibido
	pAgent->Commands();
	int numberCommands = pAgent->GetNumberCommands();
 
 
	cout << "Numero de comandos recibidos del agente: " << numberCommands << endl;
 
	for (int i = 0; i < numberCommands; i++) {
 
		Identifier * pCommand = pAgent->GetCommand(i);
 
		std::string name = pCommand->GetCommandName();
 
                // Obtenemos el valor del comando ^mensaje
		std::string mensaje = pCommand->GetParameterValue("mensaje");
 
                // Si el valor de ^mensaje es hola-mundo, imprimimos "Hola Mundo!" por pantalla
		if(mensaje=="hola-mundo"){
			cout << "Hola Mundo!" << endl;
		}
 
		// Marcamos el comando como completado
		pCommand->AddStatusComplete();
 
	}
 
	// Mira si alguien (p.ej. un depurador) ha enviado comandos a SOAR
	pKernel->CheckForIncomingCommands();
 
	// Apagado y limpieza
	pKernel->Shutdown(); // Elimina todos los agentes
	delete pKernel; // Elimina el kernel
 
	return 0;
 
} // fin main

Si ahora compilamos nuestro proyecto como vimos anteriormente y ejecutamos el ejecutable obtenido, obtenemos efectivamente el mensaje “Hola Mundo!” por pantalla:

Se ha disparado la producción: propose*hola-mundo
Se ha disparado la producción: apply*hola-mundo
El agente ha generado comandos de salida
Numero de comandos recibidos del agente: 1
Hola Mundo!

Una vez que sabemos como leer los datos de salida del agente, tendremos que hacer que lea los datos de entrada que nos interese desde nuestro entorno.

integracion_de_soar_con_ros_tomas.txt · Última modificación: 2011/05/23 21:18 (editor externo)