Headline:Implementing Expert Systems in Clay
Date:Monday, January 13, 2020
Posted By:Plaid Hatter Games

So I'm calling this effort an Expert System because I want to distinguish the process from Bayesian Programming or Artificial Neural Networks. All three are types of machine intelligence. In an Expert System the rules are baked in, but they are modular like lego. The computer can get pretty dang creative in applying them. But we aren't inventing new rules during the course of the computation.

Back in the 1980s, expert systems used to be mainly LISP based. LISP was flexible enough that it didn't usually get in the road of expressing a rule. But eventually LISP fell out of favor because the machines that could run LISP back in the 1980s were extremely expensive.

In the 90s companies were doing a lot of high performance computing applications. The C programming language became the de-facto environment of choice for these applications. I won't call C "simple", but it has few formal rules. A novice programmer in C can produce applications almost immediately, with only a passing understanding of the architecture he or she is actually targeting. The compiler does a lot of the hard work of translating human ideas to machine code. Because C is compiled, it's often desirable to make them read some sort of configuration file for guidance rather than recompile the program for each instance of an application.

This "configuration file" would eventually morph into several programming languages that we know today: Perl, Python, Lua, and (or course) Tcl.

Tcl was written by John Ousterhout, a professor at Stanford who was looking for something simple enough to use as a teaching tool, but powerful enough to use in industry. There are only 12 rules for the Tcl programming language itself. The closest thing it has to "types" is the concept that "everything is a string." And despite (or because) of this, Tcl manages to drive everything from networking to spacecraft to television broadcasting.

This concept of as few rules as possible, and no fewer, continued when Object-oriented programming. was added to the Tcl core. There are actually so few rules for TclOO that most developers find they need to add a few of their own. I do that through a library I call clay.

In addition to Composition, inheritance, and delegation, TclOO supports objects that can actually swap class and objects that can take on additional behaviors via Mixins. This "less is more" set of language rules, and the ability to modify the language itself within the interpreter, and relatively high performance allows Tcl to tackle problems that used to be in the LISP domain. And do so inside of a language that essentially ships with every Unix made. And has an event model that is handy for GUIs, and sockets for communication, and is lined up to handle text as the token of the kingdom instead of strong types.

Object that can incorporate new behaviors on demand permit the implementation of Traits. I can go off and write a block of code, store it in a library, and an object can say "If I'm configured with this option, mix in that block of code." And because of the way multiple inheritance is resolved, I don't even have to maintain a swim lane for those behaviors. They can be messy, and in conflict, and while I can be satisfied the rules for how those conflicts resolve HAVE an answer, to the untrained eye those answers may be as good as random.

clay::define agent {
  # With no rules, assume someone will start a conversation 50% of the time
  method Rule_Conversation_Will_Initiate {person} {
    return .5
  }
}

clay::define trait.neophile {
  method Rule_Conversation_Will_Initiate {person} {
    if {[my Rule_Person_Is_Familiar $person]} {
      return .9
    }
    return .2
  }
}

clay::define trait.extrovert {
  # No special rules
}

clay::define trait.introvert {
  method Rule_Conversation_Will_Initiate {person} {
    if {![my Rule_Person_Is_Familiar $person]} {
      return 0.1
    }
    return [next]
  }
}

In this snippet, assume that Rule_Conversation_Will_Initiate is a function that returns some kind of score between 0 and 1. One is "will do this every time." Zero is "will never do this." We could have a field in a database, or even just a list of traits, and develop a system that converts that information into instructions on which traits to mix into an agent to implement a character.

set object [agent new]
set blob [object_info $character]
set traits {}
foreach item [dict get $blob traits] {
  if {[info command trait.$item] ne {}} {
    # If we have a class that matches this trait, rig for it to be mixed in
    lappend traits trait.$item
  }
}
$object mixin {*}$traits

If multiple traits express the same method, the last one mixed in wins.

The next element is providing a means for the computer to be able to tell what rules exist, and how to handle cases where no rule exists. And for that we fall back on the infrastructure provided by Clay.

What those patterns are will emerge over time as I try to sit down and actually hammer out rules. But the nice part of this all is that I don't have to bake those sorts of things in at the outset of the effort.

# Assume we have gotten here and we need to know, will he or she decide to talk to another
# person
if {[$person_a rule conversation_will_initiate $person_2] > rand()} {
  $person_1 interact conversation_start object $person_2
}

Yes, I could in this case simply have one method that read out the object's configuration and branched accordingly:

clay::define agent {
  method Rule_Conversation_Will_Initiate {person} {
    set traits [my config get traits]
    if {"extrovert" in $traits} {
      return 0.5
    }
    if {[my Rule_Person_Is_Familiar $person]} {
      if {"neophile" in $traits} {
        return .9
      }
      if {"introvert" in $traits} {
        return .1
      }
    } else {
      if {"neophile" in $traits} {
        return .2
      }
      if {"introvert" in $traits} {
        return .5
      }
    }
    # No rules matched
    return .5
  }
}

