Skip to content

Decision trees that request facts as needed

License

Notifications You must be signed in to change notification settings

ConsultingMD/factree

Repository files navigation

Factree

Gem Version Build Status YARD Docs

Have a complicated decision to make? Factree will guide you through it step by step, identifying exactly which questions you need to answer along the way in order to reach a conclusion.

Installation

Add this line to your application's Gemfile:

gem 'factree'

And then execute:

$ bundle

Or install it yourself as:

$ gem install factree

Usage

For details, check out the API documentation.

Finding paths through a decision function

Factree provides tools for making choices based on a set of facts that are not yet known. You write a decision function that takes a set facts and returns a conclusion. Factree will run your function and make sure it has all of the facts it needs to complete. If any are missing, Factree will tell you what's needed to continue.

For example, say I want to pick an animal based on its attributes. First I'll write a function to make the decision.

include Factree::DSL

decide = ->(facts) do
  return conclusion :turtle unless facts[:mammal?]

  if facts[:herbivore?]
    conclusion :rabbit
  else
    conclusion :dog
  end
end

Then I'll pass that function to {Factree.find_path find_path} without any facts.

path = find_path &decide

path.complete?
#=> false

path.required_facts
#=> [:mammal?]

find_path will run my decide function until it reaches a conclusion or asks for a fact that is unknown. In this case, my function checks facts[:mammal?] right off the bat, and since I didn't provide that fact, find_path stopped there. The path through my decision tree was incomplete.

Thankfully, find_path keeps track of all of the facts that are requested as it makes its way through a decision function. If it has to stop because a fact is unknown, the fact's name will be included in {Path.required_facts required_facts}. You can check required_facts to see exactly what's required to progress through the function.

Let's give find_path another try, this time with mammal?: false.

path = find_path mammal?: false, &decide

path.complete?
#=> true

path.conclusion
#=> :turtle

This time find_path had all of the facts it needed to reach a conclusion, so it returned a complete path.

Supplying different values for facts may lead to a different path through the decision function, changing the facts that are required. For example, if mammal? is true, then we'll also need herbivore? to get to a conclusion.

path = find_path mammal?: true, &decide

path.complete?
#=> false

path.required_facts
#=> [:mammal?, :herbivore?]

Using Factree::DSL

Including {Factree::DSL Factree::DSL} in your code isn't mandatory. It just makes certain methods (like find_path and conclusion) easier to access. You can call the same methods on the {Factree Factree} module.

Factree.find_path **facts, &decide

# is the same as

include Factree::DSL
find_path **facts, &decide

Supplying facts

Factree is designed to work with decision functions that depend on many different facts. The {Factree::FactSource FactSource} mixin can help you organize them. A fact source class also provides a place for you to distill complex data into simple values, allowing you to keep your decision functions concise and readable.

Here's an example of a fact source that supplies a set of facts to help select a car for a buyer.

class DriverFactSource
  include Factree::FactSource

  def initialize(person)
    @person = person
    freeze
  end

  def_fact(:needs_fast_car?) { @person.name == 'Ricky Bobby' }

  def_fact(:needs_cheap_car?) { @person.account_balance < 500.00 }

  def_fact(:needs_extra_seats?) do
    unknown if @person.family_size == :unknown

    @person.family_size > 4
  end
end

Our DriverFactSource is just a class with some method-like fact definitions. Let's instantiate it for a person and see how it works.

Person = Struct.new(:name, :account_balance, :family_size)
tom = Person.new("Tom", 10_000.00, :unknown)
source = DriverFactSource.new(tom)

The source's primary job is to crank out a set of facts that can be fed into find_path.

source.to_h
#=> {:needs_fast_car?=>false, :needs_cheap_car?=>false}

Two of the facts we defined are included, but you might have noticed that :needs_extra_seats? is missing. Since we're dealing with facts that might not be known, FactSource provides an easy way to conditionally omit those facts. Just call {Factree::FactSource#unknown #unknown} instead of returning a value, and the fact will be omitted from the collection returned by {Factree::FactSource#to_h #to_h}. Attempting to {Factree::FactSource#fetch #fetch} it will raise an error.

source.fetch(:needs_extra_seats?)
# Factree::FactSource::UnknownFactError: unknown fact: needs_extra_seats?

Organizing your decision functions

It's recommended that you define your decision functions in their own modules, particularly when they're large or complex. For example:

module Decisions
  def self.decide_something(facts)
    # ...
  end
end

A method reference can be used to pass your decision function to #find_path.

find_path **facts, &Decisions.method(:decide_something)

Splitting up large functions

Factree comes with a tool for splitting big decisions into separate functions to simplify them and make them independently testable. Take this cheese choice, for example.

def choose_cheese(facts)
  if facts[:soft?]
    return conclusion :brie unless facts[:blue?]
    return conclusion :gorgonzola if facts[:cows_milk?]
    conclusion :brie
  else
    return conclusion :pecorino_toscano unless facts[:cows_milk?]
    return conclusion :emmental if facts[:holes?]
    conclusion :gruyere
  end
end

Say I wanted to split up my choice into separate functions for soft and hard cheeses. I can use {Factree.decide_between_alternatives decide_between_alternatives} to accomplish that.

def choose_soft_cheese(facts)
  return unless facts[:soft?]

  return conclusion :brie unless facts[:blue?]
  return conclusion :gorgonzola if facts[:cows_milk?]
  conclusion :roquefort
end

def choose_hard_cheese(facts)
  return if facts[:soft?]

  return conclusion :pecorino_toscano unless facts[:cows_milk?]
  return conclusion :emmental if facts[:holes?]
  conclusion :gruyere
end

def choose_cheese(facts)
  decide_between_alternatives facts,
    method(:choose_soft_cheese),
    method(:choose_hard_cheese)
end

decide_between_alternatives will try each decision function in order. If the first returns nil, it will try the second, and so on, until a conclusion or an unknown fact is reached.

By splitting up my decision method into two separate ones, I've made them independently testable. They can also be reordered, and I've eliminated a level of nesting inside of a conditional statement.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Pull requests are welcome on GitHub at https://github.com/ConsultingMD/factree.

This library is pretty mature and not under active development, but we're happy to discuss Github issues. Any active work will be tracked using a Grand Rounds internal Jira project ("COREX" at the moment).

License

The gem is available as open source under the terms of the MIT License.

About

Decision trees that request facts as needed

Resources

License

Stars

Watchers

Forks

Packages

No packages published