Experiment with rule-based reasoning

Arjen Markus (13 march 2020) The goal of the program below is to see how you could use simple rules as a basis for reasoning about some phenomenon. The very simple example here concerns questions about the suitability of some habitat for two species, rabbits and foxes.

Note: The code is meant to be useable for other types of questions, but I have not tried that. But it is a the reason you can define questions yourself and that the command names are neutral.

The idea here is: an environment should facilitate the living conditions for the species in question and for these species there should be food. In the example: both rabbits and foxes dig holes, so the soil should be loose enough to allow them to dig holes. They both live predominantly in temperate climates, hence the condition that the summer temperature should belong to such climates (to keep the example small, I do not bother about the winter temperature).

Both species need to eat: rabbits are assumed to be content when there is enough green stuff around, but foxes eat rabbits. So, for foxes to live somewhere, there must be rabbits.

The questions and conditions are set up via the following code:

# habitat_rules.tcl --
#     Sample collection of rules:
#     - The requirements for rabbits to live in a particular environment
#     - Similarly for foxes
#
source habitat.tcl

#
# Describe items
#
question summer-temperature "What is the typical summer temperature? (degrees C)"

#
# Define soil types: both species want to dig holes
#
# And herbs - it may be a logical parameter, but something needs to be done ...
#
choices soil "Soil types" {rock clay sand}

choices herbs "Are there many herbs?" {yes no} ;# Should be automatic

required soil rabbit sand
required soil fox sand
required food fox rabbit
required food rabbit herbs
required summer-temperature rabbit between 15 28
required summer-temperature fox between 15 28

With this base of rules set up, you can ask:

suitable rabbit

or:

# This one should work, but we need to answer questions about "rabbit" along the way
suitable fox

The fine print

Hidden in the code that helps to define and store the rules and to "reason" about the species, are a few details that I would like to explain now.

First of all, there are two types of conditions (even though the optional conditions are not implemented within the reasoning part yet):

  • Some conditions are required, which means that all such conditions must be met for the target species to be present.
  • Some conditions are optional, which is interpreted as meaning that at least one of them must be met. If it would not be necessary to meet any of them, then of course, they would have no consequence for the target species, so why define them in the first place?

The reasoning is purely logical: a condition is met or not, no nuances. That is a severe restriction. A kind of fuzzy reasoning might be more realistic, but I have not considered the consequences.

A condition, whether required or optional, may relate to a continuous variable, like the summer temperature, or to some choice. A particularly subtle one during the implementation was a logical condition - are there enough herbs? I wanted to formulate the condition as:

required food rabbit herbs

instead of something like:

required food rabbit herbs yes

The latler feels awkward. So that is handled via a "hack": if the choices are "yes" and "no", then the condition is considered to be logical - the choice "yes" is implicit.

And then, of course, a condition may be specific for a species (food) or it may be general (soil). This is handled by the slightly complicated in the procedures suitable and Ask.

Oh, and because a species may eat other species, the procedure suitable is used recursively.

The reasoning engine

The code for the actual reasoning is this:

# habitat.tcl --
#     Attempt to use straightforward knowledge rules to reason about the suitability
#     of environmental and other conditions for organisms
#
#     Note:
#     While I use the terminology of ecology, the methods are actually much more
#     general.
#

namespace eval ::habitat {
    variable choices      [dict create]
    variable requirements [dict create]
    variable options      [dict create]
}

# choices --
#     Record the possible choices for a feature
#
# Arguments:
#     feature          Name of the feature
#     question         String to display when asking for its value
#     choices          List of choices
#
# Result:
#     Choices as given are stored
#
proc choices {feature question choices} {
    dict set ::habitat::choices $feature question $question
    dict set ::habitat::choices $feature choices $choices
}

# question --
#     Record the question to ask for a feature
#
# Arguments:
#     feature          Name of the feature
#     question         String to display when asking for its value
#
# Result:
#     The question as given is stored
#
proc question {feature question} {
    dict set ::habitat::choices $feature question $question
}

# required --
#     Record a requirement regarding some feature for a species
#
# Arguments:
#     feature          Name of the feature in question
#     species          Name of the species
#     args             Details of the requirement
#
# Result:
#     The requirement and its parameters are stored
#
proc required {feature species args} {
    if { [llength $args] == 1 } {
        dict set ::habitat::requirements $species $feature value [lindex $args 0]
    } elseif { [lindex $args 0] eq "between" } {
        dict set ::habitat::requirements $species $feature range [lrange $args 1 2]
    } elseif { [lindex $args 0] eq "lower" } {
        dict set ::habitat::requirements $species $feature max [lrange $args 1]
    } elseif { [lindex $args 0] eq "greater" } {
        dict set ::habitat::requirements $species $feature min [lrange $args 1]
    }
}

# optional --
#     Record an optional condition regarding some feature for a species
#
# Arguments:
#     feature          Name of the feature in question
#     species          Name of the species
#     args             Details of the condition
#
# Result:
#     The condition and its parameters are stored
#
proc optional {feature species args} {
    if { [llength $args] == 1 } {
        dict set ::habitat::options $species $feature value [lindex $args 0]
    } elseif { [lindex $args 0] eq "between" } {
        dict set ::habitat::options $species $feature range [lrange $args 1 2]
    } elseif { [lindex $args 0] eq "lower" } {
        dict set ::habitat::options $species $feature max [lrange $args 1]
    } elseif { [lindex $args 0] eq "greater" } {
        dict set ::habitat::options $species $feature min [lrange $args 1]
    }
}