But from a code maintenance standpoint, this means that every time I do surgery, I have to double read and re-read all of the instances where that option is used. And they are used in a lot of places here. Extrovert is simple, but introvert and neophile are are implemented twice in two different branches of logic. Ok, we could go for:

clay::define agent {
  method Rule_Conversation_Will_Initiate {person} {
    set traits [my config get traits]
    if {"extrovert" in $traits} {
      return 0.5
    }
    if {"neophile" in $traits} {
      if {[my Rule_Person_Is_Familiar $person]} {
        return .9
      }
      return 0.2
    }
    if {"introvert" in $traits} {
      if {[my Rule_Person_Is_Familiar $person]} {
        return .1
      }
      return .5
    }
    # No rules matched
    return .5
  }
}

And in this simple case it's not too bad. But some traits affect how people behave in multiple areas. If you want to add a new area, do you really want to have one giant mess of a class to maintain, or several smaller classes? Ok, that's probably more a taste and style question rather than an actual technical merit. So, I guess I'll just state that I like the mixin style, and it's based on having written these sorts of environments in the past.

Another nice thing to do is instead of passing arguments as a list and assuming the other party remembers the order, it's nice to pass arguments as dictionaries.

clay::define agent {

  Method Rule_Person_Is_Familiar dictargs {
    person {type: agent description: {Agent presumably nearby}}
  } {
    if {[my <knowledge> exists person $person]} {
      return 1
    }
    return 0
  }

  # With no rules, assume someone will start a conversation 50% of the time
  Method Rule_Conversation_Will_Initiate dictargs {
    person {type: agent description: {Agent presumably nearby}}
  } {
    return .5
  }
}

clay::define trait.neophile {
  Method Rule_Conversation_Will_Initiate dictargs {
    person {type: agent description: {Agent presumably nearby}}
  } {
    if {[my Rule_Person_Is_Familiar {*}$args]} {
      return .9
    }
    return .2
  }
}

clay::define trait.extrovert {

}

clay::define trait.introvert {
  Method Rule_Conversation_Will_Initiate dictargs {
    person {type: agent description: {Agent presumably nearby}}
  } {
    if {![my Rule_Person_Is_Familiar {*}$args]} {
      return 0.1
    }
    return [next {*}$args]
  }
}

While I've had some grumblings from other programmers about this pattern, it allows arguments to be placed out of order. It also allows the entire argument stream to be shipped to a subroutine, even if one of the arguments isn't used in the master routine.

But having a framework of tools we can also implement strange superpowers in our magic system. Let's say someone has a perk that allows them to always seem familiar to strangers:

clay::define agent {

  Method Rule_Person_Is_Familiar dictargs {
    person {type: agent description: {Agent presumably nearby}}
  } {
    if {[my <knowledge> exists person $person]} {
      return 1
    }
    if {[$person perk charming]>1} {
      # Some charming people can just "look familiar"
      return 1
    }
    return 0
  }
}

That code can go anywhere in the library. Note that the way the other rules are written it won't change anything on Extroverts, will make Introverts more likely to talk to that person, and neophiles actually LESS likely to talk to that person.

We can also go so far as to make shades of this rule. Let's say there are grades of familiar from 0.0 (total stranger) to 1.0 (know everything about that person in a social sense).

clay::define agent {
  Method Rule_Person_Is_Familiar dictargs {
    person {type: agent description: {Agent presumably nearby}}
  } {
    if {[my <knowledge> exists person $person name]} {
      # If I know their name I must know a lot about them
      return 1
    }
    if {[$person perk charming]>2} {
      # High level of charming rank higher than simple familiarness
      return 0.75
    }
    if {[my <knowledge> exists person $person]} {
      # I don't know their name, but I know something about them
      return 0.25
    }
    if {[$person perk charming]>1} {
      # Some charming people can just "look familiar"
      return 0.25
    }
    return 0
  }
}

clay::define trait.neophile {
  Method Rule_Conversation_Will_Initiate dictargs {
    person {type: agent description: {Agent presumably nearby}}
  } {
    return [expr {1.0-[my Rule_Person_Is_Familiar {*}$args]}]
  }
}
clay::define trait.introvert {
  Method Rule_Conversation_Will_Initiate dictargs {
    person {type: agent description: {Agent presumably nearby}}
  } {
    return [my Rule_Person_Is_Familiar {*}$args]
  }
}

And viola, an introvert will now be as likely to strike of a conversation as they are familiar with a person. And Neophiles will to the opposite. The more the know a person the less likely they are to strike up a conversation.

We can also have modifiers for if someone is "on the prowl."

clay::define condition.horny {
  Method Rule_Conversation_Will_Initiate dictargs {
    person {type: agent description: {Agent presumably nearby}}
  } {
    if {[my Rule_Person_Is_Attractive {*}$args]} {
      return 0.80
    }
    # Fall back on normal rules
    return [next {*}$args]
  }
}

I'm off to try to start coding, but the main takeaway is that we want our rules to be pluggable like building blocks, and to mix and match gracefully. At some point I may need to have a special way of tagging rules differently than a normal method if it turns out we need to introspect across rules. But I'm also leary of imposing too many rules at the beginning. I'm still trying to understand the problem.