In Progress
Unit 1, Lesson 1
In Progress

Audited Predicate

Video transcript & code

Most programs contain their fair share of "predicate methods". A predicate is a method that answers a yes or no question. In Ruby, they usually end in a question mark. One situation we sometimes run into with predicated methods is that once we have our answer, we realize that we also want to know why the predicate arrived at that answer.

We encountered one such problem a few episodes back, in #268. After refactoring some complex predicate code into a more readable composed method, we discovered that our composed method was still difficult to debug, because the entire multi-part logical expression was treated as a single line by the debugger.

def alert_due?
   !completed? &&
   current_time_is_past_due_time? &&
   !sent_yesterday? &&
   hour_is_past? &&
   minute_is_past?
end

We wound up addressing this deficiency by refactoring the method again, into a series of short-circuiting statements. This version was more amenable to line-by-line debugging.

require "active_support/core_ext"

class Reminder
  attr_reader :reminder_at, :completed, :last_reminded_at

  def initialize(completed: false, reminder_at:, last_reminded_at: nil)
    @completed = completed
    @reminder_at = reminder_at
    @last_reminded_at = last_reminded_at
  end

  alias completed? completed

  def alert_due?
    return false if     completed?
    return false unless current_time_is_past_due_time?
    return false if     sent_yesterday?
    return false unless hour_is_past?
    return false unless minute_is_past?
    true
  end

  def current_time_is_past_due_time?
    reminder_at <= Time.current
  end

  def sent_yesterday?
    last_reminded_at != nil && last_reminded_at > 23.hours.ago
  end

  def hour_is_past?
    reminder_at.hour <= Time.current.hour
  end

  def minute_is_past?
    reminder_at.min <= Time.current.min
  end
end

That solved our debugging problem. But we programmers aren't the only people who sometimes wonder "why did that happen". Or, perhaps more often, "why didn't that happen?" When we find ourselves digging into the code to find out why some rule didn't trigger, we're often asking very same question that a user of the system will eventually ask. As business rules become more complex, it becomes more and more important that the program not only make the right decisions, but be able to explain itself in a meaningful way.

So the question I want to answer today is this: what is the smallest change we can make to this code in order to expose its reasoning to the outside world?

Let me emphasize part of that: we want to make the smallest change possible for now. There are lots of interesting patterns for modeling business rules and constraints as objects in their own right, and I hope to cover some of those patterns in later episodes. But right now, we want to disrupt the existing code as little as possible.

Here's what we'll do. We'll introduce an optional argument to the alert_due? predicate method. This argument is going to have the role of an auditor. Its job will be to note every constraint that is evaluated, and the result, either true of false, of that evaluation.

We will abbreviate this argument as just "audit". We want it to be optional, but we're not going to talk about what its default value should be just yet, so we'll just mark that as To Be Determined for now.

Then we go through each line of the predicate. For each check, we interject the auditor object. We invoke the object's #call method, using the Ruby 2.0 shorthand for callable objects, which is just a dot followed by parentheses. As an argument to the call, we provide an English description of the constraint being tested. Then we enclose the actual test inside a block.

def alert_due?(audit=TBD)
  return false if     audit.("task completed") { completed? }
  return false unless audit.("past due") { current_time_is_past_due_time? }
  return false if     audit.("sent yesterday") { sent_yesterday? }
  return false unless audit.("hour late enough") { hour_is_past? }
  return false unless audit.("minute late enough") { minute_is_past? }
  true
end

We now know that the auditor should be a "callable" object. What should our default be? Well, we don't want to change the present behavior at all. So we'll use a simple lambda which ignores its arguments, captures the passed block, invokes the block, and returns the result of the block with no further processing. In effect, this is a no-op. The result of each audited predicate execution will be exactly the same as if the auditor wasn't there.

require "./reminder"

class Reminder
  # ...
  def alert_due?(audit=->(*, &block){block.call})
    return false if     audit.("task completed") { completed? }
    return false unless audit.("past due") { current_time_is_past_due_time? }
    return false if     audit.("sent yesterday") { sent_yesterday? }
    return false unless audit.("hour late enough") { hour_is_past? }
    return false unless audit.("minute late enough") { minute_is_past? }
    true
  end
  # ...
end

Let's make sure everything still works. We create a Reminder object which passes some tests for being due, but which has been scheduled for later in the day than it currently is.

Then we ask it if there is an alert due. As expected, it returns false. This is correct, but it gives us no information about why the alert is not yet due.

require "./reminder2"

r = Reminder.new(completed: false,
                 reminder_at: Time.new(2014, 1, 15, 22, 0))

r.alert_due?                    # => false

Now, let's create our own custom auditor. We'll use another lambda. This one will capture both the constraint description and the test block. It executes the block, and captures the result into a variable. Then before returning, it outputs the description and the result to standard out. Finally, it returns the result value.

We pass our custom auditor into the #alert_due? message. This time, not only do we get a false result, we also get an audit log of exactly which checks were performed, and what was the result.

require "./reminder2"

r = Reminder.new(completed: false,
                 reminder_at: Time.new(2014, 1, 15, 22, 0))

auditor = ->(description, &test) {
  result = test.call
  puts "#{description}: #{result}"
  result
}

r.alert_due?(auditor)           # => false

# >> task completed: false
# >> past due: true
# >> sent yesterday: false
# >> hour late enough: false

We can imagine all kinds of other ways to use custom auditors. For instance, let's say we want to make some code conditional on whether an alert is due. And if the alert isn't due, we want to expose information about why not.

We could construct an auditor class that captures the auditing information into a hash for later use. Then we could send the predicate method, and branch on the outcome. As part of the send, we initialize, name, and pass a HashAuditor all at once, a technique we saw in episode #82. If it's true, we trigger the alert. If it's false, we use ask our custom auditor for an explanation.

require "./reminder2"

class HashAuditor
  def initialize
    @why = {}
  end

  def call(description)
    @why[description] = yield
  end

  def explain
    @why.map{|d, r| "#{d}: #{r}"}.join("; ")
  end
end

r = Reminder.new(completed: false,
                   reminder_at: Time.new(2014, 1, 15, 22, 0))

if r.alert_due?(audit = HashAuditor.new)
  # ... sound the alarm ...
else
  puts "no alert; here's why:"
  puts audit.explain
end

# >> no alert; here's why:
# >> task completed: false; past due: true; sent yesterday: false; hour late enough: false

As we can see, this is a very flexible technique, with almost limitless possibilities for variation by using different types of auditor. But the basic shape of our predicate method hasn't changed. And any existing code that doesn't know about auditors can go on working without any change.

And that's all for today. Happy hacking!

Responses