# Ask --
#     Ask for the value of a feature
#
# Arguments:
#     feature          Name of the feature
#     value            Registered value, may be necessary to get the right question
#
# Result:
#     Either a straightforward question or a list of choices is presented,
#     the value is recorded
#
proc ::habitat::Ask {feature value} {

    if { [dict exists $::habitat::values $feature] || [dict exists $::habitat::values $value] } {
        return
    }

    #puts $::habitat::choices
    #puts "$feature -- $value"
    #
    #puts "[expr { [dict exists $::habitat::choices $feature choices]}] -- [expr { [dict exists $::habitat::choices $value choices] }]"

    if { [dict exists $::habitat::choices $feature choices] || [dict exists $::habitat::choices $value choices] } {
        puts "Get question"
        if { [dict exists $::habitat::choices $feature choices] } {
            set question [dict get $::habitat::choices $feature question]
            set choices  [dict get $::habitat::choices $feature choices]
        } else {
            set question [dict get $::habitat::choices $value question]
            set choices  [dict get $::habitat::choices $value choices]
        }

        while {1} {
            puts "$question:"
            set n 0
            foreach c $choices {
                incr n
                puts "$n. $c"
            }

            puts -nonewline "Enter the number: "
            flush stdout
            gets stdin in

            if { $in <= 0 || $in > $n } {
                puts "Value out of range - please try again ..."
            } else {
                incr in -1
                dict set ::habitat::values $feature [lindex $choices $in]
                break
            }
        }
    } else {
        set question [dict get $::habitat::choices $value question]

        puts -nonewline "$question "
        flush stdout
        gets stdin value
        dict set ::habitat::values $feature $value
    }
}

# IsSpecies --
#     Is the name a "species"?
#
# Arguments:
#     species          Name of the species
#
# Result:
#     1 if the name appears to be a species and 0 if not
#
proc ::habitat::IsSpecies {species} {
    expr { [dict exists $::habitat::requirements $species] || [dict exists $::habitat::options $species] }
}

# GetRequiredFeatures --
#     Returned a list of required features
#
# Arguments:
#     species          Name of the species
#
# Result:
#     List of registered features
#
proc ::habitat::GetRequiredFeatures {species} {
    set features {}
    foreach {feature storedValues} [dict get $::habitat::requirements $species] {
        lappend features $feature
    }

    return $features
}

# suitable --
#     Determine if the habitat is suitable for a species
#
# Arguments:
#     species          Name of the species
#     recurse          (Optional) On the first call in the chain, clear the feature values
#
# Result:
#     Whether the habitat is suitable (1) or not (0)
#
proc suitable {species {recurse 0} } {
    if { ! $recurse } {
        set ::habitat::values [dict create]
    }

    puts "Suitability for \"$species\":"
    if { ! [::habitat::IsSpecies $species] } {
        puts "$species is not a species - at least: there are no requirements or optional conditions for it"
        return
    }

    #
    # First do the requirements: they must all be fulfilled
    #
    set suitable 1

    foreach feature [::habitat::GetRequiredFeatures $species] {
        set property [dict get $::habitat::requirements $species $feature]
        #puts "$feature... $property"

        if { [lindex $property 0] eq "value" } {
            set value [lindex $property 1]
            if { [::habitat::IsSpecies $value] } {
                puts "Requirement $feature: $value ..."
                if { ! [suitable $value 1] } {
                    puts "   - requirement fails"
                    set suitable 0
                }
            } else {
                ::habitat::Ask $feature [dict get $::habitat::requirements $species $feature value]
                set value [dict get $::habitat::values $feature]

                # Hack: get logical values in
                if { $value != [lindex $property end] && $value != "yes" } {
                    puts "   - requirement fails"
                    set suitable 0
                }
            }
        } else {
            puts "Requirement $feature in range? ..."

            ::habitat::Ask $feature $feature

            set value [dict get $::habitat::values $feature]

            switch -- [lindex $property 0] {
                "range" {
                    lassign [lindex $property end] min max
                    #puts "min/max: $min -- $max"
                    if { $value < $min || $value > $max } {
                        set suitable 0
                        puts "   - requirement fails"
                    }
                }
                "min" {
                    lassign $property dummy min
                    if { $value < $min } {
                        set suitable 0
                        puts "   - requirement fails"
                    }
                }
                "max" {
                    lassign $property dummy max
                    if { $value > $max } {
                        set suitable 0
                        puts "   - requirement fails"
                    }
                default {
                    puts "   - keyword unknown: [lindex $property 0]"
                }
                }
            }
        }
    }

    if { $suitable && ! $recurse } {
        puts "- Obligatory requirements fulfilled!"
    }

    #
    # TODO: optimal conditions - at least one must be fulfilled
    #

    return $suitable
}

Improvements

This is a simple experiment with a very limited collection of examples. So a first possibility to improve this code is to demonstrate that it is too limited for more extensive or complicated sets of rules.

The optional conditions can be easily implemented without repeating the code of `suitable`.

Perhaps cosmetic, the dialogue may be improved. Some cluttering will occur and the messages are a bit too abstract.